wizards 0.3.6 → 0.3.8

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 (71) hide show
  1. package/apps/runtime/dist/agents/studio-assistant.js +364 -38
  2. package/apps/runtime/dist/agents/website.js +256 -34
  3. package/apps/runtime/dist/assistant/runs.js +24 -0
  4. package/apps/runtime/dist/db/schema.js +1 -1
  5. package/apps/runtime/dist/docs/user/assistant.md +25 -7
  6. package/apps/runtime/dist/documents/parse.js +14 -0
  7. package/apps/runtime/dist/engine/steps.js +42 -6
  8. package/apps/runtime/dist/engine/template.js +50 -3
  9. package/apps/runtime/dist/env.js +5 -0
  10. package/apps/runtime/dist/film/project.js +54 -4
  11. package/apps/runtime/dist/manage.js +4 -4
  12. package/apps/runtime/dist/models.js +6 -0
  13. package/apps/runtime/dist/routes/studio.js +137 -40
  14. package/apps/runtime/dist/services/assistant-chats.js +49 -6
  15. package/apps/runtime/dist/services/brand.js +6 -3
  16. package/apps/runtime/dist/services/project-files.js +59 -3
  17. package/apps/runtime/dist/services/projects.js +150 -2
  18. package/apps/web/dist/assets/AboutDialog-ClV4gNiW.js +1 -0
  19. package/apps/web/dist/assets/{ChangelogDialog-m2M7gZiB.js → ChangelogDialog-DsSEkayM.js} +1 -1
  20. package/apps/web/dist/assets/ChatView-BdYCnDLk.js +10 -0
  21. package/apps/web/dist/assets/EditorPage-7PZzkNBT.js +3 -0
  22. package/apps/web/dist/assets/{FlowPreview-BdRy7FO7.js → FlowPreview-C_x5Kc2j.js} +1 -1
  23. package/apps/web/dist/assets/{Harness-DmJkTwOO.js → Harness-CvvtSTZC.js} +2 -2
  24. package/apps/web/dist/assets/NewWizardChannels-g1qCd_tQ.js +1 -0
  25. package/apps/web/dist/assets/NewWizardPage-B3hswj5r.js +7 -0
  26. package/apps/web/dist/assets/ProjectPage-CFDncRxT.js +1 -0
  27. package/apps/web/dist/assets/PublicRunner-C9N5r-sT.js +1 -0
  28. package/apps/web/dist/assets/{ReadOnly-CaGzgOPe.js → ReadOnly-C1UcqOlS.js} +1 -1
  29. package/apps/web/dist/assets/RunDrawer-B8YzFvgz.js +4 -0
  30. package/apps/web/dist/assets/RunnerView-ii-PP5c-.js +1 -0
  31. package/apps/web/dist/assets/SettingsPage-ClEP5Q0t.js +1 -0
  32. package/apps/web/dist/assets/SetupPage-CDFzo6LT.js +1 -0
  33. package/apps/web/dist/assets/SharePage-BV2S0g84.js +1 -0
  34. package/apps/web/dist/assets/changelog-wZI-UaUi.js +1 -0
  35. package/apps/web/dist/assets/{circle-alert-CpuMIhbH.js → circle-alert-D-Q6DK7b.js} +1 -1
  36. package/apps/web/dist/assets/{esm-DRaZAcXQ.js → esm-CTna4iL0.js} +1 -1
  37. package/apps/web/dist/assets/index-DUjJx-uk.css +1 -0
  38. package/apps/web/dist/assets/index-DnQgOKOQ.js +15 -0
  39. package/apps/web/dist/assets/{lock-BGotwAyN.js → lock-DwGN_KOC.js} +1 -1
  40. package/apps/web/dist/assets/{phone-CA2A8W7I.js → phone-XIVIe17d.js} +1 -1
  41. package/apps/web/dist/assets/{settings-menu-DxswVOqQ.js → settings-menu-JuSRq6NP.js} +1 -1
  42. package/apps/web/dist/assets/{ui-DAHMnAVc.js → ui-DrjXmHdO.js} +2 -2
  43. package/apps/web/dist/assets/{upload-DLbJVvfd.js → upload-CDxTpAqx.js} +1 -1
  44. package/apps/web/dist/index.html +3 -8
  45. package/apps/web/dist/mcp/flow.html +3 -3
  46. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +3 -3
  47. package/node_modules/@engenty-wizards/shared/dist/assistant.d.ts +62 -1
  48. package/node_modules/@engenty-wizards/shared/dist/assistant.js +10 -0
  49. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +138 -3
  50. package/node_modules/@engenty-wizards/shared/dist/projects.js +153 -4
  51. package/package.json +1 -1
  52. package/apps/web/dist/assets/AboutDialog-Dz_tYBSU.js +0 -1
  53. package/apps/web/dist/assets/ChatView-D21o-lq8.js +0 -10
  54. package/apps/web/dist/assets/EditorPage-D6j3dQpu.js +0 -3
  55. package/apps/web/dist/assets/Marketplace-B-gy2onK.js +0 -2
  56. package/apps/web/dist/assets/NewWizardChannels-B3FEpsJ-.js +0 -1
  57. package/apps/web/dist/assets/NewWizardPage-CT1lThNM.js +0 -7
  58. package/apps/web/dist/assets/ProjectPage-BIanCF0m.js +0 -2
  59. package/apps/web/dist/assets/PublicRunner-H1fXa9BK.js +0 -1
  60. package/apps/web/dist/assets/RunDrawer-8sjkAbm5.js +0 -4
  61. package/apps/web/dist/assets/RunnerView-CJ4cLA_0.js +0 -1
  62. package/apps/web/dist/assets/SettingsPage-BsdwE1OA.js +0 -1
  63. package/apps/web/dist/assets/SetupPage-sqrv7Auf.js +0 -1
  64. package/apps/web/dist/assets/SharePage-B6ets1ot.js +0 -1
  65. package/apps/web/dist/assets/brand-BRw18WJF.js +0 -2
  66. package/apps/web/dist/assets/changelog-BOFdRjx2.js +0 -1
  67. package/apps/web/dist/assets/index-DY9CslcT.css +0 -1
  68. package/apps/web/dist/assets/index-DkDBEMwz.js +0 -11
  69. package/apps/web/dist/assets/session-DBOQL_bg.js +0 -1
  70. package/apps/web/dist/assets/useMutation-DKyQXgyj.js +0 -1
  71. package/apps/web/dist/assets/volume-2-KaKQ4zMJ.js +0 -1
