slotlock 0.5.1 → 0.5.2
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/CHANGELOG.md +24 -0
- package/README.md +55 -1
- package/agent-server.d.ts +14 -0
- package/agent-server.js +15 -0
- package/assistant.d.ts +263 -0
- package/assistant.js +771 -0
- package/config.d.ts +10 -0
- package/config.js +11 -0
- package/dashboard.d.ts +61 -0
- package/dashboard.js +589 -11
- package/ddl.d.ts +5 -5
- package/ddl.js +199 -2
- package/index.d.ts +4 -2
- package/index.js +2 -1
- package/maintenance.d.ts +2 -1
- package/maintenance.js +22 -0
- package/package.json +1 -1
- package/rate-limit.d.ts +8 -0
- package/rate-limit.js +20 -0
- package/self-host.d.ts +2 -2
- package/self-host.js +34 -3
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +101 -1
- package/store.js +158 -0
- package/webhooks.d.ts +24 -0
- package/webhooks.js +96 -19
package/dashboard.js
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
// keeps these in Postgres, so they hold on every server that shares the database; the resource cap
|
|
15
15
|
// is held in the database too.
|
|
16
16
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { describeSlotlockAgentWrite, invokeSlotlockAgentOperation, slotlockAgentTools, } from './agent-server.js';
|
|
18
|
+
import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKey, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, } from './assistant.js';
|
|
17
19
|
import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
|
|
20
|
+
import { createSlotlockRateLimiter } from './rate-limit.js';
|
|
21
|
+
import { createSlotlockGuardedFetch } from './webhooks.js';
|
|
22
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
18
23
|
import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
|
|
19
24
|
import { ignoreRejection } from './hooks.js';
|
|
20
25
|
import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
|
|
@@ -25,6 +30,8 @@ export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
|
|
|
25
30
|
const SESSION_TTL_MS = 12 * 3_600_000;
|
|
26
31
|
const SIGN_IN_TTL_MS = 10 * 60_000;
|
|
27
32
|
const MAX_FORM_BYTES = 8_192;
|
|
33
|
+
/** A message to the assistant: 2,000 characters, each up to 9 bytes as a form encodes it. */
|
|
34
|
+
const MAX_ASSISTANT_FORM_BYTES = 24_576;
|
|
28
35
|
/**
|
|
29
36
|
* Sign-ins (record, then GitHub's token and user) one instance runs at once; more are told to retry.
|
|
30
37
|
* This also bounds how fast unauthenticated callbacks can write sign-in records.
|
|
@@ -50,6 +57,13 @@ const POST_ROUTES = new Set([
|
|
|
50
57
|
'/waitlist/leave',
|
|
51
58
|
'/hours',
|
|
52
59
|
'/organization',
|
|
60
|
+
'/assistant',
|
|
61
|
+
'/assistant/approve',
|
|
62
|
+
'/assistant/dismiss',
|
|
63
|
+
'/assistant/reset',
|
|
64
|
+
'/settings/assistant',
|
|
65
|
+
'/settings/assistant/test',
|
|
66
|
+
'/settings/assistant/remove',
|
|
53
67
|
]);
|
|
54
68
|
const SIGN_IN_AGAIN = 'That sign-in did not finish here, or took too long. Sign in again.';
|
|
55
69
|
const GITHUB_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
|
|
@@ -130,6 +144,7 @@ export function createSlotlockDashboardResources(store) {
|
|
|
130
144
|
remove: async (tenantRef, id, actor) => (await store.withTenant(tenantRef, (tenant) => tenant.deleteWebhookEndpoint({ tenantRef, id }), by(actor))).deleted,
|
|
131
145
|
enable: (tenantRef, id, actor) => store.withTenant(tenantRef, (tenant) => tenant.enableWebhookEndpoint({ tenantRef, id }), by(actor)),
|
|
132
146
|
},
|
|
147
|
+
assistant: createSlotlockAssistantStore(store),
|
|
133
148
|
};
|
|
134
149
|
}
|
|
135
150
|
function assertExpiry(expiresAt) {
|
|
@@ -315,6 +330,7 @@ const ICONS = Object.freeze({
|
|
|
315
330
|
keys: icon('<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3"/>'),
|
|
316
331
|
waitlist: icon('<path d="M4 6h16M4 12h10M4 18h6"/><circle cx="18" cy="16" r="3"/>'),
|
|
317
332
|
webhooks: icon('<path d="M9 7a3 3 0 1 1 4 2.8L16 15M6.5 13.5A3 3 0 1 0 9 18h6M15 18a3 3 0 1 0 1.5-5.6"/>'),
|
|
333
|
+
assistant: icon('<path d="M4 5h16v11H9l-5 4z"/><path d="M8 9h8M8 12h5"/>'),
|
|
318
334
|
settings: icon('<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>'),
|
|
319
335
|
check: icon('<path d="M5 12l5 5L20 7"/>'),
|
|
320
336
|
github: new Html('<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="fill" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>'),
|
|
@@ -724,6 +740,9 @@ export function createSlotlockDashboard(options) {
|
|
|
724
740
|
...(options.resources.webhooks
|
|
725
741
|
? [{ section: 'webhooks', label: 'Webhooks', path: '/webhooks', icon: ICONS.webhooks }]
|
|
726
742
|
: []),
|
|
743
|
+
...(assistantStore()
|
|
744
|
+
? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
|
|
745
|
+
: []),
|
|
727
746
|
{ section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
|
|
728
747
|
];
|
|
729
748
|
/** The workspace the session acts in: its organization, or the person themselves. */
|
|
@@ -855,7 +874,11 @@ ${served ? html `<td data-label="Created by">${key.createdBy === personOf(sessio
|
|
|
855
874
|
? 'A deleted key'
|
|
856
875
|
: /^github:\d+$/.test(change.actor)
|
|
857
876
|
? `GitHub user ${change.actor.slice(7)}`
|
|
858
|
-
: change.actor
|
|
877
|
+
: /^dashboard-assistant:github:\d+$/.test(change.actor)
|
|
878
|
+
? change.actor === assistantActor(session)
|
|
879
|
+
? `Assistant for @${session.login}`
|
|
880
|
+
: `Assistant for GitHub user ${change.actor.slice(27)}`
|
|
881
|
+
: change.actor.slice(0, 60)));
|
|
859
882
|
const where = change.resourceId
|
|
860
883
|
? resourceName(byId.get(change.resourceId), change.resourceId)
|
|
861
884
|
: null;
|
|
@@ -954,10 +977,12 @@ ${resources.length === 0
|
|
|
954
977
|
: html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a href="${root}/resources/${resource.id}">${resourceName(resource, resource.id)}</a>${resource.pool ? html `<span class="meta">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours">${hoursSummary(resource.availabilityRules)} <a href="${root}/hours?resource=${resource.id}">Set hours</a></td><td data-label="Resource id"><code class="muted">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
|
|
955
978
|
return respond(status, layout('Resources', content, session, 'resources'));
|
|
956
979
|
}
|
|
957
|
-
function settingsPage(session) {
|
|
980
|
+
async function settingsPage(session, status = 200, notice) {
|
|
958
981
|
const rules = options.availability ?? [];
|
|
959
982
|
const current = session.orgs?.find((org) => org.id === session.org);
|
|
960
|
-
const content = html `${
|
|
983
|
+
const content = html `${noticeOf(notice)}
|
|
984
|
+
${pageHead('Settings')}
|
|
985
|
+
${await assistantSettingsPanel(session)}
|
|
961
986
|
<section class="panel">
|
|
962
987
|
<h2>Endpoints</h2>
|
|
963
988
|
${endpoints()}
|
|
@@ -976,7 +1001,7 @@ ${rules.length === 0
|
|
|
976
1001
|
: html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
|
|
977
1002
|
${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
|
|
978
1003
|
</section>`;
|
|
979
|
-
return respond(
|
|
1004
|
+
return respond(status, layout('Settings', content, session, 'settings'));
|
|
980
1005
|
}
|
|
981
1006
|
/** The tenant's resource with that id and every resource (for names), or null for anything else. */
|
|
982
1007
|
async function resourceDetailPage(session, id, status = 200, notice) {
|
|
@@ -1244,6 +1269,518 @@ ${entries.length === 0
|
|
|
1244
1269
|
}
|
|
1245
1270
|
return redirect(303, `${root}/waitlist`);
|
|
1246
1271
|
}
|
|
1272
|
+
// ---- The assistant ----
|
|
1273
|
+
const assistantOptions = options.assistant;
|
|
1274
|
+
/** The assistant's records, when the assistant is on and the adapter keeps them. Per request (Workers). */
|
|
1275
|
+
const assistantStore = () => assistantOptions ? options.resources.assistant : undefined;
|
|
1276
|
+
const privateUrls = assistantOptions?.privateUrls === true;
|
|
1277
|
+
const assistantFetch = assistantOptions?.fetch ?? createSlotlockGuardedFetch({ allowPrivateNetworks: privateUrls });
|
|
1278
|
+
const resolveHost = assistantOptions?.resolve ??
|
|
1279
|
+
(async (hostname) => (await dnsLookup(hostname, { all: true })).map(({ address }) => address));
|
|
1280
|
+
const PRIVATE_URL_REFUSAL = 'That URL is, or resolves to, a local or private network address (loopback, private, link-local, shared, unique-local or cloud metadata), which this server does not reach. For a model on its own machine or network, the server’s operator can allow them with SLOTLOCK_ASSISTANT_PRIVATE_URLS=on.';
|
|
1281
|
+
const assistantTimeout = assistantOptions?.timeoutMs ?? 30_000;
|
|
1282
|
+
const assistantTurnTimeout = assistantOptions?.turnTimeoutMs ?? 90_000;
|
|
1283
|
+
const conversationTtl = assistantOptions?.conversationTtlMs ?? 2 * 3_600_000;
|
|
1284
|
+
if (assistantOptions && Buffer.byteLength(assistantOptions.secret, 'utf8') < 32) {
|
|
1285
|
+
throw new Error('Slotlock dashboard assistant secret must be at least 32 bytes');
|
|
1286
|
+
}
|
|
1287
|
+
if (assistantOptions && assistantOptions.secret === options.sessionSecret) {
|
|
1288
|
+
throw new Error('Slotlock dashboard assistant secret must differ from the session secret');
|
|
1289
|
+
}
|
|
1290
|
+
const memberMessages = createSlotlockRateLimiter(assistantOptions?.rate ?? { perSecond: 10 / 60, burst: 10 }, now);
|
|
1291
|
+
const toolTitles = new Map(slotlockAgentTools().map((tool) => [tool.name, tool.title]));
|
|
1292
|
+
/** The writes that take an idempotency key: the dashboard sets it, from the action's id. */
|
|
1293
|
+
const keyed = new Set(slotlockAgentTools()
|
|
1294
|
+
.filter((tool) => 'idempotency_key' in (tool.inputSchema.properties ?? {}))
|
|
1295
|
+
.map((tool) => tool.name));
|
|
1296
|
+
const ACTION_ID = /^[A-Za-z0-9_-]{16}$/;
|
|
1297
|
+
/** Reads one member message may make, and the resource-days they may cover together. */
|
|
1298
|
+
const MAX_READS_PER_TURN = 16;
|
|
1299
|
+
const MAX_READ_COST_PER_TURN = 400;
|
|
1300
|
+
/** What a read costs the turn: the resources it names times the days its window spans. */
|
|
1301
|
+
const readCostOf = (input) => {
|
|
1302
|
+
if (!isRecord(input))
|
|
1303
|
+
return 1;
|
|
1304
|
+
const ids = Array.isArray(input.resource_ids) ? input.resource_ids.length : 1;
|
|
1305
|
+
const start = typeof input.start === 'string' ? Date.parse(input.start) : Number.NaN;
|
|
1306
|
+
const end = typeof input.end === 'string' ? Date.parse(input.end) : Number.NaN;
|
|
1307
|
+
const days = Number.isFinite(start) && Number.isFinite(end) && end > start ? Math.ceil((end - start) / DAY_MS) : 1;
|
|
1308
|
+
return Math.max(1, ids) * Math.max(1, days);
|
|
1309
|
+
};
|
|
1310
|
+
/**
|
|
1311
|
+
* Who the assistant acts as, for the record and for ownership: the member by GitHub user id,
|
|
1312
|
+
* which a rename does not change. Shown as "Assistant for @login".
|
|
1313
|
+
*/
|
|
1314
|
+
const assistantActor = (session) => `dashboard-assistant:github:${session.uid}`;
|
|
1315
|
+
const CONFIRMED_WRITES = new Set([
|
|
1316
|
+
'slotlock_create_event',
|
|
1317
|
+
'slotlock_update_event',
|
|
1318
|
+
'slotlock_delete_event',
|
|
1319
|
+
'slotlock_book_together',
|
|
1320
|
+
'slotlock_book_pool',
|
|
1321
|
+
]);
|
|
1322
|
+
/**
|
|
1323
|
+
* The sentence a proposed change's card leads with: the agent server's confirmation question
|
|
1324
|
+
* (the resource by name, the times in its zone, an event as it stands: its title, resource and
|
|
1325
|
+
* time), or for holds the slots by resource. Read as the member's assistant, under `authorize`.
|
|
1326
|
+
*/
|
|
1327
|
+
async function summarize(session, operation, input) {
|
|
1328
|
+
const assistant = assistantOptions;
|
|
1329
|
+
let text;
|
|
1330
|
+
if (CONFIRMED_WRITES.has(operation)) {
|
|
1331
|
+
text = await describeSlotlockAgentWrite({
|
|
1332
|
+
backend: assistant.backend,
|
|
1333
|
+
authorize: assistantAuthorize,
|
|
1334
|
+
principal: assistantPrincipal(session),
|
|
1335
|
+
operation: operation,
|
|
1336
|
+
input,
|
|
1337
|
+
signal: AbortSignal.timeout(assistantTimeout),
|
|
1338
|
+
}).catch(() => '');
|
|
1339
|
+
}
|
|
1340
|
+
else if (operation === 'slotlock_hold_slots' && Array.isArray(input.slots)) {
|
|
1341
|
+
const names = new Map((await options.resources.list(tenantOf(session))).map((resource) => [resource.id, resourceName(resource, resource.id)]));
|
|
1342
|
+
const slots = input.slots.map((slot) => `${names.get(String(slot.resource_id)) ?? `resource ${String(slot.resource_id)}`} ${String(slot.start)} to ${String(slot.end)}`);
|
|
1343
|
+
text = `Hold ${slots.join('; ')} for ${String(input.ttl_seconds ?? '')} seconds, as alternatives.`;
|
|
1344
|
+
}
|
|
1345
|
+
else if (operation === 'slotlock_release_slots') {
|
|
1346
|
+
text = `Release the hold ${String(input.hold_id)} and every slot held with it.`;
|
|
1347
|
+
}
|
|
1348
|
+
else {
|
|
1349
|
+
text = '';
|
|
1350
|
+
}
|
|
1351
|
+
// At most 1,500 UTF-8 bytes, so four waiting changes stay within the conversation's bound.
|
|
1352
|
+
return text.length > 500 ? `${text.slice(0, 500)}…` : text;
|
|
1353
|
+
}
|
|
1354
|
+
/** Owners of the organization in use may change its assistant; a person always may their own. */
|
|
1355
|
+
const isAdmin = (session) => !served || session.orgs?.find((org) => org.id === session.org)?.role === 'admin';
|
|
1356
|
+
const assistantPrincipal = (session) => ({
|
|
1357
|
+
subject: assistantActor(session),
|
|
1358
|
+
tenantRef: tenantOf(session),
|
|
1359
|
+
scopes: ['read', 'write'],
|
|
1360
|
+
});
|
|
1361
|
+
/** The fixed list, then the embedder's check: nothing the model sends adds to it. */
|
|
1362
|
+
const assistantAuthorize = async (args) => SLOTLOCK_ASSISTANT_TOOL_USE[args.operation] !== 'excluded' &&
|
|
1363
|
+
(assistantOptions?.authorize ? await assistantOptions.authorize(args) : true);
|
|
1364
|
+
/**
|
|
1365
|
+
* One agent operation as the member's assistant. A `propose` call is checked as an agent's is
|
|
1366
|
+
* (arguments, scopes, authorize) and halted by `confirm` before it runs; it takes no admission
|
|
1367
|
+
* slot and no rate token, which the write takes when it runs, approved (`run`).
|
|
1368
|
+
*/
|
|
1369
|
+
function dispatch(session, operation, input, mode, confirm) {
|
|
1370
|
+
const assistant = assistantOptions;
|
|
1371
|
+
const principal = assistantPrincipal(session);
|
|
1372
|
+
return invokeSlotlockAgentOperation({
|
|
1373
|
+
operation,
|
|
1374
|
+
input,
|
|
1375
|
+
request: new Request(`${origin}${root}/assistant`, {
|
|
1376
|
+
signal: AbortSignal.timeout(assistantTimeout),
|
|
1377
|
+
}),
|
|
1378
|
+
options: {
|
|
1379
|
+
backend: assistant.backend,
|
|
1380
|
+
authenticate: async () => principal,
|
|
1381
|
+
authorize: async (args) => {
|
|
1382
|
+
if (!(await assistantAuthorize(args)))
|
|
1383
|
+
return false;
|
|
1384
|
+
if (mode === 'run')
|
|
1385
|
+
await assistant.admit?.(args);
|
|
1386
|
+
return true;
|
|
1387
|
+
},
|
|
1388
|
+
...(assistant.consumeRateLimit && mode === 'run'
|
|
1389
|
+
? { consumeRateLimit: assistant.consumeRateLimit }
|
|
1390
|
+
: {}),
|
|
1391
|
+
...(confirm ? { confirm } : {}),
|
|
1392
|
+
onError: (error) => {
|
|
1393
|
+
try {
|
|
1394
|
+
ignoreRejection(options.onError?.(error));
|
|
1395
|
+
}
|
|
1396
|
+
catch {
|
|
1397
|
+
// A failing reporter must not change the answer.
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
},
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
async function assistantModel(session) {
|
|
1404
|
+
const store = assistantStore();
|
|
1405
|
+
const settings = store ? await store.getSettings(tenantOf(session)) : null;
|
|
1406
|
+
if (!settings || !assistantOptions)
|
|
1407
|
+
return null;
|
|
1408
|
+
if (!slotlockAssistantUrlAllowed(settings.baseUrl, privateUrls) ||
|
|
1409
|
+
!(await checkSlotlockAssistantHost(settings.baseUrl, { privateUrls, resolve: resolveHost }))) {
|
|
1410
|
+
return { problem: PRIVATE_URL_REFUSAL };
|
|
1411
|
+
}
|
|
1412
|
+
let apiKey = null;
|
|
1413
|
+
if (settings.sealedKey !== null) {
|
|
1414
|
+
apiKey = await openSlotlockAssistantKey(assistantOptions.secret, tenantOf(session), settings.sealedKey);
|
|
1415
|
+
if (apiKey === null) {
|
|
1416
|
+
return {
|
|
1417
|
+
problem: 'The stored API key cannot be opened: the server’s assistant secret changed. An admin can enter the key again in Settings.',
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
return {
|
|
1422
|
+
model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the
|
|
1427
|
+
* proposal is bounded instead). A resource is named, with its id beside the name.
|
|
1428
|
+
*/
|
|
1429
|
+
function fieldValue(name, value, names) {
|
|
1430
|
+
const named = (id) => (names.has(id) ? `${names.get(id)} (${id})` : id);
|
|
1431
|
+
if (typeof value === 'string') {
|
|
1432
|
+
return html `<span class="wrap">${name === 'resource_id' ? named(value) : value}</span>`;
|
|
1433
|
+
}
|
|
1434
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
1435
|
+
return html `<span class="wrap">${value.map((item) => (name === 'resource_ids' ? named(item) : item)).join(', ')}</span>`;
|
|
1436
|
+
}
|
|
1437
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
|
|
1438
|
+
return html `${String(value)}`;
|
|
1439
|
+
}
|
|
1440
|
+
return html `<pre class="wrap">${JSON.stringify(value, null, 2)}</pre>`;
|
|
1441
|
+
}
|
|
1442
|
+
function actionCard(session, action, names) {
|
|
1443
|
+
const token = csrfToken(session);
|
|
1444
|
+
const fields = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
|
|
1445
|
+
const summary = action.summary ? html `<p class="action-summary">${action.summary}</p>` : html ``;
|
|
1446
|
+
const state = {
|
|
1447
|
+
pending: { text: 'Waiting for you', tone: 'held' },
|
|
1448
|
+
approved: { text: 'Running', tone: 'held' },
|
|
1449
|
+
dismissed: { text: 'Dismissed', tone: 'revoked' },
|
|
1450
|
+
done: { text: 'Done', tone: 'active' },
|
|
1451
|
+
failed: { text: 'Failed', tone: 'revoked' },
|
|
1452
|
+
}[action.status] ?? { text: action.status, tone: 'revoked' };
|
|
1453
|
+
const buttons = action.status === 'pending'
|
|
1454
|
+
? html `<div class="action-buttons"><form class="inline" method="post" action="${root}/assistant/approve"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${action.id}"><button class="primary" type="submit">Approve</button></form><form class="inline" method="post" action="${root}/assistant/dismiss"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${action.id}"><button type="submit">Dismiss</button></form></div>`
|
|
1455
|
+
: html ``;
|
|
1456
|
+
return html `<li class="action-card" id="action-${action.id}"><div class="action-head"><span class="title">${toolTitles.get(action.operation) ?? action.operation}</span><span class="pill ${state.tone}">${state.text}</span></div>${summary}<dl class="facts">${fields.map(([name, value]) => html `<dt>${name.replaceAll('_', ' ')}</dt><dd>${fieldValue(name, value, names)}</dd>`)}</dl>${action.status === 'failed' && action.outcome ? html `<p class="fine">Refused: <code>${action.outcome}</code></p>` : html ``}${buttons}</li>`;
|
|
1457
|
+
}
|
|
1458
|
+
function readAction(id, value) {
|
|
1459
|
+
if (!isRecord(value) || typeof value.operation !== 'string')
|
|
1460
|
+
return null;
|
|
1461
|
+
return {
|
|
1462
|
+
id,
|
|
1463
|
+
operation: value.operation,
|
|
1464
|
+
input: isRecord(value.input) ? value.input : {},
|
|
1465
|
+
...(typeof value.summary === 'string' ? { summary: value.summary } : {}),
|
|
1466
|
+
status: (typeof value.status === 'string' ? value.status : 'failed'),
|
|
1467
|
+
...(typeof value.outcome === 'string' ? { outcome: value.outcome } : {}),
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
async function assistantPage(session, status = 200, notice) {
|
|
1471
|
+
const store = assistantStore();
|
|
1472
|
+
if (!store)
|
|
1473
|
+
return notFound(session);
|
|
1474
|
+
const tenantRef = tenantOf(session);
|
|
1475
|
+
const [settings, conversation] = await Promise.all([
|
|
1476
|
+
store.getSettings(tenantRef),
|
|
1477
|
+
store.getConversation(tenantRef, personOf(session)),
|
|
1478
|
+
]);
|
|
1479
|
+
const entries = readSlotlockAssistantEntries(conversation?.messages);
|
|
1480
|
+
const actions = conversation?.actions ?? {};
|
|
1481
|
+
const names = new Map();
|
|
1482
|
+
if (Object.keys(actions).length > 0) {
|
|
1483
|
+
for (const resource of await options.resources.list(tenantRef)) {
|
|
1484
|
+
names.set(resource.id, resourceName(resource, resource.id));
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
const token = csrfToken(session);
|
|
1488
|
+
const items = [];
|
|
1489
|
+
for (const entry of entries) {
|
|
1490
|
+
if (entry.role === 'user')
|
|
1491
|
+
items.push(html `<li class="msg mine"><p>${entry.text}</p></li>`);
|
|
1492
|
+
else if (entry.role === 'assistant' && entry.text.trim() !== '') {
|
|
1493
|
+
items.push(html `<li class="msg bot"><p>${entry.text}</p></li>`);
|
|
1494
|
+
}
|
|
1495
|
+
else if (entry.role === 'note')
|
|
1496
|
+
items.push(html `<li class="notice">${entry.text}</li>`);
|
|
1497
|
+
else if (entry.role === 'tool') {
|
|
1498
|
+
for (const result of entry.results) {
|
|
1499
|
+
const action = result.action ? readAction(result.action, actions[result.action]) : null;
|
|
1500
|
+
if (action)
|
|
1501
|
+
items.push(actionCard(session, action, names));
|
|
1502
|
+
else {
|
|
1503
|
+
items.push(html `<li class="tool-line">${result.error ? 'Could not run' : 'Read'}: ${toolTitles.get(result.name) ?? result.name}</li>`);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const composer = html `<form class="composer" method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="3" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="Find a free van tomorrow morning and book it for Ana"></textarea><button class="primary" type="submit">Send</button></form>`;
|
|
1509
|
+
const reset = entries.length > 0
|
|
1510
|
+
? html `<form class="inline" method="post" action="${root}/assistant/reset"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">New conversation</button></form>`
|
|
1511
|
+
: undefined;
|
|
1512
|
+
const content = html `${noticeOf(notice)}
|
|
1513
|
+
${pageHead('Assistant', reset)}
|
|
1514
|
+
${settings
|
|
1515
|
+
? html `<section class="panel chat">${items.length === 0
|
|
1516
|
+
? html `<p class="empty">Ask about bookings and free time. Changes wait for your approval.</p>`
|
|
1517
|
+
: html `<ol class="thread">${items}</ol>`}<span id="latest"></span>${composer}</section>`
|
|
1518
|
+
: html `<p class="empty">${isAdmin(session)
|
|
1519
|
+
? html `No model is connected. <a href="${root}/settings#assistant">Connect one in Settings</a>.`
|
|
1520
|
+
: 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
|
|
1521
|
+
return respond(status, layout('Assistant', content, session, 'assistant'));
|
|
1522
|
+
}
|
|
1523
|
+
async function assistantSettingsPanel(session) {
|
|
1524
|
+
const store = assistantStore();
|
|
1525
|
+
if (!store)
|
|
1526
|
+
return html ``;
|
|
1527
|
+
const settings = await store.getSettings(tenantOf(session));
|
|
1528
|
+
const sentTo = html `<p class="fine">Members' messages, and the bookings and resources the assistant reads for them, are sent to this provider.</p>`;
|
|
1529
|
+
if (!isAdmin(session)) {
|
|
1530
|
+
return html `<section class="panel" id="assistant"><h2>Assistant</h2>${settings
|
|
1531
|
+
? html `<dl class="facts"><dt>Provider</dt><dd>${settings.provider === 'anthropic' ? 'Anthropic' : 'OpenAI-compatible'}</dd><dt>Model</dt><dd>${settings.model}</dd></dl>${sentTo}`
|
|
1532
|
+
: html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
|
|
1533
|
+
}
|
|
1534
|
+
const token = csrfToken(session);
|
|
1535
|
+
const selected = (provider) => (settings?.provider === provider ? html ` selected` : html ``);
|
|
1536
|
+
return html `<section class="panel" id="assistant">
|
|
1537
|
+
<h2>Assistant</h2>
|
|
1538
|
+
<form method="post" action="${root}/settings/assistant">
|
|
1539
|
+
<input type="hidden" name="csrf" value="${token}">
|
|
1540
|
+
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
1541
|
+
<div class="grid-form">
|
|
1542
|
+
<label>Provider<select name="provider"><option value="anthropic"${selected('anthropic')}>Anthropic Messages API</option><option value="openai"${selected('openai')}>OpenAI-compatible</option></select></label>
|
|
1543
|
+
<label>Base URL<input name="base_url" required maxlength="2048" value="${settings?.baseUrl ?? ''}" placeholder="https://api.anthropic.com"></label>
|
|
1544
|
+
<label>Model<input name="model" required maxlength="200" value="${settings?.model ?? ''}" placeholder="your-model"></label>
|
|
1545
|
+
<label>API key<input name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="${settings?.keyHint ? 'Leave empty to keep it' : 'Optional for a local model'}"></label>
|
|
1546
|
+
</div>
|
|
1547
|
+
<p class="fine">Key: ${settings?.keyHint ? html `set, ending in <code>${settings.keyHint}</code>` : 'none'}. OpenAI-compatible covers OpenAI, OpenRouter, vLLM, Ollama and LM Studio; give the URL that ends in <code>/v1</code>.</p>
|
|
1548
|
+
${settings?.keyHint ? html `<label class="choice"><input type="checkbox" name="clear_key" value="1"> Remove the key</label>` : html ``}
|
|
1549
|
+
<button class="primary" type="submit">Save</button>
|
|
1550
|
+
</form>
|
|
1551
|
+
${sentTo}
|
|
1552
|
+
${settings
|
|
1553
|
+
? html `<div class="row-actions"><form class="inline" method="post" action="${root}/settings/assistant/test"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Test connection</button></form><details class="act danger"><summary>Disconnect</summary><div class="pop"><p>Members can no longer chat. The key is deleted.</p><form method="post" action="${root}/settings/assistant/remove"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button class="danger" type="submit">Disconnect</button></form></div></details></div>`
|
|
1554
|
+
: html ``}
|
|
1555
|
+
</section>`;
|
|
1556
|
+
}
|
|
1557
|
+
const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this organization can change the assistant.');
|
|
1558
|
+
async function saveAssistantSettings(session, form) {
|
|
1559
|
+
const store = assistantStore();
|
|
1560
|
+
if (!store || !assistantOptions)
|
|
1561
|
+
return notFound(session);
|
|
1562
|
+
if (!isAdmin(session))
|
|
1563
|
+
return adminOnly(session);
|
|
1564
|
+
const provider = form.get('provider');
|
|
1565
|
+
const checkedUrl = readSlotlockAssistantBaseUrl(form.get('base_url') ?? '', { privateUrls });
|
|
1566
|
+
const model = (form.get('model') ?? '').trim();
|
|
1567
|
+
const apiKey = (form.get('api_key') ?? '').trim();
|
|
1568
|
+
if (provider !== 'anthropic' && provider !== 'openai') {
|
|
1569
|
+
return settingsPage(session, 400, 'Choose a provider from the list.');
|
|
1570
|
+
}
|
|
1571
|
+
if (!checkedUrl.ok) {
|
|
1572
|
+
return settingsPage(session, 400, checkedUrl.reason === 'private'
|
|
1573
|
+
? PRIVATE_URL_REFUSAL
|
|
1574
|
+
: 'Give an https base URL (http only for localhost or 127.0.0.1), with no user name, query or #fragment.');
|
|
1575
|
+
}
|
|
1576
|
+
const baseUrl = checkedUrl.url;
|
|
1577
|
+
if (!(await checkSlotlockAssistantHost(baseUrl, { privateUrls, resolve: resolveHost }))) {
|
|
1578
|
+
return settingsPage(session, 400, PRIVATE_URL_REFUSAL);
|
|
1579
|
+
}
|
|
1580
|
+
if (!SLOTLOCK_ASSISTANT_MODEL_NAME.test(model)) {
|
|
1581
|
+
return settingsPage(session, 400, 'Give the model name, 1 to 200 characters without spaces.');
|
|
1582
|
+
}
|
|
1583
|
+
if (apiKey !== '' && !SLOTLOCK_ASSISTANT_API_KEY.test(apiKey)) {
|
|
1584
|
+
return settingsPage(session, 400, 'An API key is up to 4,096 characters without spaces.');
|
|
1585
|
+
}
|
|
1586
|
+
const tenantRef = tenantOf(session);
|
|
1587
|
+
const key = apiKey !== ''
|
|
1588
|
+
? { sealed: await sealSlotlockAssistantKey(assistantOptions.secret, tenantRef, apiKey), hint: apiKey.slice(-4) }
|
|
1589
|
+
: form.get('clear_key') === '1'
|
|
1590
|
+
? null
|
|
1591
|
+
: 'keep';
|
|
1592
|
+
// The stored key is only kept for the provider and URL it was entered for: the store decides
|
|
1593
|
+
// that in the write itself, so an owner cannot send it elsewhere, however saves race.
|
|
1594
|
+
const { saved } = await store.putSettings(tenantRef, { provider, baseUrl, model, key }, personOf(session));
|
|
1595
|
+
if (!saved) {
|
|
1596
|
+
return settingsPage(session, 400, 'Enter the API key again: the provider or the base URL changed, and the stored key is only sent where it was entered for. Or remove the key.');
|
|
1597
|
+
}
|
|
1598
|
+
return redirect(303, `${root}/settings#assistant`);
|
|
1599
|
+
}
|
|
1600
|
+
async function removeAssistantSettings(session) {
|
|
1601
|
+
const store = assistantStore();
|
|
1602
|
+
if (!store)
|
|
1603
|
+
return notFound(session);
|
|
1604
|
+
if (!isAdmin(session))
|
|
1605
|
+
return adminOnly(session);
|
|
1606
|
+
await store.deleteSettings(tenantOf(session), personOf(session));
|
|
1607
|
+
return redirect(303, `${root}/settings#assistant`);
|
|
1608
|
+
}
|
|
1609
|
+
const tooManyMessages = (session, page) => page(session, 429, 'You have sent the assistant a lot in the last minute. Wait a moment and try again.');
|
|
1610
|
+
async function testAssistant(session) {
|
|
1611
|
+
if (!assistantStore())
|
|
1612
|
+
return notFound(session);
|
|
1613
|
+
if (!isAdmin(session))
|
|
1614
|
+
return adminOnly(session);
|
|
1615
|
+
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
1616
|
+
return tooManyMessages(session, settingsPage);
|
|
1617
|
+
}
|
|
1618
|
+
const found = await assistantModel(session);
|
|
1619
|
+
if (found === null)
|
|
1620
|
+
return settingsPage(session, 409, 'Connect a model first.');
|
|
1621
|
+
if ('problem' in found)
|
|
1622
|
+
return settingsPage(session, 409, found.problem);
|
|
1623
|
+
try {
|
|
1624
|
+
const ok = await testSlotlockAssistantModel({
|
|
1625
|
+
model: found.model,
|
|
1626
|
+
fetch: assistantFetch,
|
|
1627
|
+
timeoutMs: assistantTimeout,
|
|
1628
|
+
});
|
|
1629
|
+
return settingsPage(session, ok ? 200 : 502, ok
|
|
1630
|
+
? `Connected: ${found.model.model} answered and called a tool correctly.`
|
|
1631
|
+
: `${found.model.model} answered, but did not call the tool it was given. Choose a model that supports tool calls.`);
|
|
1632
|
+
}
|
|
1633
|
+
catch (error) {
|
|
1634
|
+
report(error instanceof Error ? error.message : 'assistant connection test failed');
|
|
1635
|
+
return settingsPage(session, 502, slotlockAssistantProviderMessage(error));
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
async function sendToAssistant(session, form) {
|
|
1639
|
+
const store = assistantStore();
|
|
1640
|
+
if (!store || !assistantOptions)
|
|
1641
|
+
return notFound(session);
|
|
1642
|
+
const text = (form.get('message') ?? '').trim();
|
|
1643
|
+
if (text === '' || text.length > SLOTLOCK_ASSISTANT_MAX_MESSAGE) {
|
|
1644
|
+
return assistantPage(session, 400, `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`);
|
|
1645
|
+
}
|
|
1646
|
+
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
1647
|
+
return tooManyMessages(session, assistantPage);
|
|
1648
|
+
}
|
|
1649
|
+
const found = await assistantModel(session);
|
|
1650
|
+
if (found === null)
|
|
1651
|
+
return assistantPage(session, 409, 'No model is connected.');
|
|
1652
|
+
if ('problem' in found)
|
|
1653
|
+
return assistantPage(session, 409, found.problem);
|
|
1654
|
+
const tenantRef = tenantOf(session);
|
|
1655
|
+
const member = personOf(session);
|
|
1656
|
+
const stored = await store.getConversation(tenantRef, member);
|
|
1657
|
+
const entries = boundSlotlockAssistantEntries([
|
|
1658
|
+
...readSlotlockAssistantEntries(stored?.messages),
|
|
1659
|
+
{ role: 'user', text },
|
|
1660
|
+
]);
|
|
1661
|
+
const proposed = {};
|
|
1662
|
+
const pendingBefore = pendingSlotlockAssistantActions(stored?.actions ?? {});
|
|
1663
|
+
let reads = 0;
|
|
1664
|
+
let readCost = 0;
|
|
1665
|
+
const current = session.orgs?.find((org) => org.id === session.org);
|
|
1666
|
+
const turn = await runSlotlockAssistantTurn({
|
|
1667
|
+
model: found.model,
|
|
1668
|
+
system: slotlockAssistantSystemPrompt({
|
|
1669
|
+
brand: brandName,
|
|
1670
|
+
workspace: current?.login ?? session.login,
|
|
1671
|
+
login: session.login,
|
|
1672
|
+
now: new Date(now()),
|
|
1673
|
+
}),
|
|
1674
|
+
entries,
|
|
1675
|
+
fetch: assistantFetch,
|
|
1676
|
+
timeoutMs: assistantTimeout,
|
|
1677
|
+
turnTimeoutMs: assistantTurnTimeout,
|
|
1678
|
+
...(assistantOptions.maxSteps !== undefined ? { maxSteps: assistantOptions.maxSteps } : {}),
|
|
1679
|
+
read: async (operation, input) => {
|
|
1680
|
+
// A turn's reads share one budget of resource-days, so 8 steps of wide reads cannot each
|
|
1681
|
+
// scan a hundred resources over years.
|
|
1682
|
+
reads += 1;
|
|
1683
|
+
readCost += readCostOf(input);
|
|
1684
|
+
if (reads > MAX_READS_PER_TURN || readCost > MAX_READ_COST_PER_TURN) {
|
|
1685
|
+
return { ok: false, code: 'turn_read_budget_exceeded' };
|
|
1686
|
+
}
|
|
1687
|
+
const outcome = await dispatch(session, operation, input, 'run');
|
|
1688
|
+
return outcome.ok
|
|
1689
|
+
? outcome
|
|
1690
|
+
: { ok: false, code: outcome.code, ...(outcome.details ? { details: outcome.details } : {}) };
|
|
1691
|
+
},
|
|
1692
|
+
propose: async (operation, input) => {
|
|
1693
|
+
if (pendingBefore + Object.keys(proposed).length >= SLOTLOCK_ASSISTANT_MAX_PENDING) {
|
|
1694
|
+
return { ok: false, code: 'too_many_pending_changes' };
|
|
1695
|
+
}
|
|
1696
|
+
const id = randomBytes(12).toString('base64url');
|
|
1697
|
+
const prepared = isRecord(input) && keyed.has(operation)
|
|
1698
|
+
? { ...input, idempotency_key: `dashboard-assistant.${id}` }
|
|
1699
|
+
: input;
|
|
1700
|
+
let checked;
|
|
1701
|
+
// Every check an agent's call passes (arguments, scopes, authorize, rate limit), then halted
|
|
1702
|
+
// here: it runs only when the member approves it.
|
|
1703
|
+
const outcome = await dispatch(session, operation, prepared, 'propose', async ({ input: parsed }) => {
|
|
1704
|
+
checked = parsed;
|
|
1705
|
+
return { status: 409, code: 'approval_required' };
|
|
1706
|
+
});
|
|
1707
|
+
if (checked === undefined || outcome.ok || outcome.code !== 'approval_required') {
|
|
1708
|
+
return { ok: false, code: outcome.ok ? 'internal_error' : outcome.code };
|
|
1709
|
+
}
|
|
1710
|
+
// The card shows every argument: one too large to show is not proposed.
|
|
1711
|
+
if (Buffer.byteLength(JSON.stringify(checked), 'utf8') > SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES) {
|
|
1712
|
+
return { ok: false, code: 'change_too_large_to_show' };
|
|
1713
|
+
}
|
|
1714
|
+
proposed[id] = {
|
|
1715
|
+
operation,
|
|
1716
|
+
input: checked,
|
|
1717
|
+
status: 'pending',
|
|
1718
|
+
summary: await summarize(session, operation, checked),
|
|
1719
|
+
};
|
|
1720
|
+
return { ok: true, actionId: id };
|
|
1721
|
+
},
|
|
1722
|
+
});
|
|
1723
|
+
if (turn.failure !== undefined) {
|
|
1724
|
+
report(turn.failure instanceof Error ? turn.failure.message : 'assistant provider failed');
|
|
1725
|
+
}
|
|
1726
|
+
const messages = boundSlotlockAssistantEntries([...entries, ...turn.added]);
|
|
1727
|
+
// Only this turn's proposals are added; the rest are kept as the row holds them now, so one
|
|
1728
|
+
// approved (or a conversation reset) while the turn ran is never put back as it stood.
|
|
1729
|
+
const kept = pruneSlotlockAssistantActions(messages, { ...(stored?.actions ?? {}), ...proposed });
|
|
1730
|
+
await store.putConversation(tenantRef, member, {
|
|
1731
|
+
messages,
|
|
1732
|
+
added: Object.fromEntries(Object.entries(proposed).filter(([id]) => id in kept)),
|
|
1733
|
+
keep: Object.keys(kept),
|
|
1734
|
+
}, conversationTtl);
|
|
1735
|
+
return redirect(303, `${root}/assistant#latest`);
|
|
1736
|
+
}
|
|
1737
|
+
async function settleAction(session, form, decision) {
|
|
1738
|
+
const store = assistantStore();
|
|
1739
|
+
if (!store || !assistantOptions)
|
|
1740
|
+
return notFound(session);
|
|
1741
|
+
const id = form.get('action') ?? '';
|
|
1742
|
+
const tenantRef = tenantOf(session);
|
|
1743
|
+
const member = personOf(session);
|
|
1744
|
+
const claimed = ACTION_ID.test(id)
|
|
1745
|
+
? await store.settleAction(tenantRef, member, id, 'pending', decision === 'approve' ? { status: 'approved' } : { status: 'dismissed', input: {} })
|
|
1746
|
+
: null;
|
|
1747
|
+
const action = claimed ? readAction(id, claimed) : null;
|
|
1748
|
+
if (!action)
|
|
1749
|
+
return assistantPage(session, 409, 'That change was already approved or dismissed.');
|
|
1750
|
+
const title = toolTitles.get(action.operation) ?? action.operation;
|
|
1751
|
+
let event;
|
|
1752
|
+
if (decision === 'dismiss') {
|
|
1753
|
+
event = `The member dismissed the proposed change ${id} (${title}); it did not run.`;
|
|
1754
|
+
}
|
|
1755
|
+
else if (SLOTLOCK_ASSISTANT_TOOL_USE[action.operation] !== 'propose') {
|
|
1756
|
+
await store.settleAction(tenantRef, member, id, 'approved', { status: 'failed', outcome: 'tool_not_available', input: {} });
|
|
1757
|
+
event = `The proposed change ${id} (${title}) could not run: tool_not_available.`;
|
|
1758
|
+
}
|
|
1759
|
+
else {
|
|
1760
|
+
const outcome = await dispatch(session, action.operation, action.input, 'run');
|
|
1761
|
+
// Settled, the card keeps what it said, not the arguments: the actions stay small.
|
|
1762
|
+
await store.settleAction(tenantRef, member, id, 'approved', outcome.ok ? { status: 'done', input: {} } : { status: 'failed', outcome: outcome.code, input: {} });
|
|
1763
|
+
const result = JSON.stringify(outcome.ok ? outcome.data : { error: { code: outcome.code } });
|
|
1764
|
+
event = `The member approved the proposed change ${id} (${title}). ${outcome.ok ? 'It ran' : 'It was refused'}: ${result.length > 4_000 ? `${result.slice(0, 4_000)} …[truncated]` : result}`;
|
|
1765
|
+
}
|
|
1766
|
+
const stored = await store.getConversation(tenantRef, member);
|
|
1767
|
+
if (stored) {
|
|
1768
|
+
const entries = [
|
|
1769
|
+
...readSlotlockAssistantEntries(stored.messages),
|
|
1770
|
+
{ role: 'event', text: event },
|
|
1771
|
+
];
|
|
1772
|
+
const messages = boundSlotlockAssistantEntries(entries);
|
|
1773
|
+
await store.putConversation(tenantRef, member, { messages, added: {}, keep: Object.keys(pruneSlotlockAssistantActions(messages, stored.actions)) }, conversationTtl);
|
|
1774
|
+
}
|
|
1775
|
+
return redirect(303, `${root}/assistant#action-${id}`);
|
|
1776
|
+
}
|
|
1777
|
+
async function resetAssistant(session) {
|
|
1778
|
+
const store = assistantStore();
|
|
1779
|
+
if (!store)
|
|
1780
|
+
return notFound(session);
|
|
1781
|
+
await store.deleteConversation(tenantOf(session), personOf(session));
|
|
1782
|
+
return redirect(303, `${root}/assistant`);
|
|
1783
|
+
}
|
|
1247
1784
|
/** The page a form was sent from, to show what went wrong with it. */
|
|
1248
1785
|
function pageFor(route) {
|
|
1249
1786
|
if (route.startsWith('/keys'))
|
|
@@ -1254,6 +1791,10 @@ ${entries.length === 0
|
|
|
1254
1791
|
return waitlistPage;
|
|
1255
1792
|
if (route.startsWith('/resources') || route === '/hours')
|
|
1256
1793
|
return resourcesPage;
|
|
1794
|
+
if (route.startsWith('/assistant') && assistantStore())
|
|
1795
|
+
return assistantPage;
|
|
1796
|
+
if (route.startsWith('/settings'))
|
|
1797
|
+
return settingsPage;
|
|
1257
1798
|
return overviewPage;
|
|
1258
1799
|
}
|
|
1259
1800
|
function hoursPage(session, state) {
|
|
@@ -1523,8 +2064,9 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1523
2064
|
continue;
|
|
1524
2065
|
}
|
|
1525
2066
|
const id = String(organization.id);
|
|
1526
|
-
if (served?.has(id))
|
|
1527
|
-
found.set(id, { id, login: organization.login });
|
|
2067
|
+
if (served?.has(id)) {
|
|
2068
|
+
found.set(id, { id, login: organization.login, role: membership.role === 'admin' ? 'admin' : 'member' });
|
|
2069
|
+
}
|
|
1528
2070
|
}
|
|
1529
2071
|
return [...found.values()]
|
|
1530
2072
|
.sort((a, b) => a.login.localeCompare(b.login))
|
|
@@ -1615,12 +2157,12 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1615
2157
|
]);
|
|
1616
2158
|
}
|
|
1617
2159
|
/** The form, `'too_large'` past MAX_FORM_BYTES (read no further), or null for another type. */
|
|
1618
|
-
async function readForm(request) {
|
|
2160
|
+
async function readForm(request, maxBytes = MAX_FORM_BYTES) {
|
|
1619
2161
|
const type = request.headers.get('content-type')?.split(';')[0]?.trim();
|
|
1620
2162
|
if (type !== 'application/x-www-form-urlencoded')
|
|
1621
2163
|
return null;
|
|
1622
2164
|
const declared = Number(request.headers.get('content-length'));
|
|
1623
|
-
if (Number.isFinite(declared) && declared >
|
|
2165
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
1624
2166
|
return 'too_large';
|
|
1625
2167
|
if (!request.body)
|
|
1626
2168
|
return new URLSearchParams();
|
|
@@ -1632,7 +2174,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1632
2174
|
if (done)
|
|
1633
2175
|
break;
|
|
1634
2176
|
size += value.byteLength;
|
|
1635
|
-
if (size >
|
|
2177
|
+
if (size > maxBytes) {
|
|
1636
2178
|
await reader.cancel().catch(() => undefined);
|
|
1637
2179
|
return 'too_large';
|
|
1638
2180
|
}
|
|
@@ -1715,7 +2257,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1715
2257
|
const session = await readSession(request);
|
|
1716
2258
|
if (!session)
|
|
1717
2259
|
return redirect(303, root);
|
|
1718
|
-
const form = await readForm(request);
|
|
2260
|
+
const form = await readForm(request, route === '/assistant' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
|
|
1719
2261
|
if (form === 'too_large') {
|
|
1720
2262
|
return respond(413, layout('Refused', html `<p class="notice">That form is too large.</p>`, session));
|
|
1721
2263
|
}
|
|
@@ -1784,6 +2326,20 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1784
2326
|
return changeWebhook(session, form, 'enable');
|
|
1785
2327
|
case '/waitlist/leave':
|
|
1786
2328
|
return leaveWaitlist(session, form);
|
|
2329
|
+
case '/assistant':
|
|
2330
|
+
return sendToAssistant(session, form);
|
|
2331
|
+
case '/assistant/approve':
|
|
2332
|
+
return settleAction(session, form, 'approve');
|
|
2333
|
+
case '/assistant/dismiss':
|
|
2334
|
+
return settleAction(session, form, 'dismiss');
|
|
2335
|
+
case '/assistant/reset':
|
|
2336
|
+
return resetAssistant(session);
|
|
2337
|
+
case '/settings/assistant':
|
|
2338
|
+
return saveAssistantSettings(session, form);
|
|
2339
|
+
case '/settings/assistant/test':
|
|
2340
|
+
return testAssistant(session);
|
|
2341
|
+
case '/settings/assistant/remove':
|
|
2342
|
+
return removeAssistantSettings(session);
|
|
1787
2343
|
default:
|
|
1788
2344
|
return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
1789
2345
|
}
|
|
@@ -1807,6 +2363,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1807
2363
|
case '/resources':
|
|
1808
2364
|
case '/webhooks':
|
|
1809
2365
|
case '/waitlist':
|
|
2366
|
+
case '/assistant':
|
|
1810
2367
|
case '/settings': {
|
|
1811
2368
|
const session = await readSession(request);
|
|
1812
2369
|
if (!session)
|
|
@@ -1819,7 +2376,9 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1819
2376
|
return await webhooksPage(session);
|
|
1820
2377
|
if (route === '/waitlist')
|
|
1821
2378
|
return await waitlistPage(session);
|
|
1822
|
-
|
|
2379
|
+
if (route === '/assistant')
|
|
2380
|
+
return await assistantPage(session);
|
|
2381
|
+
return await settingsPage(session);
|
|
1823
2382
|
}
|
|
1824
2383
|
case '/style.css':
|
|
1825
2384
|
return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
|
|
@@ -2039,6 +2598,23 @@ td[data-label] > .pill { justify-self: start; }
|
|
|
2039
2598
|
td.wrap, .wrap { white-space: normal; overflow-wrap: anywhere; }
|
|
2040
2599
|
form.inline { display: inline; margin: 0; }
|
|
2041
2600
|
form > label { margin-bottom: 4px; }
|
|
2601
|
+
.chat { display: grid; gap: 16px; }
|
|
2602
|
+
.thread { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
|
2603
|
+
.msg { max-width: 78%; padding: 10px 14px; border-radius: 12px; background: var(--wash); border: 1px solid var(--line); }
|
|
2604
|
+
.msg p { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
2605
|
+
.msg.mine { justify-self: end; background: var(--accent-wash); border-color: transparent; }
|
|
2606
|
+
.msg.bot { justify-self: start; }
|
|
2607
|
+
.tool-line { font-size: 12.5px; color: var(--ink-3); padding-left: 4px; }
|
|
2608
|
+
.action-card { justify-self: start; width: min(100%, 520px); padding: 14px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); box-shadow: var(--shadow); display: grid; gap: 10px; }
|
|
2609
|
+
.action-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
2610
|
+
.action-card .facts { font-size: 13px; }
|
|
2611
|
+
.action-card .facts dd { overflow-wrap: anywhere; }
|
|
2612
|
+
.action-card pre.wrap { white-space: pre-wrap; margin: 0; padding: 8px; font-size: 12px; }
|
|
2613
|
+
.action-summary { margin: 0; color: var(--ink); font-weight: 500; overflow-wrap: anywhere; }
|
|
2614
|
+
.action-buttons, .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
2615
|
+
.composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; margin: 0; }
|
|
2616
|
+
textarea { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); color: var(--ink); resize: vertical; min-height: 64px; min-width: 0; }
|
|
2617
|
+
textarea:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-wash); }
|
|
2042
2618
|
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
2043
2619
|
@media (max-width: 1000px) {
|
|
2044
2620
|
.split { grid-template-columns: minmax(0, 1fr); }
|
|
@@ -2066,6 +2642,8 @@ form > label { margin-bottom: 4px; }
|
|
|
2066
2642
|
.actions { text-align: left; }
|
|
2067
2643
|
.act { margin: 4px 12px 0 0; }
|
|
2068
2644
|
.pop { left: 0; right: auto; }
|
|
2645
|
+
.msg { max-width: 92%; }
|
|
2646
|
+
.composer { grid-template-columns: minmax(0, 1fr); }
|
|
2069
2647
|
}
|
|
2070
2648
|
`;
|
|
2071
2649
|
const ASSET_VERSIONS = Object.freeze({
|