wizards 0.3.7 → 0.4.0
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/README.md +28 -14
- package/apps/runtime/dist/agents/studio-assistant.js +79 -7
- package/apps/runtime/dist/assistant/runs.js +24 -0
- package/apps/runtime/dist/auth/account.js +36 -4
- package/apps/runtime/dist/auth/device.js +101 -0
- package/apps/runtime/dist/auth/index.js +4 -4
- package/apps/runtime/dist/cli/account.js +289 -0
- package/apps/runtime/dist/cli/install-env.js +33 -0
- package/apps/runtime/dist/cli/main.js +17 -3
- package/apps/runtime/dist/cli/menu.js +19 -5
- package/apps/runtime/dist/cli/setup.js +9 -16
- package/apps/runtime/dist/cli/status.js +4 -1
- package/apps/runtime/dist/cli/ui.js +14 -0
- package/apps/runtime/dist/docs/user/assistant.md +25 -7
- package/apps/runtime/dist/docs/user/command-line.md +3 -2
- package/apps/runtime/dist/docs/user/create-account.md +47 -0
- package/apps/runtime/dist/docs/user/first-start.md +9 -1
- package/apps/runtime/dist/docs/user/index.md +16 -12
- package/apps/runtime/dist/docs/user/install.md +3 -1
- package/apps/runtime/dist/docs/user/local-only.md +38 -0
- package/apps/runtime/dist/docs/user/meta.json +3 -1
- package/apps/runtime/dist/docs/user/models.md +3 -2
- package/apps/runtime/dist/env.js +5 -0
- package/apps/runtime/dist/manage.js +16 -4
- package/apps/runtime/dist/models.js +6 -0
- package/apps/runtime/dist/plugins/pro.js +16 -16
- package/apps/runtime/dist/routes/studio.js +140 -40
- package/apps/runtime/dist/services/assistant-chats.js +49 -6
- package/apps/runtime/dist/services/cloud.js +10 -10
- package/apps/runtime/dist/services/onboarding.js +152 -0
- package/apps/web/dist/assets/{AboutDialog-C0dN86zd.js → AboutDialog-CPQiN7H9.js} +1 -1
- package/apps/web/dist/assets/{ChangelogDialog-Cv81t7tj.js → ChangelogDialog-DynvQuuR.js} +1 -1
- package/apps/web/dist/assets/ChatView-CKo_9IZD.js +10 -0
- package/apps/web/dist/assets/EditorPage-CDIJXC4T.js +3 -0
- package/apps/web/dist/assets/{FlowPreview-D4dp1MWS.js → FlowPreview-BZLnG7GV.js} +1 -1
- package/apps/web/dist/assets/{Harness-v7r0e6vK.js → Harness-CNh3nbB_.js} +2 -2
- package/apps/web/dist/assets/{NewWizardChannels-Be-7VhBi.js → NewWizardChannels-C1Ss9UMY.js} +1 -1
- package/apps/web/dist/assets/{NewWizardPage-C8hPvrK8.js → NewWizardPage-2qLAbvDL.js} +2 -2
- package/apps/web/dist/assets/OnboardingPage-C1FJwEsT.js +1 -0
- package/apps/web/dist/assets/ProjectPage-CGuLUuOs.js +1 -0
- package/apps/web/dist/assets/{PublicRunner-DeCpVfZP.js → PublicRunner-BKw1lOW4.js} +1 -1
- package/apps/web/dist/assets/{ReadOnly-B-UCgnqW.js → ReadOnly-DTjv2Jzm.js} +1 -1
- package/apps/web/dist/assets/RunDrawer-D1I5xqVf.js +4 -0
- package/apps/web/dist/assets/RunnerView-BPPmlv9z.js +1 -0
- package/apps/web/dist/assets/SettingsPage-zbomQfGy.js +1 -0
- package/apps/web/dist/assets/SetupPage-D3F7M6Pz.js +1 -0
- package/apps/web/dist/assets/{SharePage-B778xUFo.js → SharePage-BY49IJgT.js} +1 -1
- package/apps/web/dist/assets/{changelog-DCJo2mbx.js → changelog-DYZC2E6q.js} +1 -1
- package/apps/web/dist/assets/{circle-alert-DLvC9ePT.js → circle-alert-CVOH4wAv.js} +1 -1
- package/apps/web/dist/assets/{esm-BJskogLD.js → esm-BfXdeWaI.js} +1 -1
- package/apps/web/dist/assets/index-B9xVkr2X.css +1 -0
- package/apps/web/dist/assets/index-BH0PYgov.js +15 -0
- package/apps/web/dist/assets/{lock-DR7NakPn.js → lock-DHBh62LA.js} +1 -1
- package/apps/web/dist/assets/{phone-BORh3jfv.js → phone-Bt1cyWpY.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-DV5D91TD.js → settings-menu-ace1vI3i.js} +1 -1
- package/apps/web/dist/assets/{ui-BOCXzzHi.js → ui-D2SqIZeQ.js} +2 -2
- package/apps/web/dist/assets/{upload-B3yv8gsk.js → upload-CCEq88l4.js} +1 -1
- package/apps/web/dist/index.html +3 -3
- package/apps/web/dist/install.sh +17 -7
- package/apps/web/dist/mcp/flow.html +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/package.json +1 -1
- package/apps/web/dist/assets/ChatView-uogJ3nht.js +0 -10
- package/apps/web/dist/assets/EditorPage-n8YuTh6j.js +0 -3
- package/apps/web/dist/assets/ProjectPage-B686ArYy.js +0 -1
- package/apps/web/dist/assets/RunDrawer-C6pzi8AJ.js +0 -4
- package/apps/web/dist/assets/RunnerView-Deenymq0.js +0 -1
- package/apps/web/dist/assets/SettingsPage-CK6L0yEe.js +0 -1
- package/apps/web/dist/assets/SetupPage-9CH3qXdH.js +0 -1
- package/apps/web/dist/assets/index-D6hUM8NU.css +0 -1
- package/apps/web/dist/assets/index-Df6UxElH.js +0 -12
|
@@ -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,10 +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
16
|
import { scanColors, scanFonts, scanImages, scanLogos } from "../agents/website.js";
|
|
17
17
|
import { answerBrowser, awaitBrowser } from "../assistant/browser.js";
|
|
18
|
+
import { endRun, signalRun, startRun, takeSignals } from "../assistant/runs.js";
|
|
18
19
|
import { accountOverview, linkedAccount, startLink, unlink } from "../auth/account.js";
|
|
19
20
|
import { createLocalKey, deleteLocalKey, listLocalKeys } from "../auth/keys.js";
|
|
20
21
|
import { appStates, connectApps } from "../cli/connect.js";
|
|
@@ -29,14 +30,14 @@ import { appleStatus } from "../harness/apple.js";
|
|
|
29
30
|
import { freshAuth, loginEnv } from "../harness/env.js";
|
|
30
31
|
import { detectHarness, detectHarnesses, HARNESS_IDS, harness, installSpec, } from "../harness/index.js";
|
|
31
32
|
import { closeTerminal, openTerminal, resizeTerminal, subscribeTerminal, writeTerminal, } from "../harness/terminal.js";
|
|
32
|
-
import { managed, tenantInfo, tenantMembers,
|
|
33
|
+
import { installMail, managed, renameTenant, tenantInfo, tenantMembers, userInfo, } from "../manage.js";
|
|
33
34
|
import { seenApps } from "../mcp/seen.js";
|
|
34
35
|
import { generateImageMedia, generateSpeechMedia, generateVideoMedia } from "../media/generate.js";
|
|
35
36
|
import { transcribeAudio } from "../media/transcribe.js";
|
|
36
37
|
import { checkKey, classProblem, classWays, embeddingModel, hasTextModel, LOCAL_KEYS, localModelSettings, ModelUnavailableError, modelSettings, saveLocalModels, saveModels, storedKey, textModel, } from "../models.js";
|
|
37
38
|
import { refreshPro } from "../plugins/pro.js";
|
|
38
39
|
import { HTML_RESPONSE_CSP } from "../render/guard.js";
|
|
39
|
-
import { isAdminRole, mayBuild, mayCreate, projectWritable, requireWritable, } from "../services/access.js";
|
|
40
|
+
import { isAdminRole, mayBuild, mayCreate, projectWritable, requireAdmin, requireWritable, } from "../services/access.js";
|
|
40
41
|
import { architectTurn } from "../services/architect.js";
|
|
41
42
|
import { addMessage, assistantLayout, currentChat, earlierChats, foldChat, newChat, openChat, readChat, turnHistory, } from "../services/assistant-chats.js";
|
|
42
43
|
import { checkForCloud, cloudLinked, cloudSpaces, cloudState, forgetCloud, publishAndSync, pushSharing, removeFromCloud, replaceCloudSpaces, rotateInCloud, syncToCloud, } from "../services/cloud.js";
|
|
@@ -44,6 +45,7 @@ import { notFound, ServiceError } from "../services/errors.js";
|
|
|
44
45
|
import { deleteFile, listFiles, readFile, writeFile } from "../services/files.js";
|
|
45
46
|
import { categoryContents, listKnowledge, resolvePath, valueContents, } from "../services/knowledge.js";
|
|
46
47
|
import { writeSummary } from "../services/knowledge-model.js";
|
|
48
|
+
import { applyWebsite, finishOnboarding, needsOnboarding } from "../services/onboarding.js";
|
|
47
49
|
import { exportWizard, importWizard } from "../services/package.js";
|
|
48
50
|
import { profileSchema, saveProfile, userProfile } from "../services/profile.js";
|
|
49
51
|
import { addProjectFile, addProjectFileFromUrl, fileView, orderProjectFiles, projectFile, projectFileContent, projectFiles, reindexProjectFile, removeProjectFile, updateProjectFile, } from "../services/project-files.js";
|
|
@@ -190,6 +192,10 @@ async function testClass(cls, lang) {
|
|
|
190
192
|
}
|
|
191
193
|
}
|
|
192
194
|
/** The size of an inline terminal, as the page lays it out. */
|
|
195
|
+
/** Whether the studio assistant's developer mode is offered to this person. */
|
|
196
|
+
async function assistantDevFor(user) {
|
|
197
|
+
return managed ? (await userInfo(user.id)).admin : env.assistantDev;
|
|
198
|
+
}
|
|
193
199
|
const terminalSize = z.object({
|
|
194
200
|
cols: z.number().int().min(10).max(500).optional(),
|
|
195
201
|
rows: z.number().int().min(4).max(200).optional(),
|
|
@@ -203,7 +209,7 @@ export const studio = new Hono()
|
|
|
203
209
|
const profile = await userProfile(user);
|
|
204
210
|
// What the Manage-App says of the tenant: its plan and feature switches; and of the person.
|
|
205
211
|
const info = managed ? await tenantInfo(user.tenantId).catch(() => null) : null;
|
|
206
|
-
const
|
|
212
|
+
const person = managed ? await userInfo(user.id) : null;
|
|
207
213
|
return c.json({
|
|
208
214
|
user: { id: user.id, name: profile.name, email: profile.email, image: user.image ?? null },
|
|
209
215
|
/** What the person says about themselves; name and e-mail are the account's when managed. */
|
|
@@ -215,7 +221,9 @@ export const studio = new Hono()
|
|
|
215
221
|
plan: info?.plan ?? null,
|
|
216
222
|
},
|
|
217
223
|
/** The feature switches: the code's defaults, what the Manage-App says of the team and the person, this install's file. */
|
|
218
|
-
features: features(managed ? { ...info?.features, ...
|
|
224
|
+
features: features(managed ? { ...info?.features, ...person?.features } : overview?.tenant.features),
|
|
225
|
+
/** The studio assistant's developer mode may be switched on: ASSISTANT_DEV here, the platform's admins when managed. */
|
|
226
|
+
assistantDev: await assistantDevFor(user),
|
|
219
227
|
mode: managed ? "managed" : "local",
|
|
220
228
|
/** The tenant's balance; null where the runtime resolves models itself. */
|
|
221
229
|
credits: managed ? await balanceCredits() : null,
|
|
@@ -225,6 +233,8 @@ export const studio = new Hono()
|
|
|
225
233
|
? {
|
|
226
234
|
name: linked.name,
|
|
227
235
|
email: linked.email,
|
|
236
|
+
/** The team the account's space belongs to, as the Manage-App names it. */
|
|
237
|
+
team: overview?.tenant.name ?? null,
|
|
228
238
|
credits: overview?.tenant.balanceCredits ?? null,
|
|
229
239
|
/** What of the credits ends on a date, the nearest first. */
|
|
230
240
|
expiring: overview?.tenant.expiring ?? [],
|
|
@@ -242,6 +252,8 @@ export const studio = new Hono()
|
|
|
242
252
|
subscriptions: managed ? [] : await subscriptionClients(),
|
|
243
253
|
/** A runtime that runs alone keeps its setup in front until a test call has worked on the chosen source and the way is still open. */
|
|
244
254
|
setupDone: managed ? true : await setupDone(),
|
|
255
|
+
/** The cloud studio's first visit: the short wizard that names the team and fills the space (services/onboarding.ts). */
|
|
256
|
+
onboarding: await needsOnboarding(user),
|
|
245
257
|
/** What answers the studio chat: a model of class `highest`, or the admin's own subscription. */
|
|
246
258
|
chatEngine: managed ? "models" : await chatEngine(await hasTextModel()),
|
|
247
259
|
aiReady: (await hasTextModel()) || (!managed && (await subscriptionClients()).length > 0),
|
|
@@ -317,6 +329,12 @@ export const studio = new Hono()
|
|
|
317
329
|
}
|
|
318
330
|
const voice = await generateSpeechMedia({ text, abortSignal: c.req.raw.signal });
|
|
319
331
|
return c.body(new Uint8Array(voice.bytes), 200, { "content-type": voice.mime });
|
|
332
|
+
})
|
|
333
|
+
// A message written while a turn runs: the turn takes it before its next step or after its answer.
|
|
334
|
+
.post("/assistant/run/:runId/signal", async (c) => {
|
|
335
|
+
const body = assistantSignalSchema.parse(await c.req.json());
|
|
336
|
+
const taken = signalRun(c.get("user").id, c.req.param("runId"), body);
|
|
337
|
+
return c.json({ ok: taken }, taken ? 200 : 404);
|
|
320
338
|
})
|
|
321
339
|
// A browser tool's result: the turn that waits for it goes on.
|
|
322
340
|
.post("/assistant/tool/:callId", async (c) => {
|
|
@@ -339,53 +357,100 @@ export const studio = new Hono()
|
|
|
339
357
|
? { route: body.page.route, title: body.page.title, view: body.page.view }
|
|
340
358
|
: (body.page ?? null);
|
|
341
359
|
const chat = await openChat(user);
|
|
342
|
-
const
|
|
360
|
+
const debug = Boolean(body.debug) && (await assistantDevFor(user));
|
|
361
|
+
let history = await turnHistory(user, chat);
|
|
343
362
|
const runId = nanoid(10);
|
|
344
363
|
const userMessageId = await addMessage(chat.id, {
|
|
345
364
|
role: "user",
|
|
346
365
|
content: body.message,
|
|
347
366
|
page: page ? { route: page.route, title: page.title } : null,
|
|
348
367
|
});
|
|
368
|
+
startRun(runId, user.id);
|
|
349
369
|
return streamSSE(c, async (stream) => {
|
|
350
370
|
const abort = new AbortController();
|
|
351
371
|
stream.onAbort(() => abort.abort());
|
|
352
372
|
const send = (event, data) => stream.writeSSE({ event, data: JSON.stringify(data) });
|
|
353
|
-
|
|
373
|
+
let messageId = nanoid(14);
|
|
354
374
|
await send("run_started", { threadId: chat.id, runId, messageId: userMessageId });
|
|
375
|
+
// The answer's text since the last message written meanwhile.
|
|
355
376
|
let reply = "";
|
|
377
|
+
let changed = false;
|
|
378
|
+
let steps = 0;
|
|
379
|
+
/** What the answer said and did up to messages written meanwhile; null when nothing. */
|
|
380
|
+
const storePart = async (before) => {
|
|
381
|
+
changed ||= before.changed;
|
|
382
|
+
return before.reply || before.toolCalls.length || before.cards.length
|
|
383
|
+
? await addMessage(chat.id, { role: "assistant", content: before.reply, ...before })
|
|
384
|
+
: null;
|
|
385
|
+
};
|
|
386
|
+
/** The messages written meanwhile, after the part of the answer before them. */
|
|
387
|
+
const storeSignals = async (taken, answerId) => {
|
|
388
|
+
for (const [i, s] of taken.entries()) {
|
|
389
|
+
const stored = await addMessage(chat.id, { role: "user", content: s.message });
|
|
390
|
+
await send("signal", {
|
|
391
|
+
ref: s.ref,
|
|
392
|
+
messageId: stored,
|
|
393
|
+
content: s.message,
|
|
394
|
+
answerId: i === 0 ? answerId : null,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
reply = "";
|
|
398
|
+
messageId = nanoid(14);
|
|
399
|
+
};
|
|
356
400
|
try {
|
|
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
|
-
|
|
388
|
-
|
|
401
|
+
let message = body.message;
|
|
402
|
+
for (;;) {
|
|
403
|
+
const result = await runStudioAssistant({
|
|
404
|
+
user,
|
|
405
|
+
chat,
|
|
406
|
+
history,
|
|
407
|
+
project,
|
|
408
|
+
writable,
|
|
409
|
+
message,
|
|
410
|
+
page,
|
|
411
|
+
browserTools: body.tools,
|
|
412
|
+
lang: body.lang,
|
|
413
|
+
signal: abort.signal,
|
|
414
|
+
onText: (delta) => {
|
|
415
|
+
reply += delta;
|
|
416
|
+
void send("text", { messageId, delta });
|
|
417
|
+
},
|
|
418
|
+
onActivity: (label) => void send("activity", { label }),
|
|
419
|
+
onChanged: () => void send("changed", {}),
|
|
420
|
+
onCard: (card) => void send("card", card),
|
|
421
|
+
onToolCall: (call) => void send("tool_call", call),
|
|
422
|
+
onToolResult: (result) => void send("tool_result", result),
|
|
423
|
+
callBrowser: (call) => awaitBrowser(user.id, call.toolCallId, abort.signal),
|
|
424
|
+
takeSignals: () => takeSignals(runId),
|
|
425
|
+
onSignal: async (taken, before) => storeSignals(taken, await storePart(before)),
|
|
426
|
+
...(debug
|
|
427
|
+
? {
|
|
428
|
+
onPrompt: (prompt) => void send("prompt", prompt),
|
|
429
|
+
onStep: (step) => void send("step", { ...step, step: ++steps }),
|
|
430
|
+
}
|
|
431
|
+
: {}),
|
|
432
|
+
});
|
|
433
|
+
// Written after the answer's last step: the turn goes on with it.
|
|
434
|
+
const left = takeSignals(runId);
|
|
435
|
+
if (!left.length || abort.signal.aborted) {
|
|
436
|
+
changed ||= result.changed;
|
|
437
|
+
reply = result.reply || (changed ? (body.lang === "de" ? "Erledigt." : "Done.") : "");
|
|
438
|
+
const stored = await addMessage(chat.id, {
|
|
439
|
+
role: "assistant",
|
|
440
|
+
content: reply,
|
|
441
|
+
cards: result.cards,
|
|
442
|
+
toolCalls: result.toolCalls,
|
|
443
|
+
changed: result.changed,
|
|
444
|
+
});
|
|
445
|
+
await send("done", { messageId: stored, reply, changed });
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
// The next answer knows this one: it is stored before the history is read again.
|
|
449
|
+
const answerId = await storePart(result);
|
|
450
|
+
history = await turnHistory(user, chat);
|
|
451
|
+
await storeSignals(left, answerId);
|
|
452
|
+
message = signalText(left);
|
|
453
|
+
}
|
|
389
454
|
}
|
|
390
455
|
catch (err) {
|
|
391
456
|
console.error("[studio-assistant]", err);
|
|
@@ -400,6 +465,7 @@ export const studio = new Hono()
|
|
|
400
465
|
: "Something went wrong. Please try again.",
|
|
401
466
|
});
|
|
402
467
|
}
|
|
468
|
+
endRun(runId);
|
|
403
469
|
// Older messages fold into the chat's running summary once enough have piled up.
|
|
404
470
|
void foldChat(chat.id).catch((err) => console.error("[studio-assistant] fold:", err));
|
|
405
471
|
});
|
|
@@ -1331,6 +1397,40 @@ export const studio = new Hono()
|
|
|
1331
1397
|
})
|
|
1332
1398
|
// The address the system browser opens: the account's sign-in, or — for someone without an
|
|
1333
1399
|
// account — its sign-up page with the invitation code, which then goes on to the sign-in.
|
|
1400
|
+
// --- the first visit of a cloud studio: who the team is, its website, first documents -----
|
|
1401
|
+
.put("/team", async (c) => {
|
|
1402
|
+
if (!managed) {
|
|
1403
|
+
return c.json({ error: "not_managed" }, 404);
|
|
1404
|
+
}
|
|
1405
|
+
await requireAdmin();
|
|
1406
|
+
const { name } = z.object({ name: z.string().trim().min(1).max(80) }).parse(await c.req.json());
|
|
1407
|
+
const info = await renameTenant(c.get("user").tenantId, name);
|
|
1408
|
+
return c.json({ name: info.name });
|
|
1409
|
+
})
|
|
1410
|
+
.post("/onboarding/done", async (c) => {
|
|
1411
|
+
if (!managed) {
|
|
1412
|
+
return c.json({ error: "not_managed" }, 404);
|
|
1413
|
+
}
|
|
1414
|
+
await finishOnboarding(c.get("user").tenantId);
|
|
1415
|
+
return c.json({ ok: true });
|
|
1416
|
+
})
|
|
1417
|
+
.post("/projects/:id/website", async (c) => {
|
|
1418
|
+
const user = c.get("user");
|
|
1419
|
+
const project = await ownedProject(user.id, c.req.param("id"));
|
|
1420
|
+
const { url } = z.object({ url: z.string().trim().min(3).max(400) }).parse(await c.req.json());
|
|
1421
|
+
return c.json(await applyWebsite(user.id, project.id, url));
|
|
1422
|
+
})
|
|
1423
|
+
// The install command and the first steps, mailed to the signed-in person by the Manage-App.
|
|
1424
|
+
.post("/install-mail", async (c) => {
|
|
1425
|
+
if (!managed) {
|
|
1426
|
+
return c.json({ error: "not_managed" }, 404);
|
|
1427
|
+
}
|
|
1428
|
+
const { lang } = z
|
|
1429
|
+
.object({ lang: z.enum(["de", "en"]).default("en") })
|
|
1430
|
+
.parse(await c.req.json().catch(() => ({})));
|
|
1431
|
+
await installMail(c.get("user").email, lang);
|
|
1432
|
+
return c.json({ ok: true });
|
|
1433
|
+
})
|
|
1334
1434
|
.post("/account/link", async (c) => {
|
|
1335
1435
|
if (managed) {
|
|
1336
1436
|
return c.notFound();
|
|
@@ -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
|
}));
|
|
@@ -25,7 +25,7 @@ export async function cloudState(wizardId) {
|
|
|
25
25
|
async function call(method, path, body, timeoutMs = 30_000) {
|
|
26
26
|
const token = await accountToken();
|
|
27
27
|
if (!token) {
|
|
28
|
-
throw new ServiceError("refused", "
|
|
28
|
+
throw new ServiceError("refused", "The account is signed out. Please sign in again.", {
|
|
29
29
|
reason: "signed_out",
|
|
30
30
|
});
|
|
31
31
|
}
|
|
@@ -39,7 +39,7 @@ async function call(method, path, body, timeoutMs = 30_000) {
|
|
|
39
39
|
signal: AbortSignal.timeout(timeoutMs),
|
|
40
40
|
}).catch(() => null);
|
|
41
41
|
if (!res) {
|
|
42
|
-
throw new ServiceError("refused", "
|
|
42
|
+
throw new ServiceError("refused", "The cloud is not reachable right now.", {
|
|
43
43
|
reason: "unreachable",
|
|
44
44
|
});
|
|
45
45
|
}
|
|
@@ -49,7 +49,7 @@ async function call(method, path, body, timeoutMs = 30_000) {
|
|
|
49
49
|
function refusal(answer, fallback) {
|
|
50
50
|
const { error, reason, ...rest } = answer.body;
|
|
51
51
|
const message = answer.status === 401
|
|
52
|
-
? "
|
|
52
|
+
? "The account is signed out. Please sign in again."
|
|
53
53
|
: typeof error === "string" && error
|
|
54
54
|
? error
|
|
55
55
|
: fallback;
|
|
@@ -213,7 +213,7 @@ export async function syncToCloud(userId, wizardId) {
|
|
|
213
213
|
const w = await ownedWizard(userId, wizardId);
|
|
214
214
|
const version = await publishedVersion(w);
|
|
215
215
|
if (!version) {
|
|
216
|
-
throw new ServiceError("refused", "
|
|
216
|
+
throw new ServiceError("refused", "Publish the wizard first.");
|
|
217
217
|
}
|
|
218
218
|
const before = await cloudState(w.id);
|
|
219
219
|
try {
|
|
@@ -231,7 +231,7 @@ export async function syncToCloud(userId, wizardId) {
|
|
|
231
231
|
dailyRunLimit: w.dailyRunLimit,
|
|
232
232
|
}, 120_000);
|
|
233
233
|
if (!answer.ok || !answer.body.shareUrl) {
|
|
234
|
-
throw refusal(answer, "
|
|
234
|
+
throw refusal(answer, "The cloud did not take the wizard.");
|
|
235
235
|
}
|
|
236
236
|
const state = {
|
|
237
237
|
copy: {
|
|
@@ -255,7 +255,7 @@ export async function syncToCloud(userId, wizardId) {
|
|
|
255
255
|
const failed = {
|
|
256
256
|
copy: before.copy,
|
|
257
257
|
error: {
|
|
258
|
-
message: err instanceof ServiceError ? err.message : "
|
|
258
|
+
message: err instanceof ServiceError ? err.message : "Sending did not work.",
|
|
259
259
|
reason: err instanceof ServiceError ? err.data.reason : undefined,
|
|
260
260
|
at: now.toISOString(),
|
|
261
261
|
tries,
|
|
@@ -309,7 +309,7 @@ export async function rotateInCloud(userId, wizardId) {
|
|
|
309
309
|
const w = await ownedWizard(userId, wizardId);
|
|
310
310
|
const answer = await call("POST", `/spaces/${w.projectId}/wizards/${w.id}/rotate-link`);
|
|
311
311
|
if (!answer.ok || !answer.body.shareUrl) {
|
|
312
|
-
throw refusal(answer, "
|
|
312
|
+
throw refusal(answer, "The link in the cloud could not be renewed.");
|
|
313
313
|
}
|
|
314
314
|
const next = {
|
|
315
315
|
...state,
|
|
@@ -416,7 +416,7 @@ export async function checkForCloud(userId, wizardId) {
|
|
|
416
416
|
files: files.map((f) => f.path),
|
|
417
417
|
});
|
|
418
418
|
if (!answer.ok) {
|
|
419
|
-
throw refusal(answer, "
|
|
419
|
+
throw refusal(answer, "The cloud did not answer.");
|
|
420
420
|
}
|
|
421
421
|
const documents = await projectFiles(w.projectId, "document");
|
|
422
422
|
return {
|
|
@@ -428,7 +428,7 @@ export async function checkForCloud(userId, wizardId) {
|
|
|
428
428
|
export async function cloudSpaces() {
|
|
429
429
|
const answer = await call("GET", "/spaces");
|
|
430
430
|
if (!answer.ok) {
|
|
431
|
-
throw refusal(answer, "
|
|
431
|
+
throw refusal(answer, "The cloud did not answer.");
|
|
432
432
|
}
|
|
433
433
|
return answer.body.spaces ?? [];
|
|
434
434
|
}
|
|
@@ -446,7 +446,7 @@ export async function replaceCloudSpaces(userId) {
|
|
|
446
446
|
if (!mine.has(space.id)) {
|
|
447
447
|
const answer = await call("DELETE", `/spaces/${space.id}`);
|
|
448
448
|
if (!answer.ok) {
|
|
449
|
-
throw refusal(answer, "
|
|
449
|
+
throw refusal(answer, "The space in the cloud could not be removed.");
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readWebsite } from "../agents/website.js";
|
|
2
|
+
import { managed } from "../manage.js";
|
|
3
|
+
import { readSetting, writeSetting } from "../settings.js";
|
|
4
|
+
import { isAdminRole } from "./access.js";
|
|
5
|
+
import { addProjectFile, projectFiles } from "./project-files.js";
|
|
6
|
+
import { listProjects, updateProject } from "./projects.js";
|
|
7
|
+
/**
|
|
8
|
+
* The first visit of a team's studio in the cloud: a short wizard asks who the team is, reads
|
|
9
|
+
* its website into the space, takes first documents and explains the space. It shows once, to
|
|
10
|
+
* the people who may change the team, and never to a team that already has wizards; the mark
|
|
11
|
+
* that it is done sits in the control database under the tenant.
|
|
12
|
+
*/
|
|
13
|
+
const key = (tenantId) => `onboarding:${tenantId}`;
|
|
14
|
+
export async function needsOnboarding(user) {
|
|
15
|
+
if (!managed || !isAdminRole(user.role)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (await readSetting(key(user.tenantId))) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const projects = await listProjects(user.id);
|
|
22
|
+
return projects.every((p) => p.wizardCount === 0);
|
|
23
|
+
}
|
|
24
|
+
export async function finishOnboarding(tenantId) {
|
|
25
|
+
await writeSetting(key(tenantId), { doneAt: new Date().toISOString() });
|
|
26
|
+
}
|
|
27
|
+
/** A hex colour as `#rrggbb`, or null when the value is no plain colour. */
|
|
28
|
+
function hex(value) {
|
|
29
|
+
const v = value.trim().toLowerCase();
|
|
30
|
+
const six = v.match(/^#([0-9a-f]{6})\b/);
|
|
31
|
+
if (six) {
|
|
32
|
+
return `#${six[1]}`;
|
|
33
|
+
}
|
|
34
|
+
const three = v.match(/^#([0-9a-f]{3})\b/);
|
|
35
|
+
if (three) {
|
|
36
|
+
return `#${[...three[1]].map((c) => c + c).join("")}`;
|
|
37
|
+
}
|
|
38
|
+
const rgb = v.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
39
|
+
if (rgb) {
|
|
40
|
+
const part = (n) => Math.min(255, Number(n)).toString(16).padStart(2, "0");
|
|
41
|
+
return `#${part(rgb[1])}${part(rgb[2])}${part(rgb[3])}`;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/** Greys and near-whites say nothing about a brand. */
|
|
46
|
+
function telling(color) {
|
|
47
|
+
const r = Number.parseInt(color.slice(1, 3), 16);
|
|
48
|
+
const g = Number.parseInt(color.slice(3, 5), 16);
|
|
49
|
+
const b = Number.parseInt(color.slice(5, 7), 16);
|
|
50
|
+
const spread = Math.max(r, g, b) - Math.min(r, g, b);
|
|
51
|
+
const light = (r + g + b) / 3;
|
|
52
|
+
return spread > 24 && light > 24 && light < 235;
|
|
53
|
+
}
|
|
54
|
+
/** A page title is often "Company – Slogan" or "Home | Company": the company's part of it. */
|
|
55
|
+
export function companyOf(title) {
|
|
56
|
+
const parts = title
|
|
57
|
+
.split(/\s+[–—|·-]\s+|:\s+/)
|
|
58
|
+
.map((p) => p.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
if (parts.length < 2) {
|
|
61
|
+
return title.trim().slice(0, 120);
|
|
62
|
+
}
|
|
63
|
+
const generic = /^(home|start|startseite|willkommen|welcome)$/i;
|
|
64
|
+
const named = parts.filter((p) => !generic.test(p));
|
|
65
|
+
// The shortest telling part is the name; a slogan is longer.
|
|
66
|
+
return (named.sort((a, b) => a.length - b.length)[0] ?? parts[0]).slice(0, 120);
|
|
67
|
+
}
|
|
68
|
+
/** The first paragraph of the page's text that reads like prose, as the about text. */
|
|
69
|
+
export function aboutOf(description, text) {
|
|
70
|
+
if (description.trim().length >= 40) {
|
|
71
|
+
return description.trim().slice(0, 600);
|
|
72
|
+
}
|
|
73
|
+
const paragraph = text
|
|
74
|
+
.split(/\n{2,}/)
|
|
75
|
+
.map((p) => p.replace(/^[#>*\-\s]+/, "").trim())
|
|
76
|
+
.find((p) => p.length >= 80 && !p.startsWith("[") && !/^!\[/.test(p));
|
|
77
|
+
return (paragraph ?? description.trim()).slice(0, 600);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Up to three brand colours out of what the site declares and uses: the theme colour first, then
|
|
81
|
+
* the stylesheet's named colours, then the most used ones; greys and near-whites left out.
|
|
82
|
+
*/
|
|
83
|
+
export function brandColors(brand) {
|
|
84
|
+
const candidates = [
|
|
85
|
+
...(brand.themeColor ? [{ name: "Brand", value: brand.themeColor }] : []),
|
|
86
|
+
...brand.variables,
|
|
87
|
+
...brand.frequent.map((value, i) => ({ name: `Colour ${i + 1}`, value })),
|
|
88
|
+
];
|
|
89
|
+
const colors = [];
|
|
90
|
+
for (const c of candidates) {
|
|
91
|
+
const value = hex(c.value);
|
|
92
|
+
if (value && telling(value) && !colors.some((x) => x.value === value)) {
|
|
93
|
+
colors.push({ name: c.name.replace(/^--/, "").slice(0, 120) || "Colour", value });
|
|
94
|
+
}
|
|
95
|
+
if (colors.length === 3) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return colors;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Reads a website into the space's Info & brand: the company's name, what it does, up to three
|
|
103
|
+
* of its colours and, where the space has none yet, its logo. What the assistant does on
|
|
104
|
+
* request, done once at the start without a model.
|
|
105
|
+
*/
|
|
106
|
+
export async function applyWebsite(userId, projectId, url) {
|
|
107
|
+
const reading = await readWebsite(url, AbortSignal.timeout(30_000));
|
|
108
|
+
const name = companyOf(reading.title || new URL(reading.url).hostname);
|
|
109
|
+
const about = aboutOf(reading.description, reading.text);
|
|
110
|
+
const colors = brandColors(reading.brand);
|
|
111
|
+
await updateProject(userId, projectId, {
|
|
112
|
+
brand: {
|
|
113
|
+
name,
|
|
114
|
+
...(about ? { about } : {}),
|
|
115
|
+
...(colors.length ? { colors } : {}),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
let logo = false;
|
|
119
|
+
const first = reading.brand.logos[0];
|
|
120
|
+
if (first && (await projectFiles(projectId, "logo")).length === 0) {
|
|
121
|
+
logo = await fetchLogo(projectId, first.url, first.hint);
|
|
122
|
+
}
|
|
123
|
+
return { name, about: Boolean(about), colors: colors.length, logo };
|
|
124
|
+
}
|
|
125
|
+
const LOGO_BYTES = 5 * 1024 * 1024;
|
|
126
|
+
async function fetchLogo(projectId, url, hint) {
|
|
127
|
+
try {
|
|
128
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
129
|
+
const mime = res.headers.get("content-type")?.split(";")[0].trim().toLowerCase() ?? "";
|
|
130
|
+
if (!res.ok || !mime.startsWith("image/")) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
const data = new Uint8Array(await res.arrayBuffer());
|
|
134
|
+
if (data.byteLength === 0 || data.byteLength > LOGO_BYTES) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
const name = decodeURIComponent(new URL(url).pathname.split("/").pop() || "logo");
|
|
138
|
+
await addProjectFile(projectId, {
|
|
139
|
+
kind: "logo",
|
|
140
|
+
name,
|
|
141
|
+
mime,
|
|
142
|
+
data,
|
|
143
|
+
description: hint || undefined,
|
|
144
|
+
source: url,
|
|
145
|
+
});
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// A logo that cannot be fetched is no loss: the person adds one under Space → Logos.
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|