@@ -3,8 +3,9 @@ import { existsSync } from "node:fs";
3
3
  import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, normalize } from "node:path";
5
5
  import { dataRef, FILM_SIZES } from "@engenty-wizards/shared/definition";
6
+ import { gradientCss, ruleLines, textStyleLine } from "@engenty-wizards/shared/projects";
6
7
  import { unzipSync } from "fflate";
7
- import { renderTemplate, resolveRef } from "../engine/template.js";
8
+ import { logoSectionLine, renderTemplate, resolveRef } from "../engine/template.js";
8
9
  import { env } from "../env.js";
9
10
  import { getBlob } from "../files/blobs.js";
10
11
  import { extFor, loadAsset } from "../files/storage.js";
@@ -128,23 +129,72 @@ export async function prepareFilmFolder(step, ctx) {
128
129
  // The space's brand.
129
130
  const b = ctx.scope.brand;
130
131
  const logos = [];
132
+ const patterns = [];
133
+ for (const p of b.patterns ?? []) {
134
+ const f = ctx.projectFiles.find((x) => x.id === p.file);
135
+ try {
136
+ if (f) {
137
+ const { data } = await projectFileContent(f.projectId, f.id);
138
+ const name = `patterns/${safeName(f.name.replace(/\.[^.]+$/, ""))}.${extFor(f.mime) === "bin" ? (f.name.split(".").pop() ?? "bin") : extFor(f.mime)}`;
139
+ await put(join(root, "brand"), name, data);
140
+ patterns.push({ file: name, name: p.name, tile: Boolean(p.tile) });
141
+ }
142
+ }
143
+ catch {
144
+ // a pattern less
145
+ }
146
+ }
131
147
  for (const f of ctx.projectFiles.filter((p) => p.kind === "logo")) {
132
148
  try {
133
149
  const { data } = await projectFileContent(f.projectId, f.id);
134
150
  const name = `logos/${safeName(f.name.replace(/\.[^.]+$/, ""))}.${extFor(f.mime) === "bin" ? (f.name.split(".").pop() ?? "bin") : extFor(f.mime)}`;
135
151
  await put(join(root, "brand"), name, data);
136
- logos.push(name);
152
+ const section = logoSectionLine(b.logoSections, f.id);
153
+ logos.push({
154
+ file: name,
155
+ ...(section ? { section } : {}),
156
+ ...(f.description ? { description: f.description } : {}),
157
+ });
137
158
  }
138
159
  catch {
139
160
  // a logo less
140
161
  }
141
162
  }
142
163
  await put(join(root, "brand"), "brand.json", JSON.stringify({
143
- note: "The space's brand. Colours are named by what they are for; the first is the accent.",
164
+ note: "The space's brand. Colours are named by what they are for; the first is the accent. A logo's or a colour's section says when to use it; colorRules, logoRules and typeRules, where there are any, say how colours, logos and type may be used; text is set in the fonts.",
144
165
  name: b.name ?? "",
145
166
  about: b.about ?? "",
146
- colors: (b.colors ?? []).map((c) => ({ name: c.name, value: c.value })),
167
+ colors: (b.colors ?? []).map((c) => {
168
+ const s = b.colorSections?.find((x) => x.id === c.section);
169
+ return { name: c.name, value: c.value, ...(s ? { section: s.title } : {}) };
170
+ }),
171
+ ...(b.gradients?.length
172
+ ? {
173
+ gradients: b.gradients.map((g) => ({
174
+ name: g.name,
175
+ section: b.colorSections?.find((x) => x.id === g.section)?.title,
176
+ css: gradientCss(g),
177
+ })),
178
+ }
179
+ : {}),
180
+ ...(patterns.length ? { patterns } : {}),
147
181
  logos,
182
+ ...(b.colorRules?.length ? { colorRules: ruleLines(b.colorRules).split("\n") } : {}),
183
+ ...(b.fonts?.length
184
+ ? {
185
+ fonts: b.fonts.map(({ family, role, weights }) => ({ family, role, weights })),
186
+ }
187
+ : {}),
188
+ ...(b.textStyles?.length
189
+ ? {
190
+ textStyles: b.textStyles.map((s) => ({
191
+ name: s.name,
192
+ style: textStyleLine(s, b.fonts ?? []),
193
+ })),
194
+ }
195
+ : {}),
196
+ ...(b.typeRules?.length ? { typeRules: ruleLines(b.typeRules).split("\n") } : {}),
197
+ ...(b.logoRules?.length ? { logoRules: ruleLines(b.logoRules).split("\n") } : {}),
148
198
  facts: (b.facts ?? []).map((f) => `${f.label}: ${f.value}`),
149
199
  }, null, 1));
150
200
  // What the run made before.
@@ -110,15 +110,15 @@ export function tenantMembers(id) {
110
110
  return call("GET", `/v1/tenants/${encodeURIComponent(id)}/members`);
111
111
  }
112
112
  const users = new Map();
113
- /** A person's own feature switches, as an admin set them at the Manage-App; cached a minute. */
114
- export async function userFeatures(userId) {
113
+ /** What the Manage-App says of a person beyond their token; cached a minute. */
114
+ export async function userInfo(userId) {
115
115
  const hit = users.get(userId);
116
116
  if (hit && Date.now() - hit.at < 60_000) {
117
117
  return hit.value;
118
118
  }
119
119
  const value = await call("GET", `/v1/users/${encodeURIComponent(userId)}`)
120
- .then((u) => u.features ?? {})
121
- .catch(() => hit?.value ?? {});
120
+ .then((u) => ({ features: u.features ?? {}, admin: u.admin === true }))
121
+ .catch(() => hit?.value ?? { features: {}, admin: false });
122
122
  users.set(userId, { at: Date.now(), value });
123
123
  return value;
124
124
  }
@@ -1013,6 +1013,12 @@ function priceOf(ref) {
1013
1013
  return (catalog.get(provider === null || provider === "gateway" ? id : `${provider}/${id}`)?.pricing ??
1014
1014
  {});
1015
1015
  }
1016
+ /** A model's context window in tokens, as the AI Gateway's catalog gives it; null where unknown. */
1017
+ export function contextWindowOf(ref) {
1018
+ const { provider, id } = parse(ref);
1019
+ const entry = catalog.get(provider === null || provider === "gateway" ? id : `${provider}/${id}`);
1020
+ return typeof entry?.context_window === "number" ? entry.context_window : null;
1021
+ }
1016
1022
  const n = (v, fallback) => {
1017
1023
  const x = Number(v);
1018
1024
  return Number.isFinite(x) ? x : fallback;
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { ASSISTANT_LAYOUTS, ASSISTANT_PAGE_BYTES, assistantTurnSchema, } from "@engenty-wizards/shared/assistant";
3
+ import { ASSISTANT_LAYOUTS, ASSISTANT_PAGE_BYTES, assistantSignalSchema, assistantTurnSchema, } from "@engenty-wizards/shared/assistant";
4
4
  import { MODEL_CLASSES, TEXT_CLASSES, } from "@engenty-wizards/shared/definition";
5
5
  import { CATEGORY_TYPES, whereSchema } from "@engenty-wizards/shared/knowledge";
6
6
  import { PROJECT_FILE_KINDS } from "@engenty-wizards/shared/projects";
@@ -11,9 +11,11 @@ import { Hono } from "hono";
11
11
  import { streamSSE } from "hono/streaming";
12
12
  import { nanoid } from "nanoid";
13
13
  import { z } from "zod";
14
- import { runStudioAssistant } from "../agents/studio-assistant.js";
14
+ import { runStudioAssistant, signalText } from "../agents/studio-assistant.js";
15
15
  import { chatEngine, setChatEngine, subscriptionClients } from "../agents/subscription.js";
16
+ import { scanColors, scanFonts, scanImages, scanLogos } from "../agents/website.js";
16
17
  import { answerBrowser, awaitBrowser } from "../assistant/browser.js";
18
+ import { endRun, signalRun, startRun, takeSignals } from "../assistant/runs.js";
17
19
  import { accountOverview, linkedAccount, startLink, unlink } from "../auth/account.js";
18
20
  import { createLocalKey, deleteLocalKey, listLocalKeys } from "../auth/keys.js";
19
21
  import { appStates, connectApps } from "../cli/connect.js";
@@ -28,7 +30,7 @@ import { appleStatus } from "../harness/apple.js";
28
30
  import { freshAuth, loginEnv } from "../harness/env.js";
29
31
  import { detectHarness, detectHarnesses, HARNESS_IDS, harness, installSpec, } from "../harness/index.js";
30
32
  import { closeTerminal, openTerminal, resizeTerminal, subscribeTerminal, writeTerminal, } from "../harness/terminal.js";
31
- import { managed, tenantInfo, tenantMembers, userFeatures } from "../manage.js";
33
+ import { managed, tenantInfo, tenantMembers, userInfo } from "../manage.js";
32
34
  import { seenApps } from "../mcp/seen.js";
33
35
  import { generateImageMedia, generateSpeechMedia, generateVideoMedia } from "../media/generate.js";
34
36
  import { transcribeAudio } from "../media/transcribe.js";
@@ -45,7 +47,7 @@ import { categoryContents, listKnowledge, resolvePath, valueContents, } from "..
45
47
  import { writeSummary } from "../services/knowledge-model.js";
46
48
  import { exportWizard, importWizard } from "../services/package.js";
47
49
  import { profileSchema, saveProfile, userProfile } from "../services/profile.js";
48
- import { addProjectFile, fileView, orderProjectFiles, projectFile, projectFileContent, projectFiles, reindexProjectFile, removeProjectFile, updateProjectFile, } from "../services/project-files.js";
50
+ import { addProjectFile, addProjectFileFromUrl, fileView, orderProjectFiles, projectFile, projectFileContent, projectFiles, reindexProjectFile, removeProjectFile, updateProjectFile, } from "../services/project-files.js";
49
51
  import { documentText, queueIndex, searchKnowledge } from "../services/project-index.js";
50
52
  import { createProject, deleteProject, listProjects, maskedServers, ownedProject, projectLimitOrNull, projectPatchSchema, updateProject, } from "../services/projects.js";
51
53
  import { runnersOf, wizardRunners } from "../services/runners.js";
@@ -189,6 +191,10 @@ async function testClass(cls, lang) {
189
191
  }
190
192
  }
191
193
  /** The size of an inline terminal, as the page lays it out. */
194
+ /** Whether the studio assistant's developer mode is offered to this person. */
195
+ async function assistantDevFor(user) {
196
+ return managed ? (await userInfo(user.id)).admin : env.assistantDev;
197
+ }
192
198
  const terminalSize = z.object({
193
199
  cols: z.number().int().min(10).max(500).optional(),
194
200
  rows: z.number().int().min(4).max(200).optional(),
@@ -202,7 +208,7 @@ export const studio = new Hono()
202
208
  const profile = await userProfile(user);
203
209
  // What the Manage-App says of the tenant: its plan and feature switches; and of the person.
204
210
  const info = managed ? await tenantInfo(user.tenantId).catch(() => null) : null;
205
- const own = managed ? await userFeatures(user.id) : null;
211
+ const person = managed ? await userInfo(user.id) : null;
206
212
  return c.json({
207
213
  user: { id: user.id, name: profile.name, email: profile.email, image: user.image ?? null },
208
214
  /** What the person says about themselves; name and e-mail are the account's when managed. */
@@ -214,7 +220,9 @@ export const studio = new Hono()
214
220
  plan: info?.plan ?? null,
215
221
  },
216
222
  /** The feature switches: the code's defaults, what the Manage-App says of the team and the person, this install's file. */
217
- features: features(managed ? { ...info?.features, ...own } : overview?.tenant.features),
223
+ features: features(managed ? { ...info?.features, ...person?.features } : overview?.tenant.features),
224
+ /** The studio assistant's developer mode may be switched on: ASSISTANT_DEV here, the platform's admins when managed. */
225
+ assistantDev: await assistantDevFor(user),
218
226
  mode: managed ? "managed" : "local",
219
227
  /** The tenant's balance; null where the runtime resolves models itself. */
220
228
  credits: managed ? await balanceCredits() : null,
@@ -316,6 +324,12 @@ export const studio = new Hono()
316
324
  }
317
325
  const voice = await generateSpeechMedia({ text, abortSignal: c.req.raw.signal });
318
326
  return c.body(new Uint8Array(voice.bytes), 200, { "content-type": voice.mime });
327
+ })
328
+ // A message written while a turn runs: the turn takes it before its next step or after its answer.
329
+ .post("/assistant/run/:runId/signal", async (c) => {
330
+ const body = assistantSignalSchema.parse(await c.req.json());
331
+ const taken = signalRun(c.get("user").id, c.req.param("runId"), body);
332
+ return c.json({ ok: taken }, taken ? 200 : 404);
319
333
  })
320
334
  // A browser tool's result: the turn that waits for it goes on.
321
335
  .post("/assistant/tool/:callId", async (c) => {
@@ -338,53 +352,100 @@ export const studio = new Hono()
338
352
  ? { route: body.page.route, title: body.page.title, view: body.page.view }
339
353
  : (body.page ?? null);
340
354
  const chat = await openChat(user);
341
- const history = await turnHistory(user, chat);
355
+ const debug = Boolean(body.debug) && (await assistantDevFor(user));
356
+ let history = await turnHistory(user, chat);
342
357
  const runId = nanoid(10);
343
358
  const userMessageId = await addMessage(chat.id, {
344
359
  role: "user",
345
360
  content: body.message,
346
361
  page: page ? { route: page.route, title: page.title } : null,
347
362
  });
363
+ startRun(runId, user.id);
348
364
  return streamSSE(c, async (stream) => {
349
365
  const abort = new AbortController();
350
366
  stream.onAbort(() => abort.abort());
351
367
  const send = (event, data) => stream.writeSSE({ event, data: JSON.stringify(data) });
352
- const messageId = nanoid(14);
368
+ let messageId = nanoid(14);
353
369
  await send("run_started", { threadId: chat.id, runId, messageId: userMessageId });
370
+ // The answer's text since the last message written meanwhile.
354
371
  let reply = "";
372
+ let changed = false;
373
+ let steps = 0;
374
+ /** What the answer said and did up to messages written meanwhile; null when nothing. */
375
+ const storePart = async (before) => {
376
+ changed ||= before.changed;
377
+ return before.reply || before.toolCalls.length || before.cards.length
378
+ ? await addMessage(chat.id, { role: "assistant", content: before.reply, ...before })
379
+ : null;
380
+ };
381
+ /** The messages written meanwhile, after the part of the answer before them. */
382
+ const storeSignals = async (taken, answerId) => {
383
+ for (const [i, s] of taken.entries()) {
384
+ const stored = await addMessage(chat.id, { role: "user", content: s.message });
385
+ await send("signal", {
386
+ ref: s.ref,
387
+ messageId: stored,
388
+ content: s.message,
389
+ answerId: i === 0 ? answerId : null,
390
+ });
391
+ }
392
+ reply = "";
393
+ messageId = nanoid(14);
394
+ };
355
395
  try {
356
- const result = await runStudioAssistant({
357
- user,
358
- chat,
359
- history,
360
- project,
361
- writable,
362
- message: body.message,
363
- page,
364
- browserTools: body.tools,
365
- lang: body.lang,
366
- signal: abort.signal,
367
- onText: (delta) => {
368
- reply += delta;
369
- void send("text", { messageId, delta });
370
- },
371
- onActivity: (label) => void send("activity", { label }),
372
- onChanged: () => void send("changed", {}),
373
- onCard: (card) => void send("card", card),
374
- onToolCall: (call) => void send("tool_call", call),
375
- onToolResult: (result) => void send("tool_result", result),
376
- callBrowser: (call) => awaitBrowser(user.id, call.toolCallId, abort.signal),
377
- });
378
- reply =
379
- result.reply || (result.changed ? (body.lang === "de" ? "Erledigt." : "Done.") : "");
380
- const stored = await addMessage(chat.id, {
381
- role: "assistant",
382
- content: reply,
383
- cards: result.cards,
384
- toolCalls: result.toolCalls,
385
- changed: result.changed,
386
- });
387
- await send("done", { messageId: stored, reply, changed: result.changed });
396
+ let message = body.message;
397
+ for (;;) {
398
+ const result = await runStudioAssistant({
399
+ user,
400
+ chat,
401
+ history,
402
+ project,
403
+ writable,
404
+ message,
405
+ page,
406
+ browserTools: body.tools,
407
+ lang: body.lang,
408
+ signal: abort.signal,
409
+ onText: (delta) => {
410
+ reply += delta;
411
+ void send("text", { messageId, delta });
412
+ },
413
+ onActivity: (label) => void send("activity", { label }),
414
+ onChanged: () => void send("changed", {}),
415
+ onCard: (card) => void send("card", card),
416
+ onToolCall: (call) => void send("tool_call", call),
417
+ onToolResult: (result) => void send("tool_result", result),
418
+ callBrowser: (call) => awaitBrowser(user.id, call.toolCallId, abort.signal),
419
+ takeSignals: () => takeSignals(runId),
420
+ onSignal: async (taken, before) => storeSignals(taken, await storePart(before)),
421
+ ...(debug
422
+ ? {
423
+ onPrompt: (prompt) => void send("prompt", prompt),
424
+ onStep: (step) => void send("step", { ...step, step: ++steps }),
425
+ }
426
+ : {}),
427
+ });
428
+ // Written after the answer's last step: the turn goes on with it.
429
+ const left = takeSignals(runId);
430
+ if (!left.length || abort.signal.aborted) {
431
+ changed ||= result.changed;
432
+ reply = result.reply || (changed ? (body.lang === "de" ? "Erledigt." : "Done.") : "");
433
+ const stored = await addMessage(chat.id, {
434
+ role: "assistant",
435
+ content: reply,
436
+ cards: result.cards,
437
+ toolCalls: result.toolCalls,
438
+ changed: result.changed,
439
+ });
440
+ await send("done", { messageId: stored, reply, changed });
441
+ break;
442
+ }
443
+ // The next answer knows this one: it is stored before the history is read again.
444
+ const answerId = await storePart(result);
445
+ history = await turnHistory(user, chat);
446
+ await storeSignals(left, answerId);
447
+ message = signalText(left);
448
+ }
388
449
  }
389
450
  catch (err) {
390
451
  console.error("[studio-assistant]", err);
@@ -399,6 +460,7 @@ export const studio = new Hono()
399
460
  : "Something went wrong. Please try again.",
400
461
  });
401
462
  }
463
+ endRun(runId);
402
464
  // Older messages fold into the chat's running summary once enough have piled up.
403
465
  void foldChat(chat.id).catch((err) => console.error("[studio-assistant] fold:", err));
404
466
  });
@@ -452,6 +514,41 @@ export const studio = new Hono()
452
514
  data: new Uint8Array(await file.arrayBuffer()),
453
515
  });
