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.
- package/apps/runtime/dist/agents/studio-assistant.js +364 -38
- package/apps/runtime/dist/agents/website.js +256 -34
- package/apps/runtime/dist/assistant/runs.js +24 -0
- package/apps/runtime/dist/db/schema.js +1 -1
- package/apps/runtime/dist/docs/user/assistant.md +25 -7
- package/apps/runtime/dist/documents/parse.js +14 -0
- package/apps/runtime/dist/engine/steps.js +42 -6
- package/apps/runtime/dist/engine/template.js +50 -3
- package/apps/runtime/dist/env.js +5 -0
- package/apps/runtime/dist/film/project.js +54 -4
- package/apps/runtime/dist/manage.js +4 -4
- package/apps/runtime/dist/models.js +6 -0
- package/apps/runtime/dist/routes/studio.js +137 -40
- package/apps/runtime/dist/services/assistant-chats.js +49 -6
- package/apps/runtime/dist/services/brand.js +6 -3
- package/apps/runtime/dist/services/project-files.js +59 -3
- package/apps/runtime/dist/services/projects.js +150 -2
- package/apps/web/dist/assets/AboutDialog-ClV4gNiW.js +1 -0
- package/apps/web/dist/assets/{ChangelogDialog-m2M7gZiB.js → ChangelogDialog-DsSEkayM.js} +1 -1
- package/apps/web/dist/assets/ChatView-BdYCnDLk.js +10 -0
- package/apps/web/dist/assets/EditorPage-7PZzkNBT.js +3 -0
- package/apps/web/dist/assets/{FlowPreview-BdRy7FO7.js → FlowPreview-C_x5Kc2j.js} +1 -1
- package/apps/web/dist/assets/{Harness-DmJkTwOO.js → Harness-CvvtSTZC.js} +2 -2
- package/apps/web/dist/assets/NewWizardChannels-g1qCd_tQ.js +1 -0
- package/apps/web/dist/assets/NewWizardPage-B3hswj5r.js +7 -0
- package/apps/web/dist/assets/ProjectPage-CFDncRxT.js +1 -0
- package/apps/web/dist/assets/PublicRunner-C9N5r-sT.js +1 -0
- package/apps/web/dist/assets/{ReadOnly-CaGzgOPe.js → ReadOnly-C1UcqOlS.js} +1 -1
- package/apps/web/dist/assets/RunDrawer-B8YzFvgz.js +4 -0
- package/apps/web/dist/assets/RunnerView-ii-PP5c-.js +1 -0
- package/apps/web/dist/assets/SettingsPage-ClEP5Q0t.js +1 -0
- package/apps/web/dist/assets/SetupPage-CDFzo6LT.js +1 -0
- package/apps/web/dist/assets/SharePage-BV2S0g84.js +1 -0
- package/apps/web/dist/assets/changelog-wZI-UaUi.js +1 -0
- package/apps/web/dist/assets/{circle-alert-CpuMIhbH.js → circle-alert-D-Q6DK7b.js} +1 -1
- package/apps/web/dist/assets/{esm-DRaZAcXQ.js → esm-CTna4iL0.js} +1 -1
- package/apps/web/dist/assets/index-DUjJx-uk.css +1 -0
- package/apps/web/dist/assets/index-DnQgOKOQ.js +15 -0
- package/apps/web/dist/assets/{lock-BGotwAyN.js → lock-DwGN_KOC.js} +1 -1
- package/apps/web/dist/assets/{phone-CA2A8W7I.js → phone-XIVIe17d.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-DxswVOqQ.js → settings-menu-JuSRq6NP.js} +1 -1
- package/apps/web/dist/assets/{ui-DAHMnAVc.js → ui-DrjXmHdO.js} +2 -2
- package/apps/web/dist/assets/{upload-DLbJVvfd.js → upload-CDxTpAqx.js} +1 -1
- package/apps/web/dist/index.html +3 -8
- package/apps/web/dist/mcp/flow.html +3 -3
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +3 -3
- package/node_modules/@engenty-wizards/shared/dist/assistant.d.ts +62 -1
- package/node_modules/@engenty-wizards/shared/dist/assistant.js +10 -0
- package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +138 -3
- package/node_modules/@engenty-wizards/shared/dist/projects.js +153 -4
- package/package.json +1 -1
- package/apps/web/dist/assets/AboutDialog-Dz_tYBSU.js +0 -1
- package/apps/web/dist/assets/ChatView-D21o-lq8.js +0 -10
- package/apps/web/dist/assets/EditorPage-D6j3dQpu.js +0 -3
- package/apps/web/dist/assets/Marketplace-B-gy2onK.js +0 -2
- package/apps/web/dist/assets/NewWizardChannels-B3FEpsJ-.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-CT1lThNM.js +0 -7
- package/apps/web/dist/assets/ProjectPage-BIanCF0m.js +0 -2
- package/apps/web/dist/assets/PublicRunner-H1fXa9BK.js +0 -1
- package/apps/web/dist/assets/RunDrawer-8sjkAbm5.js +0 -4
- package/apps/web/dist/assets/RunnerView-CJ4cLA_0.js +0 -1
- package/apps/web/dist/assets/SettingsPage-BsdwE1OA.js +0 -1
- package/apps/web/dist/assets/SetupPage-sqrv7Auf.js +0 -1
- package/apps/web/dist/assets/SharePage-B6ets1ot.js +0 -1
- package/apps/web/dist/assets/brand-BRw18WJF.js +0 -2
- package/apps/web/dist/assets/changelog-BOFdRjx2.js +0 -1
- package/apps/web/dist/assets/index-DY9CslcT.css +0 -1
- package/apps/web/dist/assets/index-DkDBEMwz.js +0 -11
- package/apps/web/dist/assets/session-DBOQL_bg.js +0 -1
- package/apps/web/dist/assets/useMutation-DKyQXgyj.js +0 -1
- 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
|
-
|
|
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) =>
|
|
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
|
-
/**
|
|
114
|
-
export async function
|
|
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,
|
|
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
|
|
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, ...
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
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
|
|
175
|
-
* an empty one opens. An open chat without
|
|
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
|
|
223
|
+
const firstAsk = all.find((m) => m.role === "user")?.content ?? "";
|
|
184
224
|
await db
|
|
185
225
|
.update(schema.assistantChat)
|
|
186
|
-
.set({ title,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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;
|