wizards 0.2.15 → 0.2.17

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.
Files changed (66) hide show
  1. package/apps/runtime/dist/credits/estimate.js +14 -8
  2. package/apps/runtime/dist/db/migrations/tenant/0008_run_event_note.sql +1 -0
  3. package/apps/runtime/dist/db/migrations/tenant/meta/0008_snapshot.json +2019 -0
  4. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  5. package/apps/runtime/dist/db/schema.js +2 -0
  6. package/apps/runtime/dist/engine/events.js +14 -2
  7. package/apps/runtime/dist/engine/prepare.js +5 -2
  8. package/apps/runtime/dist/engine/requirements.js +248 -12
  9. package/apps/runtime/dist/engine/runner.js +28 -5
  10. package/apps/runtime/dist/engine/steps.js +18 -10
  11. package/apps/runtime/dist/engine/template.js +13 -10
  12. package/apps/runtime/dist/routes/studio.js +2 -2
  13. package/apps/runtime/dist/services/marketplace.js +34 -7
  14. package/apps/runtime/dist/tools/browser.js +12 -6
  15. package/apps/runtime/dist/tools/index.js +12 -6
  16. package/apps/runtime/dist/tools/mail.js +5 -2
  17. package/apps/runtime/dist/tools/pages.js +1 -1
  18. package/apps/runtime/dist/tools/project.js +5 -2
  19. package/apps/runtime/dist/tools/store.js +4 -4
  20. package/apps/runtime/dist/widgets/step.js +10 -4
  21. package/apps/web/dist/assets/{Account-B_FCuidz.js → Account-LVXhagaS.js} +1 -1
  22. package/apps/web/dist/assets/{ChangelogDialog-BKvNJIrA.js → ChangelogDialog-DM0KAoy6.js} +1 -1
  23. package/apps/web/dist/assets/ChatPanel-CxOQmtv-.js +1 -0
  24. package/apps/web/dist/assets/CreditsDialog-DkW3YT44.js +1 -0
  25. package/apps/web/dist/assets/EditorPage-hbisE1Wf.js +2 -0
  26. package/apps/web/dist/assets/{FlowPreview-De3ZyIer.js → FlowPreview-v7or29ku.js} +1 -1
  27. package/apps/web/dist/assets/{Harness-3kuJaJoj.js → Harness-RZF-bBmb.js} +2 -2
  28. package/apps/web/dist/assets/{HomePage-DWDT64vg.js → HomePage-mxG-a_KU.js} +1 -1
  29. package/apps/web/dist/assets/{ImportWizard-CTGmBDI8.js → ImportWizard-CiPyUt3E.js} +1 -1
  30. package/apps/web/dist/assets/LocalRuntime-Dqp9wkN3.js +1 -0
  31. package/apps/web/dist/assets/NewWizardPage-DHwRsi0_.js +2 -0
  32. package/apps/web/dist/assets/ProjectPage-Bb7jN_6n.js +4 -0
  33. package/apps/web/dist/assets/PublicRunner-TGkOwbAP.js +1 -0
  34. package/apps/web/dist/assets/RunnerView-DzHGshZj.js +70 -0
  35. package/apps/web/dist/assets/SettingsPage-CG3Xtfej.js +1 -0
  36. package/apps/web/dist/assets/{SetupPage-DHQYO7zB.js → SetupPage-O1GfPlqX.js} +1 -1
  37. package/apps/web/dist/assets/SharePage-CCNUhtry.js +1 -0
  38. package/apps/web/dist/assets/{api-RJbFM_oc.js → api-DB22_B7G.js} +2 -2
  39. package/apps/web/dist/assets/changelog-BkIJdZ7M.js +1 -0
  40. package/apps/web/dist/assets/{esm-CgnVZhIQ.js → esm-3TT9i_rv.js} +1 -1
  41. package/apps/web/dist/assets/{index-9maLuK9R.css → index-BPpKz65x.css} +1 -1
  42. package/apps/web/dist/assets/index-CLe9Wrpu.js +10 -0
  43. package/apps/web/dist/assets/{meta-CchZXdWH.js → meta-CEsBCPfA.js} +1 -1
  44. package/apps/web/dist/assets/{settings-menu-CrRWSrNL.js → settings-menu-pOcjznGJ.js} +1 -1
  45. package/apps/web/dist/assets/ui-I7QYMX0F.js +1 -0
  46. package/apps/web/dist/index.html +4 -4
  47. package/apps/web/dist/mcp/flow.html +54 -54
  48. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +11 -0
  49. package/node_modules/@engenty-wizards/shared/dist/definition.js +27 -2
  50. package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +2 -0
  51. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +150 -0
  52. package/node_modules/@engenty-wizards/shared/dist/run.js +72 -1
  53. package/package.json +1 -1
  54. package/apps/web/dist/assets/ChatPanel-CPgh0skF.js +0 -1
  55. package/apps/web/dist/assets/CreditsDialog-CbFq_7-m.js +0 -1
  56. package/apps/web/dist/assets/EditorPage-8liZwYCK.js +0 -2
  57. package/apps/web/dist/assets/LocalRuntime-CVdU8RtD.js +0 -1
  58. package/apps/web/dist/assets/NewWizardPage-CqFlo0b-.js +0 -2
  59. package/apps/web/dist/assets/ProjectPage-CDw2Jcxx.js +0 -4
  60. package/apps/web/dist/assets/PublicRunner-BYnX2OmB.js +0 -1
  61. package/apps/web/dist/assets/RunnerView-Civq59Tk.js +0 -70
  62. package/apps/web/dist/assets/SettingsPage-Bj0TxbiM.js +0 -1
  63. package/apps/web/dist/assets/SharePage-CY6bwfTI.js +0 -1
  64. package/apps/web/dist/assets/changelog-BWtvjeKs.js +0 -1
  65. package/apps/web/dist/assets/index-LmRYEd-y.js +0 -10
  66. package/apps/web/dist/assets/ui-HV_XbHjw.js +0 -1