454
516
  return c.json(fileView(row));
517
+ })
518
+ .post("/projects/:id/files/url", async (c) => {
519
+ const project = await ownedProject(c.get("user").id, c.req.param("id"));
520
+ const input = z
521
+ .object({ url: z.string().min(1).max(500_000), kind: z.enum(PROJECT_FILE_KINDS) })
522
+ .parse(await c.req.json());
523
+ const url = /^(https?:\/\/|data:)/i.test(input.url) ? input.url : `https://${input.url}`;
524
+ try {
525
+ return c.json({ file: fileView(await addProjectFileFromUrl(project.id, { ...input, url })) });
526
+ }
527
+ catch (err) {
528
+ // A web page where a logo or an asset was asked for: its logos or pictures, to pick from.
529
+ if (err instanceof ServiceError && err.data.reason === "page") {
530
+ const page = String(err.data.url ?? url);
531
+ const scan = input.kind === "logo" ? scanLogos : scanImages;
532
+ return c.json({ candidates: await scan(page) });
533
+ }
534
+ throw err;
535
+ }
536
+ })
537
+ .post("/projects/:id/colors/scan", async (c) => {
538
+ await ownedProject(c.get("user").id, c.req.param("id"));
539
+ const { url } = z.object({ url: z.string().min(1).max(2000) }).parse(await c.req.json());
540
+ const colors = await scanColors(url).catch(() => {
541
+ throw new ServiceError("invalid", "Die Seite ist nicht erreichbar.");
542
+ });
543
+ return c.json({ colors });
544
+ })
545
+ .post("/projects/:id/fonts/scan", async (c) => {
546
+ await ownedProject(c.get("user").id, c.req.param("id"));
547
+ const { url } = z.object({ url: z.string().min(1).max(2000) }).parse(await c.req.json());
548
+ const fonts = await scanFonts(url).catch(() => {
549
+ throw new ServiceError("invalid", "Die Seite ist nicht erreichbar.");
550
+ });
551
+ return c.json({ fonts });
455
552
  })
