wizards 0.2.8 → 0.2.9
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/project-assistant.js +16 -2
- package/apps/runtime/dist/agents/website.js +48 -10
- package/apps/runtime/dist/app.js +2 -0
- package/apps/runtime/dist/db/control-schema.js +18 -1
- package/apps/runtime/dist/db/migrations/control/0007_common_the_hand.sql +15 -0
- package/apps/runtime/dist/db/migrations/control/meta/0007_snapshot.json +460 -0
- package/apps/runtime/dist/db/migrations/control/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/migrations/tenant/0006_hard_war_machine.sql +30 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/0006_snapshot.json +1696 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/schema.js +12 -6
- package/apps/runtime/dist/engine/steps.js +3 -0
- package/apps/runtime/dist/env.js +2 -0
- package/apps/runtime/dist/index.js +2 -0
- package/apps/runtime/dist/plugins/events.js +37 -14
- package/apps/runtime/dist/plugins/jobs.js +64 -0
- package/apps/runtime/dist/plugins/loader.js +67 -4
- package/apps/runtime/dist/plugins/public.js +31 -0
- package/apps/runtime/dist/plugins/registry.js +5 -0
- package/apps/runtime/dist/routes/plugin-public.js +59 -0
- package/apps/runtime/dist/routes/plugins.js +1 -1
- package/apps/runtime/dist/routes/studio.js +3 -0
- package/apps/runtime/dist/services/project-files.js +4 -0
- package/apps/runtime/dist/services/project-index.js +69 -9
- package/apps/runtime/dist/services/projects.js +7 -1
- package/apps/runtime/dist/services/spaces.js +2 -0
- package/apps/runtime/dist/tools/index.js +3 -2
- package/apps/runtime/dist/tools/plugin.js +89 -17
- package/apps/runtime/dist/tools/project.js +39 -31
- package/apps/web/dist/assets/Account-ZAqYVN9S.js +1 -0
- package/apps/web/dist/assets/{ChangelogDialog-BNQ9yup8.js → ChangelogDialog-CzdNuUMU.js} +1 -1
- package/apps/web/dist/assets/{ChatPanel-CtE1wcUD.js → ChatPanel-CDkHMh5g.js} +1 -1
- package/apps/web/dist/assets/{EditorPage-CAxamSak.js → EditorPage-D4mertxP.js} +2 -2
- package/apps/web/dist/assets/{FlowPreview-B3IxmikV.js → FlowPreview-DWYKllel.js} +1 -1
- package/apps/web/dist/assets/{Harness-Bo-kwm1H.js → Harness-WJ0jpIl3.js} +1 -1
- package/apps/web/dist/assets/{HomePage-BdIa2zcg.js → HomePage-2Zn7et7Z.js} +1 -1
- package/apps/web/dist/assets/{ImportWizard-DMea5ZH1.js → ImportWizard-Dn7AiRgV.js} +1 -1
- package/apps/web/dist/assets/{LocalRuntime-DFswQjxc.js → LocalRuntime-BggRFDJt.js} +1 -1
- package/apps/web/dist/assets/{NewWizardPage-Cnb6UYme.js → NewWizardPage-k-xPHAwe.js} +2 -2
- package/apps/web/dist/assets/ProjectPage-CZgR1dQj.js +2 -0
- package/apps/web/dist/assets/{PublicRunner-CIHEMLlN.js → PublicRunner-BsvhBQMD.js} +1 -1
- package/apps/web/dist/assets/{RunnerView-COKGppwq.js → RunnerView-0srHaE2T.js} +1 -1
- package/apps/web/dist/assets/SettingsPage-CNsK0n6L.js +1 -0
- package/apps/web/dist/assets/{SetupPage-Bk4qXYZV.js → SetupPage-BXQ6wfWP.js} +1 -1
- package/apps/web/dist/assets/{SharePage-ChEtCkhn.js → SharePage-CA8uuk9K.js} +1 -1
- package/apps/web/dist/assets/changelog-D7O5icPX.js +1 -0
- package/apps/web/dist/assets/{esm-Rtjq9ReB.js → esm-BbARZ7EE.js} +1 -1
- package/apps/web/dist/assets/index-BQQihL4r.js +10 -0
- package/apps/web/dist/assets/{meta-MVWkLr9H.js → meta-Dsb9LZTu.js} +1 -1
- package/apps/web/dist/assets/{outputs-s8BpXH6V.js → outputs-C806GWxt.js} +1 -1
- package/apps/web/dist/index.html +1 -1
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +183 -0
- package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +48 -3
- package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +7 -1
- package/package.json +1 -1
- package/apps/web/dist/assets/Account-YJEQEo5k.js +0 -1
- package/apps/web/dist/assets/ProjectPage-CR_FIUya.js +0 -2
- package/apps/web/dist/assets/SettingsPage-BqAogRSI.js +0 -1
- package/apps/web/dist/assets/changelog-C03GWYid.js +0 -1
- package/apps/web/dist/assets/index-BAQ2e4is.js +0 -10
|
@@ -198,18 +198,23 @@ export const projectFile = sqliteTable("project_file", {
|
|
|
198
198
|
updatedAt: updatedAt(),
|
|
199
199
|
}, (t) => [index("project_file_project").on(t.projectId, t.kind, t.position)]);
|
|
200
200
|
/**
|
|
201
|
-
* The document index: passages of a project's documents
|
|
202
|
-
* `
|
|
203
|
-
* passage's vector as float32 bytes, compared
|
|
201
|
+
* The document index: passages of a project's documents, and of the texts plugins put in
|
|
202
|
+
* (`server.index`). Keywords live in the FTS5 table `project_chunk_fts`, which triggers keep
|
|
203
|
+
* in step (see the migrations); `embedding` is the passage's vector as float32 bytes, compared
|
|
204
|
+
* with libSQL's `vector_distance_cos`.
|
|
204
205
|
*/
|
|
205
206
|
export const projectChunk = sqliteTable("project_chunk", {
|
|
206
207
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
207
208
|
projectId: text("project_id")
|
|
208
209
|
.notNull()
|
|
209
210
|
.references(() => project.id, { onDelete: "cascade" }),
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
/** The document the passage is of; null for a plugin's text. */
|
|
212
|
+
fileId: text("file_id").references(() => projectFile.id, { onDelete: "cascade" }),
|
|
213
|
+
/** A plugin's text: the plugin, its key for the text, its title and where the studio shows it. */
|
|
214
|
+
plugin: text("plugin"),
|
|
215
|
+
ref: text("ref"),
|
|
216
|
+
title: text("title"),
|
|
217
|
+
link: text("link"),
|
|
213
218
|
idx: integer("idx").notNull(),
|
|
214
219
|
text: text("text").notNull(),
|
|
215
220
|
embedding: blob("embedding", { mode: "buffer" }),
|
|
@@ -218,6 +223,7 @@ export const projectChunk = sqliteTable("project_chunk", {
|
|
|
218
223
|
}, (t) => [
|
|
219
224
|
index("project_chunk_project").on(t.projectId, t.model),
|
|
220
225
|
index("project_chunk_file").on(t.fileId),
|
|
226
|
+
index("project_chunk_ref").on(t.projectId, t.plugin, t.ref),
|
|
221
227
|
]);
|
|
222
228
|
// --- The wizard's store ------------------------------------------------------
|
|
223
229
|
// What a wizard keeps between runs for one person. `holder` is that person: "u:<userId>" for a
|
|
@@ -7,6 +7,7 @@ import { generateImageMedia, generateSpeechMedia, generateVideoMedia, } from "..
|
|
|
7
7
|
import { markMedia } from "../media/marking.js";
|
|
8
8
|
import { attachTools, costOf, isHarnessVendor, textModel } from "../models.js";
|
|
9
9
|
import { buildStepTools } from "../tools/index.js";
|
|
10
|
+
import { spaceContextOf } from "../tools/plugin.js";
|
|
10
11
|
import { personUploads } from "../tools/store.js";
|
|
11
12
|
import { runWidgetStep } from "../widgets/step.js";
|
|
12
13
|
import { prepareInputs } from "./prepare.js";
|
|
@@ -168,6 +169,8 @@ export async function runAgentStep(step, ctx) {
|
|
|
168
169
|
`Today is ${today()}.`,
|
|
169
170
|
brandBlock(ctx),
|
|
170
171
|
documentsBlock(ctx),
|
|
172
|
+
// What the space's plugins hold for its wizards: a wiki, questions and answers.
|
|
173
|
+
...(await spaceContextOf(ctx)).blocks,
|
|
171
174
|
formatHint,
|
|
172
175
|
]
|
|
173
176
|
.filter(Boolean)
|
package/apps/runtime/dist/env.js
CHANGED
|
@@ -218,6 +218,8 @@ export const env = {
|
|
|
218
218
|
* alone. A runtime of a Manage-App never reloads: it would hit every tenant.
|
|
219
219
|
*/
|
|
220
220
|
watch: !manageUrl && str("PLUGINS_WATCH", "1") === "1",
|
|
221
|
+
/** Plugins' jobs (`server.every`) run. Off (`PLUGIN_JOBS=0`) where another process runs them. */
|
|
222
|
+
jobs: str("PLUGIN_JOBS", "1") === "1",
|
|
221
223
|
},
|
|
222
224
|
limits: {
|
|
223
225
|
visitorRunsPerHour: num("LIMIT_VISITOR_RUNS_PER_HOUR", 6),
|
|
@@ -6,6 +6,7 @@ import { resumeInterruptedRuns } from "./engine/runner.js";
|
|
|
6
6
|
import { basePath, env } from "./env.js";
|
|
7
7
|
import { managed } from "./manage.js";
|
|
8
8
|
import { loadCatalog, loadLocalModels } from "./models.js";
|
|
9
|
+
import { startJobs } from "./plugins/jobs.js";
|
|
9
10
|
import { loadPlugins } from "./plugins/loader.js";
|
|
10
11
|
import { closeBrowser } from "./render/chromium.js";
|
|
11
12
|
import { clearRunning, writeRunning } from "./running.js";
|
|
@@ -17,6 +18,7 @@ import { onRestart } from "./update.js";
|
|
|
17
18
|
await migrateControlDb();
|
|
18
19
|
// Before any tenant database opens: each gets the plugins' own tables as it does.
|
|
19
20
|
await loadPlugins();
|
|
21
|
+
startJobs();
|
|
20
22
|
if (managed) {
|
|
21
23
|
// Every tenant database is brought to this release's schema; a new one migrates when first opened.
|
|
22
24
|
void migrateAllTenants().then(({ migrated, failed }) => {
|
|
@@ -3,30 +3,24 @@ import { db, schema } from "../db/client.js";
|
|
|
3
3
|
import { currentTenant } from "../tenants/tenant.js";
|
|
4
4
|
import { pluginsOf } from "./registry.js";
|
|
5
5
|
/**
|
|
6
|
-
* Tells the tenant's plugins
|
|
7
|
-
* listener throws is logged and nothing more.
|
|
6
|
+
* Tells the current tenant's plugins what happened. A plugin cannot fail what the runtime does:
|
|
7
|
+
* what a listener throws is logged and nothing more.
|
|
8
8
|
*/
|
|
9
|
-
export async function
|
|
9
|
+
export async function emitPluginEvent(event, payload) {
|
|
10
10
|
try {
|
|
11
|
-
const event = `run.${status}`;
|
|
12
11
|
const listening = (await pluginsOf(currentTenant())).filter((p) => p.listeners[event]?.length);
|
|
13
12
|
if (!listening.length) {
|
|
14
13
|
return;
|
|
15
14
|
}
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
if (!
|
|
15
|
+
// What is costly to gather is gathered only when someone listens.
|
|
16
|
+
const told = typeof payload === "function" ? await payload() : payload;
|
|
17
|
+
if (!told) {
|
|
19
18
|
return;
|
|
20
19
|
}
|
|
21
|
-
const payload = {
|
|
22
|
-
run: { id: run.id, wizardId: run.wizardId, mode: run.mode, status },
|
|
23
|
-
wizard: { id: wizard.id, title: run.definition.title, projectId: wizard.projectId },
|
|
24
|
-
values: run.state.values,
|
|
25
|
-
};
|
|
26
20
|
for (const plugin of listening) {
|
|
27
21
|
for (const listener of plugin.listeners[event] ?? []) {
|
|
28
22
|
try {
|
|
29
|
-
await listener(
|
|
23
|
+
await listener(told);
|
|
30
24
|
}
|
|
31
25
|
catch (err) {
|
|
32
26
|
console.error(`[plugin ${plugin.source.id}] ${event}:`, err);
|
|
@@ -35,6 +29,35 @@ export async function runEnded(runId, status) {
|
|
|
35
29
|
}
|
|
36
30
|
}
|
|
37
31
|
catch (err) {
|
|
38
|
-
console.error(
|
|
32
|
+
console.error(`[plugins] ${event}:`, err);
|
|
39
33
|
}
|
|
40
34
|
}
|
|
35
|
+
/** A run reached its end. */
|
|
36
|
+
export function runEnded(runId, status) {
|
|
37
|
+
return emitPluginEvent(`run.${status}`, async () => {
|
|
38
|
+
const run = await db.query.run.findFirst({ where: eq(schema.run.id, runId) });
|
|
39
|
+
const wizard = run && (await db.query.wizard.findFirst({ where: eq(schema.wizard.id, run.wizardId) }));
|
|
40
|
+
if (!run || !wizard) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
run: { id: run.id, wizardId: run.wizardId, mode: run.mode, status },
|
|
45
|
+
wizard: { id: wizard.id, title: run.definition.title, projectId: wizard.projectId },
|
|
46
|
+
values: run.state.values,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A space was made, changed or deleted. Not waited for: the request that changed the space
|
|
52
|
+
* answers without its plugins.
|
|
53
|
+
*/
|
|
54
|
+
export function spaceChanged(event, spaceId) {
|
|
55
|
+
void emitPluginEvent(event, { space: { id: spaceId } });
|
|
56
|
+
}
|
|
57
|
+
/** A file of a space was read and is ready, or was removed. Not waited for either. */
|
|
58
|
+
export function spaceFileChanged(event, file) {
|
|
59
|
+
void emitPluginEvent(event, {
|
|
60
|
+
space: { id: file.projectId },
|
|
61
|
+
file: { id: file.id, name: file.name, kind: file.kind, mime: file.mime },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import { control, controlDb, withTenant } from "../db/client.js";
|
|
3
|
+
import { env } from "../env.js";
|
|
4
|
+
import { loadedPlugins, pluginIdsOf } from "./registry.js";
|
|
5
|
+
/**
|
|
6
|
+
* Plugins' jobs (`server.every`). A tick looks once a minute which job is due for which tenant
|
|
7
|
+
* and runs it inside that tenant. When a job ran is kept in the control database, so a restart
|
|
8
|
+
* does not start the count again; a runtime that was off runs a due job once when it starts,
|
|
9
|
+
* not once per turn it missed.
|
|
10
|
+
*/
|
|
11
|
+
/** Jobs that run right now, by tenant, plugin and name: one turn at a time each. */
|
|
12
|
+
const running = new Set();
|
|
13
|
+
/** Starts every job that is due, and is done when they are. */
|
|
14
|
+
export async function runDueJobs(now = new Date()) {
|
|
15
|
+
const jobs = loadedPlugins().flatMap((plugin) => [...plugin.jobs].map(([name, job]) => ({ plugin, name, job })));
|
|
16
|
+
if (!jobs.length) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const tenants = await controlDb.query.tenant.findMany({
|
|
20
|
+
where: eq(control.tenant.status, "active"),
|
|
21
|
+
});
|
|
22
|
+
const started = [];
|
|
23
|
+
for (const tenant of tenants) {
|
|
24
|
+
const has = await pluginIdsOf(tenant.id);
|
|
25
|
+
for (const { plugin, name, job } of jobs) {
|
|
26
|
+
const id = plugin.source.id;
|
|
27
|
+
const key = `${tenant.id}/${id}/${name}`;
|
|
28
|
+
if (!has.has(id) || running.has(key)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const where = and(eq(control.pluginJob.tenantId, tenant.id), eq(control.pluginJob.plugin, id), eq(control.pluginJob.name, name));
|
|
32
|
+
const last = (await controlDb.query.pluginJob.findFirst({ where }))?.lastRunAt ?? null;
|
|
33
|
+
if (last && now.getTime() - last.getTime() < job.everyMs) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
running.add(key);
|
|
37
|
+
// The start counts as the run: a turn that throws or hangs is not repeated every minute.
|
|
38
|
+
await controlDb
|
|
39
|
+
.insert(control.pluginJob)
|
|
40
|
+
.values({ tenantId: tenant.id, plugin: id, name, lastRunAt: now })
|
|
41
|
+
.onConflictDoUpdate({
|
|
42
|
+
target: [control.pluginJob.tenantId, control.pluginJob.plugin, control.pluginJob.name],
|
|
43
|
+
set: { lastRunAt: now },
|
|
44
|
+
});
|
|
45
|
+
started.push(withTenant(tenant.id, () => job.handler({ tenantId: tenant.id, lastRun: last, signal: plugin.stopped.signal }))
|
|
46
|
+
.catch((err) => console.error(`[plugin ${id}] job ${name}:`, err))
|
|
47
|
+
.finally(() => running.delete(key)));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await Promise.all(started);
|
|
51
|
+
}
|
|
52
|
+
const TICK_MS = 60_000;
|
|
53
|
+
let timer = null;
|
|
54
|
+
/** Looks for due jobs now and then once a minute, while the runtime runs. */
|
|
55
|
+
export function startJobs() {
|
|
56
|
+
if (timer || !env.plugins.jobs) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const tick = () => void runDueJobs().catch((err) => console.error("[plugins] jobs:", err));
|
|
60
|
+
// Soon after the start, not during it: tenant databases open first.
|
|
61
|
+
setTimeout(tick, 5_000).unref();
|
|
62
|
+
timer = setInterval(tick, TICK_MS);
|
|
63
|
+
timer.unref();
|
|
64
|
+
}
|
|
@@ -4,12 +4,17 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { PLUGIN_TOOL_NAME } from "@engenty-wizards/shared/definition";
|
|
5
5
|
import { generateText, Output } from "ai";
|
|
6
6
|
import { createJiti } from "jiti";
|
|
7
|
+
import { readPage } from "../agents/website.js";
|
|
7
8
|
import { packageRoot } from "../cli/home.js";
|
|
8
9
|
import { db, onTenantOpen } from "../db/client.js";
|
|
10
|
+
import { parseDocument } from "../documents/parse.js";
|
|
9
11
|
import { env } from "../env.js";
|
|
10
12
|
import { ModelUnavailableError, textModel } from "../models.js";
|
|
13
|
+
import { putIndexEntry, removeIndexEntries } from "../services/project-index.js";
|
|
14
|
+
import { safeFetch } from "../tools/net-guard.js";
|
|
11
15
|
import { discoverPlugins } from "./discovery.js";
|
|
12
16
|
import { migrateOpenTenants, migratePlugins } from "./migrations.js";
|
|
17
|
+
import { publicUrlOf } from "./public.js";
|
|
13
18
|
import { announceChange, compileRoute, dropPlugin, emptyRecord, loadedPlugin, loadedPlugins, setPlugin, } from "./registry.js";
|
|
14
19
|
/**
|
|
15
20
|
* Loading plugins (docs/content/dev/plugins). A plugin's server half is TypeScript or JavaScript read
|
|
@@ -40,9 +45,21 @@ async function importFactory(entry) {
|
|
|
40
45
|
}
|
|
41
46
|
return factory;
|
|
42
47
|
}
|
|
48
|
+
/** A job runs at most this often. */
|
|
49
|
+
const MIN_EVERY_MS = 60_000;
|
|
43
50
|
function apiFor(record) {
|
|
44
51
|
const { source } = record;
|
|
45
52
|
const tag = `[plugin ${source.id}]`;
|
|
53
|
+
/** The names a step's model sees: the plugin's step tools and its context tools share them. */
|
|
54
|
+
const stepToolNames = () => [
|
|
55
|
+
...record.tools.keys(),
|
|
56
|
+
...record.contexts.flatMap((c) => (c.tools ?? []).map((t) => t.name)),
|
|
57
|
+
];
|
|
58
|
+
const named = (kind, name) => {
|
|
59
|
+
if (!PLUGIN_TOOL_NAME.test(name)) {
|
|
60
|
+
throw new Error(`${kind} "${name}": a name is lower case, digits and "_".`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
46
63
|
return {
|
|
47
64
|
plugin: source.manifest,
|
|
48
65
|
resolvePath: (relative) => resolve(source.root, relative),
|
|
@@ -57,14 +74,54 @@ function apiFor(record) {
|
|
|
57
74
|
record.routes.push(compileRoute(route));
|
|
58
75
|
},
|
|
59
76
|
registerTool(tool) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
if (record.tools.has(tool.name)) {
|
|
77
|
+
named("Tool", tool.name);
|
|
78
|
+
if (stepToolNames().includes(tool.name)) {
|
|
64
79
|
throw new Error(`Tool "${tool.name}" is registered twice.`);
|
|
65
80
|
}
|
|
66
81
|
record.tools.set(tool.name, tool);
|
|
67
82
|
},
|
|
83
|
+
registerSpaceContext(context) {
|
|
84
|
+
const names = (context.tools ?? []).map((t) => t.name);
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
named("Tool", name);
|
|
87
|
+
if (stepToolNames().includes(name) || names.indexOf(name) !== names.lastIndexOf(name)) {
|
|
88
|
+
throw new Error(`Tool "${name}" is registered twice.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
record.contexts.push(context);
|
|
92
|
+
},
|
|
93
|
+
registerAssistantTool(tool) {
|
|
94
|
+
named("Assistant tool", tool.name);
|
|
95
|
+
if (record.assistantTools.has(tool.name)) {
|
|
96
|
+
throw new Error(`Assistant tool "${tool.name}" is registered twice.`);
|
|
97
|
+
}
|
|
98
|
+
record.assistantTools.set(tool.name, tool);
|
|
99
|
+
},
|
|
100
|
+
registerPublicRoute(route) {
|
|
101
|
+
record.publicRoutes.push(compileRoute(route));
|
|
102
|
+
},
|
|
103
|
+
publicUrl: (path) => publicUrlOf(source.id, path),
|
|
104
|
+
every(name, everyMs, handler) {
|
|
105
|
+
named("Job", name);
|
|
106
|
+
if (record.jobs.has(name)) {
|
|
107
|
+
throw new Error(`Job "${name}" is registered twice.`);
|
|
108
|
+
}
|
|
109
|
+
record.jobs.set(name, { everyMs: Math.max(MIN_EVERY_MS, everyMs), handler });
|
|
110
|
+
},
|
|
111
|
+
index: {
|
|
112
|
+
put: (entry) => putIndexEntry(source.id, entry),
|
|
113
|
+
remove: (space, key) => removeIndexEntries(source.id, space, key),
|
|
114
|
+
},
|
|
115
|
+
web: {
|
|
116
|
+
fetch: (url, init) => safeFetch(url, init),
|
|
117
|
+
read: (url, options) => readPage(url, options?.signal),
|
|
118
|
+
},
|
|
119
|
+
documents: {
|
|
120
|
+
async parse(file, options) {
|
|
121
|
+
const parsed = await parseDocument(file, { signal: options?.signal });
|
|
122
|
+
return { markdown: parsed.markdown, pages: parsed.pages };
|
|
123
|
+
},
|
|
124
|
+
},
|
|
68
125
|
registerMigrations(folder) {
|
|
69
126
|
record.migrations.push(resolve(source.root, folder));
|
|
70
127
|
},
|
|
@@ -102,6 +159,8 @@ function apiFor(record) {
|
|
|
102
159
|
}
|
|
103
160
|
/** Runs what the plugin asked to have undone, the last first. */
|
|
104
161
|
async function dispose(record) {
|
|
162
|
+
// Its jobs are told to stop; the runtime stops starting them with the record gone.
|
|
163
|
+
record.stopped.abort();
|
|
105
164
|
for (const undo of record.disposers.splice(0).reverse()) {
|
|
106
165
|
try {
|
|
107
166
|
await undo();
|
|
@@ -123,6 +182,10 @@ async function load(source) {
|
|
|
123
182
|
await dispose(record);
|
|
124
183
|
record.routes = [];
|
|
125
184
|
record.tools.clear();
|
|
185
|
+
record.contexts = [];
|
|
186
|
+
record.assistantTools.clear();
|
|
187
|
+
record.publicRoutes = [];
|
|
188
|
+
record.jobs.clear();
|
|
126
189
|
record.listeners = {};
|
|
127
190
|
record.migrations = [];
|
|
128
191
|
record.error = err?.message || String(err);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
import { control, controlDb } from "../db/client.js";
|
|
4
|
+
import { env } from "../env.js";
|
|
5
|
+
import { currentTenant } from "../tenants/tenant.js";
|
|
6
|
+
/**
|
|
7
|
+
* Public addresses of plugins (`server.publicUrl`): `/api/public/plugins/<plugin>/<ref>/…`. The
|
|
8
|
+
* ref names the tenant without giving its id away; one per tenant and plugin, kept for good.
|
|
9
|
+
*/
|
|
10
|
+
/** The full address of a plugin's public route for the current tenant. */
|
|
11
|
+
export async function publicUrlOf(plugin, path) {
|
|
12
|
+
const tenantId = currentTenant();
|
|
13
|
+
const where = and(eq(control.pluginAddress.tenantId, tenantId), eq(control.pluginAddress.plugin, plugin));
|
|
14
|
+
let row = await controlDb.query.pluginAddress.findFirst({ where });
|
|
15
|
+
if (!row) {
|
|
16
|
+
await controlDb
|
|
17
|
+
.insert(control.pluginAddress)
|
|
18
|
+
.values({ ref: nanoid(24), tenantId, plugin })
|
|
19
|
+
.onConflictDoNothing();
|
|
20
|
+
row = (await controlDb.query.pluginAddress.findFirst({ where }));
|
|
21
|
+
}
|
|
22
|
+
const rest = path.startsWith("/") ? path : `/${path}`;
|
|
23
|
+
return `${env.appUrl}/api/public/plugins/${plugin}/${row.ref}${rest === "/" ? "" : rest}`;
|
|
24
|
+
}
|
|
25
|
+
/** The tenant a plugin's public address belongs to. */
|
|
26
|
+
export async function tenantOfAddress(plugin, ref) {
|
|
27
|
+
const row = await controlDb.query.pluginAddress.findFirst({
|
|
28
|
+
where: eq(control.pluginAddress.ref, ref),
|
|
29
|
+
});
|
|
30
|
+
return row?.plugin === plugin ? row.tenantId : null;
|
|
31
|
+
}
|
|
@@ -15,9 +15,14 @@ export function emptyRecord(source) {
|
|
|
15
15
|
error: null,
|
|
16
16
|
routes: [],
|
|
17
17
|
tools: new Map(),
|
|
18
|
+
contexts: [],
|
|
19
|
+
assistantTools: new Map(),
|
|
20
|
+
publicRoutes: [],
|
|
21
|
+
jobs: new Map(),
|
|
18
22
|
listeners: {},
|
|
19
23
|
migrations: [],
|
|
20
24
|
disposers: [],
|
|
25
|
+
stopped: new AbortController(),
|
|
21
26
|
};
|
|
22
27
|
}
|
|
23
28
|
export function setPlugin(record) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { withTenant } from "../db/client.js";
|
|
3
|
+
import { tenantOfAddress } from "../plugins/public.js";
|
|
4
|
+
import { loadedPlugin, pluginIdsOf } from "../plugins/registry.js";
|
|
5
|
+
import { tenantStatus } from "../tenants/control.js";
|
|
6
|
+
import { answerOf } from "./plugins.js";
|
|
7
|
+
/**
|
|
8
|
+
* Plugins' public routes (`server.registerPublicRoute`), below `/api/public/plugins/<id>/<ref>`.
|
|
9
|
+
* Nobody is signed in: the ref names the tenant, and the plugin checks a secret of its own.
|
|
10
|
+
*/
|
|
11
|
+
const notFound = (c) => c.json({ error: "not found", code: "not_found" }, 404);
|
|
12
|
+
async function dispatch(c) {
|
|
13
|
+
const id = c.req.param("id") ?? "";
|
|
14
|
+
const ref = c.req.param("ref") ?? "";
|
|
15
|
+
const tenantId = await tenantOfAddress(id, ref);
|
|
16
|
+
if (!tenantId || (await tenantStatus(tenantId)) !== "active") {
|
|
17
|
+
return notFound(c);
|
|
18
|
+
}
|
|
19
|
+
const plugin = (await pluginIdsOf(tenantId)).has(id) ? loadedPlugin(id) : undefined;
|
|
20
|
+
if (!plugin) {
|
|
21
|
+
return notFound(c);
|
|
22
|
+
}
|
|
23
|
+
const url = new URL(c.req.url);
|
|
24
|
+
const marker = `/plugins/${id}/${ref}`;
|
|
25
|
+
const path = url.pathname.slice(url.pathname.indexOf(marker) + marker.length);
|
|
26
|
+
for (const { route, pattern, params } of plugin.publicRoutes) {
|
|
27
|
+
const match = route.method === c.req.method ? pattern.exec(path) : null;
|
|
28
|
+
if (!match) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const request = {
|
|
32
|
+
request: c.req.raw,
|
|
33
|
+
path: path || "/",
|
|
34
|
+
params: Object.fromEntries(params.map((name, i) => [name, decodeURIComponent(match[i + 1])])),
|
|
35
|
+
query: url.searchParams,
|
|
36
|
+
tenantId,
|
|
37
|
+
json: async () => {
|
|
38
|
+
const text = await c.req.text();
|
|
39
|
+
return (text ? JSON.parse(text) : {});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
const result = await withTenant(tenantId, () => route.handler(request));
|
|
44
|
+
return result instanceof Response ? result : c.json(result ?? null);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const answer = answerOf(err);
|
|
48
|
+
if (answer) {
|
|
49
|
+
return c.json(answer.body, answer.status);
|
|
50
|
+
}
|
|
51
|
+
console.error(`[plugin ${id}] public ${route.method} ${path}:`, err);
|
|
52
|
+
return c.json({ error: "The plugin failed.", code: "plugin_failed" }, 500);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return notFound(c);
|
|
56
|
+
}
|
|
57
|
+
export const publicPluginRoutes = new Hono()
|
|
58
|
+
.all("/:id/:ref", dispatch)
|
|
59
|
+
.all("/:id/:ref/*", dispatch);
|
|
@@ -29,7 +29,7 @@ function view(plugin) {
|
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
/** A thrown error that says how to answer: `{ status, code }`, as the runtime's own errors do. */
|
|
32
|
-
function answerOf(err) {
|
|
32
|
+
export function answerOf(err) {
|
|
33
33
|
const e = err;
|
|
34
34
|
return typeof e?.status === "number" &&
|
|
35
35
|
e.status >= 400 &&
|
|
@@ -385,6 +385,9 @@ export const studio = new Hono()
|
|
|
385
385
|
onChanged: () => {
|
|
386
386
|
void stream.writeSSE({ event: "changed", data: "1" });
|
|
387
387
|
},
|
|
388
|
+
onCard: (card) => {
|
|
389
|
+
void stream.writeSSE({ event: "card", data: JSON.stringify(card) });
|
|
390
|
+
},
|
|
388
391
|
});
|
|
389
392
|
await stream.writeSSE({
|
|
390
393
|
event: "done",
|
|
@@ -5,6 +5,7 @@ import { db, schema } from "../db/client.js";
|
|
|
5
5
|
import { documentMime, UnreadableDocument } from "../documents/parse.js";
|
|
6
6
|
import { loadAsset, removeAsset, saveAsset } from "../files/storage.js";
|
|
7
7
|
import { textModel } from "../models.js";
|
|
8
|
+
import { spaceFileChanged } from "../plugins/events.js";
|
|
8
9
|
import { dropLinks, putLink } from "../tenants/control.js";
|
|
9
10
|
import { requireWritableProject } from "./access.js";
|
|
10
11
|
import { notFound, ServiceError } from "./errors.js";
|
|
@@ -192,11 +193,13 @@ async function prepare(row) {
|
|
|
192
193
|
if (row.kind !== "document") {
|
|
193
194
|
const description = await describeImage(row, data).catch(() => "");
|
|
194
195
|
await set({ status: "ready", ...(description ? { description } : {}) });
|
|
196
|
+
spaceFileChanged("space.file.ready", row);
|
|
195
197
|
return;
|
|
196
198
|
}
|
|
197
199
|
const { text, ...indexed } = await indexDocument(row, data);
|
|
198
200
|
const description = row.description || (text ? await describeDocument(row, text).catch(() => "") : "");
|
|
199
201
|
await set({ ...indexed, status: "ready", error: null, description });
|
|
202
|
+
spaceFileChanged("space.file.ready", row);
|
|
200
203
|
}
|
|
201
204
|
catch (err) {
|
|
202
205
|
console.error("[project-file]", row.name, err);
|
|
@@ -259,6 +262,7 @@ async function removeRow(row) {
|
|
|
259
262
|
if (row.kind === "logo") {
|
|
260
263
|
await dropLinks([row.id]);
|
|
261
264
|
}
|
|
265
|
+
spaceFileChanged("space.file.removed", row);
|
|
262
266
|
}
|
|
263
267
|
export async function removeProjectFile(projectId, fileId) {
|
|
264
268
|
await requireWritableProject(projectId);
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { embedMany } from "ai";
|
|
2
|
-
import { eq, inArray, sql } from "drizzle-orm";
|
|
2
|
+
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
3
3
|
import { db, schema } from "../db/client.js";
|
|
4
4
|
import { parseDocument } from "../documents/parse.js";
|
|
5
5
|
import { createSearchChunks } from "../engenty/search-index/chunks.js";
|
|
6
6
|
import { getBlob, putBlob } from "../files/blobs.js";
|
|
7
7
|
import { embeddingModel } from "../models.js";
|
|
8
|
+
import { pluginIdsOf } from "../plugins/registry.js";
|
|
9
|
+
import { currentTenant } from "../tenants/tenant.js";
|
|
10
|
+
import { ServiceError } from "./errors.js";
|
|
8
11
|
const CHUNK = { mode: "paragraph", max_chunk_length: 1200, overlap: 120 };
|
|
9
12
|
/** Passages of one document; a longer one keeps its start. */
|
|
10
13
|
const MAX_CHUNKS = 2000;
|
|
@@ -53,6 +56,51 @@ export async function indexDocument(file, data) {
|
|
|
53
56
|
text,
|
|
54
57
|
};
|
|
55
58
|
}
|
|
59
|
+
/** Puts a plugin's text into the space's index, in the place of what its key held. */
|
|
60
|
+
export async function putIndexEntry(plugin, entry) {
|
|
61
|
+
const space = await db.query.project.findFirst({ where: eq(schema.project.id, entry.space) });
|
|
62
|
+
if (!space) {
|
|
63
|
+
throw new ServiceError("not_found", `There is no space "${entry.space}".`);
|
|
64
|
+
}
|
|
65
|
+
const text = `${entry.title.trim()}\n\n${entry.text.trim()}`;
|
|
66
|
+
const chunks = createSearchChunks({ doc_id: `${plugin}:${entry.key}`, text, ...CHUNK }).slice(0, MAX_CHUNKS);
|
|
67
|
+
const embedded = await embed(chunks.map((c) => c.text)).catch((err) => {
|
|
68
|
+
console.error("[project-index] embedding failed", err);
|
|
69
|
+
return null;
|
|
70
|
+
});
|
|
71
|
+
await removeIndexEntries(plugin, entry.space, entry.key);
|
|
72
|
+
for (let i = 0; i < chunks.length; i += 100) {
|
|
73
|
+
await db.insert(schema.projectChunk).values(chunks.slice(i, i + 100).map((c, j) => ({
|
|
74
|
+
projectId: entry.space,
|
|
75
|
+
plugin,
|
|
76
|
+
ref: entry.key,
|
|
77
|
+
title: entry.title.trim().slice(0, 300),
|
|
78
|
+
link: entry.link ?? null,
|
|
79
|
+
idx: c.chunk_index,
|
|
80
|
+
text: c.text,
|
|
81
|
+
embedding: embedded ? vectorBytes(embedded.vectors[i + j]) : null,
|
|
82
|
+
model: embedded?.model ?? null,
|
|
83
|
+
})));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Takes a plugin's text out of the space's index; without a key, all of the plugin's texts. */
|
|
87
|
+
export async function removeIndexEntries(plugin, space, key) {
|
|
88
|
+
await db
|
|
89
|
+
.delete(schema.projectChunk)
|
|
90
|
+
.where(and(eq(schema.projectChunk.projectId, space), eq(schema.projectChunk.plugin, plugin), key === undefined ? undefined : eq(schema.projectChunk.ref, key)));
|
|
91
|
+
}
|
|
92
|
+
/** Whether a plugin the tenant has put a text into the space's index. */
|
|
93
|
+
export async function hasIndexEntries(projectId) {
|
|
94
|
+
const plugins = [...(await pluginIdsOf(currentTenant()))];
|
|
95
|
+
if (!plugins.length) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const found = await db.query.projectChunk.findFirst({
|
|
99
|
+
columns: { id: true },
|
|
100
|
+
where: and(eq(schema.projectChunk.projectId, projectId), inArray(schema.projectChunk.plugin, plugins)),
|
|
101
|
+
});
|
|
102
|
+
return Boolean(found);
|
|
103
|
+
}
|
|
56
104
|
export async function documentText(file) {
|
|
57
105
|
return file.textHash ? (await getBlob(file.textHash)).toString("utf8") : "";
|
|
58
106
|
}
|
|
@@ -64,23 +112,32 @@ function keywordQuery(query) {
|
|
|
64
112
|
.slice(0, 16);
|
|
65
113
|
return words.length ? words.map((w) => `"${w}"*`).join(" OR ") : null;
|
|
66
114
|
}
|
|
67
|
-
/**
|
|
115
|
+
/** A passage of a document, or of a plugin's text while the tenant has that plugin. */
|
|
116
|
+
function searchable(plugins, alias = "") {
|
|
117
|
+
const column = sql.raw(`${alias}plugin`);
|
|
118
|
+
return plugins.length
|
|
119
|
+
? sql `(${column} IS NULL OR ${column} IN (${sql.join(plugins.map((p) => sql `${p}`), sql `, `)}))`
|
|
120
|
+
: sql `${column} IS NULL`;
|
|
121
|
+
}
|
|
122
|
+
/** The passages of the project's documents, and of the texts its plugins put in, that fit a question best. */
|
|
68
123
|
export async function searchProject(projectId, query, limit = 6) {
|
|
124
|
+
const plugins = [...(await pluginIdsOf(currentTenant()))];
|
|
69
125
|
const lists = [];
|
|
70
126
|
const keywords = keywordQuery(query);
|
|
71
127
|
if (keywords) {
|
|
72
128
|
lists.push(await db.all(sql `
|
|
73
|
-
SELECT c.id, c.file_id, c.idx, c.text
|
|
129
|
+
SELECT c.id, c.file_id, c.plugin, c.ref, c.title, c.link, c.idx, c.text
|
|
74
130
|
FROM project_chunk_fts f JOIN project_chunk c ON c.id = f.rowid
|
|
75
131
|
WHERE project_chunk_fts MATCH ${keywords} AND c.project_id = ${projectId}
|
|
132
|
+
AND ${searchable(plugins, "c.")}
|
|
76
133
|
ORDER BY bm25(project_chunk_fts) LIMIT 24`));
|
|
77
134
|
}
|
|
78
135
|
const embedded = await embed([query]).catch(() => null);
|
|
79
136
|
if (embedded) {
|
|
80
137
|
lists.push(await db.all(sql `
|
|
81
|
-
SELECT id, file_id, idx, text
|
|
138
|
+
SELECT id, file_id, plugin, ref, title, link, idx, text
|
|
82
139
|
FROM project_chunk
|
|
83
|
-
WHERE project_id = ${projectId} AND model = ${embedded.model}
|
|
140
|
+
WHERE project_id = ${projectId} AND model = ${embedded.model} AND ${searchable(plugins)}
|
|
84
141
|
ORDER BY vector_distance_cos(embedding, ${vectorBytes(embedded.vectors[0])}) LIMIT 24`));
|
|
85
142
|
}
|
|
86
143
|
// Reciprocal rank fusion: a passage both lists rank high comes first.
|
|
@@ -96,12 +153,15 @@ export async function searchProject(projectId, query, limit = 6) {
|
|
|
96
153
|
if (!top.length) {
|
|
97
154
|
return [];
|
|
98
155
|
}
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
156
|
+
const fileIds = [...new Set(top.map((t) => t.hit.file_id).filter((id) => id !== null))];
|
|
157
|
+
const files = fileIds.length
|
|
158
|
+
? await db.query.projectFile.findMany({ where: inArray(schema.projectFile.id, fileIds) })
|
|
159
|
+
: [];
|
|
102
160
|
return top.map(({ hit, score }) => ({
|
|
103
161
|
fileId: hit.file_id,
|
|
104
|
-
|
|
162
|
+
plugin: hit.plugin,
|
|
163
|
+
name: hit.file_id ? (files.find((f) => f.id === hit.file_id)?.name ?? "") : (hit.title ?? ""),
|
|
164
|
+
link: hit.link,
|
|
105
165
|
index: hit.idx,
|
|
106
166
|
text: hit.text,
|
|
107
167
|
score: Math.round(score * 10_000) / 10_000,
|