@@ -57,6 +57,13 @@
57
57
  "when": 1791287441521,
58
58
  "tag": "0007_space_data",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "6",
64
+ "when": 1791307814904,
65
+ "tag": "0008_run_event_note",
66
+ "breakpoints": true
60
67
  }
61
68
  ]
62
69
  }
@@ -129,6 +129,8 @@ export const runEvent = sqliteTable("run_event", {
129
129
  stepId: text("step_id"),
130
130
  type: text("type", { enum: ["step_started", "step_done", "tool", "info", "error"] }).notNull(),
131
131
  message: text("message").notNull(),
132
+ /** The message as a note of `RUN_NOTES`, for a runner in another language. */
133
+ note: text("note", { mode: "json" }).$type(),
132
134
  /** A picture of the run the event is about; shown while the step works. */
133
135
  assetId: text("asset_id"),
134
136
  createdAt: createdAt(),
@@ -1,13 +1,23 @@
1
1
  import { AsyncResource } from "node:async_hooks";
2
2
  import { EventEmitter } from "node:events";
3
+ import { noteText } from "@engenty-wizards/shared/run";
3
4
  import { and, desc, eq, gt } from "drizzle-orm";
4
5
  import { db, schema } from "../db/client.js";
5
6
  const bus = new EventEmitter();
6
7
  bus.setMaxListeners(0);
7
- export async function emitEvent(runId, stepId, type, message, asset) {
8
+ export async function emitEvent(runId, stepId, type, said, asset) {
9
+ const note = typeof said === "string" ? null : said;
10
+ const message = note ? noteText(note, "de") : said;
8
11
  const [row] = await db
9
12
  .insert(schema.runEvent)
10
- .values({ runId, stepId, type, message: message.slice(0, 2000), assetId: asset ?? null })
13
+ .values({
14
+ runId,
15
+ stepId,
16
+ type,
17
+ message: message.slice(0, 2000),
18
+ note,
19
+ assetId: asset ?? null,
20
+ })
11
21
  .returning();
12
22
  const event = {
13
23
  id: row.id,
@@ -15,6 +25,7 @@ export async function emitEvent(runId, stepId, type, message, asset) {
15
25
  stepId: row.stepId,
16
26
  type: row.type,
17
27
  message: row.message,
28
+ note: row.note ?? null,
18
29
  asset: row.assetId,
19
30
  };
20
31
  bus.emit(runId, { runId, event });
@@ -41,6 +52,7 @@ export async function recentEvents(runId, afterId = 0, limit = 40) {
41
52
  stepId: r.stepId,
42
53
  type: r.type,
43
54
  message: r.message,
55
+ note: r.note ?? null,
44
56
  asset: r.assetId,
45
57
  }));
46
58
  }
@@ -28,7 +28,7 @@ export async function prepareInputs(ctx) {
28
28
  delete values[field.id];
29
29
  continue;
30
30
  }
31
- await ctx.emit("info", `Hört „${field.label}“ an …`);
31
+ await ctx.emit("info", { code: "listen", params: { label: field.label } });
32
32
  try {
33
33
  const transcript = await transcribeAudio({
34
34
  bytes: new Uint8Array(found.data),
@@ -45,7 +45,10 @@ export async function prepareInputs(ctx) {
45
45
  }
46
46
  // The step goes on without the words; the next one tries again.
47
47
  console.error(`[run ${ctx.runId}] transcribe ${field.id}`, err);
48
- await ctx.emit("info", `„${field.label}“ konnte nicht verschriftlicht werden.`);
48
+ await ctx.emit("info", {
49
+ code: "listenFailed",
50
+ params: { label: field.label },
51
+ });
49
52
  }
50
53
  }
51
54
  }
@@ -1,4 +1,4 @@
1
- import { isDecisionStep, } from "@engenty-wizards/shared/definition";
1
+ import { isDecisionStep, MODEL_CLASSES, ruleMatches, } from "@engenty-wizards/shared/definition";
2
2
  import { classProblem } from "../models.js";
3
3
  /**
4
4
  * What a wizard needs of the models before a run starts: each class its steps call, and whether
@@ -32,28 +32,58 @@ export function stepClasses(step) {
32
32
  default:
33
33
  return [step.model ?? "high"];
34
34
  }
35
+ case "page":
36
+ // A voice note the page asks for is listened to before the next step reads it.
37
+ return step.fields.some((f) => f.kind === "audio" && f.required) ? ["audio"] : [];
35
38
  default:
36
- // Pages, reviews (they redo a generate step counted already), widgets, results.
39
+ // Reviews (they redo a generate step counted already), widgets, results.
37
40
  return [];
38
41
  }
39
42
  }
43
+ /** The classes a step can do without: a voice note the person may leave out. */
44
+ export function optionalStepClasses(step) {
45
+ return step.type === "page" &&
46
+ step.fields.some((f) => f.kind === "audio" && !f.required) &&
47
+ !stepClasses(step).includes("audio")
48
+ ? ["audio"]
49
+ : [];
50
+ }
40
51
  /** The steps every run passes through: without one of them the end is not reachable. */
41
52
  export function unavoidableSteps(def) {
53
+ return def.steps.length ? unavoidableFrom(def, def.steps[0].id, {}) : new Set();
54
+ }
55
+ /**
56
+ * The steps every path from `from` to the end passes through. A rule on an answer in `known`
57
+ * goes one way; a rule on anything else may go either way.
58
+ */
59
+ export function unavoidableFrom(def, from, known) {
42
60
  const index = new Map(def.steps.map((s, i) => [s.id, i]));
61
+ const start = index.get(from) ?? 0;
43
62
  const successors = (i) => {
44
- const out = (def.steps[i].next ?? [])
45
- .map((rule) => (rule.goto === "end" ? def.steps.length : (index.get(rule.goto) ?? -1)))
46
- .filter((j) => j >= 0);
63
+ const out = [];
64
+ for (const rule of def.steps[i].next ?? []) {
65
+ const target = rule.goto === "end" ? def.steps.length : (index.get(rule.goto) ?? -1);
66
+ if (!(rule.when.field in known)) {
67
+ if (target >= 0) {
68
+ out.push(target);
69
+ }
70
+ continue;
71
+ }
72
+ if (ruleMatches(rule, known)) {
73
+ // The first rule that matches decides; nothing after it is tried.
74
+ return target >= 0 ? [...out, target] : out;
75
+ }
76
+ }
47
77
  // No rule matching goes on to the next step (or ends after the last).
48
78
  out.push(i + 1);
49
79
  return out;
50
80
  };
51
81
  const reachesEnd = (without) => {
52
- if (without === 0) {
82
+ if (without === start) {
53
83
  return false;
54
84
  }
55
- const seen = new Set([0]);
56
- const queue = [0];
85
+ const seen = new Set([start]);
86
+ const queue = [start];
57
87
  while (queue.length) {
58
88
  const i = queue.shift();
59
89
  if (i >= def.steps.length) {
@@ -70,24 +100,135 @@ export function unavoidableSteps(def) {
70
100
  };
71
101
  return new Set(def.steps.filter((_, i) => !reachesEnd(i)).map((s) => s.id));
72
102
  }
103
+ /** Whether a rule matches given what is known: true, false, or null = it may go either way. */
104
+ function ruleHolds(rule, facts) {
105
+ const k = facts[rule.when.field];
106
+ const value = rule.when.value;
107
+ switch (rule.when.op) {
108
+ case "empty":
109
+ return k?.filled === undefined ? (k?.is !== undefined ? false : null) : !k.filled;
110
+ case "notEmpty":
111
+ return k?.filled === undefined ? (k?.is !== undefined ? true : null) : k.filled;
112
+ case "equals":
113
+ return k?.is !== undefined
114
+ ? k.is === String(value)
115
+ : k?.not.includes(String(value))
116
+ ? false
117
+ : null;
118
+ case "notEquals":
119
+ return k?.is !== undefined
120
+ ? k.is !== String(value)
121
+ : k?.not.includes(String(value))
122
+ ? true
123
+ : null;
124
+ case "in": {
125
+ const list = Array.isArray(value) ? value.map(String) : [];
126
+ if (k?.is !== undefined) {
127
+ return list.includes(k.is);
128
+ }
129
+ return list.every((v) => k?.not.includes(v)) ? false : null;
130
+ }
131
+ }
132
+ }
133
+ /** What is known once a rule did (`held`) or did not match. */
134
+ function learn(rule, held, facts) {
135
+ const field = rule.when.field;
136
+ const k = { ...(facts[field] ?? { not: [] }) };
137
+ k.not = [...k.not];
138
+ const value = String(rule.when.value);
139
+ const op = rule.when.op;
140
+ if ((op === "equals" && held) || (op === "notEquals" && !held)) {
141
+ k.is = value;
142
+ k.filled = true;
143
+ }
144
+ else if ((op === "equals" && !held) || (op === "notEquals" && held)) {
145
+ k.not.push(value);
146
+ }
147
+ else if (op === "in" && !held && Array.isArray(rule.when.value)) {
148
+ k.not.push(...rule.when.value.map(String));
149
+ }
150
+ else if (op === "empty" || op === "notEmpty") {
151
+ k.filled = (op === "notEmpty") === held;
152
+ }
153
+ return { ...facts, [field]: k };
154
+ }
155
+ /**
156
+ * The most a run can add up to along one way through the wizard. A branch remembers what it
157
+ * implies of the answers, so ways that contradict themselves (the 720p clips and the 480p
158
+ * ones) are not added up. Steps in `closed` are not passed (no model for them here); where that
159
+ * leaves no way to the end, they count again. A branch back to an earlier step is not followed
160
+ * twice.
161
+ */
162
+ export function mostAlongOnePath(def, cost, closed = new Set()) {
163
+ const index = new Map(def.steps.map((s, i) => [s.id, i]));
164
+ const end = def.steps.length;
165
+ const walk = (avoid) => {
166
+ const best = (i, facts, seen) => {
167
+ if (i >= end) {
168
+ return 0;
169
+ }
170
+ const step = def.steps[i];
171
+ if (i < 0 || avoid.has(step.id) || seen.has(i)) {
172
+ return null;
173
+ }
174
+ const passed = new Set(seen).add(i);
175
+ let most = null;
176
+ const consider = (j, known) => {
177
+ const rest = best(j, known, passed);
178
+ if (rest !== null && (most === null || rest > most)) {
179
+ most = rest;
180
+ }
181
+ };
182
+ // The rules are tried in order; the first that matches decides.
183
+ let known = facts;
184
+ let decided = false;
185
+ for (const rule of step.next ?? []) {
186
+ const holds = ruleHolds(rule, known);
187
+ if (holds === false) {
188
+ continue;
189
+ }
190
+ consider(rule.goto === "end" ? end : (index.get(rule.goto) ?? -1), learn(rule, true, known));
191
+ if (holds === true) {
192
+ decided = true;
193
+ break;
194
+ }
195
+ known = learn(rule, false, known);
196
+ }
197
+ if (!decided) {
198
+ consider(i + 1, known);
199
+ }
200
+ return most === null ? null : most + cost(step);
201
+ };
202
+ return best(0, {}, new Set());
203
+ };
204
+ return walk(closed) ?? walk(new Set()) ?? def.steps.reduce((sum, s) => sum + cost(s), 0);
205
+ }
206
+ /** The steps that call a class this runtime cannot serve. */
207
+ export async function stepsWithoutModel(def) {
208
+ const missing = await missingClasses();
209
+ return new Set(def.steps.filter((s) => stepClasses(s).some((cls) => missing.has(cls))).map((s) => s.id));
210
+ }
73
211
  /** The classes this wizard needs that this runtime cannot serve; empty = every step can run. */
74
212
  export async function missingModels(def) {
75
213
  const unavoidable = unavoidableSteps(def);
76
214
  const needs = new Map();
77
215
  for (const step of def.steps) {
78
216
  for (const cls of new Set(stepClasses(step))) {
79
- needs.set(cls, [...(needs.get(cls) ?? []), step]);
217
+ needs.set(cls, [...(needs.get(cls) ?? []), { step, required: true }]);
218
+ }
219
+ for (const cls of optionalStepClasses(step)) {
220
+ needs.set(cls, [...(needs.get(cls) ?? []), { step, required: false }]);
80
221
  }
81
222
  }
82
223
  const missing = [];
83
- for (const [cls, steps] of needs) {
224
+ for (const [cls, uses] of needs) {
84
225
  const problem = await classProblem(cls);
85
226
  if (problem) {
86
227
  missing.push({
87
228
  cls,
88
229
  problem,
89
- steps: steps.map((s) => ({ id: s.id, title: s.title })),
90
- blocking: steps.some((s) => unavoidable.has(s.id)),
230
+ steps: uses.map(({ step }) => ({ id: step.id, title: step.title })),
231
+ blocking: uses.some(({ step, required }) => required && unavoidable.has(step.id)),
91
232
  });
92
233
  }
93
234
  }
@@ -122,3 +263,98 @@ export async function missingCapabilities() {
122
263
  }
123
264
  return missing;
124
265
  }
266
+ /** The classes this runtime cannot serve. */
267
+ export async function missingClasses() {
268
+ const missing = new Set();
269
+ for (const cls of MODEL_CLASSES) {
270
+ if (await classProblem(cls)) {
271
+ missing.add(cls);
272
+ }
273
+ }
274
+ return missing;
275
+ }
276
+ /**
277
+ * The capabilities of a wizard that only some of its paths need: without them it still starts,
278
+ * on the other paths (animated stills instead of video clips, no voice-over).
279
+ */
280
+ export function optionalCapabilities(def) {
281
+ const unavoidable = unavoidableSteps(def);
282
+ const required = new Set();
283
+ const any = new Set();
284
+ for (const step of def.steps) {
285
+ for (const cls of stepClasses(step)) {
286
+ any.add(cls);
287
+ if (unavoidable.has(step.id)) {
288
+ required.add(cls);
289
+ }
290
+ }
291
+ for (const cls of optionalStepClasses(step)) {
292
+ any.add(cls);
293
+ }
294
+ }
295
+ const optional = new Set();
296
+ for (const [capability, cls] of Object.entries(CAPABILITY_CLASS)) {
297
+ if (any.has(cls) && !required.has(cls)) {
298
+ optional.add(capability);
299
+ }
300
+ }
301
+ return optional;
302
+ }
303
+ function choicesOf(step) {
304
+ return step.fields.flatMap((f) => {
305
+ if (f.kind === "select" && f.options?.length) {
306
+ return [{ field: f.id, values: f.options }];
307
+ }
308
+ if (f.kind === "toggle") {
309
+ return [{ field: f.id, values: [true, false] }];
310
+ }
311
+ return [];
312
+ });
313
+ }
314
+ /**
315
+ * The answers on a page that lead to a step this runtime has no model for: with them the run
316
+ * would stop there, after what came before was paid for. The answers given already count; the
317
+ * page's other fields may still go either way. A choice whose every value leads there is left
318
+ * open: the run cannot go around it, and the start already warned. A voice note nothing here
319
+ * can listen to is closed as a whole (no values).
320
+ */
321
+ export async function closedChoices(def, step, values) {
322
+ const missing = await missingClasses();
323
+ if (!missing.size) {
324
+ return {};
325
+ }
326
+ const byId = new Map(def.steps.map((s) => [s.id, s]));
327
+ const known = Object.fromEntries(Object.entries(values).filter(([id]) => !step.fields.some((f) => f.id === id)));
328
+ const out = {};
329
+ // A voice note nothing here can listen to: the field is closed as a whole.
330
+ if (missing.has("audio")) {
331
+ for (const field of step.fields) {
332
+ if (field.kind === "audio") {
333
+ out[field.id] = { values: [], classes: ["audio"] };
334
+ }
335
+ }
336
+ }
337
+ for (const choice of choicesOf(step)) {
338
+ const closed = { values: [], classes: [] };
339
+ for (const value of choice.values) {
340
+ const ahead = unavoidableFrom(def, step.id, { ...known, [choice.field]: value });
341
+ const lacking = [...ahead].flatMap((id) => {
342
+ const s = byId.get(id);
343
+ return s ? stepClasses(s).filter((cls) => missing.has(cls)) : [];
344
+ });
345
+ if (lacking.length) {
346
+ closed.values.push(value);
347
+ closed.classes = [...new Set([...closed.classes, ...lacking])];
348
+ }
349
+ }
350
+ if (closed.values.length && closed.values.length < choice.values.length) {
351
+ out[choice.field] = closed;
352
+ }
353
+ }
354
+ return out;
355
+ }
356
+ /** The answer a closed choice gets instead: its first value that stays open. */
357
+ export function openValue(step, field, closed) {
358
+ const choice = choicesOf(step).find((c) => c.field === field);
359
+ return choice?.values.find((v) => !closed.values.some((c) => String(c) === String(v)));
360
+ }
@@ -20,7 +20,7 @@ import { askPerson, clearStaleAsk, unattended } from "./asks.js";
20
20
  import { emitEvent, recentEvents, signalChanged } from "./events.js";
21
21
  import { readPageInput } from "./input.js";
22
22
  import { pushRun } from "./push.js";
23
- import { blockingMessage, missingModels } from "./requirements.js";
23
+ import { blockingMessage, closedChoices, missingModels, openValue, stepsWithoutModel, } from "./requirements.js";
24
24
  import { releaseResources, resourcesFor } from "./resources.js";
25
25
  import { runAutomaticStep } from "./steps.js";
26
26
  import { resolveRef } from "./template.js";
@@ -161,7 +161,7 @@ export async function createRun(input) {
161
161
  throw new ModelUnavailableError(blocked);
162
162
  }
163
163
  // The run's expected cost is held before it starts; what it really costs is booked per call.
164
- const estimate = await estimateRun(input.wizardId, input.version, input.definition);
164
+ const estimate = await estimateRun(input.wizardId, input.version, input.definition, await stepsWithoutModel(input.definition));
165
165
  if (!(await reserveForRun(id, estimate.reserve))) {
166
166
  throw new NoCreditsError();
167
167
  }
@@ -358,6 +358,15 @@ export async function submitPage(runId, stepId, input) {
358
358
  throw new RunConflict("Not a page");
359
359
  }
360
360
  const { values, errors } = readPageInput(step, input);
361
+ // An answer that leads to a step without a model here would stop the run there, later.
362
+ const closed = await closedChoices(run.definition, step, run.state.values);
363
+ for (const [id, choice] of Object.entries(closed)) {
364
+ const answer = values[id];
365
+ if (answer !== undefined &&
366
+ (choice.values.length === 0 || choice.values.some((v) => String(v) === String(answer)))) {
367
+ errors.push({ field: id, message: "Das ist hier gerade nicht verfügbar." });
368
+ }
369
+ }
361
370
  if (errors.length) {
362
371
  throw new RunInputError(errors);
363
372
  }
@@ -483,8 +492,11 @@ export async function availableFormats(step, run, wanted) {
483
492
  }
484
493
  return formats;
485
494
  }
486
- /** What the page's fields start with, where they name an earlier step's result. */
487
- function prefillOf(step, run) {
495
+ /**
496
+ * What the page's fields start with: an earlier step's result where they name one, and for a
497
+ * choice whose default leads to a step without a model here, the first value that does not.
498
+ */
499
+ function prefillOf(step, run, closed) {
488
500
  const scope = { def: run.definition, state: run.state, brand: {} };
489
501
  const out = {};
490
502
  for (const field of step.fields) {
@@ -496,11 +508,21 @@ function prefillOf(step, run) {
496
508
  out[field.id] = value;
497
509
  }
498
510
  }
511
+ for (const [id, choice] of Object.entries(closed)) {
512
+ const field = step.fields.find((f) => f.id === id);
513
+ const start = out[id] ?? field?.default ?? (field?.kind === "toggle" ? false : undefined);
514
+ if (field && start !== undefined && choice.values.some((v) => String(v) === String(start))) {
515
+ out[id] = openValue(step, id, choice);
516
+ }
517
+ }
499
518
  return out;
500
519
  }
501
520
  export async function runView(run, brand) {
502
521
  const def = run.definition;
503
522
  const step = stepOf(def, run.cursor) ?? null;
523
+ const closed = step?.type === "page" && run.status === "waiting_input"
524
+ ? await closedChoices(def, step, run.state.values)
525
+ : {};
504
526
  const index = step ? def.steps.indexOf(step) : def.steps.length;
505
527
  const refs = step?.type === "review"
506
528
  ? step.show
@@ -549,7 +571,8 @@ export async function runView(run, brand) {
549
571
  },
550
572
  step,
551
573
  values: run.state.values,
552
- prefill: step?.type === "page" ? prefillOf(step, run) : {},
574
+ prefill: step?.type === "page" ? prefillOf(step, run, closed) : {},
575
+ closed,
553
576
  outputs: Object.fromEntries(shownIds.map((id) => [id, run.state.outputs[id]]).filter(([, o]) => o)),
554
577
  shown,
555
578
  progress: { done: run.status === "done" ? def.steps.length : index, total: def.steps.length },
@@ -1,4 +1,4 @@
1
- import { allFields, dataRef, isDecisionStep, templateRefs, } from "@engenty-wizards/shared/definition";
1
+ import { allFields, dataRef, isDecisionStep, LOCALES, templateRefs, wizardLang, } from "@engenty-wizards/shared/definition";
2
2
  import { Agent } from "@mastra/core/agent";
3
3
  import { generateText, Output } from "ai";
4
4
  import { z } from "zod";
@@ -13,8 +13,13 @@ import { runWidgetStep } from "../widgets/step.js";
13
13
  import { prepareInputs } from "./prepare.js";
14
14
  import { answersAsText, colorLine, factLines, renderTemplate, resolveRef, } from "./template.js";
15
15
  import { StepError } from "./types.js";
16
- function today() {
17
- return new Date().toLocaleDateString("de-DE", { year: "numeric", month: "long", day: "numeric" });
16
+ /** Today's date as the wizard's people write it. */
17
+ function today(ctx) {
18
+ return new Date().toLocaleDateString(LOCALES[wizardLang(ctx.def)], {
19
+ year: "numeric",
20
+ month: "long",
21
+ day: "numeric",
22
+ });
18
23
  }
19
24
  function brandBlock(ctx) {
20
25
  const b = ctx.scope.brand;
@@ -166,7 +171,7 @@ export async function runAgentStep(step, ctx) {
166
171
  : "Finish with everything the result needs in your final answer; it is turned into structured data afterwards.";
167
172
  const system = [
168
173
  GROUND_RULES,
169
- `Today is ${today()}.`,
174
+ `Today is ${today(ctx)}.`,
170
175
  brandBlock(ctx),
171
176
  documentsBlock(ctx),
172
177
  // What the space's plugins hold for its wizards: a wiki, questions and answers.
@@ -221,7 +226,7 @@ export async function runAgentStep(step, ctx) {
221
226
  // Working through a mailbox or a row of portals takes many small tool calls.
222
227
  const maxSteps = step.tools.includes("browser") || step.connections?.length ? 60 : 20;
223
228
  for (const [i, photo] of photos.entries()) {
224
- await ctx.emit("info", `Sieht sich Foto ${i + 1} von ${photos.length} an`, photo.id);
229
+ await ctx.emit("info", { code: "photo", params: { n: i + 1, total: photos.length } }, photo.id);
225
230
  }
226
231
  const message = photos.length
227
232
  ? [
@@ -381,7 +386,7 @@ async function writeHtml(step, ctx, kind) {
381
386
  ].join("\n");
382
387
  const system = [
383
388
  GROUND_RULES,
384
- `Today is ${today()}.`,
389
+ `Today is ${today(ctx)}.`,
385
390
  brandBlock(ctx),
386
391
  guide,
387
392
  images.length
@@ -474,8 +479,8 @@ async function runMediaStep(step, ctx, asset) {
474
479
  const todo = entries.filter((e) => !keep(e));
475
480
  if (asset === "video") {
476
481
  await ctx.emit("info", todo.length > 1
477
- ? `${todo.length} Clips werden gerendert – das dauert einige Minuten.`
478
- : "Das Video wird gerendert – das dauert meist 1–3 Minuten.");
482
+ ? { code: "clipsRendering", params: { count: todo.length } }
483
+ : { code: "videoRendering" });
479
484
  }
480
485
  let finished = 0;
481
486
  const note = ctx.state.notes[step.id];
@@ -496,7 +501,10 @@ async function runMediaStep(step, ctx, asset) {
496
501
  .join("\n")
497
502
  : await visualPrompt(step, ctx, entry);
498
503
  if (entry.reference && todo.length > 1) {
499
- await ctx.emit("info", `${asset === "image" ? "Bearbeitet" : "Dreht"} ${entry.index + 1} von ${entries.length}`, entry.reference);
504
+ await ctx.emit("info", {
505
+ code: asset === "image" ? "editing" : "filming",
506
+ params: { n: entry.index + 1, total: entries.length },
507
+ }, entry.reference);
500
508
  }
501
509
  const reference = await loadReference(entry.reference, ctx);
502
510
  const media = asset === "image"
@@ -529,7 +537,7 @@ async function runMediaStep(step, ctx, asset) {
529
537
  });
530
538
  finished++;
531
539
  if (todo.length > 1) {
532
- await ctx.emit("info", `${finished} von ${todo.length} fertig`, asset === "image" ? ref.id : undefined);
540
+ await ctx.emit("info", { code: "batchDone", params: { done: finished, total: todo.length } }, asset === "image" ? ref.id : undefined);
533
541
  }
534
542
  return { index: entry.index, prompt, ref };
535
543
  });
@@ -1,16 +1,17 @@
1
- import { allFields, isAudioValue, isLocationValue, itemsTotals, locationText, } from "@engenty-wizards/shared/definition";
1
+ import { allFields, isAudioValue, isLocationValue, itemsTotals, LOCALES, locationText, wizardLang, } from "@engenty-wizards/shared/definition";
2
2
  import { listAsMarkdown } from "@engenty-wizards/shared/store";
3
3
  const TEMPLATE_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
4
- function money(n, currency) {
4
+ function money(n, currency, locale) {
5
5
  try {
6
- return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(n);
6
+ return new Intl.NumberFormat(locale, { style: "currency", currency }).format(n);
7
7
  }
8
8
  catch {
9
9
  return `${n.toFixed(2)} ${currency}`;
10
10
  }
11
11
  }
12
12
  /** Line items as a Markdown table with their totals — what a model needs to write a document. */
13
- export function itemsAsMarkdown(field, value, values) {
13
+ export function itemsAsMarkdown(field, value, values, lang = "de") {
14
+ const locale = LOCALES[lang];
14
15
  const totals = itemsTotals(field, value, values);
15
16
  const cols = field.columns ?? [];
16
17
  if (!totals.rows.length) {
@@ -18,12 +19,14 @@ export function itemsAsMarkdown(field, value, values) {
18
19
  }
19
20
  const head = `| ${cols.map((c) => c.label).join(" | ")} | Amount |\n|${cols.map(() => " --- ").join("|")}| ---: |`;
20
21
  const body = totals.rows.map((r) => `| ${cols
21
- .map((c) => c.kind === "money" ? money(Number(r[c.id]) || 0, totals.currency) : String(r[c.id] ?? ""))
22
- .join(" | ")} | ${money(r.amount, totals.currency)} |`);
22
+ .map((c) => c.kind === "money"
23
+ ? money(Number(r[c.id]) || 0, totals.currency, locale)
24
+ : String(r[c.id] ?? ""))
25
+ .join(" | ")} | ${money(r.amount, totals.currency, locale)} |`);
23
26
  const sums = [
24
- `Net: ${money(totals.net, totals.currency)}`,
25
- totals.vatRate ? `VAT ${totals.vatRate}%: ${money(totals.vat, totals.currency)}` : null,
26
- `Total: ${money(totals.gross, totals.currency)}`,
27
+ `Net: ${money(totals.net, totals.currency, locale)}`,
28
+ totals.vatRate ? `VAT ${totals.vatRate}%: ${money(totals.vat, totals.currency, locale)}` : null,
29
+ `Total: ${money(totals.gross, totals.currency, locale)}`,
27
30
  ].filter(Boolean);
28
31
  return [head, ...body, "", ...sums].join("\n");
29
32
  }
@@ -118,7 +121,7 @@ export function resolveRef(ref, scope) {
118
121
  const value = state.values[head];
119
122
  if (field?.kind === "items") {
120
123
  if (!rest.length) {
121
- return itemsAsMarkdown(field, value, state.values);
124
+ return itemsAsMarkdown(field, value, state.values, wizardLang(def));
122
125
  }
123
126
  const totals = itemsTotals(field, value, state.values);
124
127
  return totals[rest[0]];
@@ -16,7 +16,7 @@ import { layout } from "../cli/home.js";
16
16
  import { connectorImportSchema, importConnector, listConnectors, previewSource, refreshConnector, registryService, removeConnector, searchRegistry, } from "../connectors/external.js";
17
17
  import { balanceCredits, canSpend } from "../credits/credits.js";
18
18
  import { estimateRun } from "../credits/estimate.js";
19
- import { missingModels } from "../engine/requirements.js";
19
+ import { missingModels, stepsWithoutModel } from "../engine/requirements.js";
20
20
  import { env } from "../env.js";
21
21
  import { freshAuth, loginEnv } from "../harness/env.js";
22
22
  import { detectHarness, detectHarnesses, HARNESS_IDS, harness, installSpec, } from "../harness/index.js";
@@ -639,7 +639,7 @@ export const studio = new Hono()
639
639
  // What a run of the draft is expected to cost, per step and in total.
640
640
  .get("/wizards/:id/estimate", async (c) => {
641
641
  const w = await ownedWizard(c.get("user").id, c.req.param("id"));
642
- return c.json(await estimateRun(w.id, null, w.draft));
642
+ return c.json(await estimateRun(w.id, null, w.draft, await stepsWithoutModel(w.draft)));
643
643
  })
644
644
  // The models the draft needs that this runtime cannot serve, by class, with the steps that call them.
645
645
  .get("/wizards/:id/models", async (c) => {