ngx-json-render 0.2.0 → 0.3.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 +363 -14
- package/fesm2022/ngx-json-render-testing.mjs +389 -0
- package/fesm2022/ngx-json-render-testing.mjs.map +1 -0
- package/fesm2022/ngx-json-render.mjs +882 -403
- package/fesm2022/ngx-json-render.mjs.map +1 -1
- package/package.json +5 -1
- package/types/ngx-json-render-testing.d.ts +197 -0
- package/types/ngx-json-render.d.ts +329 -60
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { NgComponentOutlet } from '@angular/common';
|
|
1
2
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { signal, Injectable, inject, computed, untracked, effect, isDevMode, input, output,
|
|
3
|
-
import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, createDirectiveRegistry, resolveRepeatStatePath, resolveRepeatItemStatePath,
|
|
4
|
-
export { createStateStore, nestedToFlat } from '@json-render/core';
|
|
3
|
+
import { signal, Injectable, inject, computed, untracked, effect, isDevMode, DestroyRef, InjectionToken, input, output, viewChild, afterNextRender, ChangeDetectionStrategy, Component, Injector, forwardRef } from '@angular/core';
|
|
4
|
+
import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, autoFixSpec, validateSpec, formatSpecIssues, createDirectiveRegistry, resolveRepeatStatePath, resolveRepeatItemStatePath, removeByPath, addByPath, setByPath, SPEC_DATA_PART_TYPE, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
|
|
5
|
+
export { autoFixSpec, createStateStore, formatSpecIssues, nestedToFlat, validateSpec } from '@json-render/core';
|
|
5
6
|
import { flattenToPointers } from '@json-render/core/store-utils';
|
|
6
|
-
import { NgComponentOutlet } from '@angular/common';
|
|
7
7
|
|
|
8
8
|
const EMPTY = signal(undefined, ...(ngDevMode ? [{ debugName: "EMPTY" }] : /* istanbul ignore next */ []));
|
|
9
9
|
/**
|
|
@@ -273,29 +273,73 @@ function validationConfigEqual(a, b) {
|
|
|
273
273
|
}
|
|
274
274
|
return true;
|
|
275
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Owner of a registration made through the service's own API rather than by
|
|
278
|
+
* a field component. Manual registrations release together, which is the
|
|
279
|
+
* behaviour a caller of `registerField(path, config)` would expect.
|
|
280
|
+
*/
|
|
281
|
+
const MANUAL_OWNER = { manual: true };
|
|
276
282
|
/**
|
|
277
283
|
* Form validation state of a `<json-render>` subtree. Fields register their
|
|
278
284
|
* {@link ValidationConfig}; the built-in `validateForm` action validates all
|
|
279
285
|
* registered fields and writes the result to state.
|
|
286
|
+
*
|
|
287
|
+
* Registrations are owner-scoped: a path stays registered while at least one
|
|
288
|
+
* owner holds it, and its config and state are dropped when the last one
|
|
289
|
+
* releases. A spec that swaps screens or hides fields therefore stops
|
|
290
|
+
* validating what is no longer on screen.
|
|
280
291
|
*/
|
|
281
292
|
class JsonRenderValidationService {
|
|
282
293
|
root = inject(JsonRenderRootContext);
|
|
283
294
|
state = inject(JsonRenderStateService);
|
|
284
295
|
_fieldStates = signal({}, ...(ngDevMode ? [{ debugName: "_fieldStates" }] : /* istanbul ignore next */ []));
|
|
285
296
|
_fieldConfigs = signal({}, ...(ngDevMode ? [{ debugName: "_fieldConfigs" }] : /* istanbul ignore next */ []));
|
|
297
|
+
/** Live owners per path — the reference count behind a registration. */
|
|
298
|
+
fieldOwners = new Map();
|
|
286
299
|
/** Validation state per registered field path. */
|
|
287
300
|
fieldStates = this._fieldStates.asReadonly();
|
|
288
301
|
get customFunctions() {
|
|
289
302
|
return untracked(this.root.validationFunctions) ?? {};
|
|
290
303
|
}
|
|
291
|
-
/**
|
|
292
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Register (or update) a field's validation config.
|
|
306
|
+
*
|
|
307
|
+
* @param owner Identity holding the registration — pass the same value to
|
|
308
|
+
* {@link unregisterField} to release it. Field components pass themselves,
|
|
309
|
+
* so two components bound to one path each hold their own claim.
|
|
310
|
+
*/
|
|
311
|
+
registerField(path, config, owner = MANUAL_OWNER) {
|
|
312
|
+
const owners = this.fieldOwners.get(path);
|
|
313
|
+
if (owners)
|
|
314
|
+
owners.add(owner);
|
|
315
|
+
else
|
|
316
|
+
this.fieldOwners.set(path, new Set([owner]));
|
|
293
317
|
const prev = untracked(this._fieldConfigs);
|
|
294
318
|
const existing = prev[path];
|
|
295
319
|
if (existing && validationConfigEqual(existing, config))
|
|
296
320
|
return;
|
|
297
321
|
this._fieldConfigs.set({ ...prev, [path]: config });
|
|
298
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Release one owner's registration of a field. The config and validation
|
|
325
|
+
* state survive while another owner still holds the path; once the last
|
|
326
|
+
* one lets go, `validateAll` stops seeing the field.
|
|
327
|
+
*/
|
|
328
|
+
unregisterField(path, owner = MANUAL_OWNER) {
|
|
329
|
+
const owners = this.fieldOwners.get(path);
|
|
330
|
+
if (!owners)
|
|
331
|
+
return;
|
|
332
|
+
owners.delete(owner);
|
|
333
|
+
if (owners.size > 0)
|
|
334
|
+
return;
|
|
335
|
+
this.fieldOwners.delete(path);
|
|
336
|
+
const prev = untracked(this._fieldConfigs);
|
|
337
|
+
if (path in prev) {
|
|
338
|
+
const { [path]: _removed, ...rest } = prev;
|
|
339
|
+
this._fieldConfigs.set(rest);
|
|
340
|
+
}
|
|
341
|
+
this.clear(path);
|
|
342
|
+
}
|
|
299
343
|
/** Validate a single field and record the result. */
|
|
300
344
|
validate(path, config) {
|
|
301
345
|
const currentState = this.state.getSnapshot();
|
|
@@ -368,18 +412,39 @@ function injectValidation() {
|
|
|
368
412
|
/**
|
|
369
413
|
* Field-level validation helper for catalog input components: registers the
|
|
370
414
|
* config and exposes the field's validation state as signals.
|
|
415
|
+
*
|
|
416
|
+
* The registration follows the component: it moves when the bound path
|
|
417
|
+
* changes and is released when the component is destroyed, so a field that
|
|
418
|
+
* has left the screen no longer takes part in `validateForm`.
|
|
371
419
|
*/
|
|
372
420
|
function injectFieldValidation(path, config) {
|
|
373
421
|
const validation = injectValidation();
|
|
374
422
|
const getPath = typeof path === 'function' ? path : () => path;
|
|
375
423
|
const getConfig = typeof config === 'function' ? config : () => config;
|
|
424
|
+
// Identity of this field's claim on a path. Two components bound to the
|
|
425
|
+
// same path hold separate claims, so one being destroyed does not
|
|
426
|
+
// deregister the other.
|
|
427
|
+
const owner = {};
|
|
428
|
+
let registeredPath = null;
|
|
376
429
|
effect(() => {
|
|
377
430
|
const p = getPath();
|
|
378
431
|
const c = getConfig();
|
|
379
|
-
|
|
380
|
-
|
|
432
|
+
const next = p && c ? p : null;
|
|
433
|
+
if (registeredPath !== null && registeredPath !== next) {
|
|
434
|
+
validation.unregisterField(registeredPath, owner);
|
|
435
|
+
registeredPath = null;
|
|
436
|
+
}
|
|
437
|
+
if (next && c) {
|
|
438
|
+
validation.registerField(next, c, owner);
|
|
439
|
+
registeredPath = next;
|
|
381
440
|
}
|
|
382
441
|
});
|
|
442
|
+
inject(DestroyRef).onDestroy(() => {
|
|
443
|
+
if (registeredPath === null)
|
|
444
|
+
return;
|
|
445
|
+
validation.unregisterField(registeredPath, owner);
|
|
446
|
+
registeredPath = null;
|
|
447
|
+
});
|
|
383
448
|
const state = computed(() => {
|
|
384
449
|
const current = validation.fieldStates()[getPath()];
|
|
385
450
|
return current ?? { touched: false, validated: false, result: null };
|
|
@@ -432,7 +497,7 @@ function deepResolveValue(value, get) {
|
|
|
432
497
|
* Carries a stable `name` so callers can tell a cancellation apart from a
|
|
433
498
|
* handler failure without matching on the message.
|
|
434
499
|
*
|
|
435
|
-
* @internal Not part of the public API;
|
|
500
|
+
* @internal Not part of the public API; use {@link isActionCancelled}.
|
|
436
501
|
*/
|
|
437
502
|
class ActionCancelledError extends Error {
|
|
438
503
|
constructor() {
|
|
@@ -448,9 +513,9 @@ function isActionCancelled(error) {
|
|
|
448
513
|
* Action dispatcher of a `<json-render>` subtree.
|
|
449
514
|
*
|
|
450
515
|
* Executes {@link ActionBinding}s: built-in actions (`setState`, `pushState`,
|
|
451
|
-
* `removeState`, `push`, `pop`, `validateForm`) are handled
|
|
452
|
-
* everything else is routed to the host-provided `handlers` (or
|
|
453
|
-
* `onAction` catch-all), honoring `confirm`, `onSuccess`, and `onError`.
|
|
516
|
+
* `removeState`, `push`, `pop`, `validateForm`, `submitForm`) are handled
|
|
517
|
+
* internally; everything else is routed to the host-provided `handlers` (or
|
|
518
|
+
* the `onAction` catch-all), honoring `confirm`, `onSuccess`, and `onError`.
|
|
454
519
|
*/
|
|
455
520
|
class JsonRenderActionsService {
|
|
456
521
|
root = inject(JsonRenderRootContext);
|
|
@@ -557,19 +622,36 @@ class JsonRenderActionsService {
|
|
|
557
622
|
return;
|
|
558
623
|
}
|
|
559
624
|
if (resolved.action === 'validateForm') {
|
|
560
|
-
if (
|
|
625
|
+
if (this.checkForm(resolved) === null) {
|
|
561
626
|
console.warn('validateForm action was dispatched but no JsonRenderValidationService is available.');
|
|
627
|
+
}
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (resolved.action === 'submitForm') {
|
|
631
|
+
const valid = this.checkForm(resolved);
|
|
632
|
+
if (valid === null) {
|
|
633
|
+
console.warn('submitForm action was dispatched but no JsonRenderValidationService is available; nothing was submitted.');
|
|
562
634
|
return;
|
|
563
635
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
636
|
+
// An invalid form stops here, with the errors already written to
|
|
637
|
+
// state and every field marked validated, so they are on screen.
|
|
638
|
+
if (!valid)
|
|
639
|
+
return;
|
|
640
|
+
const target = resolved.params?.['action'];
|
|
641
|
+
if (typeof target !== 'string' || !target) {
|
|
642
|
+
console.warn('submitForm needs the action to submit to: { "action": "submitForm", "params": { "action": "saveUser" } }.');
|
|
643
|
+
return;
|
|
570
644
|
}
|
|
571
|
-
|
|
572
|
-
|
|
645
|
+
// Dispatched, not called directly, so the submitted action goes
|
|
646
|
+
// through the same lookup, confirmation, loading state and observers
|
|
647
|
+
// as any other — submitForm gates it, it does not replace it.
|
|
648
|
+
await this.execute({
|
|
649
|
+
action: target,
|
|
650
|
+
params: deepResolveValue(resolved.params?.['params'], get),
|
|
651
|
+
confirm: resolved.confirm,
|
|
652
|
+
onSuccess: resolved.onSuccess,
|
|
653
|
+
onError: resolved.onError,
|
|
654
|
+
});
|
|
573
655
|
return;
|
|
574
656
|
}
|
|
575
657
|
const handler = this.lookupHandler(resolved.action);
|
|
@@ -622,6 +704,28 @@ class JsonRenderActionsService {
|
|
|
622
704
|
cancel() {
|
|
623
705
|
untracked(this._pendingConfirmation)?.reject();
|
|
624
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* Validate every registered field and write `{ valid, errors }` to the
|
|
709
|
+
* action's `statePath` (default `/formValidation`). Returns null when there
|
|
710
|
+
* is no validation service to ask.
|
|
711
|
+
*
|
|
712
|
+
* Shared by `validateForm` and `submitForm`: the two must report the same
|
|
713
|
+
* thing about the same form, and one implementation is how that stays true.
|
|
714
|
+
*/
|
|
715
|
+
checkForm(resolved) {
|
|
716
|
+
if (!this.validation)
|
|
717
|
+
return null;
|
|
718
|
+
const valid = this.validation.validateAll();
|
|
719
|
+
const errors = {};
|
|
720
|
+
for (const [path, fieldState] of Object.entries(untracked(this.validation.fieldStates))) {
|
|
721
|
+
if (fieldState.result && !fieldState.result.valid) {
|
|
722
|
+
errors[path] = fieldState.result.errors;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const statePath = resolved.params?.['statePath'] || '/formValidation';
|
|
726
|
+
this.state.set(statePath, { valid, errors });
|
|
727
|
+
return valid;
|
|
728
|
+
}
|
|
625
729
|
lookupHandler(name) {
|
|
626
730
|
const direct = this.handlers[name];
|
|
627
731
|
if (direct)
|
|
@@ -674,91 +778,178 @@ function injectAction(binding) {
|
|
|
674
778
|
};
|
|
675
779
|
}
|
|
676
780
|
|
|
781
|
+
/**
|
|
782
|
+
* Labels for the default confirmation dialog's buttons, when the spec does not
|
|
783
|
+
* supply `confirmLabel` / `cancelLabel` of its own.
|
|
784
|
+
*
|
|
785
|
+
* They are the only strings this package puts on screen, and they were English
|
|
786
|
+
* with no way to change them. Override once, in an app's providers:
|
|
787
|
+
*
|
|
788
|
+
* ```ts
|
|
789
|
+
* { provide: JR_CONFIRM_LABELS, useValue: { confirm: 'Подтвердить', cancel: 'Отмена' } }
|
|
790
|
+
* ```
|
|
791
|
+
*/
|
|
792
|
+
const JR_CONFIRM_LABELS = new InjectionToken('ngx-json-render JR_CONFIRM_LABELS', {
|
|
793
|
+
providedIn: 'root',
|
|
794
|
+
factory: () => ({ confirm: 'Confirm', cancel: 'Cancel' }),
|
|
795
|
+
});
|
|
796
|
+
/** The confirmation a replacement dialog component is rendered for. */
|
|
797
|
+
const CONFIRM_CONTEXT = new InjectionToken('ngx-json-render CONFIRM_CONTEXT');
|
|
798
|
+
/**
|
|
799
|
+
* The component `<json-render>` should render for a `confirm` on an action.
|
|
800
|
+
* Unset means the packaged {@link JrConfirmDialog}.
|
|
801
|
+
*
|
|
802
|
+
* The component is rendered with a {@link CONFIRM_CONTEXT} in its injector, so
|
|
803
|
+
* it takes no inputs and emits no outputs:
|
|
804
|
+
*
|
|
805
|
+
* ```ts
|
|
806
|
+
* @Component({ template: `<my-modal (ok)="ctx.confirm()" (dismiss)="ctx.cancel()">…` })
|
|
807
|
+
* export class AppConfirm {
|
|
808
|
+
* readonly ctx = injectConfirmContext();
|
|
809
|
+
* }
|
|
810
|
+
* // providers: [{ provide: JR_CONFIRM_DIALOG, useValue: AppConfirm }]
|
|
811
|
+
* ```
|
|
812
|
+
*/
|
|
813
|
+
const JR_CONFIRM_DIALOG = new InjectionToken('ngx-json-render JR_CONFIRM_DIALOG');
|
|
814
|
+
/** Inject the confirmation a replacement dialog component is rendering for. */
|
|
815
|
+
function injectConfirmContext() {
|
|
816
|
+
const ctx = inject(CONFIRM_CONTEXT, { optional: true });
|
|
817
|
+
if (!ctx) {
|
|
818
|
+
throw new Error('injectConfirmContext() must be used inside a component provided through JR_CONFIRM_DIALOG');
|
|
819
|
+
}
|
|
820
|
+
return ctx;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
let dialogIdCounter = 0;
|
|
677
824
|
/**
|
|
678
825
|
* Default confirmation dialog shown for action bindings with a `confirm`
|
|
679
826
|
* field. Rendered automatically by `<json-render>`; can also be used
|
|
680
827
|
* standalone with a custom action flow.
|
|
828
|
+
*
|
|
829
|
+
* It is a real dialog: `role="dialog"` with `aria-modal`, labelled by its own
|
|
830
|
+
* title and described by its message, focus moved in on open and returned to
|
|
831
|
+
* wherever it came from on close, Tab kept inside it and Escape cancelling.
|
|
832
|
+
* A generated UI can ask for confirmation of something destructive, so a
|
|
833
|
+
* keyboard or screen-reader user has to be able to answer it.
|
|
834
|
+
*
|
|
835
|
+
* Colours come from CSS custom properties with light and dark defaults, so an
|
|
836
|
+
* app can theme it — `--jr-confirm-surface`, `--jr-confirm-ink`,
|
|
837
|
+
* `--jr-confirm-muted`, `--jr-confirm-border`, `--jr-confirm-scrim`,
|
|
838
|
+
* `--jr-confirm-accent`, `--jr-confirm-danger`, `--jr-confirm-on-accent`,
|
|
839
|
+
* `--jr-confirm-radius` — and replace it outright through
|
|
840
|
+
* {@link JR_CONFIRM_DIALOG} when that is not enough.
|
|
681
841
|
*/
|
|
682
842
|
class JrConfirmDialog {
|
|
683
843
|
config = input.required(...(ngDevMode ? [{ debugName: "config" }] : /* istanbul ignore next */ []));
|
|
684
844
|
confirmed = output();
|
|
685
845
|
cancelled = output();
|
|
846
|
+
labels = inject(JR_CONFIRM_LABELS);
|
|
847
|
+
id = dialogIdCounter++;
|
|
848
|
+
titleId = `jr-confirm-title-${this.id}`;
|
|
849
|
+
messageId = `jr-confirm-message-${this.id}`;
|
|
850
|
+
cancelButton = viewChild('cancel', ...(ngDevMode ? [{ debugName: "cancelButton" }] : /* istanbul ignore next */ []));
|
|
851
|
+
confirmButton = viewChild('confirm', ...(ngDevMode ? [{ debugName: "confirmButton" }] : /* istanbul ignore next */ []));
|
|
686
852
|
isDanger = computed(() => this.config().variant === 'danger', ...(ngDevMode ? [{ debugName: "isDanger" }] : /* istanbul ignore next */ []));
|
|
853
|
+
constructor() {
|
|
854
|
+
// Whatever was focused when the action fired — usually the control that
|
|
855
|
+
// dispatched it, which is still in the document behind the dialog.
|
|
856
|
+
const previous = typeof document !== 'undefined'
|
|
857
|
+
? document.activeElement
|
|
858
|
+
: null;
|
|
859
|
+
// Cancel takes focus rather than confirm: a dialog exists to interrupt,
|
|
860
|
+
// and landing on the destructive answer makes a stray Enter destructive.
|
|
861
|
+
afterNextRender(() => this.cancelButton()?.nativeElement.focus());
|
|
862
|
+
inject(DestroyRef).onDestroy(() => previous?.focus?.());
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Keep Tab inside the dialog. With exactly two buttons the whole trap is
|
|
866
|
+
* wrapping the ends; a modal that lets focus wander behind it is a modal
|
|
867
|
+
* only for people using a mouse.
|
|
868
|
+
*/
|
|
869
|
+
onKeydown(event) {
|
|
870
|
+
if (event.key !== 'Tab')
|
|
871
|
+
return;
|
|
872
|
+
const cancel = this.cancelButton()?.nativeElement;
|
|
873
|
+
const confirm = this.confirmButton()?.nativeElement;
|
|
874
|
+
if (!cancel || !confirm)
|
|
875
|
+
return;
|
|
876
|
+
const active = event.target;
|
|
877
|
+
if (event.shiftKey && active === cancel) {
|
|
878
|
+
event.preventDefault();
|
|
879
|
+
confirm.focus();
|
|
880
|
+
}
|
|
881
|
+
else if (!event.shiftKey && active === confirm) {
|
|
882
|
+
event.preventDefault();
|
|
883
|
+
cancel.focus();
|
|
884
|
+
}
|
|
885
|
+
}
|
|
687
886
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrConfirmDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
688
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.
|
|
689
|
-
<div
|
|
690
|
-
style="position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
|
|
691
|
-
(click)="cancelled.emit()"
|
|
692
|
-
>
|
|
887
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.22", type: JrConfirmDialog, isStandalone: true, selector: "jr-confirm-dialog", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { confirmed: "confirmed", cancelled: "cancelled" }, host: { listeners: { "keydown.escape": "cancelled.emit()" } }, viewQueries: [{ propertyName: "cancelButton", first: true, predicate: ["cancel"], descendants: true, isSignal: true }, { propertyName: "confirmButton", first: true, predicate: ["confirm"], descendants: true, isSignal: true }], ngImport: i0, template: `
|
|
888
|
+
<div class="jr-confirm-scrim" (click)="cancelled.emit()">
|
|
693
889
|
<div
|
|
694
|
-
|
|
890
|
+
class="jr-confirm-panel"
|
|
891
|
+
role="dialog"
|
|
892
|
+
aria-modal="true"
|
|
893
|
+
[attr.aria-labelledby]="titleId"
|
|
894
|
+
[attr.aria-describedby]="messageId"
|
|
695
895
|
(click)="$event.stopPropagation()"
|
|
896
|
+
(keydown)="onKeydown($event)"
|
|
696
897
|
>
|
|
697
|
-
<h3
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
<button
|
|
703
|
-
type="button"
|
|
704
|
-
(click)="cancelled.emit()"
|
|
705
|
-
style="padding: 8px 16px; border-radius: 6px; border: 1px solid #d1d5db; background-color: white; cursor: pointer"
|
|
706
|
-
>
|
|
707
|
-
{{ config().cancelLabel ?? 'Cancel' }}
|
|
898
|
+
<h3 class="jr-confirm-title" [id]="titleId">{{ config().title }}</h3>
|
|
899
|
+
<p class="jr-confirm-message" [id]="messageId">{{ config().message }}</p>
|
|
900
|
+
<div class="jr-confirm-actions">
|
|
901
|
+
<button #cancel type="button" class="jr-confirm-cancel" (click)="cancelled.emit()">
|
|
902
|
+
{{ config().cancelLabel ?? labels.cancel }}
|
|
708
903
|
</button>
|
|
709
904
|
<button
|
|
905
|
+
#confirm
|
|
710
906
|
type="button"
|
|
907
|
+
class="jr-confirm-confirm"
|
|
908
|
+
[class.jr-confirm-danger]="isDanger()"
|
|
711
909
|
(click)="confirmed.emit()"
|
|
712
|
-
[style.background-color]="isDanger() ? '#dc2626' : '#3b82f6'"
|
|
713
|
-
style="padding: 8px 16px; border-radius: 6px; border: none; color: white; cursor: pointer"
|
|
714
910
|
>
|
|
715
|
-
{{ config().confirmLabel ??
|
|
911
|
+
{{ config().confirmLabel ?? labels.confirm }}
|
|
716
912
|
</button>
|
|
717
913
|
</div>
|
|
718
914
|
</div>
|
|
719
915
|
</div>
|
|
720
|
-
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
916
|
+
`, isInline: true, styles: [":host{--jr-confirm-scrim-default: rgba(0, 0, 0, .5);--jr-confirm-surface-default: #ffffff;--jr-confirm-ink-default: #111827;--jr-confirm-muted-default: #6b7280;--jr-confirm-border-default: #d1d5db;--jr-confirm-accent-default: #3b82f6;--jr-confirm-danger-default: #dc2626;--jr-confirm-on-accent-default: #ffffff}@media(prefers-color-scheme:dark){:host{--jr-confirm-scrim-default: rgba(0, 0, 0, .65);--jr-confirm-surface-default: #1f2937;--jr-confirm-ink-default: #f3f4f6;--jr-confirm-muted-default: #9ca3af;--jr-confirm-border-default: #4b5563;--jr-confirm-accent-default: #60a5fa;--jr-confirm-danger-default: #f87171;--jr-confirm-on-accent-default: #111827}}.jr-confirm-scrim{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background-color:var(--jr-confirm-scrim, var(--jr-confirm-scrim-default))}.jr-confirm-panel{width:100%;max-width:400px;padding:24px;border-radius:var(--jr-confirm-radius, 8px);background-color:var(--jr-confirm-surface, var(--jr-confirm-surface-default));color:var(--jr-confirm-ink, var(--jr-confirm-ink-default));box-shadow:0 20px 25px -5px #00000040}.jr-confirm-title{margin:0 0 8px;font-size:18px;font-weight:600}.jr-confirm-message{margin:0 0 24px;color:var(--jr-confirm-muted, var(--jr-confirm-muted-default))}.jr-confirm-actions{display:flex;gap:12px;justify-content:flex-end}.jr-confirm-actions button{padding:8px 16px;border-radius:6px;font:inherit;cursor:pointer}.jr-confirm-cancel{border:1px solid var(--jr-confirm-border, var(--jr-confirm-border-default));background-color:transparent;color:inherit}.jr-confirm-confirm{border:none;background-color:var(--jr-confirm-accent, var(--jr-confirm-accent-default));color:var(--jr-confirm-on-accent, var(--jr-confirm-on-accent-default))}.jr-confirm-confirm.jr-confirm-danger{background-color:var(--jr-confirm-danger, var(--jr-confirm-danger-default))}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
721
917
|
}
|
|
722
918
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrConfirmDialog, decorators: [{
|
|
723
919
|
type: Component,
|
|
724
|
-
args: [{
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
<div
|
|
729
|
-
style="position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
|
|
730
|
-
(click)="cancelled.emit()"
|
|
731
|
-
>
|
|
920
|
+
args: [{ selector: 'jr-confirm-dialog', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
921
|
+
'(keydown.escape)': 'cancelled.emit()',
|
|
922
|
+
}, template: `
|
|
923
|
+
<div class="jr-confirm-scrim" (click)="cancelled.emit()">
|
|
732
924
|
<div
|
|
733
|
-
|
|
925
|
+
class="jr-confirm-panel"
|
|
926
|
+
role="dialog"
|
|
927
|
+
aria-modal="true"
|
|
928
|
+
[attr.aria-labelledby]="titleId"
|
|
929
|
+
[attr.aria-describedby]="messageId"
|
|
734
930
|
(click)="$event.stopPropagation()"
|
|
931
|
+
(keydown)="onKeydown($event)"
|
|
735
932
|
>
|
|
736
|
-
<h3
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
<button
|
|
742
|
-
type="button"
|
|
743
|
-
(click)="cancelled.emit()"
|
|
744
|
-
style="padding: 8px 16px; border-radius: 6px; border: 1px solid #d1d5db; background-color: white; cursor: pointer"
|
|
745
|
-
>
|
|
746
|
-
{{ config().cancelLabel ?? 'Cancel' }}
|
|
933
|
+
<h3 class="jr-confirm-title" [id]="titleId">{{ config().title }}</h3>
|
|
934
|
+
<p class="jr-confirm-message" [id]="messageId">{{ config().message }}</p>
|
|
935
|
+
<div class="jr-confirm-actions">
|
|
936
|
+
<button #cancel type="button" class="jr-confirm-cancel" (click)="cancelled.emit()">
|
|
937
|
+
{{ config().cancelLabel ?? labels.cancel }}
|
|
747
938
|
</button>
|
|
748
939
|
<button
|
|
940
|
+
#confirm
|
|
749
941
|
type="button"
|
|
942
|
+
class="jr-confirm-confirm"
|
|
943
|
+
[class.jr-confirm-danger]="isDanger()"
|
|
750
944
|
(click)="confirmed.emit()"
|
|
751
|
-
[style.background-color]="isDanger() ? '#dc2626' : '#3b82f6'"
|
|
752
|
-
style="padding: 8px 16px; border-radius: 6px; border: none; color: white; cursor: pointer"
|
|
753
945
|
>
|
|
754
|
-
{{ config().confirmLabel ??
|
|
946
|
+
{{ config().confirmLabel ?? labels.confirm }}
|
|
755
947
|
</button>
|
|
756
948
|
</div>
|
|
757
949
|
</div>
|
|
758
950
|
</div>
|
|
759
|
-
`,
|
|
760
|
-
|
|
761
|
-
}], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], confirmed: [{ type: i0.Output, args: ["confirmed"] }], cancelled: [{ type: i0.Output, args: ["cancelled"] }] } });
|
|
951
|
+
`, styles: [":host{--jr-confirm-scrim-default: rgba(0, 0, 0, .5);--jr-confirm-surface-default: #ffffff;--jr-confirm-ink-default: #111827;--jr-confirm-muted-default: #6b7280;--jr-confirm-border-default: #d1d5db;--jr-confirm-accent-default: #3b82f6;--jr-confirm-danger-default: #dc2626;--jr-confirm-on-accent-default: #ffffff}@media(prefers-color-scheme:dark){:host{--jr-confirm-scrim-default: rgba(0, 0, 0, .65);--jr-confirm-surface-default: #1f2937;--jr-confirm-ink-default: #f3f4f6;--jr-confirm-muted-default: #9ca3af;--jr-confirm-border-default: #4b5563;--jr-confirm-accent-default: #60a5fa;--jr-confirm-danger-default: #f87171;--jr-confirm-on-accent-default: #111827}}.jr-confirm-scrim{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background-color:var(--jr-confirm-scrim, var(--jr-confirm-scrim-default))}.jr-confirm-panel{width:100%;max-width:400px;padding:24px;border-radius:var(--jr-confirm-radius, 8px);background-color:var(--jr-confirm-surface, var(--jr-confirm-surface-default));color:var(--jr-confirm-ink, var(--jr-confirm-ink-default));box-shadow:0 20px 25px -5px #00000040}.jr-confirm-title{margin:0 0 8px;font-size:18px;font-weight:600}.jr-confirm-message{margin:0 0 24px;color:var(--jr-confirm-muted, var(--jr-confirm-muted-default))}.jr-confirm-actions{display:flex;gap:12px;justify-content:flex-end}.jr-confirm-actions button{padding:8px 16px;border-radius:6px;font:inherit;cursor:pointer}.jr-confirm-cancel{border:1px solid var(--jr-confirm-border, var(--jr-confirm-border-default));background-color:transparent;color:inherit}.jr-confirm-confirm{border:none;background-color:var(--jr-confirm-accent, var(--jr-confirm-accent-default));color:var(--jr-confirm-on-accent, var(--jr-confirm-on-accent-default))}.jr-confirm-confirm.jr-confirm-danger{background-color:var(--jr-confirm-danger, var(--jr-confirm-danger-default))}\n"] }]
|
|
952
|
+
}], ctorParameters: () => [], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], confirmed: [{ type: i0.Output, args: ["confirmed"] }], cancelled: [{ type: i0.Output, args: ["cancelled"] }], cancelButton: [{ type: i0.ViewChild, args: ['cancel', { isSignal: true }] }], confirmButton: [{ type: i0.ViewChild, args: ['confirm', { isSignal: true }] }] } });
|
|
762
953
|
|
|
763
954
|
/**
|
|
764
955
|
* Reactive mirror of the json-render devtools-active flag. When active, the
|
|
@@ -776,6 +967,11 @@ function injectDevtoolsActive() {
|
|
|
776
967
|
* Provided by the renderer for every catalog component instance.
|
|
777
968
|
*/
|
|
778
969
|
const RENDER_CONTEXT = new InjectionToken('ngx-json-render RENDER_CONTEXT');
|
|
970
|
+
/**
|
|
971
|
+
* The spec key of the element currently being rendered. Provided alongside
|
|
972
|
+
* {@link RENDER_CONTEXT} for every catalog component instance.
|
|
973
|
+
*/
|
|
974
|
+
const ELEMENT_KEY = new InjectionToken('ngx-json-render ELEMENT_KEY');
|
|
779
975
|
/**
|
|
780
976
|
* The current repeat scope. Present only for elements rendered inside a
|
|
781
977
|
* `repeat` block.
|
|
@@ -802,6 +998,27 @@ function injectRenderContext() {
|
|
|
802
998
|
}
|
|
803
999
|
return ctx;
|
|
804
1000
|
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Inject the spec key of the element this component renders.
|
|
1003
|
+
*
|
|
1004
|
+
* Catalog components that collect their children — a tab group discovering
|
|
1005
|
+
* its tabs, for instance — need it to order registrations by the spec's
|
|
1006
|
+
* `children` array rather than by the order the children happened to
|
|
1007
|
+
* announce themselves in.
|
|
1008
|
+
*
|
|
1009
|
+
* @example
|
|
1010
|
+
* ```ts
|
|
1011
|
+
* const key = injectElementKey();
|
|
1012
|
+
* const index = computed(() => parent.element().children?.indexOf(key()) ?? -1);
|
|
1013
|
+
* ```
|
|
1014
|
+
*/
|
|
1015
|
+
function injectElementKey() {
|
|
1016
|
+
const key = inject(ELEMENT_KEY, { optional: true });
|
|
1017
|
+
if (!key) {
|
|
1018
|
+
throw new Error('injectElementKey() must be used inside a component rendered by <json-render>');
|
|
1019
|
+
}
|
|
1020
|
+
return key;
|
|
1021
|
+
}
|
|
805
1022
|
/**
|
|
806
1023
|
* Inject the current repeat scope, or `null` when the component is not
|
|
807
1024
|
* rendered inside a `repeat` block.
|
|
@@ -899,7 +1116,10 @@ class JrElement {
|
|
|
899
1116
|
},
|
|
900
1117
|
};
|
|
901
1118
|
outletInjector = Injector.create({
|
|
902
|
-
providers: [
|
|
1119
|
+
providers: [
|
|
1120
|
+
{ provide: RENDER_CONTEXT, useValue: this.renderCtx },
|
|
1121
|
+
{ provide: ELEMENT_KEY, useValue: this.elementKey },
|
|
1122
|
+
],
|
|
903
1123
|
parent: inject(Injector),
|
|
904
1124
|
});
|
|
905
1125
|
constructor() {
|
|
@@ -1047,6 +1267,60 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1047
1267
|
`, styles: [":host{display:contents}\n"] }]
|
|
1048
1268
|
}], ctorParameters: () => [], propDecorators: { elementKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "elementKey", required: true }] }] } });
|
|
1049
1269
|
|
|
1270
|
+
const CLEAN = {
|
|
1271
|
+
spec: null,
|
|
1272
|
+
issues: [],
|
|
1273
|
+
fixes: [],
|
|
1274
|
+
hasErrors: false,
|
|
1275
|
+
};
|
|
1276
|
+
/**
|
|
1277
|
+
* Check a spec the way `validate` asks for.
|
|
1278
|
+
*
|
|
1279
|
+
* Only lossless fixes are applied — `visible`, `on` and `repeat` that a model
|
|
1280
|
+
* put inside `props`, which the renderer would otherwise ignore, moved back
|
|
1281
|
+
* where they belong. The lossy ones prune content, and core's own guidance is
|
|
1282
|
+
* to re-prompt rather than accept that; a renderer that silently deleted
|
|
1283
|
+
* elements would be the worse failure.
|
|
1284
|
+
*/
|
|
1285
|
+
function checkSpec(spec, mode) {
|
|
1286
|
+
if (!spec)
|
|
1287
|
+
return CLEAN;
|
|
1288
|
+
if (mode === 'off') {
|
|
1289
|
+
return { spec, issues: [], fixes: [], hasErrors: false };
|
|
1290
|
+
}
|
|
1291
|
+
const fixed = autoFixSpec(spec, { lossy: false });
|
|
1292
|
+
const { issues } = validateSpec(fixed.spec);
|
|
1293
|
+
return {
|
|
1294
|
+
spec: fixed.spec,
|
|
1295
|
+
issues,
|
|
1296
|
+
fixes: fixed.fixes,
|
|
1297
|
+
hasErrors: issues.some((issue) => issue.severity === 'error'),
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Report a check to the console: fixes as information, issues at the severity
|
|
1302
|
+
* the mode gives them. `strict` refuses to render, so its errors are reported
|
|
1303
|
+
* as errors; under `warn` the spec still renders and the same finding is a
|
|
1304
|
+
* warning.
|
|
1305
|
+
*/
|
|
1306
|
+
function reportSpecCheck(check, mode) {
|
|
1307
|
+
if (mode === 'off')
|
|
1308
|
+
return;
|
|
1309
|
+
if (check.fixes.length > 0) {
|
|
1310
|
+
console.info(`[ngx-json-render] Fixed ${check.fixes.length} spec issue(s) before rendering:\n` +
|
|
1311
|
+
check.fixes.map((fix) => ` - ${fix}`).join('\n'));
|
|
1312
|
+
}
|
|
1313
|
+
if (check.issues.length === 0)
|
|
1314
|
+
return;
|
|
1315
|
+
const report = `[ngx-json-render] Spec validation:\n${formatSpecIssues([...check.issues])}`;
|
|
1316
|
+
if (mode === 'strict' && check.hasErrors) {
|
|
1317
|
+
console.error(`${report}\nNothing is rendered: validate="strict".`);
|
|
1318
|
+
}
|
|
1319
|
+
else {
|
|
1320
|
+
console.warn(report);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1050
1324
|
/**
|
|
1051
1325
|
* Renders a json-render {@link Spec} using a registry of Angular components.
|
|
1052
1326
|
*
|
|
@@ -1075,6 +1349,19 @@ class JsonRenderer {
|
|
|
1075
1349
|
loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
1076
1350
|
/** Fallback component for unknown types. */
|
|
1077
1351
|
fallback = input(null, ...(ngDevMode ? [{ debugName: "fallback" }] : /* istanbul ignore next */ []));
|
|
1352
|
+
/**
|
|
1353
|
+
* Whether to check the spec's structure before rendering it, and what to do
|
|
1354
|
+
* about what turns up. Off by default.
|
|
1355
|
+
*
|
|
1356
|
+
* `'warn'` reports the issues and renders anyway; `'strict'` renders nothing
|
|
1357
|
+
* when the spec has errors. Both first apply the lossless fixes — `visible`,
|
|
1358
|
+
* `on` and `repeat` misplaced inside `props` are moved back to the element,
|
|
1359
|
+
* where they take effect instead of being ignored.
|
|
1360
|
+
*
|
|
1361
|
+
* The check is skipped while `loading` is true: a spec that is still
|
|
1362
|
+
* arriving is expected to reference children that have not streamed in yet.
|
|
1363
|
+
*/
|
|
1364
|
+
validate = input('off', ...(ngDevMode ? [{ debugName: "validate" }] : /* istanbul ignore next */ []));
|
|
1078
1365
|
/**
|
|
1079
1366
|
* Initial state model (uncontrolled mode). Defaults to `spec.state`.
|
|
1080
1367
|
* Ignored when `store` is provided.
|
|
@@ -1100,9 +1387,17 @@ class JsonRenderer {
|
|
|
1100
1387
|
stateStore;
|
|
1101
1388
|
/** The action dispatcher of this renderer. */
|
|
1102
1389
|
actions;
|
|
1390
|
+
/**
|
|
1391
|
+
* The spec as checked, and what the check found. While `loading`, the mode
|
|
1392
|
+
* is forced off: the spec is still arriving, and the missing children it
|
|
1393
|
+
* refers to are the normal state of a stream, not a defect.
|
|
1394
|
+
*/
|
|
1395
|
+
checked = computed(() => checkSpec(this.spec(), this.loading() ? 'off' : this.validate()), ...(ngDevMode ? [{ debugName: "checked" }] : /* istanbul ignore next */ []));
|
|
1103
1396
|
constructor() {
|
|
1104
1397
|
const root = inject(JsonRenderRootContext);
|
|
1105
|
-
|
|
1398
|
+
// The fixed spec, not the input: a `visible` the check moved out of
|
|
1399
|
+
// `props` has to be the one the tree renders, or the fix is cosmetic.
|
|
1400
|
+
root.spec = computed(() => this.checked().spec, ...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
|
|
1106
1401
|
root.registry = this.registry;
|
|
1107
1402
|
root.loading = this.loading;
|
|
1108
1403
|
root.fallback = this.fallback;
|
|
@@ -1122,14 +1417,52 @@ class JsonRenderer {
|
|
|
1122
1417
|
this.stateStore = inject(JsonRenderStateService);
|
|
1123
1418
|
inject(JsonRenderValidationService);
|
|
1124
1419
|
this.actions = inject(JsonRenderActionsService);
|
|
1420
|
+
// Reports once per distinct check rather than once per render: `checked`
|
|
1421
|
+
// only recomputes when the spec, the mode or the loading flag change.
|
|
1422
|
+
effect(() => reportSpecCheck(this.checked(), this.validate()));
|
|
1125
1423
|
}
|
|
1126
1424
|
rootKey = computed(() => {
|
|
1127
|
-
const
|
|
1425
|
+
const checked = this.checked();
|
|
1426
|
+
// A spec whose errors survived the fixes does not render under `strict`.
|
|
1427
|
+
// Reporting it and drawing half of it anyway is the behaviour the mode
|
|
1428
|
+
// exists to refuse.
|
|
1429
|
+
if (this.validate() === 'strict' && checked.hasErrors)
|
|
1430
|
+
return null;
|
|
1431
|
+
const spec = checked.spec;
|
|
1128
1432
|
return spec?.root && spec.elements?.[spec.root] ? spec.root : null;
|
|
1129
1433
|
}, ...(ngDevMode ? [{ debugName: "rootKey" }] : /* istanbul ignore next */ []));
|
|
1130
1434
|
pendingConfirm = computed(() => this.actions.pendingConfirmation()?.action.confirm ?? null, ...(ngDevMode ? [{ debugName: "pendingConfirm" }] : /* istanbul ignore next */ []));
|
|
1435
|
+
/**
|
|
1436
|
+
* The app's own dialog, if it provided one. Read once: which component
|
|
1437
|
+
* answers a confirmation is an application decision, not something that
|
|
1438
|
+
* changes between two confirmations.
|
|
1439
|
+
*/
|
|
1440
|
+
customDialog = inject(JR_CONFIRM_DIALOG, {
|
|
1441
|
+
optional: true,
|
|
1442
|
+
});
|
|
1443
|
+
injector = inject(Injector);
|
|
1444
|
+
/**
|
|
1445
|
+
* A replacement dialog takes no inputs and emits no outputs — it injects
|
|
1446
|
+
* everything it needs, the way catalog components do. That keeps the seam
|
|
1447
|
+
* one token wide instead of a component contract the renderer would have to
|
|
1448
|
+
* bind to.
|
|
1449
|
+
*/
|
|
1450
|
+
confirmInjector = computed(() => {
|
|
1451
|
+
const config = this.pendingConfirm();
|
|
1452
|
+
if (!config)
|
|
1453
|
+
return null;
|
|
1454
|
+
const context = {
|
|
1455
|
+
config,
|
|
1456
|
+
confirm: () => this.actions.confirm(),
|
|
1457
|
+
cancel: () => this.actions.cancel(),
|
|
1458
|
+
};
|
|
1459
|
+
return Injector.create({
|
|
1460
|
+
providers: [{ provide: CONFIRM_CONTEXT, useValue: context }],
|
|
1461
|
+
parent: this.injector,
|
|
1462
|
+
});
|
|
1463
|
+
}, ...(ngDevMode ? [{ debugName: "confirmInjector" }] : /* istanbul ignore next */ []));
|
|
1131
1464
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderer, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1132
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JsonRenderer, isStandalone: true, selector: "json-render", inputs: { spec: { classPropertyName: "spec", publicName: "spec", isSignal: true, isRequired: true, transformFunction: null }, registry: { classPropertyName: "registry", publicName: "registry", isSignal: true, isRequired: true, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, fallback: { classPropertyName: "fallback", publicName: "fallback", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, store: { classPropertyName: "store", publicName: "store", isSignal: true, isRequired: false, transformFunction: null }, handlers: { classPropertyName: "handlers", publicName: "handlers", isSignal: true, isRequired: false, transformFunction: null }, onAction: { classPropertyName: "onAction", publicName: "onAction", isSignal: true, isRequired: false, transformFunction: null }, navigate: { classPropertyName: "navigate", publicName: "navigate", isSignal: true, isRequired: false, transformFunction: null }, validationFunctions: { classPropertyName: "validationFunctions", publicName: "validationFunctions", isSignal: true, isRequired: false, transformFunction: null }, functions: { classPropertyName: "functions", publicName: "functions", isSignal: true, isRequired: false, transformFunction: null }, directives: { classPropertyName: "directives", publicName: "directives", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { stateChange: "stateChange" }, providers: [
|
|
1465
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JsonRenderer, isStandalone: true, selector: "json-render", inputs: { spec: { classPropertyName: "spec", publicName: "spec", isSignal: true, isRequired: true, transformFunction: null }, registry: { classPropertyName: "registry", publicName: "registry", isSignal: true, isRequired: true, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, fallback: { classPropertyName: "fallback", publicName: "fallback", isSignal: true, isRequired: false, transformFunction: null }, validate: { classPropertyName: "validate", publicName: "validate", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, store: { classPropertyName: "store", publicName: "store", isSignal: true, isRequired: false, transformFunction: null }, handlers: { classPropertyName: "handlers", publicName: "handlers", isSignal: true, isRequired: false, transformFunction: null }, onAction: { classPropertyName: "onAction", publicName: "onAction", isSignal: true, isRequired: false, transformFunction: null }, navigate: { classPropertyName: "navigate", publicName: "navigate", isSignal: true, isRequired: false, transformFunction: null }, validationFunctions: { classPropertyName: "validationFunctions", publicName: "validationFunctions", isSignal: true, isRequired: false, transformFunction: null }, functions: { classPropertyName: "functions", publicName: "functions", isSignal: true, isRequired: false, transformFunction: null }, directives: { classPropertyName: "directives", publicName: "directives", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { stateChange: "stateChange" }, providers: [
|
|
1133
1466
|
JsonRenderRootContext,
|
|
1134
1467
|
JsonRenderStateService,
|
|
1135
1468
|
JsonRenderValidationService,
|
|
@@ -1139,17 +1472,23 @@ class JsonRenderer {
|
|
|
1139
1472
|
<jr-element [elementKey]="key" />
|
|
1140
1473
|
}
|
|
1141
1474
|
@if (pendingConfirm(); as confirm) {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1475
|
+
@if (customDialog; as dialog) {
|
|
1476
|
+
<ng-container
|
|
1477
|
+
*ngComponentOutlet="dialog; injector: confirmInjector() ?? undefined"
|
|
1478
|
+
/>
|
|
1479
|
+
} @else {
|
|
1480
|
+
<jr-confirm-dialog
|
|
1481
|
+
[config]="confirm"
|
|
1482
|
+
(confirmed)="actions.confirm()"
|
|
1483
|
+
(cancelled)="actions.cancel()"
|
|
1484
|
+
/>
|
|
1485
|
+
}
|
|
1147
1486
|
}
|
|
1148
|
-
`, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "component", type: JrElement, selector: "jr-element", inputs: ["elementKey"] }, { kind: "component", type: JrConfirmDialog, selector: "jr-confirm-dialog", inputs: ["config"], outputs: ["confirmed", "cancelled"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1487
|
+
`, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "component", type: JrElement, selector: "jr-element", inputs: ["elementKey"] }, { kind: "component", type: JrConfirmDialog, selector: "jr-confirm-dialog", inputs: ["config"], outputs: ["confirmed", "cancelled"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1149
1488
|
}
|
|
1150
1489
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderer, decorators: [{
|
|
1151
1490
|
type: Component,
|
|
1152
|
-
args: [{ selector: 'json-render', changeDetection: ChangeDetectionStrategy.OnPush, imports: [JrElement, JrConfirmDialog], providers: [
|
|
1491
|
+
args: [{ selector: 'json-render', changeDetection: ChangeDetectionStrategy.OnPush, imports: [JrElement, JrConfirmDialog, NgComponentOutlet], providers: [
|
|
1153
1492
|
JsonRenderRootContext,
|
|
1154
1493
|
JsonRenderStateService,
|
|
1155
1494
|
JsonRenderValidationService,
|
|
@@ -1159,14 +1498,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1159
1498
|
<jr-element [elementKey]="key" />
|
|
1160
1499
|
}
|
|
1161
1500
|
@if (pendingConfirm(); as confirm) {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1501
|
+
@if (customDialog; as dialog) {
|
|
1502
|
+
<ng-container
|
|
1503
|
+
*ngComponentOutlet="dialog; injector: confirmInjector() ?? undefined"
|
|
1504
|
+
/>
|
|
1505
|
+
} @else {
|
|
1506
|
+
<jr-confirm-dialog
|
|
1507
|
+
[config]="confirm"
|
|
1508
|
+
(confirmed)="actions.confirm()"
|
|
1509
|
+
(cancelled)="actions.cancel()"
|
|
1510
|
+
/>
|
|
1511
|
+
}
|
|
1167
1512
|
}
|
|
1168
1513
|
`, styles: [":host{display:contents}\n"] }]
|
|
1169
|
-
}], ctorParameters: () => [], propDecorators: { spec: [{ type: i0.Input, args: [{ isSignal: true, alias: "spec", required: true }] }], registry: [{ type: i0.Input, args: [{ isSignal: true, alias: "registry", required: true }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], fallback: [{ type: i0.Input, args: [{ isSignal: true, alias: "fallback", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], store: [{ type: i0.Input, args: [{ isSignal: true, alias: "store", required: false }] }], handlers: [{ type: i0.Input, args: [{ isSignal: true, alias: "handlers", required: false }] }], onAction: [{ type: i0.Input, args: [{ isSignal: true, alias: "onAction", required: false }] }], navigate: [{ type: i0.Input, args: [{ isSignal: true, alias: "navigate", required: false }] }], validationFunctions: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationFunctions", required: false }] }], functions: [{ type: i0.Input, args: [{ isSignal: true, alias: "functions", required: false }] }], directives: [{ type: i0.Input, args: [{ isSignal: true, alias: "directives", required: false }] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }] } });
|
|
1514
|
+
}], ctorParameters: () => [], propDecorators: { spec: [{ type: i0.Input, args: [{ isSignal: true, alias: "spec", required: true }] }], registry: [{ type: i0.Input, args: [{ isSignal: true, alias: "registry", required: true }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], fallback: [{ type: i0.Input, args: [{ isSignal: true, alias: "fallback", required: false }] }], validate: [{ type: i0.Input, args: [{ isSignal: true, alias: "validate", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], store: [{ type: i0.Input, args: [{ isSignal: true, alias: "store", required: false }] }], handlers: [{ type: i0.Input, args: [{ isSignal: true, alias: "handlers", required: false }] }], onAction: [{ type: i0.Input, args: [{ isSignal: true, alias: "onAction", required: false }] }], navigate: [{ type: i0.Input, args: [{ isSignal: true, alias: "navigate", required: false }] }], validationFunctions: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationFunctions", required: false }] }], functions: [{ type: i0.Input, args: [{ isSignal: true, alias: "functions", required: false }] }], directives: [{ type: i0.Input, args: [{ isSignal: true, alias: "directives", required: false }] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }] } });
|
|
1170
1515
|
|
|
1171
1516
|
/**
|
|
1172
1517
|
* Provides the repeat scope (item, index, absolute base path) to the elements
|
|
@@ -1409,33 +1754,6 @@ function createStoreSetState(store) {
|
|
|
1409
1754
|
};
|
|
1410
1755
|
}
|
|
1411
1756
|
|
|
1412
|
-
/**
|
|
1413
|
-
* Parse a single JSON line (patch or metadata).
|
|
1414
|
-
*/
|
|
1415
|
-
function parseLine(line) {
|
|
1416
|
-
try {
|
|
1417
|
-
const trimmed = line.trim();
|
|
1418
|
-
if (!trimmed || trimmed.startsWith('//')) {
|
|
1419
|
-
return null;
|
|
1420
|
-
}
|
|
1421
|
-
const parsed = JSON.parse(trimmed);
|
|
1422
|
-
// Check for usage metadata
|
|
1423
|
-
if (parsed.__meta === 'usage') {
|
|
1424
|
-
return {
|
|
1425
|
-
type: 'usage',
|
|
1426
|
-
usage: {
|
|
1427
|
-
promptTokens: parsed.promptTokens ?? 0,
|
|
1428
|
-
completionTokens: parsed.completionTokens ?? 0,
|
|
1429
|
-
totalTokens: parsed.totalTokens ?? 0,
|
|
1430
|
-
},
|
|
1431
|
-
};
|
|
1432
|
-
}
|
|
1433
|
-
return { type: 'patch', patch: parsed };
|
|
1434
|
-
}
|
|
1435
|
-
catch {
|
|
1436
|
-
return null;
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
1757
|
/**
|
|
1440
1758
|
* Split a JSON Pointer into its segments, undoing RFC 6901's escapes.
|
|
1441
1759
|
*
|
|
@@ -1452,14 +1770,15 @@ function parseJsonPointer(path) {
|
|
|
1452
1770
|
* Replace every node `path` descends through with a shallow copy of itself.
|
|
1453
1771
|
*
|
|
1454
1772
|
* `root` must already be private to the caller. Afterwards a mutating write
|
|
1455
|
-
* along that same path — `setByPath`, `removeByPath` — only ever
|
|
1456
|
-
* nodes nobody else is holding, while everything off the path stays
|
|
1457
|
-
* That keeps a patch proportional to the depth of its path rather than
|
|
1458
|
-
* size of the spec, which matters when a stream applies hundreds of
|
|
1773
|
+
* along that same path — `setByPath`, `addByPath`, `removeByPath` — only ever
|
|
1774
|
+
* touches nodes nobody else is holding, while everything off the path stays
|
|
1775
|
+
* shared. That keeps a patch proportional to the depth of its path rather than
|
|
1776
|
+
* to the size of the spec, which matters when a stream applies hundreds of
|
|
1777
|
+
* them.
|
|
1459
1778
|
*
|
|
1460
|
-
* Descent stops at anything that is not an object:
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1779
|
+
* Descent stops at anything that is not an object: the writers overwrite such
|
|
1780
|
+
* a node outright, and one that does not exist yet is created fresh, so in
|
|
1781
|
+
* neither case is there anything of the caller's left to protect.
|
|
1463
1782
|
*/
|
|
1464
1783
|
function copyAlongPath(root, path) {
|
|
1465
1784
|
const segments = parseJsonPointer(path);
|
|
@@ -1478,9 +1797,10 @@ function copyAlongPath(root, path) {
|
|
|
1478
1797
|
}
|
|
1479
1798
|
}
|
|
1480
1799
|
/**
|
|
1481
|
-
*
|
|
1800
|
+
* Write a value at a spec path, copying every node on the way so the caller's
|
|
1801
|
+
* previous spec keeps its own contents.
|
|
1482
1802
|
*/
|
|
1483
|
-
function
|
|
1803
|
+
function writeSpecValue(newSpec, path, value, write) {
|
|
1484
1804
|
if (path === '/root') {
|
|
1485
1805
|
newSpec.root = value;
|
|
1486
1806
|
return;
|
|
@@ -1495,7 +1815,7 @@ function setSpecValue(newSpec, path, value) {
|
|
|
1495
1815
|
const statePath = path.slice('/state'.length); // e.g. "/posts"
|
|
1496
1816
|
const state = newSpec.state;
|
|
1497
1817
|
copyAlongPath(state, statePath);
|
|
1498
|
-
|
|
1818
|
+
write(state, statePath, value);
|
|
1499
1819
|
return;
|
|
1500
1820
|
}
|
|
1501
1821
|
if (path.startsWith('/elements/')) {
|
|
@@ -1505,22 +1825,32 @@ function setSpecValue(newSpec, path, value) {
|
|
|
1505
1825
|
return;
|
|
1506
1826
|
if (pathParts.length === 1) {
|
|
1507
1827
|
newSpec.elements[elementKey] = value;
|
|
1828
|
+
return;
|
|
1508
1829
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
}
|
|
1830
|
+
// A prop path whose element is not there is dropped rather than
|
|
1831
|
+
// auto-created: the generic writers would happily conjure an element with
|
|
1832
|
+
// props and no `type`, which renders as nothing and warns about an unknown
|
|
1833
|
+
// component. A malformed patch should lose its own write, not add a broken
|
|
1834
|
+
// element to the tree.
|
|
1835
|
+
const element = newSpec.elements[elementKey];
|
|
1836
|
+
if (!element)
|
|
1837
|
+
return;
|
|
1838
|
+
const propPath = '/' + pathParts.slice(1).join('/');
|
|
1839
|
+
const newElement = { ...element };
|
|
1840
|
+
copyAlongPath(newElement, propPath);
|
|
1841
|
+
write(newElement, propPath, value);
|
|
1842
|
+
newSpec.elements[elementKey] = newElement;
|
|
1843
|
+
return;
|
|
1519
1844
|
}
|
|
1845
|
+
// Any other top-level path is applied verbatim. The spec grammar is
|
|
1846
|
+
// upstream's and grows without this renderer: dropping what we do not
|
|
1847
|
+
// recognise would silently discard a field a newer `@json-render/core`
|
|
1848
|
+
// understands.
|
|
1849
|
+
const root = newSpec;
|
|
1850
|
+
copyAlongPath(root, path);
|
|
1851
|
+
write(root, path, value);
|
|
1520
1852
|
}
|
|
1521
|
-
/**
|
|
1522
|
-
* Remove a value at a spec path.
|
|
1523
|
-
*/
|
|
1853
|
+
/** Remove the value at a spec path, with the same copy-on-write discipline. */
|
|
1524
1854
|
function removeSpecValue(newSpec, path) {
|
|
1525
1855
|
if (path === '/state') {
|
|
1526
1856
|
delete newSpec.state;
|
|
@@ -1539,39 +1869,38 @@ function removeSpecValue(newSpec, path) {
|
|
|
1539
1869
|
if (!elementKey)
|
|
1540
1870
|
return;
|
|
1541
1871
|
if (pathParts.length === 1) {
|
|
1542
|
-
const { [elementKey]:
|
|
1872
|
+
const { [elementKey]: _removed, ...rest } = newSpec.elements;
|
|
1543
1873
|
newSpec.elements = rest;
|
|
1874
|
+
return;
|
|
1544
1875
|
}
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
/**
|
|
1558
|
-
* Get a value at a spec path.
|
|
1559
|
-
*/
|
|
1560
|
-
function getSpecValue(spec, path) {
|
|
1561
|
-
if (path === '/root')
|
|
1562
|
-
return spec.root;
|
|
1563
|
-
if (path === '/state')
|
|
1564
|
-
return spec.state;
|
|
1565
|
-
if (path.startsWith('/state/') && spec.state) {
|
|
1566
|
-
const statePath = path.slice('/state'.length);
|
|
1567
|
-
return getByPath(spec.state, statePath);
|
|
1876
|
+
const element = newSpec.elements[elementKey];
|
|
1877
|
+
if (!element)
|
|
1878
|
+
return;
|
|
1879
|
+
const propPath = '/' + pathParts.slice(1).join('/');
|
|
1880
|
+
const newElement = { ...element };
|
|
1881
|
+
copyAlongPath(newElement, propPath);
|
|
1882
|
+
removeByPath(newElement, propPath);
|
|
1883
|
+
newSpec.elements[elementKey] = newElement;
|
|
1884
|
+
return;
|
|
1568
1885
|
}
|
|
1569
|
-
|
|
1886
|
+
const root = newSpec;
|
|
1887
|
+
copyAlongPath(root, path);
|
|
1888
|
+
removeByPath(root, path);
|
|
1570
1889
|
}
|
|
1571
1890
|
/**
|
|
1572
|
-
* Apply an RFC 6902 JSON patch to
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
1891
|
+
* Apply an RFC 6902 JSON patch to a spec, returning a new spec object and
|
|
1892
|
+
* sharing every subtree the patch did not touch.
|
|
1893
|
+
*
|
|
1894
|
+
* This is the only patch engine in the package: `injectUIStream`,
|
|
1895
|
+
* `injectChatUI` and `buildSpecFromParts` all route through it, so the same
|
|
1896
|
+
* stream produces the same spec whichever one an app reached for. The write
|
|
1897
|
+
* primitives come from `@json-render/core`, so `add` splices into an array
|
|
1898
|
+
* where `replace` overwrites, exactly as the other renderers do.
|
|
1899
|
+
*
|
|
1900
|
+
* One deliberate deviation from RFC 6902: a failing `test` op is a no-op
|
|
1901
|
+
* rather than an abort. These patches come off a model's output, where the
|
|
1902
|
+
* established behaviour — see the line parser — is to drop what does not make
|
|
1903
|
+
* sense and keep rendering rather than kill a generation mid-flight.
|
|
1575
1904
|
*/
|
|
1576
1905
|
function applyPatch(spec, patch) {
|
|
1577
1906
|
const newSpec = {
|
|
@@ -1580,9 +1909,12 @@ function applyPatch(spec, patch) {
|
|
|
1580
1909
|
...(spec.state ? { state: { ...spec.state } } : {}),
|
|
1581
1910
|
};
|
|
1582
1911
|
switch (patch.op) {
|
|
1583
|
-
case 'add':
|
|
1912
|
+
case 'add': {
|
|
1913
|
+
writeSpecValue(newSpec, patch.path, patch.value, addByPath);
|
|
1914
|
+
break;
|
|
1915
|
+
}
|
|
1584
1916
|
case 'replace': {
|
|
1585
|
-
|
|
1917
|
+
writeSpecValue(newSpec, patch.path, patch.value, setByPath);
|
|
1586
1918
|
break;
|
|
1587
1919
|
}
|
|
1588
1920
|
case 'remove': {
|
|
@@ -1592,170 +1924,29 @@ function applyPatch(spec, patch) {
|
|
|
1592
1924
|
case 'move': {
|
|
1593
1925
|
if (!patch.from)
|
|
1594
1926
|
break;
|
|
1595
|
-
const moveValue =
|
|
1927
|
+
const moveValue = getByPath(newSpec, patch.from);
|
|
1596
1928
|
removeSpecValue(newSpec, patch.from);
|
|
1597
|
-
|
|
1929
|
+
writeSpecValue(newSpec, patch.path, moveValue, addByPath);
|
|
1598
1930
|
break;
|
|
1599
1931
|
}
|
|
1600
1932
|
case 'copy': {
|
|
1601
1933
|
if (!patch.from)
|
|
1602
1934
|
break;
|
|
1603
|
-
|
|
1604
|
-
|
|
1935
|
+
// RFC 6902 copies the *value*. Handing over the live reference instead
|
|
1936
|
+
// aliases source and target, and a `path` that descends into its own
|
|
1937
|
+
// `from` then makes the spec self-referential — which the next request
|
|
1938
|
+
// discovers when it tries to serialise it into the body.
|
|
1939
|
+
const copyValue = structuredClone(getByPath(newSpec, patch.from));
|
|
1940
|
+
writeSpecValue(newSpec, patch.path, copyValue, addByPath);
|
|
1605
1941
|
break;
|
|
1606
1942
|
}
|
|
1607
1943
|
case 'test': {
|
|
1608
|
-
// test is a no-op for rendering purposes (validation only)
|
|
1609
1944
|
break;
|
|
1610
1945
|
}
|
|
1611
1946
|
}
|
|
1612
1947
|
return newSpec;
|
|
1613
1948
|
}
|
|
1614
|
-
|
|
1615
|
-
* Streaming UI generation. POSTs `{ prompt, context, currentSpec }` to the
|
|
1616
|
-
* endpoint and progressively applies the returned JSONL patch stream to the
|
|
1617
|
-
* `spec` signal, so partial UIs render as they arrive.
|
|
1618
|
-
*
|
|
1619
|
-
* Must be called in an injection context (aborts in-flight requests on
|
|
1620
|
-
* destroy).
|
|
1621
|
-
*
|
|
1622
|
-
* @example
|
|
1623
|
-
* ```ts
|
|
1624
|
-
* export class GeneratePage {
|
|
1625
|
-
* readonly ui = injectUIStream({ api: '/api/generate' });
|
|
1626
|
-
* }
|
|
1627
|
-
* // template:
|
|
1628
|
-
* // <json-render [spec]="ui.spec()" [registry]="registry" [loading]="ui.isStreaming()" />
|
|
1629
|
-
* ```
|
|
1630
|
-
*/
|
|
1631
|
-
function injectUIStream(options) {
|
|
1632
|
-
const spec = signal(null, ...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
|
|
1633
|
-
const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
|
|
1634
|
-
const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1635
|
-
const usage = signal(null, ...(ngDevMode ? [{ debugName: "usage" }] : /* istanbul ignore next */ []));
|
|
1636
|
-
const rawLines = signal([], ...(ngDevMode ? [{ debugName: "rawLines" }] : /* istanbul ignore next */ []));
|
|
1637
|
-
let abortController = null;
|
|
1638
|
-
inject(DestroyRef).onDestroy(() => {
|
|
1639
|
-
abortController?.abort();
|
|
1640
|
-
});
|
|
1641
|
-
const clear = () => {
|
|
1642
|
-
spec.set(null);
|
|
1643
|
-
error.set(null);
|
|
1644
|
-
};
|
|
1645
|
-
const send = async (prompt, context) => {
|
|
1646
|
-
// Abort any existing request
|
|
1647
|
-
abortController?.abort();
|
|
1648
|
-
const controller = new AbortController();
|
|
1649
|
-
abortController = controller;
|
|
1650
|
-
// A superseded request must not write to the shared signals on its way
|
|
1651
|
-
// out: its `finally` would otherwise clear `isStreaming` while the request
|
|
1652
|
-
// that replaced it is still streaming.
|
|
1653
|
-
const isCurrent = () => abortController === controller;
|
|
1654
|
-
isStreaming.set(true);
|
|
1655
|
-
error.set(null);
|
|
1656
|
-
usage.set(null);
|
|
1657
|
-
rawLines.set([]);
|
|
1658
|
-
// Start with previous spec if provided, otherwise empty spec
|
|
1659
|
-
const previousSpec = context?.['previousSpec'];
|
|
1660
|
-
let currentSpec = previousSpec && previousSpec.root
|
|
1661
|
-
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
|
1662
|
-
: { root: '', elements: {} };
|
|
1663
|
-
spec.set(currentSpec);
|
|
1664
|
-
const handleLine = (trimmed) => {
|
|
1665
|
-
const result = parseLine(trimmed);
|
|
1666
|
-
if (!result)
|
|
1667
|
-
return;
|
|
1668
|
-
if (result.type === 'usage') {
|
|
1669
|
-
usage.set(result.usage);
|
|
1670
|
-
}
|
|
1671
|
-
else {
|
|
1672
|
-
rawLines.update((prev) => [...prev, trimmed]);
|
|
1673
|
-
currentSpec = applyPatch(currentSpec, result.patch);
|
|
1674
|
-
spec.set(currentSpec);
|
|
1675
|
-
}
|
|
1676
|
-
};
|
|
1677
|
-
// Bound, because a bare `globalThis.fetch` called as a plain function is
|
|
1678
|
-
// an illegal invocation in the browser.
|
|
1679
|
-
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
1680
|
-
try {
|
|
1681
|
-
const response = await doFetch(options.api, {
|
|
1682
|
-
method: 'POST',
|
|
1683
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1684
|
-
body: JSON.stringify({
|
|
1685
|
-
prompt,
|
|
1686
|
-
context,
|
|
1687
|
-
currentSpec,
|
|
1688
|
-
}),
|
|
1689
|
-
signal: controller.signal,
|
|
1690
|
-
});
|
|
1691
|
-
if (!response.ok) {
|
|
1692
|
-
// Try to parse JSON error response for better error messages
|
|
1693
|
-
let errorMessage = `HTTP error: ${response.status}`;
|
|
1694
|
-
try {
|
|
1695
|
-
const errorData = await response.json();
|
|
1696
|
-
if (errorData.message) {
|
|
1697
|
-
errorMessage = errorData.message;
|
|
1698
|
-
}
|
|
1699
|
-
else if (errorData.error) {
|
|
1700
|
-
errorMessage = errorData.error;
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
catch {
|
|
1704
|
-
// Ignore JSON parsing errors, use default message
|
|
1705
|
-
}
|
|
1706
|
-
throw new Error(errorMessage);
|
|
1707
|
-
}
|
|
1708
|
-
const reader = response.body?.getReader();
|
|
1709
|
-
if (!reader) {
|
|
1710
|
-
throw new Error('No response body');
|
|
1711
|
-
}
|
|
1712
|
-
const decoder = new TextDecoder();
|
|
1713
|
-
let buffer = '';
|
|
1714
|
-
while (true) {
|
|
1715
|
-
const { done, value } = await reader.read();
|
|
1716
|
-
if (done)
|
|
1717
|
-
break;
|
|
1718
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1719
|
-
// Process complete lines
|
|
1720
|
-
const lines = buffer.split('\n');
|
|
1721
|
-
buffer = lines.pop() ?? '';
|
|
1722
|
-
for (const line of lines) {
|
|
1723
|
-
const trimmed = line.trim();
|
|
1724
|
-
if (!trimmed)
|
|
1725
|
-
continue;
|
|
1726
|
-
handleLine(trimmed);
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
// Process any remaining buffer
|
|
1730
|
-
if (buffer.trim()) {
|
|
1731
|
-
handleLine(buffer.trim());
|
|
1732
|
-
}
|
|
1733
|
-
options.onComplete?.(currentSpec);
|
|
1734
|
-
}
|
|
1735
|
-
catch (err) {
|
|
1736
|
-
if (err.name === 'AbortError' || !isCurrent()) {
|
|
1737
|
-
return;
|
|
1738
|
-
}
|
|
1739
|
-
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
|
1740
|
-
error.set(resolvedError);
|
|
1741
|
-
options.onError?.(resolvedError);
|
|
1742
|
-
}
|
|
1743
|
-
finally {
|
|
1744
|
-
if (isCurrent()) {
|
|
1745
|
-
isStreaming.set(false);
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
|
-
};
|
|
1749
|
-
return {
|
|
1750
|
-
spec: spec.asReadonly(),
|
|
1751
|
-
isStreaming: isStreaming.asReadonly(),
|
|
1752
|
-
error: error.asReadonly(),
|
|
1753
|
-
usage: usage.asReadonly(),
|
|
1754
|
-
rawLines: rawLines.asReadonly(),
|
|
1755
|
-
send,
|
|
1756
|
-
clear,
|
|
1757
|
-
};
|
|
1758
|
-
}
|
|
1949
|
+
|
|
1759
1950
|
/**
|
|
1760
1951
|
* Convert a flat element list to a Spec.
|
|
1761
1952
|
* Input elements use key/parentKey to establish identity and relationships.
|
|
@@ -1814,7 +2005,7 @@ function isSpecDataPart(data) {
|
|
|
1814
2005
|
* parts are present.
|
|
1815
2006
|
*/
|
|
1816
2007
|
function buildSpecFromParts(parts) {
|
|
1817
|
-
|
|
2008
|
+
let spec = { root: '', elements: {} };
|
|
1818
2009
|
let hasSpec = false;
|
|
1819
2010
|
for (const part of parts) {
|
|
1820
2011
|
if (part.type === SPEC_DATA_PART_TYPE) {
|
|
@@ -1823,16 +2014,15 @@ function buildSpecFromParts(parts) {
|
|
|
1823
2014
|
const payload = part.data;
|
|
1824
2015
|
if (payload.type === 'patch') {
|
|
1825
2016
|
hasSpec = true;
|
|
1826
|
-
|
|
2017
|
+
spec = applyPatch(spec, payload.patch);
|
|
1827
2018
|
}
|
|
1828
2019
|
else if (payload.type === 'flat') {
|
|
1829
2020
|
hasSpec = true;
|
|
1830
|
-
|
|
2021
|
+
spec = { ...spec, ...payload.spec };
|
|
1831
2022
|
}
|
|
1832
2023
|
else if (payload.type === 'nested') {
|
|
1833
2024
|
hasSpec = true;
|
|
1834
|
-
|
|
1835
|
-
Object.assign(spec, flat);
|
|
2025
|
+
spec = { ...spec, ...nestedToFlat(payload.spec) };
|
|
1836
2026
|
}
|
|
1837
2027
|
}
|
|
1838
2028
|
}
|
|
@@ -1876,6 +2066,278 @@ function jsonRenderMessage(parts) {
|
|
|
1876
2066
|
}),
|
|
1877
2067
|
};
|
|
1878
2068
|
}
|
|
2069
|
+
|
|
2070
|
+
function createStreamSession() {
|
|
2071
|
+
let current = null;
|
|
2072
|
+
return {
|
|
2073
|
+
begin() {
|
|
2074
|
+
current?.abort();
|
|
2075
|
+
const controller = new AbortController();
|
|
2076
|
+
current = controller;
|
|
2077
|
+
const isCurrent = () => current === controller;
|
|
2078
|
+
return {
|
|
2079
|
+
signal: controller.signal,
|
|
2080
|
+
isCurrent,
|
|
2081
|
+
commit(write) {
|
|
2082
|
+
if (isCurrent())
|
|
2083
|
+
write();
|
|
2084
|
+
},
|
|
2085
|
+
};
|
|
2086
|
+
},
|
|
2087
|
+
cancel() {
|
|
2088
|
+
current?.abort();
|
|
2089
|
+
// Cleared, not just aborted: with no current controller the in-flight
|
|
2090
|
+
// request's gate closes too, so a stop() cannot be undone a moment later
|
|
2091
|
+
// by the request it just cancelled.
|
|
2092
|
+
current = null;
|
|
2093
|
+
},
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
/** True for the rejection `fetch` and a stream reader raise on abort. */
|
|
2097
|
+
function isAbortError(error) {
|
|
2098
|
+
return error?.name === 'AbortError';
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Buffer decoded text and hand back whole lines, dropping blank ones. What is
|
|
2102
|
+
* left when the stream ends is flushed as a final line, since a model's last
|
|
2103
|
+
* line often arrives without its newline.
|
|
2104
|
+
*/
|
|
2105
|
+
function createLineConsumer(onLine) {
|
|
2106
|
+
let buffer = '';
|
|
2107
|
+
return {
|
|
2108
|
+
push(text) {
|
|
2109
|
+
buffer += text;
|
|
2110
|
+
const lines = buffer.split('\n');
|
|
2111
|
+
buffer = lines.pop() ?? '';
|
|
2112
|
+
for (const line of lines) {
|
|
2113
|
+
const trimmed = line.trim();
|
|
2114
|
+
if (trimmed)
|
|
2115
|
+
onLine(trimmed);
|
|
2116
|
+
}
|
|
2117
|
+
},
|
|
2118
|
+
flush() {
|
|
2119
|
+
const trimmed = buffer.trim();
|
|
2120
|
+
buffer = '';
|
|
2121
|
+
if (trimmed)
|
|
2122
|
+
onLine(trimmed);
|
|
2123
|
+
},
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Mine an error response for something better to show than its status code.
|
|
2128
|
+
*/
|
|
2129
|
+
async function readErrorMessage(response) {
|
|
2130
|
+
try {
|
|
2131
|
+
const body = await response.json();
|
|
2132
|
+
if (body.message)
|
|
2133
|
+
return String(body.message);
|
|
2134
|
+
if (body.error)
|
|
2135
|
+
return String(body.error);
|
|
2136
|
+
}
|
|
2137
|
+
catch {
|
|
2138
|
+
// A non-JSON error body is ordinary; the status line still says enough.
|
|
2139
|
+
}
|
|
2140
|
+
return `HTTP error: ${response.status}`;
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* POST `body` and feed the streamed response to `consumer` until it ends.
|
|
2144
|
+
*
|
|
2145
|
+
* Both hooks share this so the request lifecycle — error mining, the reader
|
|
2146
|
+
* loop, the decoder's final flush — has one implementation to get right
|
|
2147
|
+
* rather than one per hook that has to be fixed twice.
|
|
2148
|
+
*/
|
|
2149
|
+
async function streamRequest(request, consumer) {
|
|
2150
|
+
// Bound, because a bare `globalThis.fetch` called as a plain function is
|
|
2151
|
+
// an illegal invocation in the browser.
|
|
2152
|
+
const doFetch = request.fetch ?? globalThis.fetch.bind(globalThis);
|
|
2153
|
+
const response = await doFetch(request.api, {
|
|
2154
|
+
method: 'POST',
|
|
2155
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2156
|
+
body: JSON.stringify(request.body),
|
|
2157
|
+
signal: request.signal,
|
|
2158
|
+
});
|
|
2159
|
+
if (!response.ok) {
|
|
2160
|
+
throw new Error(await readErrorMessage(response));
|
|
2161
|
+
}
|
|
2162
|
+
const reader = response.body?.getReader();
|
|
2163
|
+
if (!reader) {
|
|
2164
|
+
throw new Error('No response body');
|
|
2165
|
+
}
|
|
2166
|
+
const decoder = new TextDecoder();
|
|
2167
|
+
while (true) {
|
|
2168
|
+
const { done, value } = await reader.read();
|
|
2169
|
+
if (done)
|
|
2170
|
+
break;
|
|
2171
|
+
consumer.push(decoder.decode(value, { stream: true }));
|
|
2172
|
+
}
|
|
2173
|
+
// Without the final, non-streaming decode a multi-byte character split
|
|
2174
|
+
// across the last chunk is dropped instead of surfacing.
|
|
2175
|
+
const tail = decoder.decode();
|
|
2176
|
+
if (tail)
|
|
2177
|
+
consumer.push(tail);
|
|
2178
|
+
consumer.flush();
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
/**
|
|
2182
|
+
* Parse a single JSON line (patch or metadata). Anything that is not one of
|
|
2183
|
+
* the two is dropped: half-written lines and stray prose are ordinary in a
|
|
2184
|
+
* model's output, and aborting the generation over one would lose the UI
|
|
2185
|
+
* built so far.
|
|
2186
|
+
*/
|
|
2187
|
+
function parseLine(line) {
|
|
2188
|
+
try {
|
|
2189
|
+
const trimmed = line.trim();
|
|
2190
|
+
if (!trimmed || trimmed.startsWith('//')) {
|
|
2191
|
+
return null;
|
|
2192
|
+
}
|
|
2193
|
+
const parsed = JSON.parse(trimmed);
|
|
2194
|
+
// Check for usage metadata
|
|
2195
|
+
if (parsed.__meta === 'usage') {
|
|
2196
|
+
return {
|
|
2197
|
+
type: 'usage',
|
|
2198
|
+
usage: {
|
|
2199
|
+
promptTokens: parsed.promptTokens ?? 0,
|
|
2200
|
+
completionTokens: parsed.completionTokens ?? 0,
|
|
2201
|
+
totalTokens: parsed.totalTokens ?? 0,
|
|
2202
|
+
},
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
// Parseable JSON is not yet a patch: without this, a stray object would be
|
|
2206
|
+
// recorded as a raw line and handed to the engine as an opless patch.
|
|
2207
|
+
if (typeof parsed?.op !== 'string' || typeof parsed?.path !== 'string') {
|
|
2208
|
+
return null;
|
|
2209
|
+
}
|
|
2210
|
+
return { type: 'patch', patch: parsed };
|
|
2211
|
+
}
|
|
2212
|
+
catch {
|
|
2213
|
+
return null;
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Streaming UI generation. POSTs `{ prompt, context, currentSpec }` to the
|
|
2218
|
+
* endpoint and progressively applies the returned JSONL patch stream to the
|
|
2219
|
+
* `spec` signal, so partial UIs render as they arrive.
|
|
2220
|
+
*
|
|
2221
|
+
* Must be called in an injection context (aborts in-flight requests on
|
|
2222
|
+
* destroy).
|
|
2223
|
+
*
|
|
2224
|
+
* @example
|
|
2225
|
+
* ```ts
|
|
2226
|
+
* export class GeneratePage {
|
|
2227
|
+
* readonly ui = injectUIStream({ api: '/api/generate' });
|
|
2228
|
+
* }
|
|
2229
|
+
* // template:
|
|
2230
|
+
* // <json-render [spec]="ui.spec()" [registry]="registry" [loading]="ui.isStreaming()" />
|
|
2231
|
+
* ```
|
|
2232
|
+
*/
|
|
2233
|
+
function injectUIStream(options) {
|
|
2234
|
+
const spec = signal(null, ...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
|
|
2235
|
+
const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
|
|
2236
|
+
const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
2237
|
+
const usage = signal(null, ...(ngDevMode ? [{ debugName: "usage" }] : /* istanbul ignore next */ []));
|
|
2238
|
+
const rawLines = signal([], ...(ngDevMode ? [{ debugName: "rawLines" }] : /* istanbul ignore next */ []));
|
|
2239
|
+
const issues = signal([], ...(ngDevMode ? [{ debugName: "issues" }] : /* istanbul ignore next */ []));
|
|
2240
|
+
const validate = options.validate ?? 'off';
|
|
2241
|
+
const session = createStreamSession();
|
|
2242
|
+
inject(DestroyRef).onDestroy(() => session.cancel());
|
|
2243
|
+
const stop = () => {
|
|
2244
|
+
session.cancel();
|
|
2245
|
+
isStreaming.set(false);
|
|
2246
|
+
};
|
|
2247
|
+
const clear = () => {
|
|
2248
|
+
// Aborting first is what makes this true: without it the in-flight
|
|
2249
|
+
// request puts its spec straight back on the next patch it applies.
|
|
2250
|
+
stop();
|
|
2251
|
+
spec.set(null);
|
|
2252
|
+
error.set(null);
|
|
2253
|
+
usage.set(null);
|
|
2254
|
+
rawLines.set([]);
|
|
2255
|
+
issues.set([]);
|
|
2256
|
+
};
|
|
2257
|
+
const send = async (prompt, sendOptions) => {
|
|
2258
|
+
const gate = session.begin();
|
|
2259
|
+
isStreaming.set(true);
|
|
2260
|
+
error.set(null);
|
|
2261
|
+
usage.set(null);
|
|
2262
|
+
rawLines.set([]);
|
|
2263
|
+
issues.set([]);
|
|
2264
|
+
const previousSpec = sendOptions?.previousSpec;
|
|
2265
|
+
let currentSpec = previousSpec && previousSpec.root
|
|
2266
|
+
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
|
2267
|
+
: { root: '', elements: {} };
|
|
2268
|
+
spec.set(currentSpec);
|
|
2269
|
+
const handleLine = (line) => {
|
|
2270
|
+
const parsed = parseLine(line);
|
|
2271
|
+
if (!parsed)
|
|
2272
|
+
return;
|
|
2273
|
+
gate.commit(() => {
|
|
2274
|
+
if (parsed.type === 'usage') {
|
|
2275
|
+
usage.set(parsed.usage);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
rawLines.update((prev) => [...prev, line]);
|
|
2279
|
+
currentSpec = applyPatch(currentSpec, parsed.patch);
|
|
2280
|
+
spec.set(currentSpec);
|
|
2281
|
+
});
|
|
2282
|
+
};
|
|
2283
|
+
try {
|
|
2284
|
+
await streamRequest({
|
|
2285
|
+
api: options.api,
|
|
2286
|
+
body: {
|
|
2287
|
+
prompt,
|
|
2288
|
+
context: sendOptions?.context,
|
|
2289
|
+
currentSpec,
|
|
2290
|
+
},
|
|
2291
|
+
fetch: options.fetch,
|
|
2292
|
+
signal: gate.signal,
|
|
2293
|
+
}, createLineConsumer(handleLine));
|
|
2294
|
+
gate.commit(() => {
|
|
2295
|
+
const check = checkSpec(currentSpec, validate);
|
|
2296
|
+
reportSpecCheck(check, validate);
|
|
2297
|
+
if (check.spec) {
|
|
2298
|
+
currentSpec = check.spec;
|
|
2299
|
+
spec.set(currentSpec);
|
|
2300
|
+
}
|
|
2301
|
+
issues.set(check.issues);
|
|
2302
|
+
// Under `strict` a broken generation is a failure, not a result: the
|
|
2303
|
+
// app's onComplete is where a spec usually gets persisted, and handing
|
|
2304
|
+
// it one that cannot render is the outcome this mode is asked to
|
|
2305
|
+
// prevent. The spec stays put so the app can show or discard it.
|
|
2306
|
+
if (validate === 'strict' && check.hasErrors) {
|
|
2307
|
+
const invalid = new Error(`Generated spec failed validation:\n${formatSpecIssues([...check.issues])}`);
|
|
2308
|
+
error.set(invalid);
|
|
2309
|
+
options.onError?.(invalid);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
options.onComplete?.(currentSpec);
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
catch (err) {
|
|
2316
|
+
if (isAbortError(err))
|
|
2317
|
+
return;
|
|
2318
|
+
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
|
2319
|
+
gate.commit(() => {
|
|
2320
|
+
error.set(resolvedError);
|
|
2321
|
+
options.onError?.(resolvedError);
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
finally {
|
|
2325
|
+
gate.commit(() => isStreaming.set(false));
|
|
2326
|
+
}
|
|
2327
|
+
};
|
|
2328
|
+
return {
|
|
2329
|
+
spec: spec.asReadonly(),
|
|
2330
|
+
isStreaming: isStreaming.asReadonly(),
|
|
2331
|
+
error: error.asReadonly(),
|
|
2332
|
+
usage: usage.asReadonly(),
|
|
2333
|
+
rawLines: rawLines.asReadonly(),
|
|
2334
|
+
issues: issues.asReadonly(),
|
|
2335
|
+
send,
|
|
2336
|
+
stop,
|
|
2337
|
+
clear,
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
|
|
1879
2341
|
let chatMessageIdCounter = 0;
|
|
1880
2342
|
function generateChatId() {
|
|
1881
2343
|
if (typeof crypto !== 'undefined' &&
|
|
@@ -1897,24 +2359,24 @@ function injectChatUI(options) {
|
|
|
1897
2359
|
const messages = signal([], ...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
|
|
1898
2360
|
const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
|
|
1899
2361
|
const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
2362
|
+
const issues = signal([], ...(ngDevMode ? [{ debugName: "issues" }] : /* istanbul ignore next */ []));
|
|
2363
|
+
const validate = options.validate ?? 'off';
|
|
2364
|
+
const session = createStreamSession();
|
|
2365
|
+
inject(DestroyRef).onDestroy(() => session.cancel());
|
|
2366
|
+
const stop = () => {
|
|
2367
|
+
session.cancel();
|
|
2368
|
+
isStreaming.set(false);
|
|
2369
|
+
};
|
|
1904
2370
|
const clear = () => {
|
|
2371
|
+
stop();
|
|
1905
2372
|
messages.set([]);
|
|
1906
2373
|
error.set(null);
|
|
2374
|
+
issues.set([]);
|
|
1907
2375
|
};
|
|
1908
2376
|
const send = async (text) => {
|
|
1909
2377
|
if (!text.trim())
|
|
1910
2378
|
return;
|
|
1911
|
-
|
|
1912
|
-
abortController?.abort();
|
|
1913
|
-
const controller = new AbortController();
|
|
1914
|
-
abortController = controller;
|
|
1915
|
-
// See injectUIStream: a superseded request must not clear `isStreaming`
|
|
1916
|
-
// out from under the request that replaced it.
|
|
1917
|
-
const isCurrent = () => abortController === controller;
|
|
2379
|
+
const gate = session.begin();
|
|
1918
2380
|
const userMessage = {
|
|
1919
2381
|
id: generateChatId(),
|
|
1920
2382
|
role: 'user',
|
|
@@ -1928,10 +2390,25 @@ function injectChatUI(options) {
|
|
|
1928
2390
|
text: '',
|
|
1929
2391
|
spec: null,
|
|
1930
2392
|
};
|
|
1931
|
-
|
|
2393
|
+
/**
|
|
2394
|
+
* Drop this turn's assistant bubble if it never got any text.
|
|
2395
|
+
*
|
|
2396
|
+
* Deliberately outside the gate: the edit is scoped to this request's own
|
|
2397
|
+
* message id, so it is safe even once a newer request has appended its
|
|
2398
|
+
* turn — and a superseded request is exactly when it is needed, since an
|
|
2399
|
+
* empty bubble left behind renders as a blank reply and is serialised
|
|
2400
|
+
* into every later turn's history.
|
|
2401
|
+
*/
|
|
2402
|
+
const dropEmptyPlaceholder = () => {
|
|
2403
|
+
messages.update((prev) => prev.filter((m) => m.id !== assistantId || m.text.length > 0));
|
|
2404
|
+
};
|
|
2405
|
+
// Built before the new turn is appended, so it carries the conversation so
|
|
2406
|
+
// far. Assistant turns with no text are left out: they say nothing to the
|
|
2407
|
+
// model, some providers reject empty content outright, and a turn being
|
|
2408
|
+
// superseded right now may still have its placeholder in the list.
|
|
1932
2409
|
const historyForApi = [
|
|
1933
2410
|
...messages()
|
|
1934
|
-
.filter((m) => m.
|
|
2411
|
+
.filter((m) => m.role !== 'assistant' || m.text.length > 0)
|
|
1935
2412
|
.map((m) => ({
|
|
1936
2413
|
role: m.role,
|
|
1937
2414
|
content: m.text,
|
|
@@ -1944,98 +2421,96 @@ function injectChatUI(options) {
|
|
|
1944
2421
|
error.set(null);
|
|
1945
2422
|
// Mutable state for accumulating the assistant response
|
|
1946
2423
|
let accumulatedText = '';
|
|
1947
|
-
|
|
2424
|
+
let currentSpec = { root: '', elements: {} };
|
|
1948
2425
|
let hasSpec = false;
|
|
1949
|
-
const
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2426
|
+
const parser = createMixedStreamParser({
|
|
2427
|
+
onPatch(patch) {
|
|
2428
|
+
hasSpec = true;
|
|
2429
|
+
// applyPatch returns a fresh spec that shares what the patch did not
|
|
2430
|
+
// touch, so the message can hold it directly — no snapshot copy.
|
|
2431
|
+
currentSpec = applyPatch(currentSpec, patch);
|
|
2432
|
+
const snapshot = currentSpec;
|
|
2433
|
+
gate.commit(() => messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, spec: snapshot } : m)));
|
|
2434
|
+
},
|
|
2435
|
+
onText(line) {
|
|
2436
|
+
accumulatedText += (accumulatedText ? '\n' : '') + line;
|
|
2437
|
+
const snapshot = accumulatedText;
|
|
2438
|
+
gate.commit(() => messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, text: snapshot } : m)));
|
|
2439
|
+
},
|
|
1953
2440
|
});
|
|
1954
|
-
// Bound, because a bare `globalThis.fetch` called as a plain function is
|
|
1955
|
-
// an illegal invocation in the browser.
|
|
1956
|
-
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
1957
2441
|
try {
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
signal:
|
|
1963
|
-
});
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
errorMessage = errorData.error;
|
|
1973
|
-
}
|
|
2442
|
+
await streamRequest({
|
|
2443
|
+
api: options.api,
|
|
2444
|
+
body: { messages: historyForApi },
|
|
2445
|
+
fetch: options.fetch,
|
|
2446
|
+
signal: gate.signal,
|
|
2447
|
+
}, parser);
|
|
2448
|
+
gate.commit(() => {
|
|
2449
|
+
// A reply that only talked has no spec to check, and reporting
|
|
2450
|
+
// "missing root" for a sentence would be nonsense.
|
|
2451
|
+
const check = checkSpec(hasSpec ? currentSpec : null, validate);
|
|
2452
|
+
if (hasSpec) {
|
|
2453
|
+
reportSpecCheck(check, validate);
|
|
2454
|
+
if (check.spec)
|
|
2455
|
+
currentSpec = check.spec;
|
|
1974
2456
|
}
|
|
1975
|
-
|
|
1976
|
-
|
|
2457
|
+
issues.set(check.issues);
|
|
2458
|
+
const finalMessage = {
|
|
2459
|
+
id: assistantId,
|
|
2460
|
+
role: 'assistant',
|
|
2461
|
+
text: accumulatedText,
|
|
2462
|
+
spec: hasSpec ? currentSpec : null,
|
|
2463
|
+
};
|
|
2464
|
+
// The reply keeps the fixed spec, so what renders in the bubble is
|
|
2465
|
+
// what was checked.
|
|
2466
|
+
messages.update((prev) => prev.map((m) => (m.id === assistantId ? finalMessage : m)));
|
|
2467
|
+
if (validate === 'strict' && check.hasErrors) {
|
|
2468
|
+
const invalid = new Error(`Generated spec failed validation:\n${formatSpecIssues([...check.issues])}`);
|
|
2469
|
+
error.set(invalid);
|
|
2470
|
+
options.onError?.(invalid);
|
|
2471
|
+
return;
|
|
1977
2472
|
}
|
|
1978
|
-
|
|
1979
|
-
}
|
|
1980
|
-
const reader = response.body?.getReader();
|
|
1981
|
-
if (!reader) {
|
|
1982
|
-
throw new Error('No response body');
|
|
1983
|
-
}
|
|
1984
|
-
const decoder = new TextDecoder();
|
|
1985
|
-
// Use createMixedStreamParser to classify lines
|
|
1986
|
-
const parser = createMixedStreamParser({
|
|
1987
|
-
onPatch(patch) {
|
|
1988
|
-
hasSpec = true;
|
|
1989
|
-
applySpecPatch(currentSpec, patch);
|
|
1990
|
-
const snapshot = snapshotSpec();
|
|
1991
|
-
messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, spec: snapshot } : m));
|
|
1992
|
-
},
|
|
1993
|
-
onText(line) {
|
|
1994
|
-
accumulatedText += (accumulatedText ? '\n' : '') + line;
|
|
1995
|
-
messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, text: accumulatedText } : m));
|
|
1996
|
-
},
|
|
2473
|
+
options.onComplete?.(finalMessage);
|
|
1997
2474
|
});
|
|
1998
|
-
while (true) {
|
|
1999
|
-
const { done, value } = await reader.read();
|
|
2000
|
-
if (done)
|
|
2001
|
-
break;
|
|
2002
|
-
parser.push(decoder.decode(value, { stream: true }));
|
|
2003
|
-
}
|
|
2004
|
-
parser.flush();
|
|
2005
|
-
// Build final message for onComplete callback
|
|
2006
|
-
const finalMessage = {
|
|
2007
|
-
id: assistantId,
|
|
2008
|
-
role: 'assistant',
|
|
2009
|
-
text: accumulatedText,
|
|
2010
|
-
spec: hasSpec ? snapshotSpec() : null,
|
|
2011
|
-
};
|
|
2012
|
-
options.onComplete?.(finalMessage);
|
|
2013
2475
|
}
|
|
2014
2476
|
catch (err) {
|
|
2015
|
-
|
|
2477
|
+
// Unconditional: however this turn ended, its own bubble goes with it if
|
|
2478
|
+
// nothing was ever said into it. A superseded request usually unwinds by
|
|
2479
|
+
// abort, but one whose stream failed for its own reason after being
|
|
2480
|
+
// replaced would otherwise leave the placeholder behind.
|
|
2481
|
+
dropEmptyPlaceholder();
|
|
2482
|
+
if (isAbortError(err))
|
|
2016
2483
|
return;
|
|
2017
|
-
}
|
|
2018
2484
|
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2485
|
+
gate.commit(() => {
|
|
2486
|
+
error.set(resolvedError);
|
|
2487
|
+
options.onError?.(resolvedError);
|
|
2488
|
+
});
|
|
2023
2489
|
}
|
|
2024
2490
|
finally {
|
|
2025
|
-
|
|
2026
|
-
isStreaming.set(false);
|
|
2027
|
-
}
|
|
2491
|
+
gate.commit(() => isStreaming.set(false));
|
|
2028
2492
|
}
|
|
2029
2493
|
};
|
|
2030
2494
|
return {
|
|
2031
2495
|
messages: messages.asReadonly(),
|
|
2032
2496
|
isStreaming: isStreaming.asReadonly(),
|
|
2033
2497
|
error: error.asReadonly(),
|
|
2498
|
+
issues: issues.asReadonly(),
|
|
2034
2499
|
send,
|
|
2500
|
+
stop,
|
|
2035
2501
|
clear,
|
|
2036
2502
|
};
|
|
2037
2503
|
}
|
|
2038
2504
|
|
|
2505
|
+
/**
|
|
2506
|
+
* Streaming: applying a model's output to a spec as it arrives.
|
|
2507
|
+
*
|
|
2508
|
+
* - `patch` — the immutable RFC 6902 engine every entry point applies through.
|
|
2509
|
+
* - `transport` — one streamed request lifecycle, shared by both hooks.
|
|
2510
|
+
* - `parts` — adapters for the AI SDK's message parts.
|
|
2511
|
+
* - `ui-stream` / `chat-ui` — the two hooks, composed from the above.
|
|
2512
|
+
*/
|
|
2513
|
+
|
|
2039
2514
|
/**
|
|
2040
2515
|
* The schema for ngx-json-render.
|
|
2041
2516
|
*
|
|
@@ -2104,6 +2579,10 @@ const schema = defineSchema((s) => ({
|
|
|
2104
2579
|
name: 'validateForm',
|
|
2105
2580
|
description: 'Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.',
|
|
2106
2581
|
},
|
|
2582
|
+
{
|
|
2583
|
+
name: 'submitForm',
|
|
2584
|
+
description: "Validate all registered form fields and, only if every one passes, dispatch another action. Params: { action: string, params?: object, statePath?: string }. Writes the same { valid, errors } result as validateForm. Use this on a form's submit button: it is validateForm and the submit in one binding, and the submit is skipped when the form is invalid.",
|
|
2585
|
+
},
|
|
2107
2586
|
],
|
|
2108
2587
|
defaultRules: [
|
|
2109
2588
|
// Element integrity
|
|
@@ -2132,5 +2611,5 @@ const schema = defineSchema((s) => ({
|
|
|
2132
2611
|
* Generated bundle index. Do not edit.
|
|
2133
2612
|
*/
|
|
2134
2613
|
|
|
2135
|
-
export { JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, createStoreSetState, defineRegistry, flatToTree, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectDevtoolsActive, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, jsonRenderMessage, schema };
|
|
2614
|
+
export { CONFIRM_CONTEXT, ELEMENT_KEY, JR_CONFIRM_DIALOG, JR_CONFIRM_LABELS, JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, checkSpec, createStoreSetState, defineRegistry, flatToTree, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectConfirmContext, injectDevtoolsActive, injectElementKey, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, isActionCancelled, jsonRenderMessage, schema };
|
|
2136
2615
|
//# sourceMappingURL=ngx-json-render.mjs.map
|