@elizaos/plugin-form 2.0.3-beta.5 → 2.0.3-beta.7
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/dist/actions/form.d.ts +31 -0
- package/dist/actions/form.d.ts.map +1 -0
- package/dist/actions/form.js +187 -0
- package/dist/actions/form.js.map +1 -0
- package/dist/builder.d.ts +320 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +458 -0
- package/dist/builder.js.map +1 -0
- package/dist/builtins.d.ts +128 -0
- package/dist/builtins.d.ts.map +1 -0
- package/dist/builtins.js +233 -0
- package/dist/builtins.js.map +1 -0
- package/dist/defaults.d.ts +95 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +79 -0
- package/dist/defaults.js.map +1 -0
- package/dist/evaluators/extractor.d.ts +28 -0
- package/dist/evaluators/extractor.d.ts.map +1 -0
- package/dist/evaluators/extractor.js +251 -0
- package/dist/evaluators/extractor.js.map +1 -0
- package/dist/extraction.d.ts +55 -0
- package/dist/extraction.d.ts.map +1 -0
- package/dist/extraction.js +347 -0
- package/dist/extraction.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/context.d.ts +56 -0
- package/dist/providers/context.d.ts.map +1 -0
- package/dist/providers/context.js +204 -0
- package/dist/providers/context.js.map +1 -0
- package/dist/service.d.ts +402 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +1199 -0
- package/dist/service.js.map +1 -0
- package/dist/storage.d.ts +228 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +255 -0
- package/dist/storage.js.map +1 -0
- package/dist/template.d.ts +10 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +60 -0
- package/dist/template.js.map +1 -0
- package/dist/ttl.d.ts +144 -0
- package/dist/ttl.d.ts.map +1 -0
- package/dist/ttl.js +85 -0
- package/dist/ttl.js.map +1 -0
- package/dist/types.d.ts +1213 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +39 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +156 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +289 -0
- package/dist/validation.js.map +1 -0
- package/package.json +3 -3
package/dist/service.js
ADDED
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
import {
|
|
2
|
+
logger,
|
|
3
|
+
Service
|
|
4
|
+
} from "@elizaos/core";
|
|
5
|
+
import { v4 as uuidv4 } from "uuid";
|
|
6
|
+
import { registerBuiltinTypes } from "./builtins.js";
|
|
7
|
+
import {
|
|
8
|
+
getAutofillData,
|
|
9
|
+
getSessionById,
|
|
10
|
+
saveAutofillData,
|
|
11
|
+
saveSubmission,
|
|
12
|
+
getActiveSession as storageGetActiveSession,
|
|
13
|
+
getAllActiveSessions as storageGetAllActiveSessions,
|
|
14
|
+
getStashedSessions as storageGetStashedSessions,
|
|
15
|
+
getSubmissions as storageGetSubmissions,
|
|
16
|
+
saveSession as storageSaveSession
|
|
17
|
+
} from "./storage.js";
|
|
18
|
+
import { FORM_CONTROL_DEFAULTS, FORM_DEFINITION_DEFAULTS } from "./types.js";
|
|
19
|
+
import { formatValue, validateField } from "./validation.js";
|
|
20
|
+
const UNSAFE_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
21
|
+
function assertSafeObjectKey(kind, key) {
|
|
22
|
+
if (typeof key !== "string" || key.trim() === "") {
|
|
23
|
+
throw new Error(`${kind} must be a non-empty string`);
|
|
24
|
+
}
|
|
25
|
+
if (UNSAFE_OBJECT_KEYS.has(key)) {
|
|
26
|
+
throw new Error(`${kind} uses unsafe object key: ${key}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function createValueMap() {
|
|
30
|
+
return /* @__PURE__ */ Object.create(null);
|
|
31
|
+
}
|
|
32
|
+
class FormService extends Service {
|
|
33
|
+
/** Service type identifier for runtime.getService() */
|
|
34
|
+
static serviceType = "FORM";
|
|
35
|
+
/** Description shown in agent capabilities */
|
|
36
|
+
capabilityDescription = "Manages conversational forms for data collection";
|
|
37
|
+
/**
|
|
38
|
+
* In-memory storage of form definitions.
|
|
39
|
+
*
|
|
40
|
+
* WHY Map:
|
|
41
|
+
* - O(1) lookup by ID
|
|
42
|
+
* - Forms are static after registration
|
|
43
|
+
* - No persistence needed (re-registered on startup)
|
|
44
|
+
*/
|
|
45
|
+
forms = /* @__PURE__ */ new Map();
|
|
46
|
+
/**
|
|
47
|
+
* Control type registry.
|
|
48
|
+
*
|
|
49
|
+
* Built-in types are registered on start.
|
|
50
|
+
* Plugins can register custom types.
|
|
51
|
+
*/
|
|
52
|
+
controlTypes = /* @__PURE__ */ new Map();
|
|
53
|
+
/**
|
|
54
|
+
* Start the FormService
|
|
55
|
+
*/
|
|
56
|
+
static async start(runtime) {
|
|
57
|
+
const service = new FormService(runtime);
|
|
58
|
+
registerBuiltinTypes(
|
|
59
|
+
(type, options) => service.registerControlType(type, options)
|
|
60
|
+
);
|
|
61
|
+
logger.info("[FormService] Started with built-in types");
|
|
62
|
+
return service;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Stop the FormService
|
|
66
|
+
*/
|
|
67
|
+
async stop() {
|
|
68
|
+
logger.info("[FormService] Stopped");
|
|
69
|
+
}
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// FORM DEFINITION MANAGEMENT
|
|
72
|
+
// ============================================================================
|
|
73
|
+
/**
|
|
74
|
+
* Register a form definition
|
|
75
|
+
*/
|
|
76
|
+
registerForm(definition) {
|
|
77
|
+
assertSafeObjectKey("Form id", definition?.id);
|
|
78
|
+
if (!Array.isArray(definition.controls)) {
|
|
79
|
+
throw new Error("Form controls must be an array");
|
|
80
|
+
}
|
|
81
|
+
const controlKeys = /* @__PURE__ */ new Set();
|
|
82
|
+
for (const control of definition.controls) {
|
|
83
|
+
assertSafeObjectKey("Control key", control?.key);
|
|
84
|
+
if (controlKeys.has(control.key)) {
|
|
85
|
+
throw new Error(`Duplicate control key: ${control.key}`);
|
|
86
|
+
}
|
|
87
|
+
controlKeys.add(control.key);
|
|
88
|
+
if (control.dbbind !== void 0) {
|
|
89
|
+
assertSafeObjectKey("Control dbbind", control.dbbind);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const form = {
|
|
93
|
+
...definition,
|
|
94
|
+
version: definition.version ?? FORM_DEFINITION_DEFAULTS.version,
|
|
95
|
+
status: definition.status ?? FORM_DEFINITION_DEFAULTS.status,
|
|
96
|
+
ux: { ...FORM_DEFINITION_DEFAULTS.ux, ...definition.ux },
|
|
97
|
+
ttl: { ...FORM_DEFINITION_DEFAULTS.ttl, ...definition.ttl },
|
|
98
|
+
nudge: { ...FORM_DEFINITION_DEFAULTS.nudge, ...definition.nudge },
|
|
99
|
+
debug: definition.debug ?? FORM_DEFINITION_DEFAULTS.debug,
|
|
100
|
+
controls: definition.controls.map((control) => ({
|
|
101
|
+
...control,
|
|
102
|
+
type: control.type || FORM_CONTROL_DEFAULTS.type,
|
|
103
|
+
required: control.required ?? FORM_CONTROL_DEFAULTS.required,
|
|
104
|
+
confirmThreshold: control.confirmThreshold ?? FORM_CONTROL_DEFAULTS.confirmThreshold,
|
|
105
|
+
label: control.label || prettify(control.key)
|
|
106
|
+
}))
|
|
107
|
+
};
|
|
108
|
+
this.forms.set(form.id, form);
|
|
109
|
+
logger.debug(`[FormService] Registered form: ${form.id}`);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Get a form definition by ID
|
|
113
|
+
*/
|
|
114
|
+
getForm(formId) {
|
|
115
|
+
return this.forms.get(formId);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Get all registered forms
|
|
119
|
+
*/
|
|
120
|
+
listForms() {
|
|
121
|
+
return Array.from(this.forms.values());
|
|
122
|
+
}
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// CONTROL TYPE REGISTRY
|
|
125
|
+
// ============================================================================
|
|
126
|
+
/**
|
|
127
|
+
* Register a control type.
|
|
128
|
+
*
|
|
129
|
+
* Control types define how a field type behaves:
|
|
130
|
+
* - Simple types: validate/parse/format
|
|
131
|
+
* - Composite types: have subcontrols
|
|
132
|
+
* - External types: have activate/deactivate for async processes
|
|
133
|
+
*
|
|
134
|
+
* Built-in types (text, number, email, etc.) are registered at startup
|
|
135
|
+
* and protected from override unless explicitly allowed.
|
|
136
|
+
*
|
|
137
|
+
* @param type - The ControlType definition
|
|
138
|
+
* @param options - Registration options
|
|
139
|
+
* @param options.allowOverride - Allow overriding built-in types (default: false)
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```typescript
|
|
143
|
+
* formService.registerControlType({
|
|
144
|
+
* id: 'payment',
|
|
145
|
+
* getSubControls: () => [
|
|
146
|
+
* { key: 'amount', type: 'number', label: 'Amount', required: true },
|
|
147
|
+
* { key: 'currency', type: 'select', label: 'Currency', required: true },
|
|
148
|
+
* ],
|
|
149
|
+
* activate: async (ctx) => {
|
|
150
|
+
* const paymentService = ctx.runtime.getService('PAYMENT');
|
|
151
|
+
* return paymentService.createPending(ctx.subValues);
|
|
152
|
+
* },
|
|
153
|
+
* });
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
registerControlType(type, options) {
|
|
157
|
+
const existing = this.controlTypes.get(type.id);
|
|
158
|
+
if (existing) {
|
|
159
|
+
if (existing.builtin && !options?.allowOverride) {
|
|
160
|
+
logger.warn(
|
|
161
|
+
`[FormService] Cannot override builtin type '${type.id}' without allowOverride: true`
|
|
162
|
+
);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
logger.warn(`[FormService] Overriding control type: ${type.id}`);
|
|
166
|
+
}
|
|
167
|
+
this.controlTypes.set(type.id, type);
|
|
168
|
+
logger.debug(`[FormService] Registered control type: ${type.id}`);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Get a control type by ID.
|
|
172
|
+
*
|
|
173
|
+
* @param typeId - The type ID to look up
|
|
174
|
+
* @returns The ControlType or undefined if not found
|
|
175
|
+
*/
|
|
176
|
+
getControlType(typeId) {
|
|
177
|
+
return this.controlTypes.get(typeId);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* List all registered control types.
|
|
181
|
+
*
|
|
182
|
+
* @returns Array of all registered ControlTypes
|
|
183
|
+
*/
|
|
184
|
+
listControlTypes() {
|
|
185
|
+
return Array.from(this.controlTypes.values());
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Check if a control type has subcontrols.
|
|
189
|
+
*
|
|
190
|
+
* @param typeId - The type ID to check
|
|
191
|
+
* @returns true if the type has getSubControls method
|
|
192
|
+
*/
|
|
193
|
+
isCompositeType(typeId) {
|
|
194
|
+
const type = this.controlTypes.get(typeId);
|
|
195
|
+
return !!type?.getSubControls;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Check if a control type is an external type.
|
|
199
|
+
*
|
|
200
|
+
* @param typeId - The type ID to check
|
|
201
|
+
* @returns true if the type has activate method
|
|
202
|
+
*/
|
|
203
|
+
isExternalType(typeId) {
|
|
204
|
+
const type = this.controlTypes.get(typeId);
|
|
205
|
+
return !!type?.activate;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Get subcontrols for a composite type.
|
|
209
|
+
*
|
|
210
|
+
* @param control - The parent control
|
|
211
|
+
* @returns Array of subcontrols or empty array if not composite
|
|
212
|
+
*/
|
|
213
|
+
getSubControls(control) {
|
|
214
|
+
const type = this.controlTypes.get(control.type);
|
|
215
|
+
if (!type?.getSubControls) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
return type.getSubControls(control, this.runtime);
|
|
219
|
+
}
|
|
220
|
+
// ============================================================================
|
|
221
|
+
// SESSION MANAGEMENT
|
|
222
|
+
// ============================================================================
|
|
223
|
+
/**
|
|
224
|
+
* Start a new form session
|
|
225
|
+
*/
|
|
226
|
+
async startSession(formId, entityId, roomId, options) {
|
|
227
|
+
const form = this.getForm(formId);
|
|
228
|
+
if (!form) {
|
|
229
|
+
throw new Error(`Form not found: ${formId}`);
|
|
230
|
+
}
|
|
231
|
+
const existing = await storageGetActiveSession(
|
|
232
|
+
this.runtime,
|
|
233
|
+
entityId,
|
|
234
|
+
roomId
|
|
235
|
+
);
|
|
236
|
+
if (existing) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Active session already exists for this user/room: ${existing.id}`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
const fields = createValueMap();
|
|
243
|
+
for (const control of form.controls) {
|
|
244
|
+
if (options?.initialValues?.[control.key] !== void 0) {
|
|
245
|
+
const validation = validateField(
|
|
246
|
+
options.initialValues[control.key],
|
|
247
|
+
control
|
|
248
|
+
);
|
|
249
|
+
fields[control.key] = {
|
|
250
|
+
status: validation.valid ? "filled" : "invalid",
|
|
251
|
+
value: options.initialValues[control.key],
|
|
252
|
+
source: "manual",
|
|
253
|
+
updatedAt: now,
|
|
254
|
+
error: validation.error
|
|
255
|
+
};
|
|
256
|
+
} else if (control.defaultValue !== void 0) {
|
|
257
|
+
const validation = validateField(control.defaultValue, control);
|
|
258
|
+
fields[control.key] = {
|
|
259
|
+
status: validation.valid ? "filled" : "invalid",
|
|
260
|
+
value: control.defaultValue,
|
|
261
|
+
source: "default",
|
|
262
|
+
updatedAt: now,
|
|
263
|
+
error: validation.error
|
|
264
|
+
};
|
|
265
|
+
} else {
|
|
266
|
+
fields[control.key] = { status: "empty" };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const ttlDays = form.ttl?.minDays ?? 14;
|
|
270
|
+
const expiresAt = now + ttlDays * 24 * 60 * 60 * 1e3;
|
|
271
|
+
const session = {
|
|
272
|
+
id: uuidv4(),
|
|
273
|
+
formId,
|
|
274
|
+
formVersion: form.version,
|
|
275
|
+
entityId,
|
|
276
|
+
roomId,
|
|
277
|
+
status: "active",
|
|
278
|
+
fields,
|
|
279
|
+
history: [],
|
|
280
|
+
context: options?.context,
|
|
281
|
+
locale: options?.locale,
|
|
282
|
+
effort: {
|
|
283
|
+
interactionCount: 0,
|
|
284
|
+
timeSpentMs: 0,
|
|
285
|
+
firstInteractionAt: now,
|
|
286
|
+
lastInteractionAt: now
|
|
287
|
+
},
|
|
288
|
+
expiresAt,
|
|
289
|
+
createdAt: now,
|
|
290
|
+
updatedAt: now
|
|
291
|
+
};
|
|
292
|
+
await storageSaveSession(this.runtime, session);
|
|
293
|
+
if (form.hooks?.onStart) {
|
|
294
|
+
await this.executeHook(session, "onStart");
|
|
295
|
+
}
|
|
296
|
+
logger.debug(
|
|
297
|
+
`[FormService] Started session ${session.id} for form ${formId}`
|
|
298
|
+
);
|
|
299
|
+
return session;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Get active session for entity in room
|
|
303
|
+
*/
|
|
304
|
+
async getActiveSession(entityId, roomId) {
|
|
305
|
+
return storageGetActiveSession(this.runtime, entityId, roomId);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Get all active sessions for entity (across all rooms)
|
|
309
|
+
*/
|
|
310
|
+
async getAllActiveSessions(entityId) {
|
|
311
|
+
return storageGetAllActiveSessions(this.runtime, entityId);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Get stashed sessions for entity
|
|
315
|
+
*/
|
|
316
|
+
async getStashedSessions(entityId) {
|
|
317
|
+
return storageGetStashedSessions(this.runtime, entityId);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Save a session
|
|
321
|
+
*/
|
|
322
|
+
async saveSession(session) {
|
|
323
|
+
session.updatedAt = Date.now();
|
|
324
|
+
await storageSaveSession(this.runtime, session);
|
|
325
|
+
}
|
|
326
|
+
// ============================================================================
|
|
327
|
+
// FIELD UPDATES
|
|
328
|
+
// ============================================================================
|
|
329
|
+
/**
|
|
330
|
+
* Update a field value
|
|
331
|
+
*/
|
|
332
|
+
async updateField(sessionId, entityId, field, value, confidence, source, messageId) {
|
|
333
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
334
|
+
if (!session) {
|
|
335
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
336
|
+
}
|
|
337
|
+
const form = this.getForm(session.formId);
|
|
338
|
+
if (!form) {
|
|
339
|
+
throw new Error(`Form not found: ${session.formId}`);
|
|
340
|
+
}
|
|
341
|
+
const control = form.controls.find((c) => c.key === field);
|
|
342
|
+
if (!control) {
|
|
343
|
+
throw new Error(`Field not found: ${field}`);
|
|
344
|
+
}
|
|
345
|
+
const oldValue = session.fields[field]?.value;
|
|
346
|
+
const validation = validateField(value, control);
|
|
347
|
+
let status;
|
|
348
|
+
if (!validation.valid) {
|
|
349
|
+
status = "invalid";
|
|
350
|
+
} else if (confidence < (control.confirmThreshold ?? 0.8)) {
|
|
351
|
+
status = "uncertain";
|
|
352
|
+
} else {
|
|
353
|
+
status = "filled";
|
|
354
|
+
}
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
if (oldValue !== void 0) {
|
|
357
|
+
const historyEntry = {
|
|
358
|
+
field,
|
|
359
|
+
oldValue,
|
|
360
|
+
newValue: value,
|
|
361
|
+
timestamp: now
|
|
362
|
+
};
|
|
363
|
+
session.history.push(historyEntry);
|
|
364
|
+
const maxUndo = form.ux?.maxUndoSteps ?? 5;
|
|
365
|
+
if (session.history.length > maxUndo) {
|
|
366
|
+
session.history = session.history.slice(-maxUndo);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
session.fields[field] = {
|
|
370
|
+
status,
|
|
371
|
+
value,
|
|
372
|
+
confidence,
|
|
373
|
+
source,
|
|
374
|
+
messageId,
|
|
375
|
+
updatedAt: now,
|
|
376
|
+
error: !validation.valid ? validation.error : void 0
|
|
377
|
+
};
|
|
378
|
+
session.effort.interactionCount++;
|
|
379
|
+
session.effort.lastInteractionAt = now;
|
|
380
|
+
session.effort.timeSpentMs = now - session.effort.firstInteractionAt;
|
|
381
|
+
session.expiresAt = this.calculateTTL(session);
|
|
382
|
+
const allRequiredFilled = this.checkAllRequiredFilled(session, form);
|
|
383
|
+
if (allRequiredFilled && session.status === "active") {
|
|
384
|
+
session.status = "ready";
|
|
385
|
+
if (form.hooks?.onReady) {
|
|
386
|
+
await this.executeHook(session, "onReady");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
session.updatedAt = now;
|
|
390
|
+
await storageSaveSession(this.runtime, session);
|
|
391
|
+
if (form.hooks?.onFieldChange) {
|
|
392
|
+
const hookPayload = { field, value };
|
|
393
|
+
if (oldValue !== void 0) {
|
|
394
|
+
hookPayload.oldValue = oldValue;
|
|
395
|
+
}
|
|
396
|
+
await this.executeHook(session, "onFieldChange", hookPayload);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Undo the last field change
|
|
401
|
+
*/
|
|
402
|
+
async undoLastChange(sessionId, entityId) {
|
|
403
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
404
|
+
if (!session) {
|
|
405
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
406
|
+
}
|
|
407
|
+
const form = this.getForm(session.formId);
|
|
408
|
+
if (!form?.ux?.allowUndo) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
const lastChange = session.history.pop();
|
|
412
|
+
if (!lastChange) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
if (lastChange.oldValue !== void 0) {
|
|
416
|
+
session.fields[lastChange.field] = {
|
|
417
|
+
status: "filled",
|
|
418
|
+
value: lastChange.oldValue,
|
|
419
|
+
source: "correction",
|
|
420
|
+
updatedAt: Date.now()
|
|
421
|
+
};
|
|
422
|
+
} else {
|
|
423
|
+
session.fields[lastChange.field] = { status: "empty" };
|
|
424
|
+
}
|
|
425
|
+
session.updatedAt = Date.now();
|
|
426
|
+
await storageSaveSession(this.runtime, session);
|
|
427
|
+
return { field: lastChange.field, restoredValue: lastChange.oldValue };
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Skip an optional field
|
|
431
|
+
*/
|
|
432
|
+
async skipField(sessionId, entityId, field) {
|
|
433
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
434
|
+
if (!session) {
|
|
435
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
436
|
+
}
|
|
437
|
+
const form = this.getForm(session.formId);
|
|
438
|
+
if (!form?.ux?.allowSkip) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
const control = form.controls.find((c) => c.key === field);
|
|
442
|
+
if (!control) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
if (control.required) {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
session.fields[field] = {
|
|
449
|
+
status: "skipped",
|
|
450
|
+
updatedAt: Date.now()
|
|
451
|
+
};
|
|
452
|
+
session.updatedAt = Date.now();
|
|
453
|
+
await storageSaveSession(this.runtime, session);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Confirm an uncertain field value
|
|
458
|
+
*/
|
|
459
|
+
async confirmField(sessionId, entityId, field, accepted) {
|
|
460
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
461
|
+
if (!session) {
|
|
462
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
463
|
+
}
|
|
464
|
+
const fieldState = session.fields[field];
|
|
465
|
+
if (!fieldState || fieldState.status !== "uncertain") {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const now = Date.now();
|
|
469
|
+
if (accepted) {
|
|
470
|
+
fieldState.status = "filled";
|
|
471
|
+
fieldState.confirmedAt = now;
|
|
472
|
+
} else {
|
|
473
|
+
fieldState.status = "empty";
|
|
474
|
+
fieldState.value = void 0;
|
|
475
|
+
fieldState.confidence = void 0;
|
|
476
|
+
}
|
|
477
|
+
fieldState.updatedAt = now;
|
|
478
|
+
session.updatedAt = now;
|
|
479
|
+
await storageSaveSession(this.runtime, session);
|
|
480
|
+
}
|
|
481
|
+
// ============================================================================
|
|
482
|
+
// SUBFIELD UPDATES (for composite types)
|
|
483
|
+
// ============================================================================
|
|
484
|
+
/**
|
|
485
|
+
* Update a subfield value for a composite control type.
|
|
486
|
+
*
|
|
487
|
+
* Composite types (like payment, address) have subcontrols that must
|
|
488
|
+
* all be filled before the parent field is complete.
|
|
489
|
+
*
|
|
490
|
+
* WHY separate from updateField:
|
|
491
|
+
* - Subfields are stored in fieldState.subFields, not session.fields
|
|
492
|
+
* - Parent field status depends on all subfields being filled
|
|
493
|
+
* - Allows tracking subfield confidence/status independently
|
|
494
|
+
*
|
|
495
|
+
* @param sessionId - The session ID
|
|
496
|
+
* @param entityId - The entity/user ID
|
|
497
|
+
* @param parentField - The parent control key (e.g., "payment")
|
|
498
|
+
* @param subField - The subcontrol key (e.g., "amount")
|
|
499
|
+
* @param value - The extracted value
|
|
500
|
+
* @param confidence - LLM confidence (0-1)
|
|
501
|
+
* @param messageId - Optional message ID for audit
|
|
502
|
+
*/
|
|
503
|
+
async updateSubField(sessionId, entityId, parentField, subField, value, confidence, messageId) {
|
|
504
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
505
|
+
if (!session) {
|
|
506
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
507
|
+
}
|
|
508
|
+
const form = this.getForm(session.formId);
|
|
509
|
+
if (!form) {
|
|
510
|
+
throw new Error(`Form not found: ${session.formId}`);
|
|
511
|
+
}
|
|
512
|
+
const parentControl = form.controls.find((c) => c.key === parentField);
|
|
513
|
+
if (!parentControl) {
|
|
514
|
+
throw new Error(`Parent field not found: ${parentField}`);
|
|
515
|
+
}
|
|
516
|
+
const controlType = this.getControlType(parentControl.type);
|
|
517
|
+
if (!controlType?.getSubControls) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`Control type '${parentControl.type}' is not a composite type`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
const subControls = controlType.getSubControls(parentControl, this.runtime);
|
|
523
|
+
const subControl = subControls.find((c) => c.key === subField);
|
|
524
|
+
if (!subControl) {
|
|
525
|
+
throw new Error(`Subfield not found: ${subField} in ${parentField}`);
|
|
526
|
+
}
|
|
527
|
+
const now = Date.now();
|
|
528
|
+
if (!session.fields[parentField]) {
|
|
529
|
+
session.fields[parentField] = { status: "empty" };
|
|
530
|
+
}
|
|
531
|
+
if (!session.fields[parentField].subFields) {
|
|
532
|
+
session.fields[parentField].subFields = {};
|
|
533
|
+
}
|
|
534
|
+
let subFieldStatus;
|
|
535
|
+
let error;
|
|
536
|
+
if (controlType.validate) {
|
|
537
|
+
const result = controlType.validate(value, subControl);
|
|
538
|
+
if (!result.valid) {
|
|
539
|
+
subFieldStatus = "invalid";
|
|
540
|
+
error = result.error;
|
|
541
|
+
} else if (confidence < (subControl.confirmThreshold ?? 0.8)) {
|
|
542
|
+
subFieldStatus = "uncertain";
|
|
543
|
+
} else {
|
|
544
|
+
subFieldStatus = "filled";
|
|
545
|
+
}
|
|
546
|
+
} else {
|
|
547
|
+
const validation = validateField(value, subControl);
|
|
548
|
+
if (!validation.valid) {
|
|
549
|
+
subFieldStatus = "invalid";
|
|
550
|
+
error = validation.error;
|
|
551
|
+
} else if (confidence < (subControl.confirmThreshold ?? 0.8)) {
|
|
552
|
+
subFieldStatus = "uncertain";
|
|
553
|
+
} else {
|
|
554
|
+
subFieldStatus = "filled";
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
session.fields[parentField].subFields[subField] = {
|
|
558
|
+
status: subFieldStatus,
|
|
559
|
+
value,
|
|
560
|
+
confidence,
|
|
561
|
+
source: "extraction",
|
|
562
|
+
messageId,
|
|
563
|
+
updatedAt: now,
|
|
564
|
+
error
|
|
565
|
+
};
|
|
566
|
+
session.effort.interactionCount++;
|
|
567
|
+
session.effort.lastInteractionAt = now;
|
|
568
|
+
session.effort.timeSpentMs = now - session.effort.firstInteractionAt;
|
|
569
|
+
session.updatedAt = now;
|
|
570
|
+
await storageSaveSession(this.runtime, session);
|
|
571
|
+
logger.debug(`[FormService] Updated subfield ${parentField}.${subField}`);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Check if all subfields of a composite field are filled.
|
|
575
|
+
*
|
|
576
|
+
* @param session - The form session
|
|
577
|
+
* @param parentField - The parent control key
|
|
578
|
+
* @returns true if all required subfields are filled
|
|
579
|
+
*/
|
|
580
|
+
areSubFieldsFilled(session, parentField) {
|
|
581
|
+
const form = this.getForm(session.formId);
|
|
582
|
+
if (!form) return false;
|
|
583
|
+
const parentControl = form.controls.find((c) => c.key === parentField);
|
|
584
|
+
if (!parentControl) return false;
|
|
585
|
+
const controlType = this.getControlType(parentControl.type);
|
|
586
|
+
if (!controlType?.getSubControls) return false;
|
|
587
|
+
const subControls = controlType.getSubControls(parentControl, this.runtime);
|
|
588
|
+
const subFields = session.fields[parentField]?.subFields || {};
|
|
589
|
+
for (const subControl of subControls) {
|
|
590
|
+
if (!subControl.required) continue;
|
|
591
|
+
const subField = subFields[subControl.key];
|
|
592
|
+
if (!subField || subField.status !== "filled") {
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Get the current subfield values for a composite field.
|
|
600
|
+
*
|
|
601
|
+
* @param session - The form session
|
|
602
|
+
* @param parentField - The parent control key
|
|
603
|
+
* @returns Record of subfield key to value
|
|
604
|
+
*/
|
|
605
|
+
getSubFieldValues(session, parentField) {
|
|
606
|
+
const subFields = session.fields[parentField]?.subFields || {};
|
|
607
|
+
const values = {};
|
|
608
|
+
for (const [key, state] of Object.entries(subFields)) {
|
|
609
|
+
if (state.value !== void 0) {
|
|
610
|
+
values[key] = state.value;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return values;
|
|
614
|
+
}
|
|
615
|
+
// ============================================================================
|
|
616
|
+
// EXTERNAL FIELD ACTIVATION
|
|
617
|
+
// ============================================================================
|
|
618
|
+
/**
|
|
619
|
+
* Activate an external field.
|
|
620
|
+
*
|
|
621
|
+
* External types (payment, signature) require an async activation process.
|
|
622
|
+
* This is called when all subcontrols are filled and the external process
|
|
623
|
+
* should begin (e.g., generate payment address, show signing instructions).
|
|
624
|
+
*
|
|
625
|
+
* WHY this method:
|
|
626
|
+
* - Decouples activation trigger from the widget itself
|
|
627
|
+
* - Stores activation state in the session
|
|
628
|
+
* - Provides a clear API for the evaluator to call
|
|
629
|
+
*
|
|
630
|
+
* @param sessionId - The session ID
|
|
631
|
+
* @param entityId - The entity/user ID
|
|
632
|
+
* @param field - The field key
|
|
633
|
+
* @returns The activation details (instructions, reference, etc.)
|
|
634
|
+
*/
|
|
635
|
+
async activateExternalField(sessionId, entityId, field) {
|
|
636
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
637
|
+
if (!session) {
|
|
638
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
639
|
+
}
|
|
640
|
+
const form = this.getForm(session.formId);
|
|
641
|
+
if (!form) {
|
|
642
|
+
throw new Error(`Form not found: ${session.formId}`);
|
|
643
|
+
}
|
|
644
|
+
const control = form.controls.find((c) => c.key === field);
|
|
645
|
+
if (!control) {
|
|
646
|
+
throw new Error(`Field not found: ${field}`);
|
|
647
|
+
}
|
|
648
|
+
const controlType = this.getControlType(control.type);
|
|
649
|
+
if (!controlType?.activate) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
`Control type '${control.type}' does not support activation`
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
const subValues = this.getSubFieldValues(session, field);
|
|
655
|
+
const context = {
|
|
656
|
+
runtime: this.runtime,
|
|
657
|
+
session,
|
|
658
|
+
control,
|
|
659
|
+
subValues
|
|
660
|
+
};
|
|
661
|
+
const activation = await controlType.activate(context);
|
|
662
|
+
const now = Date.now();
|
|
663
|
+
if (!session.fields[field]) {
|
|
664
|
+
session.fields[field] = { status: "empty" };
|
|
665
|
+
}
|
|
666
|
+
session.fields[field].status = "pending";
|
|
667
|
+
session.fields[field].externalState = {
|
|
668
|
+
status: "pending",
|
|
669
|
+
reference: activation.reference,
|
|
670
|
+
instructions: activation.instructions,
|
|
671
|
+
address: activation.address,
|
|
672
|
+
activatedAt: now
|
|
673
|
+
};
|
|
674
|
+
session.updatedAt = now;
|
|
675
|
+
await storageSaveSession(this.runtime, session);
|
|
676
|
+
logger.info(
|
|
677
|
+
`[FormService] Activated external field ${field} with reference ${activation.reference}`
|
|
678
|
+
);
|
|
679
|
+
return activation;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Confirm an external field.
|
|
683
|
+
*
|
|
684
|
+
* Called by external services (payment, blockchain, etc.) when the
|
|
685
|
+
* external process is complete (e.g., payment received, signature verified).
|
|
686
|
+
*
|
|
687
|
+
* WHY separate from confirmField:
|
|
688
|
+
* - External confirmation includes external data (txId, etc.)
|
|
689
|
+
* - Updates externalState, not just field status
|
|
690
|
+
* - Emits events for other systems to react
|
|
691
|
+
*
|
|
692
|
+
* @param sessionId - The session ID
|
|
693
|
+
* @param entityId - The entity/user ID
|
|
694
|
+
* @param field - The field key
|
|
695
|
+
* @param value - The final value to store (usually the confirmed data)
|
|
696
|
+
* @param externalData - Additional data from the external source (txId, etc.)
|
|
697
|
+
*/
|
|
698
|
+
async confirmExternalField(sessionId, entityId, field, value, externalData) {
|
|
699
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
700
|
+
if (!session) {
|
|
701
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
702
|
+
}
|
|
703
|
+
const fieldState = session.fields[field];
|
|
704
|
+
if (!fieldState || fieldState.status !== "pending") {
|
|
705
|
+
logger.warn(
|
|
706
|
+
`[FormService] Cannot confirm field ${field}: not in pending state`
|
|
707
|
+
);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const now = Date.now();
|
|
711
|
+
fieldState.status = "filled";
|
|
712
|
+
fieldState.value = value;
|
|
713
|
+
fieldState.source = "external";
|
|
714
|
+
fieldState.updatedAt = now;
|
|
715
|
+
if (fieldState.externalState) {
|
|
716
|
+
fieldState.externalState.status = "confirmed";
|
|
717
|
+
fieldState.externalState.confirmedAt = now;
|
|
718
|
+
fieldState.externalState.externalData = externalData;
|
|
719
|
+
}
|
|
720
|
+
const form = this.getForm(session.formId);
|
|
721
|
+
if (form && this.checkAllRequiredFilled(session, form)) {
|
|
722
|
+
if (session.status === "active") {
|
|
723
|
+
session.status = "ready";
|
|
724
|
+
if (form.hooks?.onReady) {
|
|
725
|
+
await this.executeHook(session, "onReady");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
session.updatedAt = now;
|
|
730
|
+
await storageSaveSession(this.runtime, session);
|
|
731
|
+
try {
|
|
732
|
+
await this.runtime.emitEvent("FORM_FIELD_CONFIRMED", {
|
|
733
|
+
runtime: this.runtime,
|
|
734
|
+
sessionId,
|
|
735
|
+
entityId,
|
|
736
|
+
field,
|
|
737
|
+
value,
|
|
738
|
+
externalData
|
|
739
|
+
});
|
|
740
|
+
} catch (_error) {
|
|
741
|
+
logger.debug(`[FormService] No event handler for FORM_FIELD_CONFIRMED`);
|
|
742
|
+
}
|
|
743
|
+
logger.info(`[FormService] Confirmed external field ${field}`);
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Cancel an external field activation.
|
|
747
|
+
*
|
|
748
|
+
* Called when the external process fails, times out, or user cancels.
|
|
749
|
+
*
|
|
750
|
+
* @param sessionId - The session ID
|
|
751
|
+
* @param entityId - The entity/user ID
|
|
752
|
+
* @param field - The field key
|
|
753
|
+
* @param reason - Reason for cancellation
|
|
754
|
+
*/
|
|
755
|
+
async cancelExternalField(sessionId, entityId, field, reason) {
|
|
756
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
757
|
+
if (!session) {
|
|
758
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
759
|
+
}
|
|
760
|
+
const form = this.getForm(session.formId);
|
|
761
|
+
const control = form?.controls.find((c) => c.key === field);
|
|
762
|
+
const controlType = control ? this.getControlType(control.type) : void 0;
|
|
763
|
+
if (controlType?.deactivate && control) {
|
|
764
|
+
try {
|
|
765
|
+
await controlType.deactivate({
|
|
766
|
+
runtime: this.runtime,
|
|
767
|
+
session,
|
|
768
|
+
control,
|
|
769
|
+
subValues: this.getSubFieldValues(session, field)
|
|
770
|
+
});
|
|
771
|
+
} catch (error) {
|
|
772
|
+
logger.error(
|
|
773
|
+
`[FormService] Deactivate failed for ${field}: ${String(error)}`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const fieldState = session.fields[field];
|
|
778
|
+
if (fieldState) {
|
|
779
|
+
fieldState.status = "empty";
|
|
780
|
+
fieldState.error = reason;
|
|
781
|
+
if (fieldState.externalState) {
|
|
782
|
+
fieldState.externalState.status = "failed";
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
session.updatedAt = Date.now();
|
|
786
|
+
await storageSaveSession(this.runtime, session);
|
|
787
|
+
try {
|
|
788
|
+
await this.runtime.emitEvent("FORM_FIELD_CANCELLED", {
|
|
789
|
+
runtime: this.runtime,
|
|
790
|
+
sessionId,
|
|
791
|
+
entityId,
|
|
792
|
+
field,
|
|
793
|
+
reason
|
|
794
|
+
});
|
|
795
|
+
} catch (_error) {
|
|
796
|
+
logger.debug(`[FormService] No event handler for FORM_FIELD_CANCELLED`);
|
|
797
|
+
}
|
|
798
|
+
logger.info(`[FormService] Cancelled external field ${field}: ${reason}`);
|
|
799
|
+
}
|
|
800
|
+
// ============================================================================
|
|
801
|
+
// LIFECYCLE
|
|
802
|
+
// ============================================================================
|
|
803
|
+
/**
|
|
804
|
+
* Submit a form session
|
|
805
|
+
*/
|
|
806
|
+
async submit(sessionId, entityId) {
|
|
807
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
808
|
+
if (!session) {
|
|
809
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
810
|
+
}
|
|
811
|
+
const form = this.getForm(session.formId);
|
|
812
|
+
if (!form) {
|
|
813
|
+
throw new Error(`Form not found: ${session.formId}`);
|
|
814
|
+
}
|
|
815
|
+
if (!this.checkAllRequiredFilled(session, form)) {
|
|
816
|
+
throw new Error("Not all required fields are filled");
|
|
817
|
+
}
|
|
818
|
+
const now = Date.now();
|
|
819
|
+
const values = createValueMap();
|
|
820
|
+
const mappedValues = createValueMap();
|
|
821
|
+
const files = createValueMap();
|
|
822
|
+
for (const control of form.controls) {
|
|
823
|
+
const fieldState = session.fields[control.key];
|
|
824
|
+
if (fieldState?.value !== void 0) {
|
|
825
|
+
const validation = validateField(fieldState.value, control);
|
|
826
|
+
if (!validation.valid) {
|
|
827
|
+
const error = validation.error ?? "validation failed";
|
|
828
|
+
throw new Error(
|
|
829
|
+
`Field ${control.key} is invalid: ${error}`
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
values[control.key] = fieldState.value;
|
|
833
|
+
const dbKey = control.dbbind || control.key;
|
|
834
|
+
mappedValues[dbKey] = fieldState.value;
|
|
835
|
+
}
|
|
836
|
+
if (fieldState?.files) {
|
|
837
|
+
files[control.key] = fieldState.files;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const submission = {
|
|
841
|
+
id: uuidv4(),
|
|
842
|
+
formId: session.formId,
|
|
843
|
+
formVersion: session.formVersion,
|
|
844
|
+
sessionId: session.id,
|
|
845
|
+
entityId: session.entityId,
|
|
846
|
+
values,
|
|
847
|
+
mappedValues,
|
|
848
|
+
files: Object.keys(files).length > 0 ? files : void 0,
|
|
849
|
+
submittedAt: now,
|
|
850
|
+
meta: session.meta
|
|
851
|
+
};
|
|
852
|
+
await saveSubmission(this.runtime, submission);
|
|
853
|
+
await saveAutofillData(this.runtime, entityId, session.formId, values);
|
|
854
|
+
session.status = "submitted";
|
|
855
|
+
session.submittedAt = now;
|
|
856
|
+
session.updatedAt = now;
|
|
857
|
+
await storageSaveSession(this.runtime, session);
|
|
858
|
+
if (form.hooks?.onSubmit) {
|
|
859
|
+
const submissionPayload = JSON.parse(
|
|
860
|
+
JSON.stringify(submission)
|
|
861
|
+
);
|
|
862
|
+
await this.executeHook(session, "onSubmit", {
|
|
863
|
+
submission: submissionPayload
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
logger.debug(`[FormService] Submitted session ${sessionId}`);
|
|
867
|
+
return submission;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Stash a session for later
|
|
871
|
+
*/
|
|
872
|
+
async stash(sessionId, entityId) {
|
|
873
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
874
|
+
if (!session) {
|
|
875
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
876
|
+
}
|
|
877
|
+
const form = this.getForm(session.formId);
|
|
878
|
+
session.status = "stashed";
|
|
879
|
+
session.updatedAt = Date.now();
|
|
880
|
+
await storageSaveSession(this.runtime, session);
|
|
881
|
+
if (form?.hooks?.onCancel) {
|
|
882
|
+
}
|
|
883
|
+
logger.debug(`[FormService] Stashed session ${sessionId}`);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Restore a stashed session
|
|
887
|
+
*/
|
|
888
|
+
async restore(sessionId, entityId) {
|
|
889
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
890
|
+
if (!session) {
|
|
891
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
892
|
+
}
|
|
893
|
+
if (session.status !== "stashed") {
|
|
894
|
+
throw new Error(`Session is not stashed: ${session.status}`);
|
|
895
|
+
}
|
|
896
|
+
const existing = await storageGetActiveSession(
|
|
897
|
+
this.runtime,
|
|
898
|
+
entityId,
|
|
899
|
+
session.roomId
|
|
900
|
+
);
|
|
901
|
+
if (existing && existing.id !== sessionId) {
|
|
902
|
+
throw new Error(`Active session already exists in room: ${existing.id}`);
|
|
903
|
+
}
|
|
904
|
+
session.status = "active";
|
|
905
|
+
session.updatedAt = Date.now();
|
|
906
|
+
session.expiresAt = this.calculateTTL(session);
|
|
907
|
+
await storageSaveSession(this.runtime, session);
|
|
908
|
+
logger.debug(`[FormService] Restored session ${sessionId}`);
|
|
909
|
+
return session;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Cancel a session
|
|
913
|
+
*/
|
|
914
|
+
async cancel(sessionId, entityId, force = false) {
|
|
915
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
916
|
+
if (!session) {
|
|
917
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
918
|
+
}
|
|
919
|
+
if (!force && this.shouldConfirmCancel(session) && !session.cancelConfirmationAsked) {
|
|
920
|
+
session.cancelConfirmationAsked = true;
|
|
921
|
+
session.updatedAt = Date.now();
|
|
922
|
+
await storageSaveSession(this.runtime, session);
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
const form = this.getForm(session.formId);
|
|
926
|
+
session.status = "cancelled";
|
|
927
|
+
session.updatedAt = Date.now();
|
|
928
|
+
await storageSaveSession(this.runtime, session);
|
|
929
|
+
if (form?.hooks?.onCancel) {
|
|
930
|
+
await this.executeHook(session, "onCancel");
|
|
931
|
+
}
|
|
932
|
+
logger.debug(`[FormService] Cancelled session ${sessionId}`);
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
// ============================================================================
|
|
936
|
+
// SUBMISSIONS
|
|
937
|
+
// ============================================================================
|
|
938
|
+
/**
|
|
939
|
+
* Get submissions for entity, optionally filtered by form ID
|
|
940
|
+
*/
|
|
941
|
+
async getSubmissions(entityId, formId) {
|
|
942
|
+
return storageGetSubmissions(this.runtime, entityId, formId);
|
|
943
|
+
}
|
|
944
|
+
// ============================================================================
|
|
945
|
+
// AUTOFILL
|
|
946
|
+
// ============================================================================
|
|
947
|
+
/**
|
|
948
|
+
* Get autofill data for a form
|
|
949
|
+
*/
|
|
950
|
+
async getAutofill(entityId, formId) {
|
|
951
|
+
const data = await getAutofillData(this.runtime, entityId, formId);
|
|
952
|
+
return data?.values || null;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Apply autofill to a session
|
|
956
|
+
*/
|
|
957
|
+
async applyAutofill(session) {
|
|
958
|
+
const form = this.getForm(session.formId);
|
|
959
|
+
if (!form?.ux?.allowAutofill) {
|
|
960
|
+
return [];
|
|
961
|
+
}
|
|
962
|
+
const autofill = await getAutofillData(
|
|
963
|
+
this.runtime,
|
|
964
|
+
session.entityId,
|
|
965
|
+
session.formId
|
|
966
|
+
);
|
|
967
|
+
if (!autofill) {
|
|
968
|
+
return [];
|
|
969
|
+
}
|
|
970
|
+
const appliedFields = [];
|
|
971
|
+
const now = Date.now();
|
|
972
|
+
for (const control of form.controls) {
|
|
973
|
+
if (session.fields[control.key]?.status !== "empty") {
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
const value = autofill.values[control.key];
|
|
977
|
+
if (value !== void 0) {
|
|
978
|
+
session.fields[control.key] = {
|
|
979
|
+
status: "filled",
|
|
980
|
+
value,
|
|
981
|
+
source: "autofill",
|
|
982
|
+
updatedAt: now
|
|
983
|
+
};
|
|
984
|
+
appliedFields.push(control.key);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (appliedFields.length > 0) {
|
|
988
|
+
session.updatedAt = now;
|
|
989
|
+
await storageSaveSession(this.runtime, session);
|
|
990
|
+
}
|
|
991
|
+
return appliedFields;
|
|
992
|
+
}
|
|
993
|
+
// ============================================================================
|
|
994
|
+
// CONTEXT HELPERS
|
|
995
|
+
// ============================================================================
|
|
996
|
+
/**
|
|
997
|
+
* Get session context for provider
|
|
998
|
+
*/
|
|
999
|
+
getSessionContext(session) {
|
|
1000
|
+
const form = this.getForm(session.formId);
|
|
1001
|
+
if (!form) {
|
|
1002
|
+
return {
|
|
1003
|
+
hasActiveForm: false,
|
|
1004
|
+
progress: 0,
|
|
1005
|
+
filledFields: [],
|
|
1006
|
+
missingRequired: [],
|
|
1007
|
+
uncertainFields: [],
|
|
1008
|
+
nextField: null,
|
|
1009
|
+
pendingExternalFields: []
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
const filledFields = [];
|
|
1013
|
+
const missingRequired = [];
|
|
1014
|
+
const uncertainFields = [];
|
|
1015
|
+
const pendingExternalFields = [];
|
|
1016
|
+
let nextField = null;
|
|
1017
|
+
let filledCount = 0;
|
|
1018
|
+
let totalRequired = 0;
|
|
1019
|
+
for (const control of form.controls) {
|
|
1020
|
+
if (control.hidden) continue;
|
|
1021
|
+
const fieldState = session.fields[control.key];
|
|
1022
|
+
if (control.required) {
|
|
1023
|
+
totalRequired++;
|
|
1024
|
+
}
|
|
1025
|
+
if (fieldState?.status === "filled") {
|
|
1026
|
+
filledCount++;
|
|
1027
|
+
filledFields.push({
|
|
1028
|
+
key: control.key,
|
|
1029
|
+
label: control.label,
|
|
1030
|
+
displayValue: formatValue(fieldState.value ?? null, control)
|
|
1031
|
+
});
|
|
1032
|
+
} else if (fieldState?.status === "pending") {
|
|
1033
|
+
if (fieldState.externalState) {
|
|
1034
|
+
pendingExternalFields.push({
|
|
1035
|
+
key: control.key,
|
|
1036
|
+
label: control.label,
|
|
1037
|
+
instructions: fieldState.externalState.instructions || "Waiting for confirmation...",
|
|
1038
|
+
reference: fieldState.externalState.reference || "",
|
|
1039
|
+
activatedAt: fieldState.externalState.activatedAt || Date.now(),
|
|
1040
|
+
address: fieldState.externalState.address
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
} else if (fieldState?.status === "uncertain") {
|
|
1044
|
+
uncertainFields.push({
|
|
1045
|
+
key: control.key,
|
|
1046
|
+
label: control.label,
|
|
1047
|
+
value: fieldState.value ?? null,
|
|
1048
|
+
confidence: fieldState.confidence ?? 0
|
|
1049
|
+
});
|
|
1050
|
+
} else if (fieldState?.status === "invalid") {
|
|
1051
|
+
missingRequired.push({
|
|
1052
|
+
key: control.key,
|
|
1053
|
+
label: control.label,
|
|
1054
|
+
description: control.description,
|
|
1055
|
+
askPrompt: control.askPrompt
|
|
1056
|
+
});
|
|
1057
|
+
if (!nextField) nextField = control;
|
|
1058
|
+
} else if (control.required && fieldState?.status !== "skipped") {
|
|
1059
|
+
missingRequired.push({
|
|
1060
|
+
key: control.key,
|
|
1061
|
+
label: control.label,
|
|
1062
|
+
description: control.description,
|
|
1063
|
+
askPrompt: control.askPrompt
|
|
1064
|
+
});
|
|
1065
|
+
if (!nextField) nextField = control;
|
|
1066
|
+
} else if (!nextField && fieldState?.status === "empty") {
|
|
1067
|
+
nextField = control;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const progress = totalRequired > 0 ? Math.round(filledCount / totalRequired * 100) : 100;
|
|
1071
|
+
return {
|
|
1072
|
+
hasActiveForm: true,
|
|
1073
|
+
formId: session.formId,
|
|
1074
|
+
formName: form.name,
|
|
1075
|
+
progress,
|
|
1076
|
+
filledFields,
|
|
1077
|
+
missingRequired,
|
|
1078
|
+
uncertainFields,
|
|
1079
|
+
nextField,
|
|
1080
|
+
status: session.status,
|
|
1081
|
+
pendingCancelConfirmation: session.cancelConfirmationAsked && session.status === "active",
|
|
1082
|
+
pendingExternalFields
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Get current values from session
|
|
1087
|
+
*/
|
|
1088
|
+
getValues(session) {
|
|
1089
|
+
const values = createValueMap();
|
|
1090
|
+
for (const [key, state] of Object.entries(session.fields)) {
|
|
1091
|
+
if (state.value !== void 0) {
|
|
1092
|
+
values[key] = state.value;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return values;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Get mapped values (using dbbind)
|
|
1099
|
+
*/
|
|
1100
|
+
getMappedValues(session) {
|
|
1101
|
+
const form = this.getForm(session.formId);
|
|
1102
|
+
if (!form) return {};
|
|
1103
|
+
const values = createValueMap();
|
|
1104
|
+
for (const control of form.controls) {
|
|
1105
|
+
const state = session.fields[control.key];
|
|
1106
|
+
if (state?.value !== void 0) {
|
|
1107
|
+
const key = control.dbbind || control.key;
|
|
1108
|
+
values[key] = state.value;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return values;
|
|
1112
|
+
}
|
|
1113
|
+
// ============================================================================
|
|
1114
|
+
// TTL & EFFORT
|
|
1115
|
+
// ============================================================================
|
|
1116
|
+
/**
|
|
1117
|
+
* Calculate TTL based on effort
|
|
1118
|
+
*/
|
|
1119
|
+
calculateTTL(session) {
|
|
1120
|
+
const form = this.getForm(session.formId);
|
|
1121
|
+
const config = form?.ttl || {};
|
|
1122
|
+
const minDays = config.minDays ?? 14;
|
|
1123
|
+
const maxDays = config.maxDays ?? 90;
|
|
1124
|
+
const multiplier = config.effortMultiplier ?? 0.5;
|
|
1125
|
+
const minutesSpent = session.effort.timeSpentMs / 6e4;
|
|
1126
|
+
const effortDays = minutesSpent * multiplier;
|
|
1127
|
+
const ttlDays = Math.min(maxDays, Math.max(minDays, effortDays));
|
|
1128
|
+
return Date.now() + ttlDays * 24 * 60 * 60 * 1e3;
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Check if cancel should require confirmation
|
|
1132
|
+
*/
|
|
1133
|
+
shouldConfirmCancel(session) {
|
|
1134
|
+
const minEffortMs = 5 * 60 * 1e3;
|
|
1135
|
+
return session.effort.timeSpentMs > minEffortMs;
|
|
1136
|
+
}
|
|
1137
|
+
// ============================================================================
|
|
1138
|
+
// HOOKS
|
|
1139
|
+
// ============================================================================
|
|
1140
|
+
/**
|
|
1141
|
+
* Execute a form hook
|
|
1142
|
+
*/
|
|
1143
|
+
async executeHook(session, hookName, options) {
|
|
1144
|
+
const form = this.getForm(session.formId);
|
|
1145
|
+
const workerName = form?.hooks?.[hookName];
|
|
1146
|
+
if (!workerName) return;
|
|
1147
|
+
const worker = this.runtime.getTaskWorker(workerName);
|
|
1148
|
+
if (!worker) {
|
|
1149
|
+
logger.warn(`[FormService] Hook worker not found: ${workerName}`);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
try {
|
|
1153
|
+
const task = {
|
|
1154
|
+
id: session.id,
|
|
1155
|
+
name: workerName,
|
|
1156
|
+
roomId: session.roomId,
|
|
1157
|
+
entityId: session.entityId,
|
|
1158
|
+
tags: []
|
|
1159
|
+
};
|
|
1160
|
+
await worker.execute(
|
|
1161
|
+
this.runtime,
|
|
1162
|
+
{
|
|
1163
|
+
session,
|
|
1164
|
+
form,
|
|
1165
|
+
...options
|
|
1166
|
+
},
|
|
1167
|
+
task
|
|
1168
|
+
);
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
logger.error(
|
|
1171
|
+
`[FormService] Hook execution failed: ${hookName}`,
|
|
1172
|
+
String(error)
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
// ============================================================================
|
|
1177
|
+
// HELPERS
|
|
1178
|
+
// ============================================================================
|
|
1179
|
+
/**
|
|
1180
|
+
* Check if all required fields are filled
|
|
1181
|
+
*/
|
|
1182
|
+
checkAllRequiredFilled(session, form) {
|
|
1183
|
+
for (const control of form.controls) {
|
|
1184
|
+
if (!control.required) continue;
|
|
1185
|
+
const fieldState = session.fields[control.key];
|
|
1186
|
+
if (!fieldState || fieldState.status === "empty" || fieldState.status === "invalid") {
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
function prettify(key) {
|
|
1194
|
+
return key.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1195
|
+
}
|
|
1196
|
+
export {
|
|
1197
|
+
FormService
|
|
1198
|
+
};
|
|
1199
|
+
//# sourceMappingURL=service.js.map
|