@elizaos/plugin-form 2.0.0-alpha.10 → 2.0.0-alpha.11
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/chunk-4B5QLNVA.js +187 -0
- package/dist/chunk-ARWZY3NX.js +284 -0
- package/dist/chunk-R4VBS2YK.js +1597 -0
- package/dist/chunk-TBCL2ILB.js +172 -0
- package/dist/chunk-WY4WK3HD.js +57 -0
- package/dist/chunk-XHECCAUT.js +544 -0
- package/dist/chunk-YTWANJ3R.js +64 -0
- package/dist/context-MHPFYZZ2.js +9 -0
- package/dist/extractor-UWASKXKD.js +11 -0
- package/dist/index.d.ts +3213 -2
- package/dist/index.js +428 -2829
- package/dist/restore-S7JLME4H.js +9 -0
- package/dist/service-TCCXKV3T.js +7 -0
- package/package.json +13 -11
- package/dist/index.js.map +0 -30
|
@@ -0,0 +1,1597 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatValue,
|
|
3
|
+
getTypeHandler,
|
|
4
|
+
registerTypeHandler,
|
|
5
|
+
validateField
|
|
6
|
+
} from "./chunk-ARWZY3NX.js";
|
|
7
|
+
|
|
8
|
+
// src/service.ts
|
|
9
|
+
import {
|
|
10
|
+
logger,
|
|
11
|
+
Service
|
|
12
|
+
} from "@elizaos/core";
|
|
13
|
+
import { v4 as uuidv42 } from "uuid";
|
|
14
|
+
|
|
15
|
+
// src/builtins.ts
|
|
16
|
+
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
17
|
+
var ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
18
|
+
var textType = {
|
|
19
|
+
id: "text",
|
|
20
|
+
builtin: true,
|
|
21
|
+
validate: (value, control) => {
|
|
22
|
+
if (value === null || value === void 0) {
|
|
23
|
+
return { valid: true };
|
|
24
|
+
}
|
|
25
|
+
const str = String(value);
|
|
26
|
+
if (control.minLength !== void 0 && str.length < control.minLength) {
|
|
27
|
+
return {
|
|
28
|
+
valid: false,
|
|
29
|
+
error: `Must be at least ${control.minLength} characters`
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (control.maxLength !== void 0 && str.length > control.maxLength) {
|
|
33
|
+
return {
|
|
34
|
+
valid: false,
|
|
35
|
+
error: `Must be at most ${control.maxLength} characters`
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (control.pattern) {
|
|
39
|
+
const regex = new RegExp(control.pattern);
|
|
40
|
+
if (!regex.test(str)) {
|
|
41
|
+
return { valid: false, error: "Invalid format" };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (control.enum && !control.enum.includes(str)) {
|
|
45
|
+
return {
|
|
46
|
+
valid: false,
|
|
47
|
+
error: `Must be one of: ${control.enum.join(", ")}`
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { valid: true };
|
|
51
|
+
},
|
|
52
|
+
parse: (value) => String(value).trim(),
|
|
53
|
+
format: (value) => String(value ?? ""),
|
|
54
|
+
extractionPrompt: "a text string"
|
|
55
|
+
};
|
|
56
|
+
var numberType = {
|
|
57
|
+
id: "number",
|
|
58
|
+
builtin: true,
|
|
59
|
+
validate: (value, control) => {
|
|
60
|
+
if (value === null || value === void 0 || value === "") {
|
|
61
|
+
return { valid: true };
|
|
62
|
+
}
|
|
63
|
+
const num = typeof value === "number" ? value : parseFloat(String(value));
|
|
64
|
+
if (Number.isNaN(num)) {
|
|
65
|
+
return { valid: false, error: "Must be a valid number" };
|
|
66
|
+
}
|
|
67
|
+
if (control.min !== void 0 && num < control.min) {
|
|
68
|
+
return { valid: false, error: `Must be at least ${control.min}` };
|
|
69
|
+
}
|
|
70
|
+
if (control.max !== void 0 && num > control.max) {
|
|
71
|
+
return { valid: false, error: `Must be at most ${control.max}` };
|
|
72
|
+
}
|
|
73
|
+
return { valid: true };
|
|
74
|
+
},
|
|
75
|
+
parse: (value) => {
|
|
76
|
+
const cleaned = value.replace(/[,$\s]/g, "");
|
|
77
|
+
return parseFloat(cleaned);
|
|
78
|
+
},
|
|
79
|
+
format: (value) => {
|
|
80
|
+
if (value === null || value === void 0) return "";
|
|
81
|
+
const num = typeof value === "number" ? value : parseFloat(String(value));
|
|
82
|
+
if (Number.isNaN(num)) return String(value);
|
|
83
|
+
return num.toLocaleString();
|
|
84
|
+
},
|
|
85
|
+
extractionPrompt: "a number (integer or decimal)"
|
|
86
|
+
};
|
|
87
|
+
var emailType = {
|
|
88
|
+
id: "email",
|
|
89
|
+
builtin: true,
|
|
90
|
+
validate: (value) => {
|
|
91
|
+
if (value === null || value === void 0 || value === "") {
|
|
92
|
+
return { valid: true };
|
|
93
|
+
}
|
|
94
|
+
const str = String(value).trim().toLowerCase();
|
|
95
|
+
if (!EMAIL_REGEX.test(str)) {
|
|
96
|
+
return { valid: false, error: "Invalid email format" };
|
|
97
|
+
}
|
|
98
|
+
return { valid: true };
|
|
99
|
+
},
|
|
100
|
+
parse: (value) => value.trim().toLowerCase(),
|
|
101
|
+
format: (value) => String(value ?? "").toLowerCase(),
|
|
102
|
+
extractionPrompt: "an email address (e.g., user@example.com)"
|
|
103
|
+
};
|
|
104
|
+
var booleanType = {
|
|
105
|
+
id: "boolean",
|
|
106
|
+
builtin: true,
|
|
107
|
+
validate: (value) => {
|
|
108
|
+
if (value === null || value === void 0) {
|
|
109
|
+
return { valid: true };
|
|
110
|
+
}
|
|
111
|
+
if (typeof value === "boolean") {
|
|
112
|
+
return { valid: true };
|
|
113
|
+
}
|
|
114
|
+
const str = String(value).toLowerCase();
|
|
115
|
+
const validValues = ["true", "false", "yes", "no", "1", "0", "on", "off"];
|
|
116
|
+
if (!validValues.includes(str)) {
|
|
117
|
+
return { valid: false, error: "Must be yes/no or true/false" };
|
|
118
|
+
}
|
|
119
|
+
return { valid: true };
|
|
120
|
+
},
|
|
121
|
+
parse: (value) => {
|
|
122
|
+
const str = value.toLowerCase();
|
|
123
|
+
return ["true", "yes", "1", "on"].includes(str);
|
|
124
|
+
},
|
|
125
|
+
format: (value) => {
|
|
126
|
+
if (value === true) return "Yes";
|
|
127
|
+
if (value === false) return "No";
|
|
128
|
+
return String(value ?? "");
|
|
129
|
+
},
|
|
130
|
+
extractionPrompt: "a yes/no or true/false value"
|
|
131
|
+
};
|
|
132
|
+
var selectType = {
|
|
133
|
+
id: "select",
|
|
134
|
+
builtin: true,
|
|
135
|
+
validate: (value, control) => {
|
|
136
|
+
if (value === null || value === void 0 || value === "") {
|
|
137
|
+
return { valid: true };
|
|
138
|
+
}
|
|
139
|
+
const str = String(value);
|
|
140
|
+
if (control.options) {
|
|
141
|
+
const validValues = control.options.map((o) => o.value);
|
|
142
|
+
if (!validValues.includes(str)) {
|
|
143
|
+
const labels = control.options.map((o) => o.label).join(", ");
|
|
144
|
+
return { valid: false, error: `Must be one of: ${labels}` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (control.enum && !control.options) {
|
|
148
|
+
if (!control.enum.includes(str)) {
|
|
149
|
+
return {
|
|
150
|
+
valid: false,
|
|
151
|
+
error: `Must be one of: ${control.enum.join(", ")}`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { valid: true };
|
|
156
|
+
},
|
|
157
|
+
parse: (value) => value.trim(),
|
|
158
|
+
format: (value) => String(value ?? ""),
|
|
159
|
+
// Note: extractionPrompt is typically customized per-field with option labels
|
|
160
|
+
extractionPrompt: "one of the available options"
|
|
161
|
+
};
|
|
162
|
+
var dateType = {
|
|
163
|
+
id: "date",
|
|
164
|
+
builtin: true,
|
|
165
|
+
validate: (value) => {
|
|
166
|
+
if (value === null || value === void 0 || value === "") {
|
|
167
|
+
return { valid: true };
|
|
168
|
+
}
|
|
169
|
+
const str = String(value);
|
|
170
|
+
if (!ISO_DATE_REGEX.test(str)) {
|
|
171
|
+
return { valid: false, error: "Must be in YYYY-MM-DD format" };
|
|
172
|
+
}
|
|
173
|
+
const date = new Date(str);
|
|
174
|
+
if (Number.isNaN(date.getTime())) {
|
|
175
|
+
return { valid: false, error: "Invalid date" };
|
|
176
|
+
}
|
|
177
|
+
return { valid: true };
|
|
178
|
+
},
|
|
179
|
+
parse: (value) => {
|
|
180
|
+
const date = new Date(value);
|
|
181
|
+
if (!Number.isNaN(date.getTime())) {
|
|
182
|
+
return date.toISOString().split("T")[0];
|
|
183
|
+
}
|
|
184
|
+
return value.trim();
|
|
185
|
+
},
|
|
186
|
+
format: (value) => {
|
|
187
|
+
if (!value) return "";
|
|
188
|
+
const date = new Date(String(value));
|
|
189
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
190
|
+
return date.toLocaleDateString();
|
|
191
|
+
},
|
|
192
|
+
extractionPrompt: "a date (preferably in YYYY-MM-DD format)"
|
|
193
|
+
};
|
|
194
|
+
var fileType = {
|
|
195
|
+
id: "file",
|
|
196
|
+
builtin: true,
|
|
197
|
+
validate: (value, _control) => {
|
|
198
|
+
if (value === null || value === void 0) {
|
|
199
|
+
return { valid: true };
|
|
200
|
+
}
|
|
201
|
+
if (typeof value === "object") {
|
|
202
|
+
return { valid: true };
|
|
203
|
+
}
|
|
204
|
+
return { valid: false, error: "Invalid file data" };
|
|
205
|
+
},
|
|
206
|
+
format: (value) => {
|
|
207
|
+
if (!value) return "";
|
|
208
|
+
if (Array.isArray(value)) {
|
|
209
|
+
return `${value.length} file(s)`;
|
|
210
|
+
}
|
|
211
|
+
if (typeof value === "object" && value !== null && "name" in value) {
|
|
212
|
+
return String(value.name);
|
|
213
|
+
}
|
|
214
|
+
return "File attached";
|
|
215
|
+
},
|
|
216
|
+
extractionPrompt: "a file attachment (upload required)"
|
|
217
|
+
};
|
|
218
|
+
var BUILTIN_TYPES = [
|
|
219
|
+
textType,
|
|
220
|
+
numberType,
|
|
221
|
+
emailType,
|
|
222
|
+
booleanType,
|
|
223
|
+
selectType,
|
|
224
|
+
dateType,
|
|
225
|
+
fileType
|
|
226
|
+
];
|
|
227
|
+
var BUILTIN_TYPE_MAP = new Map(
|
|
228
|
+
BUILTIN_TYPES.map((t) => [t.id, t])
|
|
229
|
+
);
|
|
230
|
+
function registerBuiltinTypes(registerFn) {
|
|
231
|
+
for (const type of BUILTIN_TYPES) {
|
|
232
|
+
registerFn(type);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function getBuiltinType(id) {
|
|
236
|
+
return BUILTIN_TYPE_MAP.get(id);
|
|
237
|
+
}
|
|
238
|
+
function isBuiltinType(id) {
|
|
239
|
+
return BUILTIN_TYPE_MAP.has(id);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/storage.ts
|
|
243
|
+
import { v4 as uuidv4 } from "uuid";
|
|
244
|
+
|
|
245
|
+
// src/types.ts
|
|
246
|
+
var FORM_CONTROL_DEFAULTS = {
|
|
247
|
+
type: "text",
|
|
248
|
+
required: false,
|
|
249
|
+
confirmThreshold: 0.8
|
|
250
|
+
};
|
|
251
|
+
var FORM_DEFINITION_DEFAULTS = {
|
|
252
|
+
version: 1,
|
|
253
|
+
status: "active",
|
|
254
|
+
ux: {
|
|
255
|
+
allowUndo: true,
|
|
256
|
+
allowSkip: true,
|
|
257
|
+
maxUndoSteps: 5,
|
|
258
|
+
showExamples: true,
|
|
259
|
+
showExplanations: true,
|
|
260
|
+
allowAutofill: true
|
|
261
|
+
},
|
|
262
|
+
ttl: {
|
|
263
|
+
minDays: 14,
|
|
264
|
+
maxDays: 90,
|
|
265
|
+
effortMultiplier: 0.5
|
|
266
|
+
},
|
|
267
|
+
nudge: {
|
|
268
|
+
enabled: true,
|
|
269
|
+
afterInactiveHours: 48,
|
|
270
|
+
maxNudges: 3
|
|
271
|
+
},
|
|
272
|
+
debug: false
|
|
273
|
+
};
|
|
274
|
+
var FORM_SESSION_COMPONENT = "form_session";
|
|
275
|
+
var FORM_SUBMISSION_COMPONENT = "form_submission";
|
|
276
|
+
var FORM_AUTOFILL_COMPONENT = "form_autofill";
|
|
277
|
+
|
|
278
|
+
// src/storage.ts
|
|
279
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
280
|
+
var resolveComponentContext = async (runtime, roomId) => {
|
|
281
|
+
if (roomId) {
|
|
282
|
+
const room = await runtime.getRoom(roomId);
|
|
283
|
+
return { roomId, worldId: room?.worldId ?? runtime.agentId };
|
|
284
|
+
}
|
|
285
|
+
return { roomId: runtime.agentId, worldId: runtime.agentId };
|
|
286
|
+
};
|
|
287
|
+
var isFormSession = (data) => {
|
|
288
|
+
if (!isRecord(data)) return false;
|
|
289
|
+
return typeof data.id === "string" && typeof data.formId === "string" && typeof data.entityId === "string" && typeof data.roomId === "string";
|
|
290
|
+
};
|
|
291
|
+
var isFormSubmission = (data) => {
|
|
292
|
+
if (!isRecord(data)) return false;
|
|
293
|
+
return typeof data.id === "string" && typeof data.formId === "string" && typeof data.sessionId === "string" && typeof data.entityId === "string";
|
|
294
|
+
};
|
|
295
|
+
var isFormAutofillData = (data) => {
|
|
296
|
+
if (!isRecord(data)) return false;
|
|
297
|
+
return typeof data.formId === "string" && typeof data.updatedAt === "number" && typeof data.values === "object";
|
|
298
|
+
};
|
|
299
|
+
async function getActiveSession(runtime, entityId, roomId) {
|
|
300
|
+
const component = await runtime.getComponent(entityId, `${FORM_SESSION_COMPONENT}:${roomId}`);
|
|
301
|
+
if (!component?.data || !isFormSession(component.data)) return null;
|
|
302
|
+
const session = component.data;
|
|
303
|
+
if (session.status === "active" || session.status === "ready") {
|
|
304
|
+
return session;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
async function getAllActiveSessions(runtime, entityId) {
|
|
309
|
+
const components = await runtime.getComponents(entityId);
|
|
310
|
+
const sessions = [];
|
|
311
|
+
for (const component of components) {
|
|
312
|
+
if (component.type.startsWith(`${FORM_SESSION_COMPONENT}:`)) {
|
|
313
|
+
if (component.data && isFormSession(component.data)) {
|
|
314
|
+
const session = component.data;
|
|
315
|
+
if (session.status === "active" || session.status === "ready") {
|
|
316
|
+
sessions.push(session);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return sessions;
|
|
322
|
+
}
|
|
323
|
+
async function getStashedSessions(runtime, entityId) {
|
|
324
|
+
const components = await runtime.getComponents(entityId);
|
|
325
|
+
const sessions = [];
|
|
326
|
+
for (const component of components) {
|
|
327
|
+
if (component.type.startsWith(`${FORM_SESSION_COMPONENT}:`)) {
|
|
328
|
+
if (component.data && isFormSession(component.data)) {
|
|
329
|
+
const session = component.data;
|
|
330
|
+
if (session.status === "stashed") {
|
|
331
|
+
sessions.push(session);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return sessions;
|
|
337
|
+
}
|
|
338
|
+
async function getSessionById(runtime, entityId, sessionId) {
|
|
339
|
+
const components = await runtime.getComponents(entityId);
|
|
340
|
+
for (const component of components) {
|
|
341
|
+
if (component.type.startsWith(`${FORM_SESSION_COMPONENT}:`)) {
|
|
342
|
+
if (component.data && isFormSession(component.data)) {
|
|
343
|
+
const session = component.data;
|
|
344
|
+
if (session.id === sessionId) {
|
|
345
|
+
return session;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
async function saveSession(runtime, session) {
|
|
353
|
+
const componentType = `${FORM_SESSION_COMPONENT}:${session.roomId}`;
|
|
354
|
+
const existing = await runtime.getComponent(session.entityId, componentType);
|
|
355
|
+
const context = await resolveComponentContext(runtime, session.roomId);
|
|
356
|
+
const resolvedWorldId = existing?.worldId ?? context.worldId;
|
|
357
|
+
const component = {
|
|
358
|
+
id: existing?.id || uuidv4(),
|
|
359
|
+
entityId: session.entityId,
|
|
360
|
+
agentId: runtime.agentId,
|
|
361
|
+
roomId: session.roomId,
|
|
362
|
+
// WHY preserve worldId: Avoids breaking existing component relationships
|
|
363
|
+
worldId: resolvedWorldId,
|
|
364
|
+
sourceEntityId: runtime.agentId,
|
|
365
|
+
type: componentType,
|
|
366
|
+
createdAt: existing?.createdAt || Date.now(),
|
|
367
|
+
// Store session as component data
|
|
368
|
+
data: JSON.parse(JSON.stringify(session))
|
|
369
|
+
};
|
|
370
|
+
if (existing) {
|
|
371
|
+
await runtime.updateComponent(component);
|
|
372
|
+
} else {
|
|
373
|
+
await runtime.createComponent(component);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async function deleteSession(runtime, session) {
|
|
377
|
+
const componentType = `${FORM_SESSION_COMPONENT}:${session.roomId}`;
|
|
378
|
+
const existing = await runtime.getComponent(session.entityId, componentType);
|
|
379
|
+
if (existing) {
|
|
380
|
+
await runtime.deleteComponent(existing.id);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async function saveSubmission(runtime, submission) {
|
|
384
|
+
const componentType = `${FORM_SUBMISSION_COMPONENT}:${submission.formId}:${submission.id}`;
|
|
385
|
+
const context = await resolveComponentContext(runtime);
|
|
386
|
+
const component = {
|
|
387
|
+
id: uuidv4(),
|
|
388
|
+
entityId: submission.entityId,
|
|
389
|
+
agentId: runtime.agentId,
|
|
390
|
+
roomId: context.roomId,
|
|
391
|
+
worldId: context.worldId,
|
|
392
|
+
sourceEntityId: runtime.agentId,
|
|
393
|
+
type: componentType,
|
|
394
|
+
createdAt: submission.submittedAt,
|
|
395
|
+
data: JSON.parse(JSON.stringify(submission))
|
|
396
|
+
};
|
|
397
|
+
await runtime.createComponent(component);
|
|
398
|
+
}
|
|
399
|
+
async function getSubmissions(runtime, entityId, formId) {
|
|
400
|
+
const components = await runtime.getComponents(entityId);
|
|
401
|
+
const submissions = [];
|
|
402
|
+
const prefix = formId ? `${FORM_SUBMISSION_COMPONENT}:${formId}:` : `${FORM_SUBMISSION_COMPONENT}:`;
|
|
403
|
+
for (const component of components) {
|
|
404
|
+
if (component.type.startsWith(prefix)) {
|
|
405
|
+
if (component.data && isFormSubmission(component.data)) {
|
|
406
|
+
submissions.push(component.data);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
submissions.sort((a, b) => b.submittedAt - a.submittedAt);
|
|
411
|
+
return submissions;
|
|
412
|
+
}
|
|
413
|
+
async function getAutofillData(runtime, entityId, formId) {
|
|
414
|
+
const componentType = `${FORM_AUTOFILL_COMPONENT}:${formId}`;
|
|
415
|
+
const component = await runtime.getComponent(entityId, componentType);
|
|
416
|
+
if (!component?.data || !isFormAutofillData(component.data)) return null;
|
|
417
|
+
return component.data;
|
|
418
|
+
}
|
|
419
|
+
async function saveAutofillData(runtime, entityId, formId, values) {
|
|
420
|
+
const componentType = `${FORM_AUTOFILL_COMPONENT}:${formId}`;
|
|
421
|
+
const existing = await runtime.getComponent(entityId, componentType);
|
|
422
|
+
const context = await resolveComponentContext(runtime);
|
|
423
|
+
const resolvedWorldId = existing?.worldId ?? context.worldId;
|
|
424
|
+
const data = {
|
|
425
|
+
formId,
|
|
426
|
+
values,
|
|
427
|
+
updatedAt: Date.now()
|
|
428
|
+
};
|
|
429
|
+
const component = {
|
|
430
|
+
id: existing?.id || uuidv4(),
|
|
431
|
+
entityId,
|
|
432
|
+
agentId: runtime.agentId,
|
|
433
|
+
roomId: context.roomId,
|
|
434
|
+
worldId: resolvedWorldId,
|
|
435
|
+
sourceEntityId: runtime.agentId,
|
|
436
|
+
type: componentType,
|
|
437
|
+
createdAt: existing?.createdAt || Date.now(),
|
|
438
|
+
data: JSON.parse(JSON.stringify(data))
|
|
439
|
+
};
|
|
440
|
+
if (existing) {
|
|
441
|
+
await runtime.updateComponent(component);
|
|
442
|
+
} else {
|
|
443
|
+
await runtime.createComponent(component);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/service.ts
|
|
448
|
+
var FormService = class _FormService extends Service {
|
|
449
|
+
/** Service type identifier for runtime.getService() */
|
|
450
|
+
static serviceType = "FORM";
|
|
451
|
+
/** Description shown in agent capabilities */
|
|
452
|
+
capabilityDescription = "Manages conversational forms for data collection";
|
|
453
|
+
/**
|
|
454
|
+
* In-memory storage of form definitions.
|
|
455
|
+
*
|
|
456
|
+
* WHY Map:
|
|
457
|
+
* - O(1) lookup by ID
|
|
458
|
+
* - Forms are static after registration
|
|
459
|
+
* - No persistence needed (re-registered on startup)
|
|
460
|
+
*/
|
|
461
|
+
forms = /* @__PURE__ */ new Map();
|
|
462
|
+
/**
|
|
463
|
+
* Control type registry.
|
|
464
|
+
*
|
|
465
|
+
* WHY separate from TypeHandler:
|
|
466
|
+
* - ControlType is the new unified interface
|
|
467
|
+
* - Supports simple, composite, and external types
|
|
468
|
+
* - TypeHandler is legacy but still supported
|
|
469
|
+
*
|
|
470
|
+
* Built-in types are registered on start.
|
|
471
|
+
* Plugins can register custom types.
|
|
472
|
+
*/
|
|
473
|
+
controlTypes = /* @__PURE__ */ new Map();
|
|
474
|
+
/**
|
|
475
|
+
* Start the FormService
|
|
476
|
+
*/
|
|
477
|
+
static async start(runtime) {
|
|
478
|
+
const service = new _FormService(runtime);
|
|
479
|
+
registerBuiltinTypes((type, options) => service.registerControlType(type, options));
|
|
480
|
+
logger.info("[FormService] Started with built-in types");
|
|
481
|
+
return service;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Stop the FormService
|
|
485
|
+
*/
|
|
486
|
+
async stop() {
|
|
487
|
+
logger.info("[FormService] Stopped");
|
|
488
|
+
}
|
|
489
|
+
// ============================================================================
|
|
490
|
+
// FORM DEFINITION MANAGEMENT
|
|
491
|
+
// ============================================================================
|
|
492
|
+
/**
|
|
493
|
+
* Register a form definition
|
|
494
|
+
*/
|
|
495
|
+
registerForm(definition) {
|
|
496
|
+
const form = {
|
|
497
|
+
...definition,
|
|
498
|
+
version: definition.version ?? FORM_DEFINITION_DEFAULTS.version,
|
|
499
|
+
status: definition.status ?? FORM_DEFINITION_DEFAULTS.status,
|
|
500
|
+
ux: { ...FORM_DEFINITION_DEFAULTS.ux, ...definition.ux },
|
|
501
|
+
ttl: { ...FORM_DEFINITION_DEFAULTS.ttl, ...definition.ttl },
|
|
502
|
+
nudge: { ...FORM_DEFINITION_DEFAULTS.nudge, ...definition.nudge },
|
|
503
|
+
debug: definition.debug ?? FORM_DEFINITION_DEFAULTS.debug,
|
|
504
|
+
controls: definition.controls.map((control) => ({
|
|
505
|
+
...control,
|
|
506
|
+
type: control.type || FORM_CONTROL_DEFAULTS.type,
|
|
507
|
+
required: control.required ?? FORM_CONTROL_DEFAULTS.required,
|
|
508
|
+
confirmThreshold: control.confirmThreshold ?? FORM_CONTROL_DEFAULTS.confirmThreshold,
|
|
509
|
+
label: control.label || prettify(control.key)
|
|
510
|
+
}))
|
|
511
|
+
};
|
|
512
|
+
this.forms.set(form.id, form);
|
|
513
|
+
logger.debug(`[FormService] Registered form: ${form.id}`);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Get a form definition by ID
|
|
517
|
+
*/
|
|
518
|
+
getForm(formId) {
|
|
519
|
+
return this.forms.get(formId);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Get all registered forms
|
|
523
|
+
*/
|
|
524
|
+
listForms() {
|
|
525
|
+
return Array.from(this.forms.values());
|
|
526
|
+
}
|
|
527
|
+
// ============================================================================
|
|
528
|
+
// TYPE HANDLER REGISTRY (Legacy)
|
|
529
|
+
// ============================================================================
|
|
530
|
+
/**
|
|
531
|
+
* Register a custom type handler (legacy API)
|
|
532
|
+
* @deprecated Use registerControlType instead
|
|
533
|
+
*/
|
|
534
|
+
registerType(type, handler) {
|
|
535
|
+
registerTypeHandler(type, handler);
|
|
536
|
+
logger.debug(`[FormService] Registered type handler: ${type}`);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Get a type handler (legacy API)
|
|
540
|
+
* @deprecated Use getControlType instead
|
|
541
|
+
*/
|
|
542
|
+
getTypeHandler(type) {
|
|
543
|
+
return getTypeHandler(type);
|
|
544
|
+
}
|
|
545
|
+
// ============================================================================
|
|
546
|
+
// CONTROL TYPE REGISTRY
|
|
547
|
+
// ============================================================================
|
|
548
|
+
/**
|
|
549
|
+
* Register a control type.
|
|
550
|
+
*
|
|
551
|
+
* Control types define how a field type behaves:
|
|
552
|
+
* - Simple types: validate/parse/format
|
|
553
|
+
* - Composite types: have subcontrols
|
|
554
|
+
* - External types: have activate/deactivate for async processes
|
|
555
|
+
*
|
|
556
|
+
* Built-in types (text, number, email, etc.) are registered at startup
|
|
557
|
+
* and protected from override unless explicitly allowed.
|
|
558
|
+
*
|
|
559
|
+
* @param type - The ControlType definition
|
|
560
|
+
* @param options - Registration options
|
|
561
|
+
* @param options.allowOverride - Allow overriding built-in types (default: false)
|
|
562
|
+
*
|
|
563
|
+
* @example
|
|
564
|
+
* ```typescript
|
|
565
|
+
* formService.registerControlType({
|
|
566
|
+
* id: 'payment',
|
|
567
|
+
* getSubControls: () => [
|
|
568
|
+
* { key: 'amount', type: 'number', label: 'Amount', required: true },
|
|
569
|
+
* { key: 'currency', type: 'select', label: 'Currency', required: true },
|
|
570
|
+
* ],
|
|
571
|
+
* activate: async (ctx) => {
|
|
572
|
+
* const paymentService = ctx.runtime.getService('PAYMENT');
|
|
573
|
+
* return paymentService.createPending(ctx.subValues);
|
|
574
|
+
* },
|
|
575
|
+
* });
|
|
576
|
+
* ```
|
|
577
|
+
*/
|
|
578
|
+
registerControlType(type, options) {
|
|
579
|
+
const existing = this.controlTypes.get(type.id);
|
|
580
|
+
if (existing) {
|
|
581
|
+
if (existing.builtin && !options?.allowOverride) {
|
|
582
|
+
logger.warn(
|
|
583
|
+
`[FormService] Cannot override builtin type '${type.id}' without allowOverride: true`
|
|
584
|
+
);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
logger.warn(`[FormService] Overriding control type: ${type.id}`);
|
|
588
|
+
}
|
|
589
|
+
this.controlTypes.set(type.id, type);
|
|
590
|
+
logger.debug(`[FormService] Registered control type: ${type.id}`);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Get a control type by ID.
|
|
594
|
+
*
|
|
595
|
+
* @param typeId - The type ID to look up
|
|
596
|
+
* @returns The ControlType or undefined if not found
|
|
597
|
+
*/
|
|
598
|
+
getControlType(typeId) {
|
|
599
|
+
return this.controlTypes.get(typeId);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* List all registered control types.
|
|
603
|
+
*
|
|
604
|
+
* @returns Array of all registered ControlTypes
|
|
605
|
+
*/
|
|
606
|
+
listControlTypes() {
|
|
607
|
+
return Array.from(this.controlTypes.values());
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Check if a control type has subcontrols.
|
|
611
|
+
*
|
|
612
|
+
* @param typeId - The type ID to check
|
|
613
|
+
* @returns true if the type has getSubControls method
|
|
614
|
+
*/
|
|
615
|
+
isCompositeType(typeId) {
|
|
616
|
+
const type = this.controlTypes.get(typeId);
|
|
617
|
+
return !!type?.getSubControls;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Check if a control type is an external type.
|
|
621
|
+
*
|
|
622
|
+
* @param typeId - The type ID to check
|
|
623
|
+
* @returns true if the type has activate method
|
|
624
|
+
*/
|
|
625
|
+
isExternalType(typeId) {
|
|
626
|
+
const type = this.controlTypes.get(typeId);
|
|
627
|
+
return !!type?.activate;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Get subcontrols for a composite type.
|
|
631
|
+
*
|
|
632
|
+
* @param control - The parent control
|
|
633
|
+
* @returns Array of subcontrols or empty array if not composite
|
|
634
|
+
*/
|
|
635
|
+
getSubControls(control) {
|
|
636
|
+
const type = this.controlTypes.get(control.type);
|
|
637
|
+
if (!type?.getSubControls) {
|
|
638
|
+
return [];
|
|
639
|
+
}
|
|
640
|
+
return type.getSubControls(control, this.runtime);
|
|
641
|
+
}
|
|
642
|
+
// ============================================================================
|
|
643
|
+
// SESSION MANAGEMENT
|
|
644
|
+
// ============================================================================
|
|
645
|
+
/**
|
|
646
|
+
* Start a new form session
|
|
647
|
+
*/
|
|
648
|
+
async startSession(formId, entityId, roomId, options) {
|
|
649
|
+
const form = this.getForm(formId);
|
|
650
|
+
if (!form) {
|
|
651
|
+
throw new Error(`Form not found: ${formId}`);
|
|
652
|
+
}
|
|
653
|
+
const existing = await getActiveSession(this.runtime, entityId, roomId);
|
|
654
|
+
if (existing) {
|
|
655
|
+
throw new Error(`Active session already exists for this user/room: ${existing.id}`);
|
|
656
|
+
}
|
|
657
|
+
const now = Date.now();
|
|
658
|
+
const fields = {};
|
|
659
|
+
for (const control of form.controls) {
|
|
660
|
+
if (options?.initialValues?.[control.key] !== void 0) {
|
|
661
|
+
fields[control.key] = {
|
|
662
|
+
status: "filled",
|
|
663
|
+
value: options.initialValues[control.key],
|
|
664
|
+
source: "manual",
|
|
665
|
+
updatedAt: now
|
|
666
|
+
};
|
|
667
|
+
} else if (control.defaultValue !== void 0) {
|
|
668
|
+
fields[control.key] = {
|
|
669
|
+
status: "filled",
|
|
670
|
+
value: control.defaultValue,
|
|
671
|
+
source: "default",
|
|
672
|
+
updatedAt: now
|
|
673
|
+
};
|
|
674
|
+
} else {
|
|
675
|
+
fields[control.key] = { status: "empty" };
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const ttlDays = form.ttl?.minDays ?? 14;
|
|
679
|
+
const expiresAt = now + ttlDays * 24 * 60 * 60 * 1e3;
|
|
680
|
+
const session = {
|
|
681
|
+
id: uuidv42(),
|
|
682
|
+
formId,
|
|
683
|
+
formVersion: form.version,
|
|
684
|
+
entityId,
|
|
685
|
+
roomId,
|
|
686
|
+
status: "active",
|
|
687
|
+
fields,
|
|
688
|
+
history: [],
|
|
689
|
+
context: options?.context,
|
|
690
|
+
locale: options?.locale,
|
|
691
|
+
effort: {
|
|
692
|
+
interactionCount: 0,
|
|
693
|
+
timeSpentMs: 0,
|
|
694
|
+
firstInteractionAt: now,
|
|
695
|
+
lastInteractionAt: now
|
|
696
|
+
},
|
|
697
|
+
expiresAt,
|
|
698
|
+
createdAt: now,
|
|
699
|
+
updatedAt: now
|
|
700
|
+
};
|
|
701
|
+
await saveSession(this.runtime, session);
|
|
702
|
+
if (form.hooks?.onStart) {
|
|
703
|
+
await this.executeHook(session, "onStart");
|
|
704
|
+
}
|
|
705
|
+
logger.debug(`[FormService] Started session ${session.id} for form ${formId}`);
|
|
706
|
+
return session;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Get active session for entity in room
|
|
710
|
+
*/
|
|
711
|
+
async getActiveSession(entityId, roomId) {
|
|
712
|
+
return getActiveSession(this.runtime, entityId, roomId);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Get all active sessions for entity (across all rooms)
|
|
716
|
+
*/
|
|
717
|
+
async getAllActiveSessions(entityId) {
|
|
718
|
+
return getAllActiveSessions(this.runtime, entityId);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Get stashed sessions for entity
|
|
722
|
+
*/
|
|
723
|
+
async getStashedSessions(entityId) {
|
|
724
|
+
return getStashedSessions(this.runtime, entityId);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Save a session
|
|
728
|
+
*/
|
|
729
|
+
async saveSession(session) {
|
|
730
|
+
session.updatedAt = Date.now();
|
|
731
|
+
await saveSession(this.runtime, session);
|
|
732
|
+
}
|
|
733
|
+
// ============================================================================
|
|
734
|
+
// FIELD UPDATES
|
|
735
|
+
// ============================================================================
|
|
736
|
+
/**
|
|
737
|
+
* Update a field value
|
|
738
|
+
*/
|
|
739
|
+
async updateField(sessionId, entityId, field, value, confidence, source, messageId) {
|
|
740
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
741
|
+
if (!session) {
|
|
742
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
743
|
+
}
|
|
744
|
+
const form = this.getForm(session.formId);
|
|
745
|
+
if (!form) {
|
|
746
|
+
throw new Error(`Form not found: ${session.formId}`);
|
|
747
|
+
}
|
|
748
|
+
const control = form.controls.find((c) => c.key === field);
|
|
749
|
+
if (!control) {
|
|
750
|
+
throw new Error(`Field not found: ${field}`);
|
|
751
|
+
}
|
|
752
|
+
const oldValue = session.fields[field]?.value;
|
|
753
|
+
const validation = validateField(value, control);
|
|
754
|
+
let status;
|
|
755
|
+
if (!validation.valid) {
|
|
756
|
+
status = "invalid";
|
|
757
|
+
} else if (confidence < (control.confirmThreshold ?? 0.8)) {
|
|
758
|
+
status = "uncertain";
|
|
759
|
+
} else {
|
|
760
|
+
status = "filled";
|
|
761
|
+
}
|
|
762
|
+
const now = Date.now();
|
|
763
|
+
if (oldValue !== void 0) {
|
|
764
|
+
const historyEntry = {
|
|
765
|
+
field,
|
|
766
|
+
oldValue,
|
|
767
|
+
newValue: value,
|
|
768
|
+
timestamp: now
|
|
769
|
+
};
|
|
770
|
+
session.history.push(historyEntry);
|
|
771
|
+
const maxUndo = form.ux?.maxUndoSteps ?? 5;
|
|
772
|
+
if (session.history.length > maxUndo) {
|
|
773
|
+
session.history = session.history.slice(-maxUndo);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
session.fields[field] = {
|
|
777
|
+
status,
|
|
778
|
+
value,
|
|
779
|
+
confidence,
|
|
780
|
+
source,
|
|
781
|
+
messageId,
|
|
782
|
+
updatedAt: now,
|
|
783
|
+
error: !validation.valid ? validation.error : void 0
|
|
784
|
+
};
|
|
785
|
+
session.effort.interactionCount++;
|
|
786
|
+
session.effort.lastInteractionAt = now;
|
|
787
|
+
session.effort.timeSpentMs = now - session.effort.firstInteractionAt;
|
|
788
|
+
session.expiresAt = this.calculateTTL(session);
|
|
789
|
+
const allRequiredFilled = this.checkAllRequiredFilled(session, form);
|
|
790
|
+
if (allRequiredFilled && session.status === "active") {
|
|
791
|
+
session.status = "ready";
|
|
792
|
+
if (form.hooks?.onReady) {
|
|
793
|
+
await this.executeHook(session, "onReady");
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
session.updatedAt = now;
|
|
797
|
+
await saveSession(this.runtime, session);
|
|
798
|
+
if (form.hooks?.onFieldChange) {
|
|
799
|
+
const hookPayload = { field, value };
|
|
800
|
+
if (oldValue !== void 0) {
|
|
801
|
+
hookPayload.oldValue = oldValue;
|
|
802
|
+
}
|
|
803
|
+
await this.executeHook(session, "onFieldChange", hookPayload);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Undo the last field change
|
|
808
|
+
*/
|
|
809
|
+
async undoLastChange(sessionId, entityId) {
|
|
810
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
811
|
+
if (!session) {
|
|
812
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
813
|
+
}
|
|
814
|
+
const form = this.getForm(session.formId);
|
|
815
|
+
if (!form?.ux?.allowUndo) {
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
const lastChange = session.history.pop();
|
|
819
|
+
if (!lastChange) {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
if (lastChange.oldValue !== void 0) {
|
|
823
|
+
session.fields[lastChange.field] = {
|
|
824
|
+
status: "filled",
|
|
825
|
+
value: lastChange.oldValue,
|
|
826
|
+
source: "correction",
|
|
827
|
+
updatedAt: Date.now()
|
|
828
|
+
};
|
|
829
|
+
} else {
|
|
830
|
+
session.fields[lastChange.field] = { status: "empty" };
|
|
831
|
+
}
|
|
832
|
+
session.updatedAt = Date.now();
|
|
833
|
+
await saveSession(this.runtime, session);
|
|
834
|
+
return { field: lastChange.field, restoredValue: lastChange.oldValue };
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Skip an optional field
|
|
838
|
+
*/
|
|
839
|
+
async skipField(sessionId, entityId, field) {
|
|
840
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
841
|
+
if (!session) {
|
|
842
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
843
|
+
}
|
|
844
|
+
const form = this.getForm(session.formId);
|
|
845
|
+
if (!form?.ux?.allowSkip) {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
const control = form.controls.find((c) => c.key === field);
|
|
849
|
+
if (!control) {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
if (control.required) {
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
session.fields[field] = {
|
|
856
|
+
status: "skipped",
|
|
857
|
+
updatedAt: Date.now()
|
|
858
|
+
};
|
|
859
|
+
session.updatedAt = Date.now();
|
|
860
|
+
await saveSession(this.runtime, session);
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Confirm an uncertain field value
|
|
865
|
+
*/
|
|
866
|
+
async confirmField(sessionId, entityId, field, accepted) {
|
|
867
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
868
|
+
if (!session) {
|
|
869
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
870
|
+
}
|
|
871
|
+
const fieldState = session.fields[field];
|
|
872
|
+
if (!fieldState || fieldState.status !== "uncertain") {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const now = Date.now();
|
|
876
|
+
if (accepted) {
|
|
877
|
+
fieldState.status = "filled";
|
|
878
|
+
fieldState.confirmedAt = now;
|
|
879
|
+
} else {
|
|
880
|
+
fieldState.status = "empty";
|
|
881
|
+
fieldState.value = void 0;
|
|
882
|
+
fieldState.confidence = void 0;
|
|
883
|
+
}
|
|
884
|
+
fieldState.updatedAt = now;
|
|
885
|
+
session.updatedAt = now;
|
|
886
|
+
await saveSession(this.runtime, session);
|
|
887
|
+
}
|
|
888
|
+
// ============================================================================
|
|
889
|
+
// SUBFIELD UPDATES (for composite types)
|
|
890
|
+
// ============================================================================
|
|
891
|
+
/**
|
|
892
|
+
* Update a subfield value for a composite control type.
|
|
893
|
+
*
|
|
894
|
+
* Composite types (like payment, address) have subcontrols that must
|
|
895
|
+
* all be filled before the parent field is complete.
|
|
896
|
+
*
|
|
897
|
+
* WHY separate from updateField:
|
|
898
|
+
* - Subfields are stored in fieldState.subFields, not session.fields
|
|
899
|
+
* - Parent field status depends on all subfields being filled
|
|
900
|
+
* - Allows tracking subfield confidence/status independently
|
|
901
|
+
*
|
|
902
|
+
* @param sessionId - The session ID
|
|
903
|
+
* @param entityId - The entity/user ID
|
|
904
|
+
* @param parentField - The parent control key (e.g., "payment")
|
|
905
|
+
* @param subField - The subcontrol key (e.g., "amount")
|
|
906
|
+
* @param value - The extracted value
|
|
907
|
+
* @param confidence - LLM confidence (0-1)
|
|
908
|
+
* @param messageId - Optional message ID for audit
|
|
909
|
+
*/
|
|
910
|
+
async updateSubField(sessionId, entityId, parentField, subField, value, confidence, messageId) {
|
|
911
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
912
|
+
if (!session) {
|
|
913
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
914
|
+
}
|
|
915
|
+
const form = this.getForm(session.formId);
|
|
916
|
+
if (!form) {
|
|
917
|
+
throw new Error(`Form not found: ${session.formId}`);
|
|
918
|
+
}
|
|
919
|
+
const parentControl = form.controls.find((c) => c.key === parentField);
|
|
920
|
+
if (!parentControl) {
|
|
921
|
+
throw new Error(`Parent field not found: ${parentField}`);
|
|
922
|
+
}
|
|
923
|
+
const controlType = this.getControlType(parentControl.type);
|
|
924
|
+
if (!controlType?.getSubControls) {
|
|
925
|
+
throw new Error(`Control type '${parentControl.type}' is not a composite type`);
|
|
926
|
+
}
|
|
927
|
+
const subControls = controlType.getSubControls(parentControl, this.runtime);
|
|
928
|
+
const subControl = subControls.find((c) => c.key === subField);
|
|
929
|
+
if (!subControl) {
|
|
930
|
+
throw new Error(`Subfield not found: ${subField} in ${parentField}`);
|
|
931
|
+
}
|
|
932
|
+
const now = Date.now();
|
|
933
|
+
if (!session.fields[parentField]) {
|
|
934
|
+
session.fields[parentField] = { status: "empty" };
|
|
935
|
+
}
|
|
936
|
+
if (!session.fields[parentField].subFields) {
|
|
937
|
+
session.fields[parentField].subFields = {};
|
|
938
|
+
}
|
|
939
|
+
let subFieldStatus;
|
|
940
|
+
let error;
|
|
941
|
+
if (controlType.validate) {
|
|
942
|
+
const result = controlType.validate(value, subControl);
|
|
943
|
+
if (!result.valid) {
|
|
944
|
+
subFieldStatus = "invalid";
|
|
945
|
+
error = result.error;
|
|
946
|
+
} else if (confidence < (subControl.confirmThreshold ?? 0.8)) {
|
|
947
|
+
subFieldStatus = "uncertain";
|
|
948
|
+
} else {
|
|
949
|
+
subFieldStatus = "filled";
|
|
950
|
+
}
|
|
951
|
+
} else {
|
|
952
|
+
const validation = validateField(value, subControl);
|
|
953
|
+
if (!validation.valid) {
|
|
954
|
+
subFieldStatus = "invalid";
|
|
955
|
+
error = validation.error;
|
|
956
|
+
} else if (confidence < (subControl.confirmThreshold ?? 0.8)) {
|
|
957
|
+
subFieldStatus = "uncertain";
|
|
958
|
+
} else {
|
|
959
|
+
subFieldStatus = "filled";
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
session.fields[parentField].subFields[subField] = {
|
|
963
|
+
status: subFieldStatus,
|
|
964
|
+
value,
|
|
965
|
+
confidence,
|
|
966
|
+
source: "extraction",
|
|
967
|
+
messageId,
|
|
968
|
+
updatedAt: now,
|
|
969
|
+
error
|
|
970
|
+
};
|
|
971
|
+
session.effort.interactionCount++;
|
|
972
|
+
session.effort.lastInteractionAt = now;
|
|
973
|
+
session.effort.timeSpentMs = now - session.effort.firstInteractionAt;
|
|
974
|
+
session.updatedAt = now;
|
|
975
|
+
await saveSession(this.runtime, session);
|
|
976
|
+
logger.debug(`[FormService] Updated subfield ${parentField}.${subField}`);
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Check if all subfields of a composite field are filled.
|
|
980
|
+
*
|
|
981
|
+
* @param session - The form session
|
|
982
|
+
* @param parentField - The parent control key
|
|
983
|
+
* @returns true if all required subfields are filled
|
|
984
|
+
*/
|
|
985
|
+
areSubFieldsFilled(session, parentField) {
|
|
986
|
+
const form = this.getForm(session.formId);
|
|
987
|
+
if (!form) return false;
|
|
988
|
+
const parentControl = form.controls.find((c) => c.key === parentField);
|
|
989
|
+
if (!parentControl) return false;
|
|
990
|
+
const controlType = this.getControlType(parentControl.type);
|
|
991
|
+
if (!controlType?.getSubControls) return false;
|
|
992
|
+
const subControls = controlType.getSubControls(parentControl, this.runtime);
|
|
993
|
+
const subFields = session.fields[parentField]?.subFields || {};
|
|
994
|
+
for (const subControl of subControls) {
|
|
995
|
+
if (!subControl.required) continue;
|
|
996
|
+
const subField = subFields[subControl.key];
|
|
997
|
+
if (!subField || subField.status !== "filled") {
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Get the current subfield values for a composite field.
|
|
1005
|
+
*
|
|
1006
|
+
* @param session - The form session
|
|
1007
|
+
* @param parentField - The parent control key
|
|
1008
|
+
* @returns Record of subfield key to value
|
|
1009
|
+
*/
|
|
1010
|
+
getSubFieldValues(session, parentField) {
|
|
1011
|
+
const subFields = session.fields[parentField]?.subFields || {};
|
|
1012
|
+
const values = {};
|
|
1013
|
+
for (const [key, state] of Object.entries(subFields)) {
|
|
1014
|
+
if (state.value !== void 0) {
|
|
1015
|
+
values[key] = state.value;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return values;
|
|
1019
|
+
}
|
|
1020
|
+
// ============================================================================
|
|
1021
|
+
// EXTERNAL FIELD ACTIVATION
|
|
1022
|
+
// ============================================================================
|
|
1023
|
+
/**
|
|
1024
|
+
* Activate an external field.
|
|
1025
|
+
*
|
|
1026
|
+
* External types (payment, signature) require an async activation process.
|
|
1027
|
+
* This is called when all subcontrols are filled and the external process
|
|
1028
|
+
* should begin (e.g., generate payment address, show signing instructions).
|
|
1029
|
+
*
|
|
1030
|
+
* WHY this method:
|
|
1031
|
+
* - Decouples activation trigger from the widget itself
|
|
1032
|
+
* - Stores activation state in the session
|
|
1033
|
+
* - Provides a clear API for the evaluator to call
|
|
1034
|
+
*
|
|
1035
|
+
* @param sessionId - The session ID
|
|
1036
|
+
* @param entityId - The entity/user ID
|
|
1037
|
+
* @param field - The field key
|
|
1038
|
+
* @returns The activation details (instructions, reference, etc.)
|
|
1039
|
+
*/
|
|
1040
|
+
async activateExternalField(sessionId, entityId, field) {
|
|
1041
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
1042
|
+
if (!session) {
|
|
1043
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1044
|
+
}
|
|
1045
|
+
const form = this.getForm(session.formId);
|
|
1046
|
+
if (!form) {
|
|
1047
|
+
throw new Error(`Form not found: ${session.formId}`);
|
|
1048
|
+
}
|
|
1049
|
+
const control = form.controls.find((c) => c.key === field);
|
|
1050
|
+
if (!control) {
|
|
1051
|
+
throw new Error(`Field not found: ${field}`);
|
|
1052
|
+
}
|
|
1053
|
+
const controlType = this.getControlType(control.type);
|
|
1054
|
+
if (!controlType?.activate) {
|
|
1055
|
+
throw new Error(`Control type '${control.type}' does not support activation`);
|
|
1056
|
+
}
|
|
1057
|
+
const subValues = this.getSubFieldValues(session, field);
|
|
1058
|
+
const context = {
|
|
1059
|
+
runtime: this.runtime,
|
|
1060
|
+
session,
|
|
1061
|
+
control,
|
|
1062
|
+
subValues
|
|
1063
|
+
};
|
|
1064
|
+
const activation = await controlType.activate(context);
|
|
1065
|
+
const now = Date.now();
|
|
1066
|
+
if (!session.fields[field]) {
|
|
1067
|
+
session.fields[field] = { status: "empty" };
|
|
1068
|
+
}
|
|
1069
|
+
session.fields[field].status = "pending";
|
|
1070
|
+
session.fields[field].externalState = {
|
|
1071
|
+
status: "pending",
|
|
1072
|
+
reference: activation.reference,
|
|
1073
|
+
instructions: activation.instructions,
|
|
1074
|
+
address: activation.address,
|
|
1075
|
+
activatedAt: now
|
|
1076
|
+
};
|
|
1077
|
+
session.updatedAt = now;
|
|
1078
|
+
await saveSession(this.runtime, session);
|
|
1079
|
+
logger.info(
|
|
1080
|
+
`[FormService] Activated external field ${field} with reference ${activation.reference}`
|
|
1081
|
+
);
|
|
1082
|
+
return activation;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Confirm an external field.
|
|
1086
|
+
*
|
|
1087
|
+
* Called by external services (payment, blockchain, etc.) when the
|
|
1088
|
+
* external process is complete (e.g., payment received, signature verified).
|
|
1089
|
+
*
|
|
1090
|
+
* WHY separate from confirmField:
|
|
1091
|
+
* - External confirmation includes external data (txId, etc.)
|
|
1092
|
+
* - Updates externalState, not just field status
|
|
1093
|
+
* - Emits events for other systems to react
|
|
1094
|
+
*
|
|
1095
|
+
* @param sessionId - The session ID
|
|
1096
|
+
* @param entityId - The entity/user ID
|
|
1097
|
+
* @param field - The field key
|
|
1098
|
+
* @param value - The final value to store (usually the confirmed data)
|
|
1099
|
+
* @param externalData - Additional data from the external source (txId, etc.)
|
|
1100
|
+
*/
|
|
1101
|
+
async confirmExternalField(sessionId, entityId, field, value, externalData) {
|
|
1102
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
1103
|
+
if (!session) {
|
|
1104
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1105
|
+
}
|
|
1106
|
+
const fieldState = session.fields[field];
|
|
1107
|
+
if (!fieldState || fieldState.status !== "pending") {
|
|
1108
|
+
logger.warn(`[FormService] Cannot confirm field ${field}: not in pending state`);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
const now = Date.now();
|
|
1112
|
+
fieldState.status = "filled";
|
|
1113
|
+
fieldState.value = value;
|
|
1114
|
+
fieldState.source = "external";
|
|
1115
|
+
fieldState.updatedAt = now;
|
|
1116
|
+
if (fieldState.externalState) {
|
|
1117
|
+
fieldState.externalState.status = "confirmed";
|
|
1118
|
+
fieldState.externalState.confirmedAt = now;
|
|
1119
|
+
fieldState.externalState.externalData = externalData;
|
|
1120
|
+
}
|
|
1121
|
+
const form = this.getForm(session.formId);
|
|
1122
|
+
if (form && this.checkAllRequiredFilled(session, form)) {
|
|
1123
|
+
if (session.status === "active") {
|
|
1124
|
+
session.status = "ready";
|
|
1125
|
+
if (form.hooks?.onReady) {
|
|
1126
|
+
await this.executeHook(session, "onReady");
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
session.updatedAt = now;
|
|
1131
|
+
await saveSession(this.runtime, session);
|
|
1132
|
+
try {
|
|
1133
|
+
await this.runtime.emitEvent("FORM_FIELD_CONFIRMED", {
|
|
1134
|
+
runtime: this.runtime,
|
|
1135
|
+
sessionId,
|
|
1136
|
+
entityId,
|
|
1137
|
+
field,
|
|
1138
|
+
value,
|
|
1139
|
+
externalData
|
|
1140
|
+
});
|
|
1141
|
+
} catch (_error) {
|
|
1142
|
+
logger.debug(`[FormService] No event handler for FORM_FIELD_CONFIRMED`);
|
|
1143
|
+
}
|
|
1144
|
+
logger.info(`[FormService] Confirmed external field ${field}`);
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Cancel an external field activation.
|
|
1148
|
+
*
|
|
1149
|
+
* Called when the external process fails, times out, or user cancels.
|
|
1150
|
+
*
|
|
1151
|
+
* @param sessionId - The session ID
|
|
1152
|
+
* @param entityId - The entity/user ID
|
|
1153
|
+
* @param field - The field key
|
|
1154
|
+
* @param reason - Reason for cancellation
|
|
1155
|
+
*/
|
|
1156
|
+
async cancelExternalField(sessionId, entityId, field, reason) {
|
|
1157
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
1158
|
+
if (!session) {
|
|
1159
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1160
|
+
}
|
|
1161
|
+
const form = this.getForm(session.formId);
|
|
1162
|
+
const control = form?.controls.find((c) => c.key === field);
|
|
1163
|
+
const controlType = control ? this.getControlType(control.type) : void 0;
|
|
1164
|
+
if (controlType?.deactivate && control) {
|
|
1165
|
+
try {
|
|
1166
|
+
await controlType.deactivate({
|
|
1167
|
+
runtime: this.runtime,
|
|
1168
|
+
session,
|
|
1169
|
+
control,
|
|
1170
|
+
subValues: this.getSubFieldValues(session, field)
|
|
1171
|
+
});
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
logger.error(`[FormService] Deactivate failed for ${field}: ${String(error)}`);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
const fieldState = session.fields[field];
|
|
1177
|
+
if (fieldState) {
|
|
1178
|
+
fieldState.status = "empty";
|
|
1179
|
+
fieldState.error = reason;
|
|
1180
|
+
if (fieldState.externalState) {
|
|
1181
|
+
fieldState.externalState.status = "failed";
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
session.updatedAt = Date.now();
|
|
1185
|
+
await saveSession(this.runtime, session);
|
|
1186
|
+
try {
|
|
1187
|
+
await this.runtime.emitEvent("FORM_FIELD_CANCELLED", {
|
|
1188
|
+
runtime: this.runtime,
|
|
1189
|
+
sessionId,
|
|
1190
|
+
entityId,
|
|
1191
|
+
field,
|
|
1192
|
+
reason
|
|
1193
|
+
});
|
|
1194
|
+
} catch (_error) {
|
|
1195
|
+
logger.debug(`[FormService] No event handler for FORM_FIELD_CANCELLED`);
|
|
1196
|
+
}
|
|
1197
|
+
logger.info(`[FormService] Cancelled external field ${field}: ${reason}`);
|
|
1198
|
+
}
|
|
1199
|
+
// ============================================================================
|
|
1200
|
+
// LIFECYCLE
|
|
1201
|
+
// ============================================================================
|
|
1202
|
+
/**
|
|
1203
|
+
* Submit a form session
|
|
1204
|
+
*/
|
|
1205
|
+
async submit(sessionId, entityId) {
|
|
1206
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
1207
|
+
if (!session) {
|
|
1208
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1209
|
+
}
|
|
1210
|
+
const form = this.getForm(session.formId);
|
|
1211
|
+
if (!form) {
|
|
1212
|
+
throw new Error(`Form not found: ${session.formId}`);
|
|
1213
|
+
}
|
|
1214
|
+
if (!this.checkAllRequiredFilled(session, form)) {
|
|
1215
|
+
throw new Error("Not all required fields are filled");
|
|
1216
|
+
}
|
|
1217
|
+
const now = Date.now();
|
|
1218
|
+
const values = {};
|
|
1219
|
+
const mappedValues = {};
|
|
1220
|
+
const files = {};
|
|
1221
|
+
for (const control of form.controls) {
|
|
1222
|
+
const fieldState = session.fields[control.key];
|
|
1223
|
+
if (fieldState?.value !== void 0) {
|
|
1224
|
+
values[control.key] = fieldState.value;
|
|
1225
|
+
const dbKey = control.dbbind || control.key;
|
|
1226
|
+
mappedValues[dbKey] = fieldState.value;
|
|
1227
|
+
}
|
|
1228
|
+
if (fieldState?.files) {
|
|
1229
|
+
files[control.key] = fieldState.files;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const submission = {
|
|
1233
|
+
id: uuidv42(),
|
|
1234
|
+
formId: session.formId,
|
|
1235
|
+
formVersion: session.formVersion,
|
|
1236
|
+
sessionId: session.id,
|
|
1237
|
+
entityId: session.entityId,
|
|
1238
|
+
values,
|
|
1239
|
+
mappedValues,
|
|
1240
|
+
files: Object.keys(files).length > 0 ? files : void 0,
|
|
1241
|
+
submittedAt: now,
|
|
1242
|
+
meta: session.meta
|
|
1243
|
+
};
|
|
1244
|
+
await saveSubmission(this.runtime, submission);
|
|
1245
|
+
await saveAutofillData(this.runtime, entityId, session.formId, values);
|
|
1246
|
+
session.status = "submitted";
|
|
1247
|
+
session.submittedAt = now;
|
|
1248
|
+
session.updatedAt = now;
|
|
1249
|
+
await saveSession(this.runtime, session);
|
|
1250
|
+
if (form.hooks?.onSubmit) {
|
|
1251
|
+
const submissionPayload = JSON.parse(JSON.stringify(submission));
|
|
1252
|
+
await this.executeHook(session, "onSubmit", {
|
|
1253
|
+
submission: submissionPayload
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
logger.debug(`[FormService] Submitted session ${sessionId}`);
|
|
1257
|
+
return submission;
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Stash a session for later
|
|
1261
|
+
*/
|
|
1262
|
+
async stash(sessionId, entityId) {
|
|
1263
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
1264
|
+
if (!session) {
|
|
1265
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1266
|
+
}
|
|
1267
|
+
const form = this.getForm(session.formId);
|
|
1268
|
+
session.status = "stashed";
|
|
1269
|
+
session.updatedAt = Date.now();
|
|
1270
|
+
await saveSession(this.runtime, session);
|
|
1271
|
+
if (form?.hooks?.onCancel) {
|
|
1272
|
+
}
|
|
1273
|
+
logger.debug(`[FormService] Stashed session ${sessionId}`);
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Restore a stashed session
|
|
1277
|
+
*/
|
|
1278
|
+
async restore(sessionId, entityId) {
|
|
1279
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
1280
|
+
if (!session) {
|
|
1281
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1282
|
+
}
|
|
1283
|
+
if (session.status !== "stashed") {
|
|
1284
|
+
throw new Error(`Session is not stashed: ${session.status}`);
|
|
1285
|
+
}
|
|
1286
|
+
const existing = await getActiveSession(this.runtime, entityId, session.roomId);
|
|
1287
|
+
if (existing && existing.id !== sessionId) {
|
|
1288
|
+
throw new Error(`Active session already exists in room: ${existing.id}`);
|
|
1289
|
+
}
|
|
1290
|
+
session.status = "active";
|
|
1291
|
+
session.updatedAt = Date.now();
|
|
1292
|
+
session.expiresAt = this.calculateTTL(session);
|
|
1293
|
+
await saveSession(this.runtime, session);
|
|
1294
|
+
logger.debug(`[FormService] Restored session ${sessionId}`);
|
|
1295
|
+
return session;
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Cancel a session
|
|
1299
|
+
*/
|
|
1300
|
+
async cancel(sessionId, entityId, force = false) {
|
|
1301
|
+
const session = await getSessionById(this.runtime, entityId, sessionId);
|
|
1302
|
+
if (!session) {
|
|
1303
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1304
|
+
}
|
|
1305
|
+
if (!force && this.shouldConfirmCancel(session) && !session.cancelConfirmationAsked) {
|
|
1306
|
+
session.cancelConfirmationAsked = true;
|
|
1307
|
+
session.updatedAt = Date.now();
|
|
1308
|
+
await saveSession(this.runtime, session);
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
const form = this.getForm(session.formId);
|
|
1312
|
+
session.status = "cancelled";
|
|
1313
|
+
session.updatedAt = Date.now();
|
|
1314
|
+
await saveSession(this.runtime, session);
|
|
1315
|
+
if (form?.hooks?.onCancel) {
|
|
1316
|
+
await this.executeHook(session, "onCancel");
|
|
1317
|
+
}
|
|
1318
|
+
logger.debug(`[FormService] Cancelled session ${sessionId}`);
|
|
1319
|
+
return true;
|
|
1320
|
+
}
|
|
1321
|
+
// ============================================================================
|
|
1322
|
+
// SUBMISSIONS
|
|
1323
|
+
// ============================================================================
|
|
1324
|
+
/**
|
|
1325
|
+
* Get submissions for entity, optionally filtered by form ID
|
|
1326
|
+
*/
|
|
1327
|
+
async getSubmissions(entityId, formId) {
|
|
1328
|
+
return getSubmissions(this.runtime, entityId, formId);
|
|
1329
|
+
}
|
|
1330
|
+
// ============================================================================
|
|
1331
|
+
// AUTOFILL
|
|
1332
|
+
// ============================================================================
|
|
1333
|
+
/**
|
|
1334
|
+
* Get autofill data for a form
|
|
1335
|
+
*/
|
|
1336
|
+
async getAutofill(entityId, formId) {
|
|
1337
|
+
const data = await getAutofillData(this.runtime, entityId, formId);
|
|
1338
|
+
return data?.values || null;
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Apply autofill to a session
|
|
1342
|
+
*/
|
|
1343
|
+
async applyAutofill(session) {
|
|
1344
|
+
const form = this.getForm(session.formId);
|
|
1345
|
+
if (!form?.ux?.allowAutofill) {
|
|
1346
|
+
return [];
|
|
1347
|
+
}
|
|
1348
|
+
const autofill = await getAutofillData(this.runtime, session.entityId, session.formId);
|
|
1349
|
+
if (!autofill) {
|
|
1350
|
+
return [];
|
|
1351
|
+
}
|
|
1352
|
+
const appliedFields = [];
|
|
1353
|
+
const now = Date.now();
|
|
1354
|
+
for (const control of form.controls) {
|
|
1355
|
+
if (session.fields[control.key]?.status !== "empty") {
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
const value = autofill.values[control.key];
|
|
1359
|
+
if (value !== void 0) {
|
|
1360
|
+
session.fields[control.key] = {
|
|
1361
|
+
status: "filled",
|
|
1362
|
+
value,
|
|
1363
|
+
source: "autofill",
|
|
1364
|
+
updatedAt: now
|
|
1365
|
+
};
|
|
1366
|
+
appliedFields.push(control.key);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
if (appliedFields.length > 0) {
|
|
1370
|
+
session.updatedAt = now;
|
|
1371
|
+
await saveSession(this.runtime, session);
|
|
1372
|
+
}
|
|
1373
|
+
return appliedFields;
|
|
1374
|
+
}
|
|
1375
|
+
// ============================================================================
|
|
1376
|
+
// CONTEXT HELPERS
|
|
1377
|
+
// ============================================================================
|
|
1378
|
+
/**
|
|
1379
|
+
* Get session context for provider
|
|
1380
|
+
*/
|
|
1381
|
+
getSessionContext(session) {
|
|
1382
|
+
const form = this.getForm(session.formId);
|
|
1383
|
+
if (!form) {
|
|
1384
|
+
return {
|
|
1385
|
+
hasActiveForm: false,
|
|
1386
|
+
progress: 0,
|
|
1387
|
+
filledFields: [],
|
|
1388
|
+
missingRequired: [],
|
|
1389
|
+
uncertainFields: [],
|
|
1390
|
+
nextField: null,
|
|
1391
|
+
pendingExternalFields: []
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
const filledFields = [];
|
|
1395
|
+
const missingRequired = [];
|
|
1396
|
+
const uncertainFields = [];
|
|
1397
|
+
const pendingExternalFields = [];
|
|
1398
|
+
let nextField = null;
|
|
1399
|
+
let filledCount = 0;
|
|
1400
|
+
let totalRequired = 0;
|
|
1401
|
+
for (const control of form.controls) {
|
|
1402
|
+
if (control.hidden) continue;
|
|
1403
|
+
const fieldState = session.fields[control.key];
|
|
1404
|
+
if (control.required) {
|
|
1405
|
+
totalRequired++;
|
|
1406
|
+
}
|
|
1407
|
+
if (fieldState?.status === "filled") {
|
|
1408
|
+
filledCount++;
|
|
1409
|
+
filledFields.push({
|
|
1410
|
+
key: control.key,
|
|
1411
|
+
label: control.label,
|
|
1412
|
+
displayValue: formatValue(fieldState.value ?? null, control)
|
|
1413
|
+
});
|
|
1414
|
+
} else if (fieldState?.status === "pending") {
|
|
1415
|
+
if (fieldState.externalState) {
|
|
1416
|
+
pendingExternalFields.push({
|
|
1417
|
+
key: control.key,
|
|
1418
|
+
label: control.label,
|
|
1419
|
+
instructions: fieldState.externalState.instructions || "Waiting for confirmation...",
|
|
1420
|
+
reference: fieldState.externalState.reference || "",
|
|
1421
|
+
activatedAt: fieldState.externalState.activatedAt || Date.now(),
|
|
1422
|
+
address: fieldState.externalState.address
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
} else if (fieldState?.status === "uncertain") {
|
|
1426
|
+
uncertainFields.push({
|
|
1427
|
+
key: control.key,
|
|
1428
|
+
label: control.label,
|
|
1429
|
+
value: fieldState.value ?? null,
|
|
1430
|
+
confidence: fieldState.confidence ?? 0
|
|
1431
|
+
});
|
|
1432
|
+
} else if (fieldState?.status === "invalid") {
|
|
1433
|
+
missingRequired.push({
|
|
1434
|
+
key: control.key,
|
|
1435
|
+
label: control.label,
|
|
1436
|
+
description: control.description,
|
|
1437
|
+
askPrompt: control.askPrompt
|
|
1438
|
+
});
|
|
1439
|
+
if (!nextField) nextField = control;
|
|
1440
|
+
} else if (control.required && fieldState?.status !== "skipped") {
|
|
1441
|
+
missingRequired.push({
|
|
1442
|
+
key: control.key,
|
|
1443
|
+
label: control.label,
|
|
1444
|
+
description: control.description,
|
|
1445
|
+
askPrompt: control.askPrompt
|
|
1446
|
+
});
|
|
1447
|
+
if (!nextField) nextField = control;
|
|
1448
|
+
} else if (!nextField && fieldState?.status === "empty") {
|
|
1449
|
+
nextField = control;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
const progress = totalRequired > 0 ? Math.round(filledCount / totalRequired * 100) : 100;
|
|
1453
|
+
return {
|
|
1454
|
+
hasActiveForm: true,
|
|
1455
|
+
formId: session.formId,
|
|
1456
|
+
formName: form.name,
|
|
1457
|
+
progress,
|
|
1458
|
+
filledFields,
|
|
1459
|
+
missingRequired,
|
|
1460
|
+
uncertainFields,
|
|
1461
|
+
nextField,
|
|
1462
|
+
status: session.status,
|
|
1463
|
+
pendingCancelConfirmation: session.cancelConfirmationAsked && session.status === "active",
|
|
1464
|
+
pendingExternalFields
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Get current values from session
|
|
1469
|
+
*/
|
|
1470
|
+
getValues(session) {
|
|
1471
|
+
const values = {};
|
|
1472
|
+
for (const [key, state] of Object.entries(session.fields)) {
|
|
1473
|
+
if (state.value !== void 0) {
|
|
1474
|
+
values[key] = state.value;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return values;
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Get mapped values (using dbbind)
|
|
1481
|
+
*/
|
|
1482
|
+
getMappedValues(session) {
|
|
1483
|
+
const form = this.getForm(session.formId);
|
|
1484
|
+
if (!form) return {};
|
|
1485
|
+
const values = {};
|
|
1486
|
+
for (const control of form.controls) {
|
|
1487
|
+
const state = session.fields[control.key];
|
|
1488
|
+
if (state?.value !== void 0) {
|
|
1489
|
+
const key = control.dbbind || control.key;
|
|
1490
|
+
values[key] = state.value;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
return values;
|
|
1494
|
+
}
|
|
1495
|
+
// ============================================================================
|
|
1496
|
+
// TTL & EFFORT
|
|
1497
|
+
// ============================================================================
|
|
1498
|
+
/**
|
|
1499
|
+
* Calculate TTL based on effort
|
|
1500
|
+
*/
|
|
1501
|
+
calculateTTL(session) {
|
|
1502
|
+
const form = this.getForm(session.formId);
|
|
1503
|
+
const config = form?.ttl || {};
|
|
1504
|
+
const minDays = config.minDays ?? 14;
|
|
1505
|
+
const maxDays = config.maxDays ?? 90;
|
|
1506
|
+
const multiplier = config.effortMultiplier ?? 0.5;
|
|
1507
|
+
const minutesSpent = session.effort.timeSpentMs / 6e4;
|
|
1508
|
+
const effortDays = minutesSpent * multiplier;
|
|
1509
|
+
const ttlDays = Math.min(maxDays, Math.max(minDays, effortDays));
|
|
1510
|
+
return Date.now() + ttlDays * 24 * 60 * 60 * 1e3;
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Check if cancel should require confirmation
|
|
1514
|
+
*/
|
|
1515
|
+
shouldConfirmCancel(session) {
|
|
1516
|
+
const minEffortMs = 5 * 60 * 1e3;
|
|
1517
|
+
return session.effort.timeSpentMs > minEffortMs;
|
|
1518
|
+
}
|
|
1519
|
+
// ============================================================================
|
|
1520
|
+
// HOOKS
|
|
1521
|
+
// ============================================================================
|
|
1522
|
+
/**
|
|
1523
|
+
* Execute a form hook
|
|
1524
|
+
*/
|
|
1525
|
+
async executeHook(session, hookName, options) {
|
|
1526
|
+
const form = this.getForm(session.formId);
|
|
1527
|
+
const workerName = form?.hooks?.[hookName];
|
|
1528
|
+
if (!workerName) return;
|
|
1529
|
+
const worker = this.runtime.getTaskWorker(workerName);
|
|
1530
|
+
if (!worker) {
|
|
1531
|
+
logger.warn(`[FormService] Hook worker not found: ${workerName}`);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
try {
|
|
1535
|
+
const task = {
|
|
1536
|
+
id: session.id,
|
|
1537
|
+
name: workerName,
|
|
1538
|
+
roomId: session.roomId,
|
|
1539
|
+
entityId: session.entityId,
|
|
1540
|
+
tags: []
|
|
1541
|
+
};
|
|
1542
|
+
await worker.execute(
|
|
1543
|
+
this.runtime,
|
|
1544
|
+
{
|
|
1545
|
+
session,
|
|
1546
|
+
form,
|
|
1547
|
+
...options
|
|
1548
|
+
},
|
|
1549
|
+
task
|
|
1550
|
+
);
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
logger.error(`[FormService] Hook execution failed: ${hookName}`, String(error));
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
// ============================================================================
|
|
1556
|
+
// HELPERS
|
|
1557
|
+
// ============================================================================
|
|
1558
|
+
/**
|
|
1559
|
+
* Check if all required fields are filled
|
|
1560
|
+
*/
|
|
1561
|
+
checkAllRequiredFilled(session, form) {
|
|
1562
|
+
for (const control of form.controls) {
|
|
1563
|
+
if (!control.required) continue;
|
|
1564
|
+
const fieldState = session.fields[control.key];
|
|
1565
|
+
if (!fieldState || fieldState.status === "empty" || fieldState.status === "invalid") {
|
|
1566
|
+
return false;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
return true;
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
function prettify(key) {
|
|
1573
|
+
return key.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
export {
|
|
1577
|
+
FORM_CONTROL_DEFAULTS,
|
|
1578
|
+
FORM_DEFINITION_DEFAULTS,
|
|
1579
|
+
FORM_SESSION_COMPONENT,
|
|
1580
|
+
FORM_SUBMISSION_COMPONENT,
|
|
1581
|
+
FORM_AUTOFILL_COMPONENT,
|
|
1582
|
+
BUILTIN_TYPES,
|
|
1583
|
+
BUILTIN_TYPE_MAP,
|
|
1584
|
+
registerBuiltinTypes,
|
|
1585
|
+
getBuiltinType,
|
|
1586
|
+
isBuiltinType,
|
|
1587
|
+
getActiveSession,
|
|
1588
|
+
getAllActiveSessions,
|
|
1589
|
+
getStashedSessions,
|
|
1590
|
+
saveSession,
|
|
1591
|
+
deleteSession,
|
|
1592
|
+
saveSubmission,
|
|
1593
|
+
getSubmissions,
|
|
1594
|
+
getAutofillData,
|
|
1595
|
+
saveAutofillData,
|
|
1596
|
+
FormService
|
|
1597
|
+
};
|