@happyvertical/smrt-svelte 0.43.4 → 0.43.6
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/AGENTS.md +23 -0
- package/README.md +23 -1
- package/dist/components/forms/AddressInput.svelte +155 -42
- package/dist/components/forms/AddressInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/CheckboxInput.svelte +40 -13
- package/dist/components/forms/CheckboxInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/DateRangeInput.svelte +132 -16
- package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/DateTimeInput.svelte +48 -14
- package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/Form.svelte +599 -70
- package/dist/components/forms/Form.svelte.d.ts +6 -2
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/MeasurementInput.svelte +137 -31
- package/dist/components/forms/MeasurementInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/MoneyInput.svelte +42 -23
- package/dist/components/forms/MoneyInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/NumberInput.svelte +59 -27
- package/dist/components/forms/NumberInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/PhoneInput.svelte +51 -22
- package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/SelectInput.svelte +38 -22
- package/dist/components/forms/SelectInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/TextInput.svelte +32 -18
- package/dist/components/forms/TextInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/TextareaInput.svelte +25 -18
- package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/AddressInput.behavior.test.js +94 -0
- package/dist/components/forms/__tests__/Form.behavior.test.js +1 -1
- package/dist/components/forms/__tests__/Form.extraction.test.js +68 -0
- package/dist/components/forms/__tests__/Form.test.js +28 -0
- package/dist/components/forms/__tests__/Form.webmcp.test.js +2123 -114
- package/dist/components/forms/__tests__/FormMicButton.test.js +0 -1
- package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte +30 -0
- package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts +7 -0
- package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/async-validation-form.fixture.svelte +19 -0
- package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte +21 -0
- package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte +27 -0
- package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte +48 -0
- package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts +10 -0
- package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte +26 -0
- package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts +13 -0
- package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte +52 -0
- package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts +16 -0
- package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte +108 -5
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts +34 -0
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte +24 -0
- package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts +11 -0
- package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte +69 -4
- package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts +32 -0
- package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte +44 -0
- package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/policy-field.fixture.svelte +35 -0
- package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/prepare-field-value.d.ts +5 -0
- package/dist/components/forms/prepare-field-value.d.ts.map +1 -0
- package/dist/components/forms/prepare-field-value.js +15 -0
- package/dist/state/form-context.d.ts +28 -6
- package/dist/state/form-context.d.ts.map +1 -1
- package/dist/state/form-context.js +75 -7
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte +62 -0
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts +8 -0
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/webmcp-composed.fixture.svelte +52 -0
- package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts +12 -0
- package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/webmcp-composed.integration.svelte.test.js +247 -0
- package/dist/web/__tests__/webmcp-ssr.fixture.svelte +13 -0
- package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts +19 -0
- package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/webmcp-ui.test.js +209 -33
- package/dist/web/__tests__/webmcp.test.js +20 -0
- package/dist/web/webmcp-ui.d.ts.map +1 -1
- package/dist/web/webmcp-ui.js +50 -31
- package/dist/web/webmcp.svelte.d.ts.map +1 -1
- package/dist/web/webmcp.svelte.js +6 -1
- package/package.json +8 -6
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import {
|
|
3
|
+
type ControlIdentity,
|
|
3
4
|
type ControlInteractionEvent,
|
|
4
5
|
type ControlInteractionRegistry,
|
|
5
6
|
type ControlKind,
|
|
7
|
+
type ControlRuntimeState,
|
|
8
|
+
type ControlSubject,
|
|
6
9
|
createControlInteractionRegistry,
|
|
10
|
+
StagedControlReview,
|
|
7
11
|
setControlInteractionContext,
|
|
8
12
|
} from '@happyvertical/smrt-ui/forms';
|
|
9
13
|
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
10
14
|
import type { Snippet } from 'svelte';
|
|
11
|
-
import { onDestroy } from 'svelte';
|
|
15
|
+
import { onDestroy, untrack } from 'svelte';
|
|
12
16
|
import { useAppState } from '../../hooks/useAppState.svelte.js';
|
|
13
17
|
import { useSTT } from '../../hooks/useSTT.svelte.js';
|
|
14
18
|
import { M } from '../../i18n/strings.forms.js';
|
|
@@ -39,7 +43,7 @@ export interface Props {
|
|
|
39
43
|
llmModel?: LLMModelId;
|
|
40
44
|
/** Called when form is submitted (if provided, prevents native submission) */
|
|
41
45
|
onsubmit?: (data: Record<string, unknown>) => void | Promise<void>;
|
|
42
|
-
/**
|
|
46
|
+
/** Let WebMCP propose field changes for local human review. */
|
|
43
47
|
webmcp?: boolean | { name?: string; description?: string };
|
|
44
48
|
/** Collection/model identity used when naming the generated intent. */
|
|
45
49
|
collection?: string;
|
|
@@ -49,8 +53,12 @@ export interface Props {
|
|
|
49
53
|
action?: string;
|
|
50
54
|
/** Stable identity used by control/agent interaction adapters. */
|
|
51
55
|
formId?: string;
|
|
56
|
+
/** Default record-qualified identity for registered fields. */
|
|
57
|
+
subject?: ControlSubject;
|
|
52
58
|
interactionRegistry?: ControlInteractionRegistry;
|
|
53
59
|
oninteraction?: (event: ControlInteractionEvent) => void;
|
|
60
|
+
/** Render the built-in human review surface for staged changes. */
|
|
61
|
+
stagedReview?: boolean;
|
|
54
62
|
id?: string;
|
|
55
63
|
name?: string;
|
|
56
64
|
class?: string;
|
|
@@ -69,8 +77,10 @@ const {
|
|
|
69
77
|
method,
|
|
70
78
|
action,
|
|
71
79
|
formId,
|
|
80
|
+
subject,
|
|
72
81
|
interactionRegistry,
|
|
73
82
|
oninteraction,
|
|
83
|
+
stagedReview = true,
|
|
74
84
|
id,
|
|
75
85
|
name,
|
|
76
86
|
class: className = '',
|
|
@@ -90,12 +100,18 @@ const resolvedInteractionRegistry = $derived(
|
|
|
90
100
|
: undefined) ??
|
|
91
101
|
localInteractionRegistry,
|
|
92
102
|
);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
interface InteractionRegistration {
|
|
104
|
+
field: FieldDefinition;
|
|
105
|
+
subject: ControlSubject | undefined;
|
|
106
|
+
identity: ControlIdentity;
|
|
107
|
+
ownershipKey: string;
|
|
108
|
+
disposeRegistration: () => void;
|
|
109
|
+
restoreAttributes: () => void;
|
|
110
|
+
}
|
|
111
|
+
const interactionDisposers = new Map<string, InteractionRegistration>();
|
|
97
112
|
let activeInteractionRegistry: ControlInteractionRegistry | undefined;
|
|
98
113
|
let activeInteractionFormId: string | undefined;
|
|
114
|
+
let formElement = $state<HTMLFormElement | null>(null);
|
|
99
115
|
|
|
100
116
|
setControlInteractionContext({
|
|
101
117
|
get formId() {
|
|
@@ -120,6 +136,11 @@ $effect(() => {
|
|
|
120
136
|
|
|
121
137
|
// Internal state
|
|
122
138
|
let fields = $state<Map<string, FieldDefinition>>(new Map());
|
|
139
|
+
const fieldGenerations = new Map<string, symbol>();
|
|
140
|
+
const fieldRegistrationOrder = new Map<
|
|
141
|
+
string,
|
|
142
|
+
Array<{ field: FieldDefinition; generation: symbol }>
|
|
143
|
+
>();
|
|
123
144
|
let isFormListening = $state(false);
|
|
124
145
|
let isExtracting = $state(false);
|
|
125
146
|
let spokenText = $state('');
|
|
@@ -150,41 +171,295 @@ function interactionKind(field: FieldDefinition): ControlKind {
|
|
|
150
171
|
}
|
|
151
172
|
}
|
|
152
173
|
|
|
174
|
+
function fieldRuntimeState(field: FieldDefinition): ControlRuntimeState {
|
|
175
|
+
const controls = fieldControls(field);
|
|
176
|
+
const domState: ControlRuntimeState = {
|
|
177
|
+
disabled:
|
|
178
|
+
controls.length > 0
|
|
179
|
+
? controls.some((control) => control.matches(':disabled'))
|
|
180
|
+
: undefined,
|
|
181
|
+
readonly:
|
|
182
|
+
controls.some(
|
|
183
|
+
(control) => 'readOnly' in control && control.readOnly === true,
|
|
184
|
+
) || undefined,
|
|
185
|
+
};
|
|
186
|
+
const declaredState = field.getState?.() ?? {};
|
|
187
|
+
return {
|
|
188
|
+
...domState,
|
|
189
|
+
...declaredState,
|
|
190
|
+
disabled: domState.disabled === true || declaredState.disabled === true,
|
|
191
|
+
readonly: domState.readonly === true || declaredState.readonly === true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function fieldControls(field: FieldDefinition) {
|
|
196
|
+
if (!formElement) return [];
|
|
197
|
+
const controls = Array.from(formElement.elements).filter(
|
|
198
|
+
(
|
|
199
|
+
control,
|
|
200
|
+
): control is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
|
|
201
|
+
(control instanceof HTMLInputElement ||
|
|
202
|
+
control instanceof HTMLSelectElement ||
|
|
203
|
+
control instanceof HTMLTextAreaElement) &&
|
|
204
|
+
control.type !== 'hidden',
|
|
205
|
+
);
|
|
206
|
+
if (field.ownerToken) {
|
|
207
|
+
return controls.filter(
|
|
208
|
+
(control) =>
|
|
209
|
+
control
|
|
210
|
+
.closest('[data-smrt-field-owner]')
|
|
211
|
+
?.getAttribute('data-smrt-field-owner') === field.ownerToken,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
// Custom registrations without an owner token retain exact-name support,
|
|
215
|
+
// but never claim composite-looking sibling controls heuristically.
|
|
216
|
+
const exactControls = controls.filter(
|
|
217
|
+
(control) => control.name === field.name,
|
|
218
|
+
);
|
|
219
|
+
const hasExactId = exactControls.some((control) => control.id === field.name);
|
|
220
|
+
return hasExactId
|
|
221
|
+
? exactControls.filter((control) => control.id === field.name)
|
|
222
|
+
: exactControls;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function decorateFieldControls(
|
|
226
|
+
field: FieldDefinition,
|
|
227
|
+
currentFormId: string,
|
|
228
|
+
resolvedSubject: ControlSubject | undefined,
|
|
229
|
+
): () => void {
|
|
230
|
+
const attributes = new Map<string, string | undefined>([
|
|
231
|
+
['data-smrt-control', field.controlId ?? field.name],
|
|
232
|
+
['data-smrt-form', currentFormId],
|
|
233
|
+
['data-smrt-subject-type', resolvedSubject?.type],
|
|
234
|
+
['data-smrt-subject-id', resolvedSubject?.id],
|
|
235
|
+
]);
|
|
236
|
+
const controls = fieldControls(field).map((control) => ({
|
|
237
|
+
control,
|
|
238
|
+
previous: new Map(
|
|
239
|
+
Array.from(attributes.keys(), (attribute) => [
|
|
240
|
+
attribute,
|
|
241
|
+
control.getAttribute(attribute),
|
|
242
|
+
]),
|
|
243
|
+
),
|
|
244
|
+
}));
|
|
245
|
+
for (const { control } of controls) {
|
|
246
|
+
try {
|
|
247
|
+
for (const [attribute, next] of attributes) {
|
|
248
|
+
if (next === undefined) control.removeAttribute(attribute);
|
|
249
|
+
else control.setAttribute(attribute, next);
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
for (const { control: assignedControl, previous } of controls) {
|
|
253
|
+
for (const attribute of attributes.keys()) {
|
|
254
|
+
const original = previous.get(attribute);
|
|
255
|
+
if (original === null || original === undefined) {
|
|
256
|
+
assignedControl.removeAttribute(attribute);
|
|
257
|
+
} else {
|
|
258
|
+
assignedControl.setAttribute(attribute, original);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return () => {
|
|
266
|
+
for (const { control, previous } of controls) {
|
|
267
|
+
for (const [attribute, assigned] of attributes) {
|
|
268
|
+
if (control.getAttribute(attribute) !== (assigned ?? null)) continue;
|
|
269
|
+
const original = previous.get(attribute);
|
|
270
|
+
if (original === null || original === undefined) {
|
|
271
|
+
control.removeAttribute(attribute);
|
|
272
|
+
} else {
|
|
273
|
+
control.setAttribute(attribute, original);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function recordDirectUserEdit(event: Event) {
|
|
281
|
+
if (!event.isTrusted) return;
|
|
282
|
+
const target = event.target;
|
|
283
|
+
if (!(target instanceof HTMLElement)) return;
|
|
284
|
+
const field = Array.from(fields.values()).find((candidate) =>
|
|
285
|
+
fieldControls(candidate).includes(
|
|
286
|
+
target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
if (!field) return;
|
|
290
|
+
resolvedInteractionRegistry.recordUserEdit?.({
|
|
291
|
+
formId: resolvedFormId,
|
|
292
|
+
controlId: field.controlId ?? field.name,
|
|
293
|
+
subject: resolvedFieldSubject(field),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
$effect(() => {
|
|
298
|
+
const form = formElement;
|
|
299
|
+
if (!form) return;
|
|
300
|
+
form.addEventListener('input', recordDirectUserEdit);
|
|
301
|
+
form.addEventListener('change', recordDirectUserEdit);
|
|
302
|
+
return () => {
|
|
303
|
+
form.removeEventListener('input', recordDirectUserEdit);
|
|
304
|
+
form.removeEventListener('change', recordDirectUserEdit);
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
|
|
153
308
|
function registerInteraction(
|
|
154
309
|
registry: ControlInteractionRegistry,
|
|
155
310
|
currentFormId: string,
|
|
156
311
|
field: FieldDefinition,
|
|
157
|
-
|
|
312
|
+
resolvedSubject: ControlSubject | undefined,
|
|
313
|
+
previous?: InteractionRegistration,
|
|
314
|
+
): InteractionRegistration | undefined {
|
|
158
315
|
const identity = {
|
|
159
316
|
formId: currentFormId,
|
|
160
317
|
controlId: field.controlId ?? field.name,
|
|
318
|
+
subject: resolvedSubject,
|
|
161
319
|
};
|
|
162
|
-
|
|
320
|
+
const replacesOwnedIdentity =
|
|
321
|
+
previous !== undefined && sameRegistryIdentity(previous.identity, identity);
|
|
322
|
+
const ownershipKey = Array.from(fields.keys()).sort().join('\0');
|
|
323
|
+
if (registry.get(identity) && !replacesOwnedIdentity) {
|
|
163
324
|
logger.warn('Form: duplicate interaction identity rejected', { identity });
|
|
164
325
|
return undefined;
|
|
165
326
|
}
|
|
166
|
-
|
|
327
|
+
// Restore the old DOM decoration while its registry generation is still
|
|
328
|
+
// active. This makes either decoration or registration failure recoverable.
|
|
329
|
+
if (replacesOwnedIdentity) {
|
|
330
|
+
previous.restoreAttributes();
|
|
331
|
+
}
|
|
332
|
+
let restoreAttributes: () => void;
|
|
333
|
+
try {
|
|
334
|
+
restoreAttributes = decorateFieldControls(
|
|
335
|
+
field,
|
|
336
|
+
currentFormId,
|
|
337
|
+
resolvedSubject,
|
|
338
|
+
);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (replacesOwnedIdentity) {
|
|
341
|
+
previous.restoreAttributes = decorateFieldControls(
|
|
342
|
+
previous.field,
|
|
343
|
+
previous.identity.formId,
|
|
344
|
+
previous.subject,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
let disposeRegistration: () => void;
|
|
350
|
+
try {
|
|
351
|
+
disposeRegistration = registry.register({
|
|
352
|
+
identity,
|
|
353
|
+
metadata: {
|
|
354
|
+
kind: interactionKind(field),
|
|
355
|
+
get label() {
|
|
356
|
+
return field.label;
|
|
357
|
+
},
|
|
358
|
+
get description() {
|
|
359
|
+
return field.description;
|
|
360
|
+
},
|
|
361
|
+
get sensitivity() {
|
|
362
|
+
return field.sensitivity ?? 'public';
|
|
363
|
+
},
|
|
364
|
+
get readable() {
|
|
365
|
+
return field.readable;
|
|
366
|
+
},
|
|
367
|
+
get writable() {
|
|
368
|
+
return field.writable;
|
|
369
|
+
},
|
|
370
|
+
get constraints() {
|
|
371
|
+
return field.constraints;
|
|
372
|
+
},
|
|
373
|
+
get options() {
|
|
374
|
+
return field.options;
|
|
375
|
+
},
|
|
376
|
+
get unit() {
|
|
377
|
+
return field.unit;
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
getValue: field.getValue,
|
|
381
|
+
setValue: field.setValue,
|
|
382
|
+
setValueWithContext: field.setValueWithContext,
|
|
383
|
+
// The registry calls this inside its stage retry loop, so partial
|
|
384
|
+
// structured proposals merge with the latest stable human value.
|
|
385
|
+
prepareValue: (value) => prepareInteractionValue(field, value),
|
|
386
|
+
prepareReviewedValue: field.prepareReviewedValue,
|
|
387
|
+
clear:
|
|
388
|
+
field.clear ??
|
|
389
|
+
(() => {
|
|
390
|
+
field.setValue('');
|
|
391
|
+
}),
|
|
392
|
+
focus:
|
|
393
|
+
field.focus ??
|
|
394
|
+
(() =>
|
|
395
|
+
fieldControls(field)
|
|
396
|
+
.find((control) => !control.disabled)
|
|
397
|
+
?.focus()),
|
|
398
|
+
reveal: field.reveal,
|
|
399
|
+
highlight: field.highlight,
|
|
400
|
+
validate: field.validate,
|
|
401
|
+
validateValue: field.validateValue,
|
|
402
|
+
getState: () => fieldRuntimeState(field),
|
|
403
|
+
});
|
|
404
|
+
} catch (error) {
|
|
405
|
+
restoreAttributes();
|
|
406
|
+
if (replacesOwnedIdentity) {
|
|
407
|
+
previous.restoreAttributes = decorateFieldControls(
|
|
408
|
+
previous.field,
|
|
409
|
+
previous.identity.formId,
|
|
410
|
+
previous.subject,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
// Install the same-key replacement before retiring the old registry
|
|
416
|
+
// generation, making the old disposer a no-op and preserving internal state.
|
|
417
|
+
if (replacesOwnedIdentity) previous.disposeRegistration();
|
|
418
|
+
return {
|
|
419
|
+
field,
|
|
420
|
+
subject: resolvedSubject,
|
|
167
421
|
identity,
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
422
|
+
ownershipKey,
|
|
423
|
+
disposeRegistration,
|
|
424
|
+
restoreAttributes,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function disposeInteraction(registration: InteractionRegistration): void {
|
|
429
|
+
registration.disposeRegistration();
|
|
430
|
+
registration.restoreAttributes();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function sameRegistryIdentity(
|
|
434
|
+
left: ControlIdentity,
|
|
435
|
+
right: ControlIdentity,
|
|
436
|
+
): boolean {
|
|
437
|
+
return (
|
|
438
|
+
left.formId === right.formId &&
|
|
439
|
+
left.controlId === right.controlId &&
|
|
440
|
+
left.subject?.type === right.subject?.type &&
|
|
441
|
+
left.subject?.id === right.subject?.id
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function resolvedFieldSubject(
|
|
446
|
+
field: FieldDefinition,
|
|
447
|
+
): ControlSubject | undefined {
|
|
448
|
+
const current = field.subject ?? subject;
|
|
449
|
+
return current
|
|
450
|
+
? { type: current.type, id: current.id, label: current.label }
|
|
451
|
+
: undefined;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function sameSubject(
|
|
455
|
+
left: ControlSubject | undefined,
|
|
456
|
+
right: ControlSubject | undefined,
|
|
457
|
+
): boolean {
|
|
458
|
+
return (
|
|
459
|
+
left?.type === right?.type &&
|
|
460
|
+
left?.id === right?.id &&
|
|
461
|
+
left?.label === right?.label
|
|
462
|
+
);
|
|
188
463
|
}
|
|
189
464
|
|
|
190
465
|
let interactionRegistryRevision = $state(0);
|
|
@@ -192,8 +467,13 @@ let interactionRegistryRevision = $state(0);
|
|
|
192
467
|
$effect(() => {
|
|
193
468
|
const registry = resolvedInteractionRegistry;
|
|
194
469
|
return registry.subscribe((event) => {
|
|
195
|
-
if (
|
|
196
|
-
|
|
470
|
+
if (
|
|
471
|
+
event.type === 'registered' ||
|
|
472
|
+
event.type === 'unregistered' ||
|
|
473
|
+
(event.type === 'refreshed' && event.identity.formId === resolvedFormId)
|
|
474
|
+
) {
|
|
475
|
+
interactionRegistryRevision =
|
|
476
|
+
untrack(() => interactionRegistryRevision) + 1;
|
|
197
477
|
}
|
|
198
478
|
});
|
|
199
479
|
});
|
|
@@ -202,54 +482,146 @@ $effect(() => {
|
|
|
202
482
|
const registry = resolvedInteractionRegistry;
|
|
203
483
|
const currentFormId = resolvedFormId;
|
|
204
484
|
const currentFields = new Map(fields);
|
|
485
|
+
const currentOwnershipKey = Array.from(currentFields.keys())
|
|
486
|
+
.sort()
|
|
487
|
+
.join('\0');
|
|
205
488
|
void interactionRegistryRevision;
|
|
206
489
|
if (
|
|
207
490
|
activeInteractionRegistry !== registry ||
|
|
208
491
|
activeInteractionFormId !== currentFormId
|
|
209
492
|
) {
|
|
210
493
|
for (const registration of interactionDisposers.values()) {
|
|
211
|
-
registration
|
|
494
|
+
disposeInteraction(registration);
|
|
212
495
|
}
|
|
213
496
|
interactionDisposers.clear();
|
|
214
497
|
activeInteractionRegistry = registry;
|
|
215
498
|
activeInteractionFormId = currentFormId;
|
|
216
499
|
}
|
|
217
500
|
for (const [name, registration] of interactionDisposers) {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
501
|
+
const nextField = currentFields.get(name);
|
|
502
|
+
if (!nextField) {
|
|
503
|
+
disposeInteraction(registration);
|
|
504
|
+
interactionDisposers.delete(name);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const nextSubject = resolvedFieldSubject(nextField);
|
|
222
508
|
if (
|
|
223
|
-
|
|
224
|
-
|
|
509
|
+
nextField === registration.field &&
|
|
510
|
+
sameSubject(registration.subject, nextSubject) &&
|
|
511
|
+
registry.get(registration.identity)
|
|
225
512
|
) {
|
|
226
|
-
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const nextIdentity = {
|
|
516
|
+
formId: currentFormId,
|
|
517
|
+
controlId: nextField.controlId ?? nextField.name,
|
|
518
|
+
subject: nextSubject,
|
|
519
|
+
};
|
|
520
|
+
if (!sameRegistryIdentity(registration.identity, nextIdentity)) {
|
|
521
|
+
disposeInteraction(registration);
|
|
227
522
|
interactionDisposers.delete(name);
|
|
228
523
|
}
|
|
229
524
|
}
|
|
230
525
|
for (const [name, field] of currentFields) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
526
|
+
const previous = interactionDisposers.get(name);
|
|
527
|
+
if (
|
|
528
|
+
previous?.field === field &&
|
|
529
|
+
previous.ownershipKey === currentOwnershipKey &&
|
|
530
|
+
sameSubject(previous.subject, resolvedFieldSubject(field)) &&
|
|
531
|
+
registry.get(previous.identity)
|
|
532
|
+
)
|
|
533
|
+
continue;
|
|
534
|
+
const resolvedSubject = resolvedFieldSubject(field);
|
|
535
|
+
const registration = registerInteraction(
|
|
536
|
+
registry,
|
|
537
|
+
currentFormId,
|
|
235
538
|
field,
|
|
236
|
-
|
|
237
|
-
|
|
539
|
+
resolvedSubject,
|
|
540
|
+
previous,
|
|
541
|
+
);
|
|
542
|
+
if (!registration) continue;
|
|
543
|
+
interactionDisposers.set(name, registration);
|
|
238
544
|
}
|
|
239
545
|
});
|
|
240
546
|
|
|
547
|
+
// Field metadata and DOM-backed runtime state are live getters. Notify review
|
|
548
|
+
// consumers when either changes so hidden staged entries disappear immediately
|
|
549
|
+
// without unregistering the internal proposal needed for trusted discard.
|
|
550
|
+
$effect(() => {
|
|
551
|
+
const registry = resolvedInteractionRegistry;
|
|
552
|
+
const currentFormId = resolvedFormId;
|
|
553
|
+
for (const field of fields.values()) {
|
|
554
|
+
void field.label;
|
|
555
|
+
void field.description;
|
|
556
|
+
void field.sensitivity;
|
|
557
|
+
void field.readable;
|
|
558
|
+
void field.writable;
|
|
559
|
+
void field.constraints;
|
|
560
|
+
void field.options;
|
|
561
|
+
void field.unit;
|
|
562
|
+
const state = fieldRuntimeState(field);
|
|
563
|
+
void state.disabled;
|
|
564
|
+
void state.readonly;
|
|
565
|
+
}
|
|
566
|
+
registry.refresh?.(currentFormId);
|
|
567
|
+
});
|
|
568
|
+
|
|
241
569
|
// Create form context
|
|
242
570
|
const formContext: SMRTFormContext = {
|
|
243
571
|
get mode() {
|
|
244
572
|
return app.state.mode === 'smrt' ? 'smrt' : 'default';
|
|
245
573
|
},
|
|
246
574
|
registerField(field: FieldDefinition) {
|
|
247
|
-
|
|
248
|
-
|
|
575
|
+
const registeredName = field.name;
|
|
576
|
+
const generation = Symbol(registeredName);
|
|
577
|
+
const registrations = fieldRegistrationOrder.get(registeredName) ?? [];
|
|
578
|
+
const registration = { field, generation };
|
|
579
|
+
registrations.push(registration);
|
|
580
|
+
fieldRegistrationOrder.set(registeredName, registrations);
|
|
581
|
+
const currentFields = untrack(() => fields);
|
|
582
|
+
currentFields.set(registeredName, field);
|
|
583
|
+
fieldGenerations.set(registeredName, generation);
|
|
584
|
+
fields = new Map(currentFields); // Trigger reactivity
|
|
585
|
+
return () => {
|
|
586
|
+
const orderedRegistrations = fieldRegistrationOrder.get(registeredName);
|
|
587
|
+
const registrationIndex = orderedRegistrations?.indexOf(registration);
|
|
588
|
+
if (registrationIndex !== undefined && registrationIndex >= 0) {
|
|
589
|
+
orderedRegistrations?.splice(registrationIndex, 1);
|
|
590
|
+
if (orderedRegistrations?.length === 0) {
|
|
591
|
+
fieldRegistrationOrder.delete(registeredName);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (fieldGenerations.get(registeredName) !== generation) return;
|
|
595
|
+
const registeredFields = untrack(() => fields);
|
|
596
|
+
if (registeredFields.get(registeredName) !== field) return;
|
|
597
|
+
const previousRegistration = orderedRegistrations?.at(-1);
|
|
598
|
+
if (previousRegistration) {
|
|
599
|
+
fieldGenerations.set(registeredName, previousRegistration.generation);
|
|
600
|
+
registeredFields.set(registeredName, previousRegistration.field);
|
|
601
|
+
} else {
|
|
602
|
+
fieldGenerations.delete(registeredName);
|
|
603
|
+
registeredFields.delete(registeredName);
|
|
604
|
+
}
|
|
605
|
+
fields = new Map(registeredFields);
|
|
606
|
+
};
|
|
249
607
|
},
|
|
250
608
|
unregisterField(name: string) {
|
|
251
|
-
|
|
252
|
-
|
|
609
|
+
const registrations = fieldRegistrationOrder.get(name);
|
|
610
|
+
const registration = registrations?.pop();
|
|
611
|
+
if (registrations?.length === 0) fieldRegistrationOrder.delete(name);
|
|
612
|
+
if (!registration) return;
|
|
613
|
+
if (fieldGenerations.get(name) !== registration.generation) return;
|
|
614
|
+
const registeredFields = untrack(() => fields);
|
|
615
|
+
if (registeredFields.get(name) !== registration.field) return;
|
|
616
|
+
const previousRegistration = registrations?.at(-1);
|
|
617
|
+
if (previousRegistration) {
|
|
618
|
+
fieldGenerations.set(name, previousRegistration.generation);
|
|
619
|
+
registeredFields.set(name, previousRegistration.field);
|
|
620
|
+
} else {
|
|
621
|
+
fieldGenerations.delete(name);
|
|
622
|
+
registeredFields.delete(name);
|
|
623
|
+
}
|
|
624
|
+
fields = new Map(registeredFields);
|
|
253
625
|
},
|
|
254
626
|
getFieldSchema() {
|
|
255
627
|
return Array.from(fields.values());
|
|
@@ -278,6 +650,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
278
650
|
case 'measurement':
|
|
279
651
|
schema = {
|
|
280
652
|
type: 'object',
|
|
653
|
+
additionalProperties: false,
|
|
281
654
|
properties: {
|
|
282
655
|
value: {
|
|
283
656
|
type: 'number',
|
|
@@ -301,6 +674,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
301
674
|
case 'daterange':
|
|
302
675
|
schema = {
|
|
303
676
|
type: 'object',
|
|
677
|
+
additionalProperties: false,
|
|
304
678
|
properties: {
|
|
305
679
|
startDate: { type: 'string' },
|
|
306
680
|
endDate: { type: 'string' },
|
|
@@ -313,6 +687,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
313
687
|
case 'address':
|
|
314
688
|
schema = {
|
|
315
689
|
type: 'object',
|
|
690
|
+
additionalProperties: false,
|
|
316
691
|
properties: {
|
|
317
692
|
street: { type: 'string' },
|
|
318
693
|
city: { type: 'string' },
|
|
@@ -334,9 +709,14 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
334
709
|
};
|
|
335
710
|
break;
|
|
336
711
|
case 'number':
|
|
337
|
-
case 'money':
|
|
338
712
|
schema = { type: 'number' };
|
|
339
713
|
break;
|
|
714
|
+
case 'money':
|
|
715
|
+
schema = {
|
|
716
|
+
type: 'integer',
|
|
717
|
+
description: 'Amount in minor currency units (cents)',
|
|
718
|
+
};
|
|
719
|
+
break;
|
|
340
720
|
case 'checkbox':
|
|
341
721
|
schema = { type: 'boolean' };
|
|
342
722
|
break;
|
|
@@ -346,14 +726,25 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
346
726
|
}
|
|
347
727
|
|
|
348
728
|
if (field.label) schema.title = field.label;
|
|
349
|
-
if (field.description)
|
|
729
|
+
if (field.description) {
|
|
730
|
+
schema.description =
|
|
731
|
+
field.type === 'money'
|
|
732
|
+
? `${field.description}. WebMCP values use integer minor units (cents).`
|
|
733
|
+
: field.description;
|
|
734
|
+
}
|
|
350
735
|
if (field.options) {
|
|
351
736
|
schema.enum = field.options.map((option) => option.value);
|
|
352
737
|
}
|
|
353
|
-
if (
|
|
738
|
+
if (
|
|
739
|
+
(schema.type === 'number' || schema.type === 'integer') &&
|
|
740
|
+
field.constraints?.min !== undefined
|
|
741
|
+
) {
|
|
354
742
|
schema.minimum = Number(field.constraints.min);
|
|
355
743
|
}
|
|
356
|
-
if (
|
|
744
|
+
if (
|
|
745
|
+
(schema.type === 'number' || schema.type === 'integer') &&
|
|
746
|
+
field.constraints?.max !== undefined
|
|
747
|
+
) {
|
|
357
748
|
schema.maximum = Number(field.constraints.max);
|
|
358
749
|
}
|
|
359
750
|
if (schema.type === 'string' && field.constraints?.minLength !== undefined) {
|
|
@@ -368,17 +759,75 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
368
759
|
return schema;
|
|
369
760
|
}
|
|
370
761
|
|
|
762
|
+
function structuredProposalProperties(
|
|
763
|
+
field: FieldDefinition,
|
|
764
|
+
): Record<string, unknown> | undefined {
|
|
765
|
+
return (
|
|
766
|
+
webMcpFieldSchema(field) as {
|
|
767
|
+
properties?: Record<string, unknown>;
|
|
768
|
+
}
|
|
769
|
+
).properties;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function prepareInteractionValue(field: FieldDefinition, value: unknown) {
|
|
773
|
+
let preparedValue = value;
|
|
774
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
775
|
+
const properties = structuredProposalProperties(field);
|
|
776
|
+
if (properties) {
|
|
777
|
+
const currentValue = field.getValue();
|
|
778
|
+
const currentObject =
|
|
779
|
+
currentValue !== null &&
|
|
780
|
+
typeof currentValue === 'object' &&
|
|
781
|
+
!Array.isArray(currentValue)
|
|
782
|
+
? (currentValue as Record<string, unknown>)
|
|
783
|
+
: undefined;
|
|
784
|
+
const projectedCurrent = currentObject
|
|
785
|
+
? Object.fromEntries(
|
|
786
|
+
Object.entries(currentObject).filter(([key]) =>
|
|
787
|
+
Object.hasOwn(properties, key),
|
|
788
|
+
),
|
|
789
|
+
)
|
|
790
|
+
: {};
|
|
791
|
+
// WebMCP sanitizes its transport payload before it enters the registry.
|
|
792
|
+
// Preserve unknown members from direct registry callers so the field's
|
|
793
|
+
// validator can reject them rather than silently laundering them away.
|
|
794
|
+
preparedValue = { ...projectedCurrent, ...value };
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (
|
|
798
|
+
field.type === 'measurement' &&
|
|
799
|
+
preparedValue !== null &&
|
|
800
|
+
typeof preparedValue === 'object' &&
|
|
801
|
+
!Array.isArray(preparedValue) &&
|
|
802
|
+
!('unit' in preparedValue) &&
|
|
803
|
+
field.unit
|
|
804
|
+
) {
|
|
805
|
+
preparedValue = { ...preparedValue, unit: field.unit };
|
|
806
|
+
}
|
|
807
|
+
return field.prepareValue ? field.prepareValue(preparedValue) : preparedValue;
|
|
808
|
+
}
|
|
809
|
+
|
|
371
810
|
function formInputSchema(): Record<string, unknown> {
|
|
811
|
+
void interactionRegistryRevision;
|
|
372
812
|
const properties: Record<string, unknown> = {};
|
|
373
813
|
const required: string[] = [];
|
|
374
814
|
|
|
375
815
|
for (const field of fields.values()) {
|
|
816
|
+
const state = fieldRuntimeState(field);
|
|
817
|
+
if (
|
|
818
|
+
field.sensitivity === 'secret' ||
|
|
819
|
+
field.writable === false ||
|
|
820
|
+
state.disabled ||
|
|
821
|
+
state.readonly
|
|
822
|
+
)
|
|
823
|
+
continue;
|
|
376
824
|
properties[field.name] = webMcpFieldSchema(field);
|
|
377
825
|
if (field.constraints?.required) required.push(field.name);
|
|
378
826
|
}
|
|
379
827
|
|
|
380
828
|
return {
|
|
381
829
|
type: 'object',
|
|
830
|
+
additionalProperties: false,
|
|
382
831
|
properties,
|
|
383
832
|
...(required.length > 0 ? { required } : {}),
|
|
384
833
|
};
|
|
@@ -387,16 +836,15 @@ function formInputSchema(): Record<string, unknown> {
|
|
|
387
836
|
// Provide context to children
|
|
388
837
|
setFormContext(formContext);
|
|
389
838
|
|
|
390
|
-
async function
|
|
391
|
-
// Stage tool arguments through the same field setters used by native input
|
|
392
|
-
// controls, then run the shared validation and submit path.
|
|
393
|
-
for (const [name, value] of Object.entries(args)) {
|
|
394
|
-
fields.get(name)?.setValue(value);
|
|
395
|
-
}
|
|
396
|
-
|
|
839
|
+
async function submitCurrentForm(): Promise<string> {
|
|
397
840
|
const data: Record<string, unknown> = {};
|
|
398
841
|
let valid = true;
|
|
399
|
-
|
|
842
|
+
const fieldSnapshot = Array.from(fields);
|
|
843
|
+
|
|
844
|
+
// Snapshot every field before running a validator: a validator may await,
|
|
845
|
+
// while the user can continue editing later controls. The submit handler must
|
|
846
|
+
// receive one coherent gesture-time payload, not a mixture of revisions.
|
|
847
|
+
for (const [name, field] of fieldSnapshot) {
|
|
400
848
|
data[name] = field.getValue();
|
|
401
849
|
if (
|
|
402
850
|
field.constraints?.required &&
|
|
@@ -404,9 +852,15 @@ async function submitForWebMcp(args: Record<string, unknown>): Promise<string> {
|
|
|
404
852
|
) {
|
|
405
853
|
valid = false;
|
|
406
854
|
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
for (const [, field] of fieldSnapshot) {
|
|
407
858
|
if (field.validate) {
|
|
408
859
|
try {
|
|
409
|
-
|
|
860
|
+
const validation = field.validate();
|
|
861
|
+
valid =
|
|
862
|
+
(typeof validation === 'boolean' ? validation : await validation) &&
|
|
863
|
+
valid;
|
|
410
864
|
} catch {
|
|
411
865
|
valid = false;
|
|
412
866
|
}
|
|
@@ -419,17 +873,73 @@ async function submitForWebMcp(args: Record<string, unknown>): Promise<string> {
|
|
|
419
873
|
return 'Submitted successfully';
|
|
420
874
|
}
|
|
421
875
|
|
|
876
|
+
async function stageForWebMcp(args: Record<string, unknown>): Promise<string> {
|
|
877
|
+
const commands = Object.entries(args).flatMap(([name, value]) => {
|
|
878
|
+
const field = fields.get(name);
|
|
879
|
+
const state = field ? fieldRuntimeState(field) : undefined;
|
|
880
|
+
if (
|
|
881
|
+
!field ||
|
|
882
|
+
field.sensitivity === 'secret' ||
|
|
883
|
+
field.writable === false ||
|
|
884
|
+
state?.disabled ||
|
|
885
|
+
state?.readonly
|
|
886
|
+
) {
|
|
887
|
+
return [];
|
|
888
|
+
}
|
|
889
|
+
let sanitizedValue = value;
|
|
890
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
891
|
+
const properties = structuredProposalProperties(field);
|
|
892
|
+
if (properties) {
|
|
893
|
+
sanitizedValue = Object.fromEntries(
|
|
894
|
+
Object.entries(value).filter(([key]) =>
|
|
895
|
+
Object.hasOwn(properties, key),
|
|
896
|
+
),
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return [
|
|
901
|
+
{
|
|
902
|
+
action: 'stage' as const,
|
|
903
|
+
identity: {
|
|
904
|
+
formId: resolvedFormId,
|
|
905
|
+
controlId: field.controlId ?? field.name,
|
|
906
|
+
subject: field.subject ?? subject,
|
|
907
|
+
},
|
|
908
|
+
value: sanitizedValue,
|
|
909
|
+
},
|
|
910
|
+
];
|
|
911
|
+
});
|
|
912
|
+
if (commands.length === 0) return 'No reviewable changes provided';
|
|
913
|
+
|
|
914
|
+
const context = { source: 'agent' as const, actorId: 'webmcp' };
|
|
915
|
+
let batch;
|
|
916
|
+
if (resolvedInteractionRegistry.executeBatch) {
|
|
917
|
+
batch = await resolvedInteractionRegistry.executeBatch(commands, context);
|
|
918
|
+
} else {
|
|
919
|
+
const results = [];
|
|
920
|
+
for (const command of commands) {
|
|
921
|
+
results.push(await resolvedInteractionRegistry.execute(command, context));
|
|
922
|
+
}
|
|
923
|
+
batch = { ok: results.every((result) => result.ok), results };
|
|
924
|
+
}
|
|
925
|
+
const completed = batch.results.filter((result) => result.ok).length;
|
|
926
|
+
const rejected = batch.results.length - completed;
|
|
927
|
+
return rejected === 0
|
|
928
|
+
? `Staged ${completed} change${completed === 1 ? '' : 's'} for review`
|
|
929
|
+
: `Staged ${completed} changes for review; ${rejected} rejected`;
|
|
930
|
+
}
|
|
931
|
+
|
|
422
932
|
useWebMcpTool(() => {
|
|
423
933
|
if (!webmcp) return null;
|
|
424
934
|
const options = typeof webmcp === 'object' ? webmcp : {};
|
|
425
935
|
return {
|
|
426
|
-
name: options.name ?? `${collection ?? resolvedFormId}
|
|
936
|
+
name: options.name ?? `${collection ?? resolvedFormId}_stage_changes`,
|
|
427
937
|
description:
|
|
428
938
|
options.description ??
|
|
429
|
-
`
|
|
939
|
+
`Propose changes to the ${collection ?? name ?? resolvedFormId} form for local human review`,
|
|
430
940
|
inputSchema: formInputSchema(),
|
|
431
941
|
annotations: { readOnlyHint: false },
|
|
432
|
-
execute:
|
|
942
|
+
execute: stageForWebMcp,
|
|
433
943
|
};
|
|
434
944
|
});
|
|
435
945
|
|
|
@@ -550,12 +1060,24 @@ async function extractFields(text: string): Promise<Record<string, unknown>> {
|
|
|
550
1060
|
}
|
|
551
1061
|
|
|
552
1062
|
// Apply extracted values to fields with cleanup
|
|
553
|
-
function applyExtractedValues(values: Record<string, unknown>) {
|
|
1063
|
+
async function applyExtractedValues(values: Record<string, unknown>) {
|
|
554
1064
|
for (const [name, value] of Object.entries(values)) {
|
|
555
1065
|
const field = fields.get(name);
|
|
556
1066
|
if (field && value !== undefined && value !== null) {
|
|
557
1067
|
const cleanedValue = cleanValue(value, field.type);
|
|
558
|
-
|
|
1068
|
+
try {
|
|
1069
|
+
const preparedValue = field.prepareExtractedValue
|
|
1070
|
+
? await field.prepareExtractedValue(cleanedValue)
|
|
1071
|
+
: (field.prepareValue?.(cleanedValue) ?? cleanedValue);
|
|
1072
|
+
field.setValue(preparedValue);
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
// One uncanonicalizable transcript fragment must not abort later
|
|
1075
|
+
// extracted fields or expose an internal preparation error to the UI.
|
|
1076
|
+
logger.warn('Form: skipped invalid extracted field value', {
|
|
1077
|
+
field: name,
|
|
1078
|
+
error,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
559
1081
|
}
|
|
560
1082
|
}
|
|
561
1083
|
}
|
|
@@ -815,7 +1337,7 @@ async function stopFormListening() {
|
|
|
815
1337
|
|
|
816
1338
|
try {
|
|
817
1339
|
const values = await extractFields(textToExtract);
|
|
818
|
-
applyExtractedValues(values);
|
|
1340
|
+
await applyExtractedValues(values);
|
|
819
1341
|
} catch (err) {
|
|
820
1342
|
extractError =
|
|
821
1343
|
err instanceof Error ? err.message : 'Failed to extract fields';
|
|
@@ -838,7 +1360,7 @@ onDestroy(() => {
|
|
|
838
1360
|
}
|
|
839
1361
|
stopAudioLevelMonitoring();
|
|
840
1362
|
for (const registration of interactionDisposers.values()) {
|
|
841
|
-
registration
|
|
1363
|
+
disposeInteraction(registration);
|
|
842
1364
|
}
|
|
843
1365
|
interactionDisposers.clear();
|
|
844
1366
|
});
|
|
@@ -853,7 +1375,7 @@ function handleSubmit(e: Event) {
|
|
|
853
1375
|
// This allows native form submission for SvelteKit form actions
|
|
854
1376
|
if (onsubmit) {
|
|
855
1377
|
e.preventDefault();
|
|
856
|
-
void
|
|
1378
|
+
void submitCurrentForm().catch((error) => {
|
|
857
1379
|
logger.error('Form submit failed', { error });
|
|
858
1380
|
});
|
|
859
1381
|
}
|
|
@@ -874,6 +1396,7 @@ export function getInteractionRegistry(): ControlInteractionRegistry {
|
|
|
874
1396
|
</script>
|
|
875
1397
|
|
|
876
1398
|
<form
|
|
1399
|
+
bind:this={formElement}
|
|
877
1400
|
{id}
|
|
878
1401
|
{name}
|
|
879
1402
|
class="smrt-form {className}"
|
|
@@ -984,6 +1507,12 @@ export function getInteractionRegistry(): ControlInteractionRegistry {
|
|
|
984
1507
|
<div class="form-fields">
|
|
985
1508
|
{@render children()}
|
|
986
1509
|
</div>
|
|
1510
|
+
<StagedControlReview
|
|
1511
|
+
registry={resolvedInteractionRegistry}
|
|
1512
|
+
formId={resolvedFormId}
|
|
1513
|
+
{formElement}
|
|
1514
|
+
summary={stagedReview}
|
|
1515
|
+
/>
|
|
987
1516
|
</form>
|
|
988
1517
|
|
|
989
1518
|
{#if isFormListening && spokenText}
|