@happyvertical/smrt-svelte 0.43.3 → 0.43.5
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 +45 -0
- package/README.md +73 -0
- package/dist/Provider.svelte +83 -26
- package/dist/Provider.svelte.d.ts +4 -12
- package/dist/Provider.svelte.d.ts.map +1 -1
- package/dist/__tests__/provider-webmcp-harness.svelte +14 -0
- package/dist/__tests__/provider-webmcp-harness.svelte.d.ts +8 -0
- package/dist/__tests__/provider-webmcp-harness.svelte.d.ts.map +1 -0
- 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 +667 -61
- 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 +2120 -111
- 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/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/provider-webmcp.test.js +244 -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__/provider-ui-registry.fixture.svelte +43 -0
- package/dist/web/__tests__/provider-ui-registry.fixture.svelte.d.ts +13 -0
- package/dist/web/__tests__/provider-ui-registry.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/provider-ui-registry.test.js +235 -0
- 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 +586 -0
- package/dist/web/__tests__/webmcp.test.js +21 -1
- package/dist/web/index.d.ts +3 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +2 -0
- package/dist/web/webmcp-provider.d.ts +23 -0
- package/dist/web/webmcp-provider.d.ts.map +1 -0
- package/dist/web/webmcp-provider.js +1 -0
- package/dist/web/webmcp-ui-context.d.ts +12 -0
- package/dist/web/webmcp-ui-context.d.ts.map +1 -0
- package/dist/web/webmcp-ui-context.js +16 -0
- package/dist/web/webmcp-ui.d.ts +21 -0
- package/dist/web/webmcp-ui.d.ts.map +1 -0
- package/dist/web/webmcp-ui.js +474 -0
- package/dist/web/webmcp.d.ts +1 -1
- package/dist/web/webmcp.svelte.d.ts +4 -1
- 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';
|
|
@@ -19,6 +23,7 @@ import {
|
|
|
19
23
|
setFormContext,
|
|
20
24
|
} from '../../state/form-context.js';
|
|
21
25
|
import { useWebMcpTool } from '../../web/webmcp.svelte.js';
|
|
26
|
+
import { tryGetWebMcpUiContext } from '../../web/webmcp-ui-context.js';
|
|
22
27
|
import type { LLMModelId, STTAdapterType } from './types.js';
|
|
23
28
|
|
|
24
29
|
const { t } = useI18n();
|
|
@@ -38,7 +43,7 @@ export interface Props {
|
|
|
38
43
|
llmModel?: LLMModelId;
|
|
39
44
|
/** Called when form is submitted (if provided, prevents native submission) */
|
|
40
45
|
onsubmit?: (data: Record<string, unknown>) => void | Promise<void>;
|
|
41
|
-
/**
|
|
46
|
+
/** Let WebMCP propose field changes for local human review. */
|
|
42
47
|
webmcp?: boolean | { name?: string; description?: string };
|
|
43
48
|
/** Collection/model identity used when naming the generated intent. */
|
|
44
49
|
collection?: string;
|
|
@@ -48,8 +53,12 @@ export interface Props {
|
|
|
48
53
|
action?: string;
|
|
49
54
|
/** Stable identity used by control/agent interaction adapters. */
|
|
50
55
|
formId?: string;
|
|
56
|
+
/** Default record-qualified identity for registered fields. */
|
|
57
|
+
subject?: ControlSubject;
|
|
51
58
|
interactionRegistry?: ControlInteractionRegistry;
|
|
52
59
|
oninteraction?: (event: ControlInteractionEvent) => void;
|
|
60
|
+
/** Render the built-in human review surface for staged changes. */
|
|
61
|
+
stagedReview?: boolean;
|
|
53
62
|
id?: string;
|
|
54
63
|
name?: string;
|
|
55
64
|
class?: string;
|
|
@@ -68,8 +77,10 @@ const {
|
|
|
68
77
|
method,
|
|
69
78
|
action,
|
|
70
79
|
formId,
|
|
80
|
+
subject,
|
|
71
81
|
interactionRegistry,
|
|
72
82
|
oninteraction,
|
|
83
|
+
stagedReview = true,
|
|
73
84
|
id,
|
|
74
85
|
name,
|
|
75
86
|
class: className = '',
|
|
@@ -80,11 +91,27 @@ const stt = useSTT();
|
|
|
80
91
|
const instanceId = $props.id();
|
|
81
92
|
const generatedFormId = `smrt-form-${instanceId}`;
|
|
82
93
|
const localInteractionRegistry = createControlInteractionRegistry();
|
|
94
|
+
const providerWebMcpUi = tryGetWebMcpUiContext();
|
|
83
95
|
const resolvedFormId = $derived(formId ?? id ?? name ?? generatedFormId);
|
|
84
96
|
const resolvedInteractionRegistry = $derived(
|
|
85
|
-
interactionRegistry ??
|
|
97
|
+
interactionRegistry ??
|
|
98
|
+
(providerWebMcpUi?.enabled
|
|
99
|
+
? providerWebMcpUi.controlRegistry
|
|
100
|
+
: undefined) ??
|
|
101
|
+
localInteractionRegistry,
|
|
86
102
|
);
|
|
87
|
-
|
|
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>();
|
|
112
|
+
let activeInteractionRegistry: ControlInteractionRegistry | undefined;
|
|
113
|
+
let activeInteractionFormId: string | undefined;
|
|
114
|
+
let formElement = $state<HTMLFormElement | null>(null);
|
|
88
115
|
|
|
89
116
|
setControlInteractionContext({
|
|
90
117
|
get formId() {
|
|
@@ -97,11 +124,23 @@ setControlInteractionContext({
|
|
|
97
124
|
|
|
98
125
|
$effect(() => {
|
|
99
126
|
if (!oninteraction) return;
|
|
100
|
-
return resolvedInteractionRegistry.subscribe(
|
|
127
|
+
return resolvedInteractionRegistry.subscribe((event) => {
|
|
128
|
+
if (event.identity.formId !== resolvedFormId) return;
|
|
129
|
+
try {
|
|
130
|
+
oninteraction(event);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
logger.warn('Form: interaction observer failed', { error });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
101
135
|
});
|
|
102
136
|
|
|
103
137
|
// Internal state
|
|
104
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
|
+
>();
|
|
105
144
|
let isFormListening = $state(false);
|
|
106
145
|
let isExtracting = $state(false);
|
|
107
146
|
let spokenText = $state('');
|
|
@@ -132,49 +171,457 @@ function interactionKind(field: FieldDefinition): ControlKind {
|
|
|
132
171
|
}
|
|
133
172
|
}
|
|
134
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
|
+
|
|
308
|
+
function registerInteraction(
|
|
309
|
+
registry: ControlInteractionRegistry,
|
|
310
|
+
currentFormId: string,
|
|
311
|
+
field: FieldDefinition,
|
|
312
|
+
resolvedSubject: ControlSubject | undefined,
|
|
313
|
+
previous?: InteractionRegistration,
|
|
314
|
+
): InteractionRegistration | undefined {
|
|
315
|
+
const identity = {
|
|
316
|
+
formId: currentFormId,
|
|
317
|
+
controlId: field.controlId ?? field.name,
|
|
318
|
+
subject: resolvedSubject,
|
|
319
|
+
};
|
|
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) {
|
|
324
|
+
logger.warn('Form: duplicate interaction identity rejected', { identity });
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
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,
|
|
421
|
+
identity,
|
|
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
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let interactionRegistryRevision = $state(0);
|
|
466
|
+
|
|
467
|
+
$effect(() => {
|
|
468
|
+
const registry = resolvedInteractionRegistry;
|
|
469
|
+
return registry.subscribe((event) => {
|
|
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;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
$effect(() => {
|
|
482
|
+
const registry = resolvedInteractionRegistry;
|
|
483
|
+
const currentFormId = resolvedFormId;
|
|
484
|
+
const currentFields = new Map(fields);
|
|
485
|
+
const currentOwnershipKey = Array.from(currentFields.keys())
|
|
486
|
+
.sort()
|
|
487
|
+
.join('\0');
|
|
488
|
+
void interactionRegistryRevision;
|
|
489
|
+
if (
|
|
490
|
+
activeInteractionRegistry !== registry ||
|
|
491
|
+
activeInteractionFormId !== currentFormId
|
|
492
|
+
) {
|
|
493
|
+
for (const registration of interactionDisposers.values()) {
|
|
494
|
+
disposeInteraction(registration);
|
|
495
|
+
}
|
|
496
|
+
interactionDisposers.clear();
|
|
497
|
+
activeInteractionRegistry = registry;
|
|
498
|
+
activeInteractionFormId = currentFormId;
|
|
499
|
+
}
|
|
500
|
+
for (const [name, registration] of interactionDisposers) {
|
|
501
|
+
const nextField = currentFields.get(name);
|
|
502
|
+
if (!nextField) {
|
|
503
|
+
disposeInteraction(registration);
|
|
504
|
+
interactionDisposers.delete(name);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const nextSubject = resolvedFieldSubject(nextField);
|
|
508
|
+
if (
|
|
509
|
+
nextField === registration.field &&
|
|
510
|
+
sameSubject(registration.subject, nextSubject) &&
|
|
511
|
+
registry.get(registration.identity)
|
|
512
|
+
) {
|
|
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);
|
|
522
|
+
interactionDisposers.delete(name);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
for (const [name, field] of currentFields) {
|
|
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,
|
|
538
|
+
field,
|
|
539
|
+
resolvedSubject,
|
|
540
|
+
previous,
|
|
541
|
+
);
|
|
542
|
+
if (!registration) continue;
|
|
543
|
+
interactionDisposers.set(name, registration);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
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
|
+
|
|
135
569
|
// Create form context
|
|
136
570
|
const formContext: SMRTFormContext = {
|
|
137
571
|
get mode() {
|
|
138
572
|
return app.state.mode === 'smrt' ? 'smrt' : 'default';
|
|
139
573
|
},
|
|
140
574
|
registerField(field: FieldDefinition) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
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
|
+
};
|
|
172
607
|
},
|
|
173
608
|
unregisterField(name: string) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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);
|
|
178
625
|
},
|
|
179
626
|
getFieldSchema() {
|
|
180
627
|
return Array.from(fields.values());
|
|
@@ -203,6 +650,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
203
650
|
case 'measurement':
|
|
204
651
|
schema = {
|
|
205
652
|
type: 'object',
|
|
653
|
+
additionalProperties: false,
|
|
206
654
|
properties: {
|
|
207
655
|
value: {
|
|
208
656
|
type: 'number',
|
|
@@ -226,6 +674,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
226
674
|
case 'daterange':
|
|
227
675
|
schema = {
|
|
228
676
|
type: 'object',
|
|
677
|
+
additionalProperties: false,
|
|
229
678
|
properties: {
|
|
230
679
|
startDate: { type: 'string' },
|
|
231
680
|
endDate: { type: 'string' },
|
|
@@ -238,6 +687,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
238
687
|
case 'address':
|
|
239
688
|
schema = {
|
|
240
689
|
type: 'object',
|
|
690
|
+
additionalProperties: false,
|
|
241
691
|
properties: {
|
|
242
692
|
street: { type: 'string' },
|
|
243
693
|
city: { type: 'string' },
|
|
@@ -259,9 +709,14 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
259
709
|
};
|
|
260
710
|
break;
|
|
261
711
|
case 'number':
|
|
262
|
-
case 'money':
|
|
263
712
|
schema = { type: 'number' };
|
|
264
713
|
break;
|
|
714
|
+
case 'money':
|
|
715
|
+
schema = {
|
|
716
|
+
type: 'integer',
|
|
717
|
+
description: 'Amount in minor currency units (cents)',
|
|
718
|
+
};
|
|
719
|
+
break;
|
|
265
720
|
case 'checkbox':
|
|
266
721
|
schema = { type: 'boolean' };
|
|
267
722
|
break;
|
|
@@ -271,14 +726,25 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
271
726
|
}
|
|
272
727
|
|
|
273
728
|
if (field.label) schema.title = field.label;
|
|
274
|
-
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
|
+
}
|
|
275
735
|
if (field.options) {
|
|
276
736
|
schema.enum = field.options.map((option) => option.value);
|
|
277
737
|
}
|
|
278
|
-
if (
|
|
738
|
+
if (
|
|
739
|
+
(schema.type === 'number' || schema.type === 'integer') &&
|
|
740
|
+
field.constraints?.min !== undefined
|
|
741
|
+
) {
|
|
279
742
|
schema.minimum = Number(field.constraints.min);
|
|
280
743
|
}
|
|
281
|
-
if (
|
|
744
|
+
if (
|
|
745
|
+
(schema.type === 'number' || schema.type === 'integer') &&
|
|
746
|
+
field.constraints?.max !== undefined
|
|
747
|
+
) {
|
|
282
748
|
schema.maximum = Number(field.constraints.max);
|
|
283
749
|
}
|
|
284
750
|
if (schema.type === 'string' && field.constraints?.minLength !== undefined) {
|
|
@@ -293,17 +759,75 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
|
|
|
293
759
|
return schema;
|
|
294
760
|
}
|
|
295
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
|
+
|
|
296
810
|
function formInputSchema(): Record<string, unknown> {
|
|
811
|
+
void interactionRegistryRevision;
|
|
297
812
|
const properties: Record<string, unknown> = {};
|
|
298
813
|
const required: string[] = [];
|
|
299
814
|
|
|
300
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;
|
|
301
824
|
properties[field.name] = webMcpFieldSchema(field);
|
|
302
825
|
if (field.constraints?.required) required.push(field.name);
|
|
303
826
|
}
|
|
304
827
|
|
|
305
828
|
return {
|
|
306
829
|
type: 'object',
|
|
830
|
+
additionalProperties: false,
|
|
307
831
|
properties,
|
|
308
832
|
...(required.length > 0 ? { required } : {}),
|
|
309
833
|
};
|
|
@@ -312,16 +836,15 @@ function formInputSchema(): Record<string, unknown> {
|
|
|
312
836
|
// Provide context to children
|
|
313
837
|
setFormContext(formContext);
|
|
314
838
|
|
|
315
|
-
async function
|
|
316
|
-
// Stage tool arguments through the same field setters used by native input
|
|
317
|
-
// controls, then run the shared validation and submit path.
|
|
318
|
-
for (const [name, value] of Object.entries(args)) {
|
|
319
|
-
fields.get(name)?.setValue(value);
|
|
320
|
-
}
|
|
321
|
-
|
|
839
|
+
async function submitCurrentForm(): Promise<string> {
|
|
322
840
|
const data: Record<string, unknown> = {};
|
|
323
841
|
let valid = true;
|
|
324
|
-
|
|
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) {
|
|
325
848
|
data[name] = field.getValue();
|
|
326
849
|
if (
|
|
327
850
|
field.constraints?.required &&
|
|
@@ -329,9 +852,15 @@ async function submitForWebMcp(args: Record<string, unknown>): Promise<string> {
|
|
|
329
852
|
) {
|
|
330
853
|
valid = false;
|
|
331
854
|
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
for (const [, field] of fieldSnapshot) {
|
|
332
858
|
if (field.validate) {
|
|
333
859
|
try {
|
|
334
|
-
|
|
860
|
+
const validation = field.validate();
|
|
861
|
+
valid =
|
|
862
|
+
(typeof validation === 'boolean' ? validation : await validation) &&
|
|
863
|
+
valid;
|
|
335
864
|
} catch {
|
|
336
865
|
valid = false;
|
|
337
866
|
}
|
|
@@ -344,17 +873,73 @@ async function submitForWebMcp(args: Record<string, unknown>): Promise<string> {
|
|
|
344
873
|
return 'Submitted successfully';
|
|
345
874
|
}
|
|
346
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
|
+
|
|
347
932
|
useWebMcpTool(() => {
|
|
348
933
|
if (!webmcp) return null;
|
|
349
934
|
const options = typeof webmcp === 'object' ? webmcp : {};
|
|
350
935
|
return {
|
|
351
|
-
name: options.name ?? `${collection ?? resolvedFormId}
|
|
936
|
+
name: options.name ?? `${collection ?? resolvedFormId}_stage_changes`,
|
|
352
937
|
description:
|
|
353
938
|
options.description ??
|
|
354
|
-
`
|
|
939
|
+
`Propose changes to the ${collection ?? name ?? resolvedFormId} form for local human review`,
|
|
355
940
|
inputSchema: formInputSchema(),
|
|
356
941
|
annotations: { readOnlyHint: false },
|
|
357
|
-
execute:
|
|
942
|
+
execute: stageForWebMcp,
|
|
358
943
|
};
|
|
359
944
|
});
|
|
360
945
|
|
|
@@ -475,12 +1060,24 @@ async function extractFields(text: string): Promise<Record<string, unknown>> {
|
|
|
475
1060
|
}
|
|
476
1061
|
|
|
477
1062
|
// Apply extracted values to fields with cleanup
|
|
478
|
-
function applyExtractedValues(values: Record<string, unknown>) {
|
|
1063
|
+
async function applyExtractedValues(values: Record<string, unknown>) {
|
|
479
1064
|
for (const [name, value] of Object.entries(values)) {
|
|
480
1065
|
const field = fields.get(name);
|
|
481
1066
|
if (field && value !== undefined && value !== null) {
|
|
482
1067
|
const cleanedValue = cleanValue(value, field.type);
|
|
483
|
-
|
|
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
|
+
}
|
|
484
1081
|
}
|
|
485
1082
|
}
|
|
486
1083
|
}
|
|
@@ -740,7 +1337,7 @@ async function stopFormListening() {
|
|
|
740
1337
|
|
|
741
1338
|
try {
|
|
742
1339
|
const values = await extractFields(textToExtract);
|
|
743
|
-
applyExtractedValues(values);
|
|
1340
|
+
await applyExtractedValues(values);
|
|
744
1341
|
} catch (err) {
|
|
745
1342
|
extractError =
|
|
746
1343
|
err instanceof Error ? err.message : 'Failed to extract fields';
|
|
@@ -762,7 +1359,9 @@ onDestroy(() => {
|
|
|
762
1359
|
clearTimeout(silenceTimer);
|
|
763
1360
|
}
|
|
764
1361
|
stopAudioLevelMonitoring();
|
|
765
|
-
for (const
|
|
1362
|
+
for (const registration of interactionDisposers.values()) {
|
|
1363
|
+
disposeInteraction(registration);
|
|
1364
|
+
}
|
|
766
1365
|
interactionDisposers.clear();
|
|
767
1366
|
});
|
|
768
1367
|
|
|
@@ -776,7 +1375,7 @@ function handleSubmit(e: Event) {
|
|
|
776
1375
|
// This allows native form submission for SvelteKit form actions
|
|
777
1376
|
if (onsubmit) {
|
|
778
1377
|
e.preventDefault();
|
|
779
|
-
void
|
|
1378
|
+
void submitCurrentForm().catch((error) => {
|
|
780
1379
|
logger.error('Form submit failed', { error });
|
|
781
1380
|
});
|
|
782
1381
|
}
|
|
@@ -797,6 +1396,7 @@ export function getInteractionRegistry(): ControlInteractionRegistry {
|
|
|
797
1396
|
</script>
|
|
798
1397
|
|
|
799
1398
|
<form
|
|
1399
|
+
bind:this={formElement}
|
|
800
1400
|
{id}
|
|
801
1401
|
{name}
|
|
802
1402
|
class="smrt-form {className}"
|
|
@@ -907,6 +1507,12 @@ export function getInteractionRegistry(): ControlInteractionRegistry {
|
|
|
907
1507
|
<div class="form-fields">
|
|
908
1508
|
{@render children()}
|
|
909
1509
|
</div>
|
|
1510
|
+
<StagedControlReview
|
|
1511
|
+
registry={resolvedInteractionRegistry}
|
|
1512
|
+
formId={resolvedFormId}
|
|
1513
|
+
{formElement}
|
|
1514
|
+
summary={stagedReview}
|
|
1515
|
+
/>
|
|
910
1516
|
</form>
|
|
911
1517
|
|
|
912
1518
|
{#if isFormListening && spokenText}
|