456
553
  .put("/projects/:id/files/order", async (c) => {
457
554
  const project = await ownedProject(c.get("user").id, c.req.param("id"));
@@ -9,8 +9,8 @@ import { ServiceError } from "./errors.js";
9
9
  import { trigramQuery } from "./project-index.js";
10
10
  /**
11
11
  * The studio assistant's chats: one open chat per person and tenant, the same on every page and
12
- * space. "New chat" closes it with a dense summary; every turn of the next one carries the
13
- * summaries of the last chats, and older ones are found with `searchChats`.
12
+ * space. "New chat" closes it at once and writes its dense summary meanwhile; every turn of the
13
+ * next one carries the summaries of the last chats, and older ones are found with `searchChats`.
14
14
  */
15
15
  /** Messages of the open chat a turn sends as they are; older ones live in its running summary. */
16
16
  export const TURN_MESSAGES = 12;
@@ -74,6 +74,7 @@ export async function addMessage(chatId, message) {
74
74
  }
75
75
  /** What a turn of the open chat carries besides the new message. */
76
76
  export async function turnHistory(user, chat) {
77
+ await closedSummaries(user);
77
78
  const all = await messagesOf(chat.id);
78
79
  const earlier = await db.query.assistantChat.findMany({
79
80
  where: and(mine(user), isNotNull(schema.assistantChat.closedAt)),
@@ -81,8 +82,11 @@ export async function turnHistory(user, chat) {
81
82
  limit: EARLIER_IN_TURN,
82
83
  });
83
84
  return {
85
+ // A part of an answer that only called tools (before a message written meanwhile) has no
86
+ // text: models refuse empty messages.
84
87
  messages: all
85
88
  .slice(chat.folded)
89
+ .filter((m) => m.content.trim())
86
90
  .slice(-TURN_MESSAGES)
87
91
  .map(({ role, content }) => ({ role, content })),
88
92
  running: chat.summary,
@@ -170,9 +174,45 @@ export async function foldChat(chatId) {
170
174
  .set({ summary, folded: upTo })
171
175
  .where(and(eq(schema.assistantChat.id, chat.id), eq(schema.assistantChat.folded, chat.folded)));
172
176
  }
177
+ /** Summaries of chats "New chat" closed, still being written: by chat id, with whose chat it is. */
178
+ const summarising = new Map();
179
+ const whose = (user) => `${user.tenantId}:${user.id}`;
180
+ /** Writes the summary and the title of a closed chat. */
181
+ function summariseClosed(user, chat) {
182
+ const running = summarising.get(chat.id);
183
+ if (running) {
184
+ return running.done;
185
+ }
186
+ const done = (async () => {
187
+ const all = await messagesOf(chat.id);
188
+ const { title, summary } = await summarise(all.slice(chat.folded), chat.summary);
189
+ await db
190
+ .update(schema.assistantChat)
191
+ .set({ ...(title ? { title } : {}), summary })
192
+ .where(eq(schema.assistantChat.id, chat.id));
193
+ })()
194
+ .catch((err) => console.error("[assistant] summary of a closed chat:", err.message))
195
+ .finally(() => summarising.delete(chat.id));
196
+ summarising.set(chat.id, { user: whose(user), done });
197
+ return done;
198
+ }
199
+ /**
200
+ * Waits for the summaries of the person's closed chats: those being written, and any a stopped
201
+ * runtime left unwritten.
202
+ */
203
+ export async function closedSummaries(user) {
204
+ const unwritten = await db.query.assistantChat.findMany({
205
+ where: and(mine(user), isNotNull(schema.assistantChat.closedAt), isNull(schema.assistantChat.summary)),
206
+ });
207
+ await Promise.all([
208
+ ...unwritten.map((chat) => summariseClosed(user, chat)),
209
+ ...[...summarising.values()].filter((s) => s.user === whose(user)).map((s) => s.done),
210
+ ]);
211
+ }
173
212
  /**
174
- * "New chat": the open chat is summarised and closed (kept to read under "Earlier chats"), and
175
- * an empty one opens. An open chat without messages stays as it is.
213
+ * "New chat": the open chat is closed at once (kept to read under "Earlier chats", titled by its
214
+ * first question until its summary is written) and an empty one opens. An open chat without
215
+ * messages stays as it is.
176
216
  */
177
217
  export async function newChat(user) {
178
218
  const chat = await openChat(user);
@@ -180,11 +220,12 @@ export async function newChat(user) {
180
220
  if (!all.length) {
181
221
  return { id: chat.id, messages: [] };
182
222
  }
183
- const { title, summary } = await summarise(all.slice(chat.folded), chat.summary);
223
+ const firstAsk = all.find((m) => m.role === "user")?.content ?? "";
184
224
  await db
185
225
  .update(schema.assistantChat)
186
- .set({ title, summary, closedAt: new Date() })
226
+ .set({ title: firstAsk.replace(/\s+/g, " ").trim().slice(0, 60), closedAt: new Date() })
187
227
  .where(eq(schema.assistantChat.id, chat.id));
228
+ void summariseClosed(user, chat);
188
229
  const next = await openChat(user);
189
230
  return { id: next.id, messages: [] };
190
231
  }
@@ -199,6 +240,8 @@ export async function earlierChats(user, limit = 50) {
199
240
  id: c.id,
200
241
  title: c.title ?? "",
201
242
  summary: c.summary ?? "",
243
+ /** Its summary is still being written. */
244
+ summarising: summarising.has(c.id),
202
245
  startedAt: c.createdAt.toISOString(),
203
246
  closedAt: c.closedAt.toISOString(),
204
247
  }));
@@ -1,12 +1,15 @@
1
+ import { mainLogo } from "@engenty-wizards/shared/projects";
1
2
  import { and, asc, eq } from "drizzle-orm";
2
3
  import { db, schema } from "../db/client.js";
3
- /** The project's first logo: the one end users, documents and widgets get. */
4
+ /** The project's main logo, the first standard one: what end users, documents and widgets get. */
4
5
  export async function mainLogoId(projectId) {
5
- const logo = await db.query.projectFile.findFirst({
6
+ const p = await db.query.project.findFirst({ where: eq(schema.project.id, projectId) });
7
+ const logos = await db.query.projectFile.findMany({
8
+ columns: { id: true },
6
9
  where: and(eq(schema.projectFile.projectId, projectId), eq(schema.projectFile.kind, "logo")),
7
10
  orderBy: [asc(schema.projectFile.position), asc(schema.projectFile.createdAt)],
8
11
  });
9
- return logo?.id ?? null;
12
+ return mainLogo(logos, p?.brand.logoSections)?.id ?? null;
10
13
  }
11
14
  /** What end users see of the project: its title, accent and logo — never the project name. */
12
15
  export async function brandView(projectId) {
@@ -7,6 +7,7 @@ import { loadAsset, removeAsset, replaceAsset, saveAsset } from "../files/storag
7
7
  import { embeddingModel, textModel } from "../models.js";
8
8
  import { spaceFileChanged } from "../plugins/events.js";
9
9
  import { dropLinks, putLink } from "../tenants/control.js";
10
+ import { safeFetch } from "../tools/net-guard.js";
10
11
  import { requireWritableProject } from "./access.js";
11
12
  import { notFound, ServiceError } from "./errors.js";
12
13
  import { convertDocument, fillCategories } from "./knowledge-model.js";
@@ -58,12 +59,17 @@ export async function projectFileContent(projectId, fileId) {
58
59
  }
59
60
  return { row, data: found.data };
60
61
  }
62
+ /**
63
+ * Assets are what wizards put into what they make: pictures, clips, recordings, and files to
64
+ * hand on as they are — PDFs, fonts, Lottie animations (JSON), 3D models.
65
+ */
66
+ const ASSET_TYPES = /^((image|video|audio|font)\/|application\/(pdf|json)$|model\/gltf)/;
61
67
  function accepts(kind, mime) {
62
- if (kind === "logo") {
68
+ if (kind === "logo" || kind === "pattern") {
63
69
  return mime.startsWith("image/");
64
70
  }
65
71
  if (kind === "asset") {
66
- return /^(image|video|audio)\//.test(mime);
72
+ return ASSET_TYPES.test(mime);
67
73
  }
68
74
  return true;
69
75
  }
@@ -73,7 +79,9 @@ export async function addProjectFile(projectId, input) {
73
79
  if (!accepts(input.kind, mime)) {
74
80
  throw new ServiceError("invalid", input.kind === "logo"
75
81
  ? "Ein Logo ist ein Bild (PNG, SVG, JPEG, WebP)."
76
- : "Hierher gehören Bilder, Grafiken, Videos und Audio. Anderes bitte zu den Dokumenten.");
82
+ : input.kind === "pattern"
83
+ ? "Ein Muster ist ein Bild (SVG, PNG, JPEG, WebP)."
84
+ : "Hierher gehören Bilder, Videos, Audio, PDFs, Schriften, Lottie und 3D-Modelle. Texte zum Nachschlagen bitte zu den Dokumenten.");
77
85
  }
78
86
  const limit = projectFileLimit(input.kind, mime);
79
87
  if (!input.data.byteLength || input.data.byteLength > limit) {
@@ -120,6 +128,54 @@ export async function addProjectFile(projectId, input) {
120
128
  }
121
129
  return row;
122
130
  }
131
+ /**
132
+ * Fetches a file from a public web address into the project, as an upload of it would add it.
133
+ * A drawing a page holds inline comes as a `data:image/svg+xml` address. A web page where a
134
+ * picture was asked for is refused with the reason `page`.
135
+ */
136
+ export async function addProjectFileFromUrl(projectId, input) {
137
+ const { url, kind, signal } = input;
138
+ const inline = url.match(/^data:image\/svg\+xml;base64,([A-Za-z0-9+/=]+)$/);
139
+ if (inline) {
140
+ return addProjectFile(projectId, {
141
+ kind,
142
+ name: `${kind}.svg`,
143
+ mime: "image/svg+xml",
144
+ data: new Uint8Array(Buffer.from(inline[1], "base64")),
145
+ description: input.description,
146
+ });
147
+ }
148
+ const res = await safeFetch(url, {
149
+ headers: { "user-agent": "Mozilla/5.0 (compatible; engenty-wizards/0.1)" },
150
+ signal: AbortSignal.any([AbortSignal.timeout(60_000), ...(signal ? [signal] : [])]),
151
+ }).catch(() => {
152
+ throw new ServiceError("invalid", "Die Adresse ist nicht erreichbar.");
153
+ });
154
+ if (!res.ok) {
155
+ throw new ServiceError("invalid", `Die Adresse antwortete ${res.status}.`);
156
+ }
157
+ const mime = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
158
+ if (mime === "text/html" && kind !== "document") {
159
+ await res.body?.cancel();
160
+ throw new ServiceError("invalid", "Unter der Adresse steht eine Webseite, kein Bild.", {
161
+ reason: "page",
162
+ url: res.url,
163
+ });
164
+ }
165
+ if (Number(res.headers.get("content-length") ?? 0) > projectFileLimit(kind, mime)) {
166
+ throw new ServiceError("invalid", "Die Datei ist zu groß.");
167
+ }
168
+ const data = new Uint8Array(await res.arrayBuffer());
169
+ const name = decodeURIComponent(new URL(res.url).pathname.split("/").pop() || kind);
170
+ return addProjectFile(projectId, {
171
+ kind,
172
+ name,
173
+ mime,
174
+ data,
175
+ description: input.description,
176
+ source: res.url,
177
+ });
178
+ }
123
179
  // --- reading, describing, indexing -------------------------------------------------
124
180
  /** Files are worked through two at a time: each costs model calls. */
125
181
  let running = 0;