slotlock 0.5.1 → 0.5.3
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 +122 -0
- package/README.md +152 -19
- package/SPEC.md +10 -7
- package/agent-server.d.ts +52 -4
- package/agent-server.js +479 -145
- package/agent-store-backend.d.ts +9 -0
- package/agent-store-backend.js +83 -15
- package/api-keys.d.ts +12 -2
- package/api-keys.js +36 -7
- package/application-api.d.ts +7 -1
- package/application-api.js +67 -7
- package/assistant.d.ts +263 -0
- package/assistant.js +772 -0
- package/booking-rules.d.ts +61 -9
- package/booking-rules.js +106 -16
- package/change-watch.d.ts +56 -0
- package/change-watch.js +213 -0
- package/config.d.ts +10 -0
- package/config.js +11 -0
- package/dashboard.d.ts +64 -1
- package/dashboard.js +687 -26
- package/ddl.d.ts +5 -5
- package/ddl.js +594 -6
- package/event-feed.d.ts +37 -4
- package/event-feed.js +74 -6
- package/index.d.ts +11 -8
- package/index.js +7 -5
- package/maintenance.d.ts +2 -1
- package/maintenance.js +33 -9
- package/openapi.json +1284 -82
- 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 +42 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +132 -3
- package/store.js +482 -124
- package/types.d.ts +14 -1
- package/webhooks.d.ts +33 -2
- package/webhooks.js +102 -20
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}$/;
|
|
@@ -88,7 +102,8 @@ const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-sr
|
|
|
88
102
|
* in the database (a READ COMMITTED transaction, under a per-tenant lock).
|
|
89
103
|
*/
|
|
90
104
|
export function createSlotlockDashboardResources(store) {
|
|
91
|
-
|
|
105
|
+
// Every change here is the dashboard's, made by the person signed in (actor) when known.
|
|
106
|
+
const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
|
|
92
107
|
return {
|
|
93
108
|
list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listResources({ tenantRef, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES + 1 })),
|
|
94
109
|
add: (tenantRef, externalRef, timezone, actor) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
|
|
@@ -106,6 +121,7 @@ export function createSlotlockDashboardResources(store) {
|
|
|
106
121
|
agenda: (tenantRef, window, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAgenda({ tenantRef, window, limit })),
|
|
107
122
|
recentChanges: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listRecentChanges({ tenantRef, limit })),
|
|
108
123
|
setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
|
|
124
|
+
ruleDefaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getBookingRuleDefaults({ tenantRef })),
|
|
109
125
|
setPool: (tenantRef, id, pool, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourcePool({ tenantRef, id, pool }), by(actor)),
|
|
110
126
|
waitlist: {
|
|
111
127
|
list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listWaitlist({ tenantRef, limit })),
|
|
@@ -114,7 +130,9 @@ export function createSlotlockDashboardResources(store) {
|
|
|
114
130
|
// An offered entry's hold went with it: offer that time to the next, as the API does.
|
|
115
131
|
if (left) {
|
|
116
132
|
await store
|
|
117
|
-
.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 })
|
|
133
|
+
.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), {
|
|
134
|
+
origin: 'maintenance',
|
|
135
|
+
})
|
|
118
136
|
.catch(() => undefined);
|
|
119
137
|
}
|
|
120
138
|
return left;
|
|
@@ -130,6 +148,7 @@ export function createSlotlockDashboardResources(store) {
|
|
|
130
148
|
remove: async (tenantRef, id, actor) => (await store.withTenant(tenantRef, (tenant) => tenant.deleteWebhookEndpoint({ tenantRef, id }), by(actor))).deleted,
|
|
131
149
|
enable: (tenantRef, id, actor) => store.withTenant(tenantRef, (tenant) => tenant.enableWebhookEndpoint({ tenantRef, id }), by(actor)),
|
|
132
150
|
},
|
|
151
|
+
assistant: createSlotlockAssistantStore(store),
|
|
133
152
|
};
|
|
134
153
|
}
|
|
135
154
|
function assertExpiry(expiresAt) {
|
|
@@ -296,6 +315,8 @@ const RULE_NAMES = Object.freeze({
|
|
|
296
315
|
minNoticeMinutes: 'Minimum notice',
|
|
297
316
|
maxAdvanceDays: 'Book up to',
|
|
298
317
|
slotStepMinutes: 'Start step',
|
|
318
|
+
stepOffsetMinutes: 'Step offset',
|
|
319
|
+
bufferBeforeMinutes: 'Buffer before',
|
|
299
320
|
turnaroundMinutes: 'Turnaround',
|
|
300
321
|
checkInMinutes: 'Check-in release',
|
|
301
322
|
});
|
|
@@ -304,17 +325,59 @@ const RULE_FIELDS = Object.freeze({
|
|
|
304
325
|
minNoticeMinutes: 'min_notice_minutes',
|
|
305
326
|
maxAdvanceDays: 'max_advance_days',
|
|
306
327
|
slotStepMinutes: 'slot_step_minutes',
|
|
328
|
+
stepOffsetMinutes: 'step_offset_minutes',
|
|
329
|
+
bufferBeforeMinutes: 'buffer_before_minutes',
|
|
307
330
|
turnaroundMinutes: 'turnaround_minutes',
|
|
308
331
|
checkInMinutes: 'check_in_minutes',
|
|
309
332
|
});
|
|
333
|
+
/** How the inherited-rules note names each rule, with its unit. */
|
|
334
|
+
const INHERITED_NAMES = Object.freeze({
|
|
335
|
+
...RULE_NAMES,
|
|
336
|
+
maxAdvanceDays: 'Book up to (days ahead)',
|
|
337
|
+
enforceHours: 'Only inside bookable hours',
|
|
338
|
+
requireCertain: 'Only in time its calendar syncs have read today',
|
|
339
|
+
});
|
|
340
|
+
/**
|
|
341
|
+
* The rules a resource takes from defaults, each with where it comes from: a rule its pool's
|
|
342
|
+
* defaults set, else one the tenant's set, where its own rules do not. Without the defaults, the
|
|
343
|
+
* rules that apply less its own, from "defaults".
|
|
344
|
+
*/
|
|
345
|
+
function inheritedRules(resource, defaults) {
|
|
346
|
+
const own = resource.bookingRules ?? {};
|
|
347
|
+
const levels = defaults
|
|
348
|
+
? [
|
|
349
|
+
['every resource', defaults.tenant],
|
|
350
|
+
[`pool ${resource.pool ?? ''}`, resource.pool === undefined ? undefined : defaults.pools[resource.pool]],
|
|
351
|
+
]
|
|
352
|
+
: [['defaults', resource.effectiveBookingRules]];
|
|
353
|
+
const inherited = new Map();
|
|
354
|
+
for (const [from, rules] of levels) {
|
|
355
|
+
for (const [name, value] of Object.entries(rules ?? {})) {
|
|
356
|
+
if (own[name] === undefined)
|
|
357
|
+
inherited.set(name, { value, from });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return inherited;
|
|
361
|
+
}
|
|
362
|
+
function inheritedNote(inherited) {
|
|
363
|
+
if (inherited.size === 0)
|
|
364
|
+
return html ``;
|
|
365
|
+
const parts = [...inherited].map(([name, { value, from }]) => typeof value === 'boolean'
|
|
366
|
+
? `${INHERITED_NAMES[name]}${value ? '' : ': off'} (${from})`
|
|
367
|
+
: `${INHERITED_NAMES[name]} ${value.toLocaleString('en')} (${from})`);
|
|
368
|
+
return html `<p class="fine">Inherited: ${parts.join('; ')}.</p>`;
|
|
369
|
+
}
|
|
310
370
|
const icon = (paths) => new Html(`<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`);
|
|
311
371
|
const ICONS = Object.freeze({
|
|
312
|
-
mark: new Html(
|
|
372
|
+
mark: new Html(
|
|
373
|
+
// The Slotlock mark: a padlock whose body holds one booked slot (the site's logo, without the wordmark).
|
|
374
|
+
'<svg class="mark" viewBox="0 0 85.32 77.12" aria-hidden="true" focusable="false"><path class="mark-ink" d="M13.13 34.46L72.2 34.46C79.45 34.46 85.32 40.33 85.32 47.58L85.32 63.99C85.32 71.24 79.45 77.12 72.2 77.12L13.13 77.12C5.88 77.12 0 71.24 0 63.99L0 47.58C0 40.33 5.88 34.46 13.13 34.46ZM22.97 39.38L22.97 19.69C22.97 8.82 31.79 0 42.66 0C53.54 0 62.35 8.82 62.35 19.69L62.35 39.38L56.61 39.38L56.61 19.69C56.61 11.99 50.36 5.74 42.66 5.74C34.96 5.74 28.71 11.99 28.71 19.69L28.71 39.38Z"></path><path class="mark-slot" d="M19.69 52.51L65.63 52.51C67.45 52.51 68.91 53.98 68.91 55.79C68.91 57.6 67.45 59.07 65.63 59.07L19.69 59.07C17.88 59.07 16.41 57.6 16.41 55.79C16.41 53.98 17.88 52.51 19.69 52.51Z"></path></svg>'),
|
|
313
375
|
overview: icon('<path d="M4 13h6V4H4zM14 20h6v-9h-6zM4 20h6v-3H4zM14 7h6V4h-6z"/>'),
|
|
314
376
|
resources: icon('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
|
|
315
377
|
keys: icon('<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3"/>'),
|
|
316
378
|
waitlist: icon('<path d="M4 6h16M4 12h10M4 18h6"/><circle cx="18" cy="16" r="3"/>'),
|
|
317
379
|
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"/>'),
|
|
380
|
+
assistant: icon('<path d="M4 5h16v11H9l-5 4z"/><path d="M8 9h8M8 12h5"/>'),
|
|
318
381
|
settings: icon('<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>'),
|
|
319
382
|
check: icon('<path d="M5 12l5 5L20 7"/>'),
|
|
320
383
|
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 +787,9 @@ export function createSlotlockDashboard(options) {
|
|
|
724
787
|
...(options.resources.webhooks
|
|
725
788
|
? [{ section: 'webhooks', label: 'Webhooks', path: '/webhooks', icon: ICONS.webhooks }]
|
|
726
789
|
: []),
|
|
790
|
+
...(assistantStore()
|
|
791
|
+
? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
|
|
792
|
+
: []),
|
|
727
793
|
{ section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
|
|
728
794
|
];
|
|
729
795
|
/** The workspace the session acts in: its organization, or the person themselves. */
|
|
@@ -855,7 +921,11 @@ ${served ? html `<td data-label="Created by">${key.createdBy === personOf(sessio
|
|
|
855
921
|
? 'A deleted key'
|
|
856
922
|
: /^github:\d+$/.test(change.actor)
|
|
857
923
|
? `GitHub user ${change.actor.slice(7)}`
|
|
858
|
-
: change.actor
|
|
924
|
+
: /^dashboard-assistant:github:\d+$/.test(change.actor)
|
|
925
|
+
? change.actor === assistantActor(session)
|
|
926
|
+
? `Assistant for @${session.login}`
|
|
927
|
+
: `Assistant for GitHub user ${change.actor.slice(27)}`
|
|
928
|
+
: change.actor.slice(0, 60)));
|
|
859
929
|
const where = change.resourceId
|
|
860
930
|
? resourceName(byId.get(change.resourceId), change.resourceId)
|
|
861
931
|
: null;
|
|
@@ -954,10 +1024,12 @@ ${resources.length === 0
|
|
|
954
1024
|
: 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
1025
|
return respond(status, layout('Resources', content, session, 'resources'));
|
|
956
1026
|
}
|
|
957
|
-
function settingsPage(session) {
|
|
1027
|
+
async function settingsPage(session, status = 200, notice) {
|
|
958
1028
|
const rules = options.availability ?? [];
|
|
959
1029
|
const current = session.orgs?.find((org) => org.id === session.org);
|
|
960
|
-
const content = html `${
|
|
1030
|
+
const content = html `${noticeOf(notice)}
|
|
1031
|
+
${pageHead('Settings')}
|
|
1032
|
+
${await assistantSettingsPanel(session)}
|
|
961
1033
|
<section class="panel">
|
|
962
1034
|
<h2>Endpoints</h2>
|
|
963
1035
|
${endpoints()}
|
|
@@ -976,7 +1048,7 @@ ${rules.length === 0
|
|
|
976
1048
|
: html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
|
|
977
1049
|
${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
|
|
978
1050
|
</section>`;
|
|
979
|
-
return respond(
|
|
1051
|
+
return respond(status, layout('Settings', content, session, 'settings'));
|
|
980
1052
|
}
|
|
981
1053
|
/** The tenant's resource with that id and every resource (for names), or null for anything else. */
|
|
982
1054
|
async function resourceDetailPage(session, id, status = 200, notice) {
|
|
@@ -986,17 +1058,33 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
|
|
|
986
1058
|
return notFound(session);
|
|
987
1059
|
const start = new Date(now());
|
|
988
1060
|
const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
|
|
989
|
-
const [agenda, changes, keys] = await Promise.all([
|
|
1061
|
+
const [agenda, changes, keys, defaults] = await Promise.all([
|
|
990
1062
|
options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
|
|
991
1063
|
options.resources.recentChanges?.(tenantRef, 100),
|
|
992
1064
|
options.keys.list({ tenantRef }),
|
|
1065
|
+
options.resources.ruleDefaults?.(tenantRef),
|
|
993
1066
|
]);
|
|
994
1067
|
const byId = new Map([[resource.id, resource]]);
|
|
995
1068
|
const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
|
|
996
1069
|
const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
|
|
997
1070
|
const token = csrfToken(session);
|
|
998
1071
|
const rules = resource.bookingRules ?? {};
|
|
999
|
-
const
|
|
1072
|
+
const inherited = inheritedRules(resource, defaults);
|
|
1073
|
+
const field = (name, label, unit) => {
|
|
1074
|
+
const from = inherited.get(name);
|
|
1075
|
+
return html `<label>${label}<span class="unit-input"><input type="number" inputmode="numeric" min="${String(RULE_LIMITS[name][0])}" max="${String(RULE_LIMITS[name][1])}" name="${RULE_FIELDS[name]}" value="${rules[name] === undefined ? '' : String(rules[name])}"${from ? html ` placeholder="${String(from.value)}"` : html ``}><span>${unit}</span></span></label>`;
|
|
1076
|
+
};
|
|
1077
|
+
// On or off: a checkbox, or, once a default sets it (or the resource turned it off), a choice
|
|
1078
|
+
// of the default, on or off, so a resource can turn an inherited rule off.
|
|
1079
|
+
const toggle = (name, key, label) => {
|
|
1080
|
+
const from = inherited.get(name);
|
|
1081
|
+
if (from === undefined && rules[name] !== false) {
|
|
1082
|
+
return html `<label class="choice"><input type="checkbox" name="${key}" value="1"${rules[name] ? html ` checked` : html ``}> ${label}</label>`;
|
|
1083
|
+
}
|
|
1084
|
+
const chosen = (value) => (rules[name] === value ? html ` selected` : html ``);
|
|
1085
|
+
const fallback = from ? `As its default (${from.value ? 'on' : 'off'}, ${from.from})` : 'Off';
|
|
1086
|
+
return html `<label>${label}<select name="${key}"><option value=""${chosen(undefined)}>${fallback}</option><option value="1"${chosen(true)}>On</option><option value="0"${chosen(false)}>Off</option></select></label>`;
|
|
1087
|
+
};
|
|
1000
1088
|
const content = html `${noticeOf(notice)}
|
|
1001
1089
|
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1002
1090
|
${pageHead(resourceName(resource, resource.id))}
|
|
@@ -1014,12 +1102,15 @@ ${options.resources.setRules
|
|
|
1014
1102
|
${field('minNoticeMinutes', 'Minimum notice', 'min')}
|
|
1015
1103
|
${field('maxAdvanceDays', 'Book up to', 'days ahead')}
|
|
1016
1104
|
${field('slotStepMinutes', 'Start on a step of', 'min')}
|
|
1105
|
+
${field('stepOffsetMinutes', 'Steps start past each step by', 'min')}
|
|
1106
|
+
${field('bufferBeforeMinutes', 'Kept free before each booking', 'min')}
|
|
1017
1107
|
${field('turnaroundMinutes', 'Turnaround after each booking', 'min')}
|
|
1018
1108
|
${field('checkInMinutes', 'Release if not checked in after', 'min')}
|
|
1019
1109
|
</div>
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1110
|
+
${toggle('enforceHours', 'enforce_hours', 'Only inside bookable hours')}
|
|
1111
|
+
${toggle('requireCertain', 'require_certain', 'Only in time its calendar syncs have read today')}
|
|
1112
|
+
${inheritedNote(inherited)}
|
|
1113
|
+
<p class="fine">Leave a field empty for no rule${inherited.size > 0 ? ' of its own' : ''}.</p>
|
|
1023
1114
|
<button class="primary" type="submit">Save rules</button>
|
|
1024
1115
|
</form>
|
|
1025
1116
|
</section>`
|
|
@@ -1061,10 +1152,17 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1061
1152
|
}
|
|
1062
1153
|
rules[name] = Number(value);
|
|
1063
1154
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1155
|
+
// '1' on, '0' off (over a default that is on), anything else not set.
|
|
1156
|
+
const choice = (key) => {
|
|
1157
|
+
const value = form.get(key);
|
|
1158
|
+
return value === '1' ? true : value === '0' ? false : undefined;
|
|
1159
|
+
};
|
|
1160
|
+
const enforceHours = choice('enforce_hours');
|
|
1161
|
+
if (enforceHours !== undefined)
|
|
1162
|
+
rules.enforceHours = enforceHours;
|
|
1163
|
+
const requireCertain = choice('require_certain');
|
|
1164
|
+
if (requireCertain !== undefined)
|
|
1165
|
+
rules.requireCertain = requireCertain;
|
|
1068
1166
|
try {
|
|
1069
1167
|
await options.resources.setRules(tenantOf(session), id, Object.keys(rules).length === 0 ? null : rules, personOf(session));
|
|
1070
1168
|
}
|
|
@@ -1244,6 +1342,519 @@ ${entries.length === 0
|
|
|
1244
1342
|
}
|
|
1245
1343
|
return redirect(303, `${root}/waitlist`);
|
|
1246
1344
|
}
|
|
1345
|
+
// ---- The assistant ----
|
|
1346
|
+
const assistantOptions = options.assistant;
|
|
1347
|
+
/** The assistant's records, when the assistant is on and the adapter keeps them. Per request (Workers). */
|
|
1348
|
+
const assistantStore = () => assistantOptions ? options.resources.assistant : undefined;
|
|
1349
|
+
const privateUrls = assistantOptions?.privateUrls === true;
|
|
1350
|
+
const assistantFetch = assistantOptions?.fetch ?? createSlotlockGuardedFetch({ allowPrivateNetworks: privateUrls });
|
|
1351
|
+
const resolveHost = assistantOptions?.resolve ??
|
|
1352
|
+
(async (hostname) => (await dnsLookup(hostname, { all: true })).map(({ address }) => address));
|
|
1353
|
+
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.';
|
|
1354
|
+
const assistantTimeout = assistantOptions?.timeoutMs ?? 30_000;
|
|
1355
|
+
const assistantTurnTimeout = assistantOptions?.turnTimeoutMs ?? 90_000;
|
|
1356
|
+
const conversationTtl = assistantOptions?.conversationTtlMs ?? 2 * 3_600_000;
|
|
1357
|
+
if (assistantOptions && Buffer.byteLength(assistantOptions.secret, 'utf8') < 32) {
|
|
1358
|
+
throw new Error('Slotlock dashboard assistant secret must be at least 32 bytes');
|
|
1359
|
+
}
|
|
1360
|
+
if (assistantOptions && assistantOptions.secret === options.sessionSecret) {
|
|
1361
|
+
throw new Error('Slotlock dashboard assistant secret must differ from the session secret');
|
|
1362
|
+
}
|
|
1363
|
+
const memberMessages = createSlotlockRateLimiter(assistantOptions?.rate ?? { perSecond: 10 / 60, burst: 10 }, now);
|
|
1364
|
+
const toolTitles = new Map(slotlockAgentTools().map((tool) => [tool.name, tool.title]));
|
|
1365
|
+
/** The writes that take an idempotency key: the dashboard sets it, from the action's id. */
|
|
1366
|
+
const keyed = new Set(slotlockAgentTools()
|
|
1367
|
+
.filter((tool) => 'idempotency_key' in (tool.inputSchema.properties ?? {}))
|
|
1368
|
+
.map((tool) => tool.name));
|
|
1369
|
+
const ACTION_ID = /^[A-Za-z0-9_-]{16}$/;
|
|
1370
|
+
/** Reads one member message may make, and the resource-days they may cover together. */
|
|
1371
|
+
const MAX_READS_PER_TURN = 16;
|
|
1372
|
+
const MAX_READ_COST_PER_TURN = 400;
|
|
1373
|
+
/** What a read costs the turn: the resources it names times the days its window spans. */
|
|
1374
|
+
const readCostOf = (input) => {
|
|
1375
|
+
if (!isRecord(input))
|
|
1376
|
+
return 1;
|
|
1377
|
+
const ids = Array.isArray(input.resource_ids) ? input.resource_ids.length : 1;
|
|
1378
|
+
const start = typeof input.start === 'string' ? Date.parse(input.start) : Number.NaN;
|
|
1379
|
+
const end = typeof input.end === 'string' ? Date.parse(input.end) : Number.NaN;
|
|
1380
|
+
const days = Number.isFinite(start) && Number.isFinite(end) && end > start ? Math.ceil((end - start) / DAY_MS) : 1;
|
|
1381
|
+
return Math.max(1, ids) * Math.max(1, days);
|
|
1382
|
+
};
|
|
1383
|
+
/**
|
|
1384
|
+
* Who the assistant acts as, for the record and for ownership: the member by GitHub user id,
|
|
1385
|
+
* which a rename does not change. Shown as "Assistant for @login".
|
|
1386
|
+
*/
|
|
1387
|
+
const assistantActor = (session) => `dashboard-assistant:github:${session.uid}`;
|
|
1388
|
+
const CONFIRMED_WRITES = new Set([
|
|
1389
|
+
'slotlock_create_event',
|
|
1390
|
+
'slotlock_update_event',
|
|
1391
|
+
'slotlock_delete_event',
|
|
1392
|
+
'slotlock_book_together',
|
|
1393
|
+
'slotlock_book_pool',
|
|
1394
|
+
]);
|
|
1395
|
+
/**
|
|
1396
|
+
* The sentence a proposed change's card leads with: the agent server's confirmation question
|
|
1397
|
+
* (the resource by name, the times in its zone, an event as it stands: its title, resource and
|
|
1398
|
+
* time), or for holds the slots by resource. Read as the member's assistant, under `authorize`.
|
|
1399
|
+
*/
|
|
1400
|
+
async function summarize(session, operation, input) {
|
|
1401
|
+
const assistant = assistantOptions;
|
|
1402
|
+
let text;
|
|
1403
|
+
if (CONFIRMED_WRITES.has(operation)) {
|
|
1404
|
+
text = await describeSlotlockAgentWrite({
|
|
1405
|
+
backend: assistant.backend,
|
|
1406
|
+
authorize: assistantAuthorize,
|
|
1407
|
+
principal: assistantPrincipal(session),
|
|
1408
|
+
operation: operation,
|
|
1409
|
+
input,
|
|
1410
|
+
signal: AbortSignal.timeout(assistantTimeout),
|
|
1411
|
+
}).catch(() => '');
|
|
1412
|
+
}
|
|
1413
|
+
else if (operation === 'slotlock_hold_slots' && Array.isArray(input.slots)) {
|
|
1414
|
+
const names = new Map((await options.resources.list(tenantOf(session))).map((resource) => [resource.id, resourceName(resource, resource.id)]));
|
|
1415
|
+
const slots = input.slots.map((slot) => `${names.get(String(slot.resource_id)) ?? `resource ${String(slot.resource_id)}`} ${String(slot.start)} to ${String(slot.end)}`);
|
|
1416
|
+
text = `Hold ${slots.join('; ')} for ${String(input.ttl_seconds ?? '')} seconds, as alternatives.`;
|
|
1417
|
+
}
|
|
1418
|
+
else if (operation === 'slotlock_release_slots') {
|
|
1419
|
+
text = `Release the hold ${String(input.hold_id)} and every slot held with it.`;
|
|
1420
|
+
}
|
|
1421
|
+
else {
|
|
1422
|
+
text = '';
|
|
1423
|
+
}
|
|
1424
|
+
// At most 1,500 UTF-8 bytes, so four waiting changes stay within the conversation's bound.
|
|
1425
|
+
return text.length > 500 ? `${text.slice(0, 500)}…` : text;
|
|
1426
|
+
}
|
|
1427
|
+
/** Owners of the organization in use may change its assistant; a person always may their own. */
|
|
1428
|
+
const isAdmin = (session) => !served || session.orgs?.find((org) => org.id === session.org)?.role === 'admin';
|
|
1429
|
+
const assistantPrincipal = (session) => ({
|
|
1430
|
+
subject: assistantActor(session),
|
|
1431
|
+
tenantRef: tenantOf(session),
|
|
1432
|
+
scopes: ['read', 'write'],
|
|
1433
|
+
});
|
|
1434
|
+
/** The fixed list, then the embedder's check: nothing the model sends adds to it. */
|
|
1435
|
+
const assistantAuthorize = async (args) => SLOTLOCK_ASSISTANT_TOOL_USE[args.operation] !== 'excluded' &&
|
|
1436
|
+
(assistantOptions?.authorize ? await assistantOptions.authorize(args) : true);
|
|
1437
|
+
/**
|
|
1438
|
+
* One agent operation as the member's assistant. A `propose` call is checked as an agent's is
|
|
1439
|
+
* (arguments, scopes, authorize) and halted by `confirm` before it runs; it takes no admission
|
|
1440
|
+
* slot and no rate token, which the write takes when it runs, approved (`run`).
|
|
1441
|
+
*/
|
|
1442
|
+
function dispatch(session, operation, input, mode, confirm) {
|
|
1443
|
+
const assistant = assistantOptions;
|
|
1444
|
+
const principal = assistantPrincipal(session);
|
|
1445
|
+
return invokeSlotlockAgentOperation({
|
|
1446
|
+
operation,
|
|
1447
|
+
origin: 'dashboard-assistant',
|
|
1448
|
+
input,
|
|
1449
|
+
request: new Request(`${origin}${root}/assistant`, {
|
|
1450
|
+
signal: AbortSignal.timeout(assistantTimeout),
|
|
1451
|
+
}),
|
|
1452
|
+
options: {
|
|
1453
|
+
backend: assistant.backend,
|
|
1454
|
+
authenticate: async () => principal,
|
|
1455
|
+
authorize: async (args) => {
|
|
1456
|
+
if (!(await assistantAuthorize(args)))
|
|
1457
|
+
return false;
|
|
1458
|
+
if (mode === 'run')
|
|
1459
|
+
await assistant.admit?.(args);
|
|
1460
|
+
return true;
|
|
1461
|
+
},
|
|
1462
|
+
...(assistant.consumeRateLimit && mode === 'run'
|
|
1463
|
+
? { consumeRateLimit: assistant.consumeRateLimit }
|
|
1464
|
+
: {}),
|
|
1465
|
+
...(confirm ? { confirm } : {}),
|
|
1466
|
+
onError: (error) => {
|
|
1467
|
+
try {
|
|
1468
|
+
ignoreRejection(options.onError?.(error));
|
|
1469
|
+
}
|
|
1470
|
+
catch {
|
|
1471
|
+
// A failing reporter must not change the answer.
|
|
1472
|
+
}
|
|
1473
|
+
},
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
async function assistantModel(session) {
|
|
1478
|
+
const store = assistantStore();
|
|
1479
|
+
const settings = store ? await store.getSettings(tenantOf(session)) : null;
|
|
1480
|
+
if (!settings || !assistantOptions)
|
|
1481
|
+
return null;
|
|
1482
|
+
if (!slotlockAssistantUrlAllowed(settings.baseUrl, privateUrls) ||
|
|
1483
|
+
!(await checkSlotlockAssistantHost(settings.baseUrl, { privateUrls, resolve: resolveHost }))) {
|
|
1484
|
+
return { problem: PRIVATE_URL_REFUSAL };
|
|
1485
|
+
}
|
|
1486
|
+
let apiKey = null;
|
|
1487
|
+
if (settings.sealedKey !== null) {
|
|
1488
|
+
apiKey = await openSlotlockAssistantKey(assistantOptions.secret, tenantOf(session), settings.sealedKey);
|
|
1489
|
+
if (apiKey === null) {
|
|
1490
|
+
return {
|
|
1491
|
+
problem: 'The stored API key cannot be opened: the server’s assistant secret changed. An admin can enter the key again in Settings.',
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return {
|
|
1496
|
+
model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the
|
|
1501
|
+
* proposal is bounded instead). A resource is named, with its id beside the name.
|
|
1502
|
+
*/
|
|
1503
|
+
function fieldValue(name, value, names) {
|
|
1504
|
+
const named = (id) => (names.has(id) ? `${names.get(id)} (${id})` : id);
|
|
1505
|
+
if (typeof value === 'string') {
|
|
1506
|
+
return html `<span class="wrap">${name === 'resource_id' ? named(value) : value}</span>`;
|
|
1507
|
+
}
|
|
1508
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
1509
|
+
return html `<span class="wrap">${value.map((item) => (name === 'resource_ids' ? named(item) : item)).join(', ')}</span>`;
|
|
1510
|
+
}
|
|
1511
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
|
|
1512
|
+
return html `${String(value)}`;
|
|
1513
|
+
}
|
|
1514
|
+
return html `<pre class="wrap">${JSON.stringify(value, null, 2)}</pre>`;
|
|
1515
|
+
}
|
|
1516
|
+
function actionCard(session, action, names) {
|
|
1517
|
+
const token = csrfToken(session);
|
|
1518
|
+
const fields = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
|
|
1519
|
+
const summary = action.summary ? html `<p class="action-summary">${action.summary}</p>` : html ``;
|
|
1520
|
+
const state = {
|
|
1521
|
+
pending: { text: 'Waiting for you', tone: 'held' },
|
|
1522
|
+
approved: { text: 'Running', tone: 'held' },
|
|
1523
|
+
dismissed: { text: 'Dismissed', tone: 'revoked' },
|
|
1524
|
+
done: { text: 'Done', tone: 'active' },
|
|
1525
|
+
failed: { text: 'Failed', tone: 'revoked' },
|
|
1526
|
+
}[action.status] ?? { text: action.status, tone: 'revoked' };
|
|
1527
|
+
const buttons = action.status === 'pending'
|
|
1528
|
+
? 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>`
|
|
1529
|
+
: html ``;
|
|
1530
|
+
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>`;
|
|
1531
|
+
}
|
|
1532
|
+
function readAction(id, value) {
|
|
1533
|
+
if (!isRecord(value) || typeof value.operation !== 'string')
|
|
1534
|
+
return null;
|
|
1535
|
+
return {
|
|
1536
|
+
id,
|
|
1537
|
+
operation: value.operation,
|
|
1538
|
+
input: isRecord(value.input) ? value.input : {},
|
|
1539
|
+
...(typeof value.summary === 'string' ? { summary: value.summary } : {}),
|
|
1540
|
+
status: (typeof value.status === 'string' ? value.status : 'failed'),
|
|
1541
|
+
...(typeof value.outcome === 'string' ? { outcome: value.outcome } : {}),
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
async function assistantPage(session, status = 200, notice) {
|
|
1545
|
+
const store = assistantStore();
|
|
1546
|
+
if (!store)
|
|
1547
|
+
return notFound(session);
|
|
1548
|
+
const tenantRef = tenantOf(session);
|
|
1549
|
+
const [settings, conversation] = await Promise.all([
|
|
1550
|
+
store.getSettings(tenantRef),
|
|
1551
|
+
store.getConversation(tenantRef, personOf(session)),
|
|
1552
|
+
]);
|
|
1553
|
+
const entries = readSlotlockAssistantEntries(conversation?.messages);
|
|
1554
|
+
const actions = conversation?.actions ?? {};
|
|
1555
|
+
const names = new Map();
|
|
1556
|
+
if (Object.keys(actions).length > 0) {
|
|
1557
|
+
for (const resource of await options.resources.list(tenantRef)) {
|
|
1558
|
+
names.set(resource.id, resourceName(resource, resource.id));
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
const token = csrfToken(session);
|
|
1562
|
+
const items = [];
|
|
1563
|
+
for (const entry of entries) {
|
|
1564
|
+
if (entry.role === 'user')
|
|
1565
|
+
items.push(html `<li class="msg mine"><p>${entry.text}</p></li>`);
|
|
1566
|
+
else if (entry.role === 'assistant' && entry.text.trim() !== '') {
|
|
1567
|
+
items.push(html `<li class="msg bot"><p>${entry.text}</p></li>`);
|
|
1568
|
+
}
|
|
1569
|
+
else if (entry.role === 'note')
|
|
1570
|
+
items.push(html `<li class="notice">${entry.text}</li>`);
|
|
1571
|
+
else if (entry.role === 'tool') {
|
|
1572
|
+
for (const result of entry.results) {
|
|
1573
|
+
const action = result.action ? readAction(result.action, actions[result.action]) : null;
|
|
1574
|
+
if (action)
|
|
1575
|
+
items.push(actionCard(session, action, names));
|
|
1576
|
+
else {
|
|
1577
|
+
items.push(html `<li class="tool-line">${result.error ? 'Could not run' : 'Read'}: ${toolTitles.get(result.name) ?? result.name}</li>`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
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>`;
|
|
1583
|
+
const reset = entries.length > 0
|
|
1584
|
+
? 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>`
|
|
1585
|
+
: undefined;
|
|
1586
|
+
const content = html `${noticeOf(notice)}
|
|
1587
|
+
${pageHead('Assistant', reset)}
|
|
1588
|
+
${settings
|
|
1589
|
+
? html `<section class="panel chat">${items.length === 0
|
|
1590
|
+
? html `<p class="empty">Ask about bookings and free time. Changes wait for your approval.</p>`
|
|
1591
|
+
: html `<ol class="thread">${items}</ol>`}<span id="latest"></span>${composer}</section>`
|
|
1592
|
+
: html `<p class="empty">${isAdmin(session)
|
|
1593
|
+
? html `No model is connected. <a href="${root}/settings#assistant">Connect one in Settings</a>.`
|
|
1594
|
+
: 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
|
|
1595
|
+
return respond(status, layout('Assistant', content, session, 'assistant'));
|
|
1596
|
+
}
|
|
1597
|
+
async function assistantSettingsPanel(session) {
|
|
1598
|
+
const store = assistantStore();
|
|
1599
|
+
if (!store)
|
|
1600
|
+
return html ``;
|
|
1601
|
+
const settings = await store.getSettings(tenantOf(session));
|
|
1602
|
+
const sentTo = html `<p class="fine">Members' messages, and the bookings and resources the assistant reads for them, are sent to this provider.</p>`;
|
|
1603
|
+
if (!isAdmin(session)) {
|
|
1604
|
+
return html `<section class="panel" id="assistant"><h2>Assistant</h2>${settings
|
|
1605
|
+
? html `<dl class="facts"><dt>Provider</dt><dd>${settings.provider === 'anthropic' ? 'Anthropic' : 'OpenAI-compatible'}</dd><dt>Model</dt><dd>${settings.model}</dd></dl>${sentTo}`
|
|
1606
|
+
: html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
|
|
1607
|
+
}
|
|
1608
|
+
const token = csrfToken(session);
|
|
1609
|
+
const selected = (provider) => (settings?.provider === provider ? html ` selected` : html ``);
|
|
1610
|
+
return html `<section class="panel" id="assistant">
|
|
1611
|
+
<h2>Assistant</h2>
|
|
1612
|
+
<form method="post" action="${root}/settings/assistant">
|
|
1613
|
+
<input type="hidden" name="csrf" value="${token}">
|
|
1614
|
+
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
1615
|
+
<div class="grid-form">
|
|
1616
|
+
<label>Provider<select name="provider"><option value="anthropic"${selected('anthropic')}>Anthropic Messages API</option><option value="openai"${selected('openai')}>OpenAI-compatible</option></select></label>
|
|
1617
|
+
<label>Base URL<input name="base_url" required maxlength="2048" value="${settings?.baseUrl ?? ''}" placeholder="https://api.anthropic.com"></label>
|
|
1618
|
+
<label>Model<input name="model" required maxlength="200" value="${settings?.model ?? ''}" placeholder="your-model"></label>
|
|
1619
|
+
<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>
|
|
1620
|
+
</div>
|
|
1621
|
+
<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>
|
|
1622
|
+
${settings?.keyHint ? html `<label class="choice"><input type="checkbox" name="clear_key" value="1"> Remove the key</label>` : html ``}
|
|
1623
|
+
<button class="primary" type="submit">Save</button>
|
|
1624
|
+
</form>
|
|
1625
|
+
${sentTo}
|
|
1626
|
+
${settings
|
|
1627
|
+
? 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>`
|
|
1628
|
+
: html ``}
|
|
1629
|
+
</section>`;
|
|
1630
|
+
}
|
|
1631
|
+
const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this organization can change the assistant.');
|
|
1632
|
+
async function saveAssistantSettings(session, form) {
|
|
1633
|
+
const store = assistantStore();
|
|
1634
|
+
if (!store || !assistantOptions)
|
|
1635
|
+
return notFound(session);
|
|
1636
|
+
if (!isAdmin(session))
|
|
1637
|
+
return adminOnly(session);
|
|
1638
|
+
const provider = form.get('provider');
|
|
1639
|
+
const checkedUrl = readSlotlockAssistantBaseUrl(form.get('base_url') ?? '', { privateUrls });
|
|
1640
|
+
const model = (form.get('model') ?? '').trim();
|
|
1641
|
+
const apiKey = (form.get('api_key') ?? '').trim();
|
|
1642
|
+
if (provider !== 'anthropic' && provider !== 'openai') {
|
|
1643
|
+
return settingsPage(session, 400, 'Choose a provider from the list.');
|
|
1644
|
+
}
|
|
1645
|
+
if (!checkedUrl.ok) {
|
|
1646
|
+
return settingsPage(session, 400, checkedUrl.reason === 'private'
|
|
1647
|
+
? PRIVATE_URL_REFUSAL
|
|
1648
|
+
: 'Give an https base URL (http only for localhost or 127.0.0.1), with no user name, query or #fragment.');
|
|
1649
|
+
}
|
|
1650
|
+
const baseUrl = checkedUrl.url;
|
|
1651
|
+
if (!(await checkSlotlockAssistantHost(baseUrl, { privateUrls, resolve: resolveHost }))) {
|
|
1652
|
+
return settingsPage(session, 400, PRIVATE_URL_REFUSAL);
|
|
1653
|
+
}
|
|
1654
|
+
if (!SLOTLOCK_ASSISTANT_MODEL_NAME.test(model)) {
|
|
1655
|
+
return settingsPage(session, 400, 'Give the model name, 1 to 200 characters without spaces.');
|
|
1656
|
+
}
|
|
1657
|
+
if (apiKey !== '' && !SLOTLOCK_ASSISTANT_API_KEY.test(apiKey)) {
|
|
1658
|
+
return settingsPage(session, 400, 'An API key is up to 4,096 characters without spaces.');
|
|
1659
|
+
}
|
|
1660
|
+
const tenantRef = tenantOf(session);
|
|
1661
|
+
const key = apiKey !== ''
|
|
1662
|
+
? { sealed: await sealSlotlockAssistantKey(assistantOptions.secret, tenantRef, apiKey), hint: apiKey.slice(-4) }
|
|
1663
|
+
: form.get('clear_key') === '1'
|
|
1664
|
+
? null
|
|
1665
|
+
: 'keep';
|
|
1666
|
+
// The stored key is only kept for the provider and URL it was entered for: the store decides
|
|
1667
|
+
// that in the write itself, so an owner cannot send it elsewhere, however saves race.
|
|
1668
|
+
const { saved } = await store.putSettings(tenantRef, { provider, baseUrl, model, key }, personOf(session));
|
|
1669
|
+
if (!saved) {
|
|
1670
|
+
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.');
|
|
1671
|
+
}
|
|
1672
|
+
return redirect(303, `${root}/settings#assistant`);
|
|
1673
|
+
}
|
|
1674
|
+
async function removeAssistantSettings(session) {
|
|
1675
|
+
const store = assistantStore();
|
|
1676
|
+
if (!store)
|
|
1677
|
+
return notFound(session);
|
|
1678
|
+
if (!isAdmin(session))
|
|
1679
|
+
return adminOnly(session);
|
|
1680
|
+
await store.deleteSettings(tenantOf(session), personOf(session));
|
|
1681
|
+
return redirect(303, `${root}/settings#assistant`);
|
|
1682
|
+
}
|
|
1683
|
+
const tooManyMessages = (session, page) => page(session, 429, 'You have sent the assistant a lot in the last minute. Wait a moment and try again.');
|
|
1684
|
+
async function testAssistant(session) {
|
|
1685
|
+
if (!assistantStore())
|
|
1686
|
+
return notFound(session);
|
|
1687
|
+
if (!isAdmin(session))
|
|
1688
|
+
return adminOnly(session);
|
|
1689
|
+
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
1690
|
+
return tooManyMessages(session, settingsPage);
|
|
1691
|
+
}
|
|
1692
|
+
const found = await assistantModel(session);
|
|
1693
|
+
if (found === null)
|
|
1694
|
+
return settingsPage(session, 409, 'Connect a model first.');
|
|
1695
|
+
if ('problem' in found)
|
|
1696
|
+
return settingsPage(session, 409, found.problem);
|
|
1697
|
+
try {
|
|
1698
|
+
const ok = await testSlotlockAssistantModel({
|
|
1699
|
+
model: found.model,
|
|
1700
|
+
fetch: assistantFetch,
|
|
1701
|
+
timeoutMs: assistantTimeout,
|
|
1702
|
+
});
|
|
1703
|
+
return settingsPage(session, ok ? 200 : 502, ok
|
|
1704
|
+
? `Connected: ${found.model.model} answered and called a tool correctly.`
|
|
1705
|
+
: `${found.model.model} answered, but did not call the tool it was given. Choose a model that supports tool calls.`);
|
|
1706
|
+
}
|
|
1707
|
+
catch (error) {
|
|
1708
|
+
report(error instanceof Error ? error.message : 'assistant connection test failed');
|
|
1709
|
+
return settingsPage(session, 502, slotlockAssistantProviderMessage(error));
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
async function sendToAssistant(session, form) {
|
|
1713
|
+
const store = assistantStore();
|
|
1714
|
+
if (!store || !assistantOptions)
|
|
1715
|
+
return notFound(session);
|
|
1716
|
+
const text = (form.get('message') ?? '').trim();
|
|
1717
|
+
if (text === '' || text.length > SLOTLOCK_ASSISTANT_MAX_MESSAGE) {
|
|
1718
|
+
return assistantPage(session, 400, `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`);
|
|
1719
|
+
}
|
|
1720
|
+
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
1721
|
+
return tooManyMessages(session, assistantPage);
|
|
1722
|
+
}
|
|
1723
|
+
const found = await assistantModel(session);
|
|
1724
|
+
if (found === null)
|
|
1725
|
+
return assistantPage(session, 409, 'No model is connected.');
|
|
1726
|
+
if ('problem' in found)
|
|
1727
|
+
return assistantPage(session, 409, found.problem);
|
|
1728
|
+
const tenantRef = tenantOf(session);
|
|
1729
|
+
const member = personOf(session);
|
|
1730
|
+
const stored = await store.getConversation(tenantRef, member);
|
|
1731
|
+
const entries = boundSlotlockAssistantEntries([
|
|
1732
|
+
...readSlotlockAssistantEntries(stored?.messages),
|
|
1733
|
+
{ role: 'user', text },
|
|
1734
|
+
]);
|
|
1735
|
+
const proposed = {};
|
|
1736
|
+
const pendingBefore = pendingSlotlockAssistantActions(stored?.actions ?? {});
|
|
1737
|
+
let reads = 0;
|
|
1738
|
+
let readCost = 0;
|
|
1739
|
+
const current = session.orgs?.find((org) => org.id === session.org);
|
|
1740
|
+
const turn = await runSlotlockAssistantTurn({
|
|
1741
|
+
model: found.model,
|
|
1742
|
+
system: slotlockAssistantSystemPrompt({
|
|
1743
|
+
brand: brandName,
|
|
1744
|
+
workspace: current?.login ?? session.login,
|
|
1745
|
+
login: session.login,
|
|
1746
|
+
now: new Date(now()),
|
|
1747
|
+
}),
|
|
1748
|
+
entries,
|
|
1749
|
+
fetch: assistantFetch,
|
|
1750
|
+
timeoutMs: assistantTimeout,
|
|
1751
|
+
turnTimeoutMs: assistantTurnTimeout,
|
|
1752
|
+
...(assistantOptions.maxSteps !== undefined ? { maxSteps: assistantOptions.maxSteps } : {}),
|
|
1753
|
+
read: async (operation, input) => {
|
|
1754
|
+
// A turn's reads share one budget of resource-days, so 8 steps of wide reads cannot each
|
|
1755
|
+
// scan a hundred resources over years.
|
|
1756
|
+
reads += 1;
|
|
1757
|
+
readCost += readCostOf(input);
|
|
1758
|
+
if (reads > MAX_READS_PER_TURN || readCost > MAX_READ_COST_PER_TURN) {
|
|
1759
|
+
return { ok: false, code: 'turn_read_budget_exceeded' };
|
|
1760
|
+
}
|
|
1761
|
+
const outcome = await dispatch(session, operation, input, 'run');
|
|
1762
|
+
return outcome.ok
|
|
1763
|
+
? outcome
|
|
1764
|
+
: { ok: false, code: outcome.code, ...(outcome.details ? { details: outcome.details } : {}) };
|
|
1765
|
+
},
|
|
1766
|
+
propose: async (operation, input) => {
|
|
1767
|
+
if (pendingBefore + Object.keys(proposed).length >= SLOTLOCK_ASSISTANT_MAX_PENDING) {
|
|
1768
|
+
return { ok: false, code: 'too_many_pending_changes' };
|
|
1769
|
+
}
|
|
1770
|
+
const id = randomBytes(12).toString('base64url');
|
|
1771
|
+
const prepared = isRecord(input) && keyed.has(operation)
|
|
1772
|
+
? { ...input, idempotency_key: `dashboard-assistant.${id}` }
|
|
1773
|
+
: input;
|
|
1774
|
+
let checked;
|
|
1775
|
+
// Every check an agent's call passes (arguments, scopes, authorize, rate limit), then halted
|
|
1776
|
+
// here: it runs only when the member approves it.
|
|
1777
|
+
const outcome = await dispatch(session, operation, prepared, 'propose', async ({ input: parsed }) => {
|
|
1778
|
+
checked = parsed;
|
|
1779
|
+
return { status: 409, code: 'approval_required' };
|
|
1780
|
+
});
|
|
1781
|
+
if (checked === undefined || outcome.ok || outcome.code !== 'approval_required') {
|
|
1782
|
+
return { ok: false, code: outcome.ok ? 'internal_error' : outcome.code };
|
|
1783
|
+
}
|
|
1784
|
+
// The card shows every argument: one too large to show is not proposed.
|
|
1785
|
+
if (Buffer.byteLength(JSON.stringify(checked), 'utf8') > SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES) {
|
|
1786
|
+
return { ok: false, code: 'change_too_large_to_show' };
|
|
1787
|
+
}
|
|
1788
|
+
proposed[id] = {
|
|
1789
|
+
operation,
|
|
1790
|
+
input: checked,
|
|
1791
|
+
status: 'pending',
|
|
1792
|
+
summary: await summarize(session, operation, checked),
|
|
1793
|
+
};
|
|
1794
|
+
return { ok: true, actionId: id };
|
|
1795
|
+
},
|
|
1796
|
+
});
|
|
1797
|
+
if (turn.failure !== undefined) {
|
|
1798
|
+
report(turn.failure instanceof Error ? turn.failure.message : 'assistant provider failed');
|
|
1799
|
+
}
|
|
1800
|
+
const messages = boundSlotlockAssistantEntries([...entries, ...turn.added]);
|
|
1801
|
+
// Only this turn's proposals are added; the rest are kept as the row holds them now, so one
|
|
1802
|
+
// approved (or a conversation reset) while the turn ran is never put back as it stood.
|
|
1803
|
+
const kept = pruneSlotlockAssistantActions(messages, { ...(stored?.actions ?? {}), ...proposed });
|
|
1804
|
+
await store.putConversation(tenantRef, member, {
|
|
1805
|
+
messages,
|
|
1806
|
+
added: Object.fromEntries(Object.entries(proposed).filter(([id]) => id in kept)),
|
|
1807
|
+
keep: Object.keys(kept),
|
|
1808
|
+
}, conversationTtl);
|
|
1809
|
+
return redirect(303, `${root}/assistant#latest`);
|
|
1810
|
+
}
|
|
1811
|
+
async function settleAction(session, form, decision) {
|
|
1812
|
+
const store = assistantStore();
|
|
1813
|
+
if (!store || !assistantOptions)
|
|
1814
|
+
return notFound(session);
|
|
1815
|
+
const id = form.get('action') ?? '';
|
|
1816
|
+
const tenantRef = tenantOf(session);
|
|
1817
|
+
const member = personOf(session);
|
|
1818
|
+
const claimed = ACTION_ID.test(id)
|
|
1819
|
+
? await store.settleAction(tenantRef, member, id, 'pending', decision === 'approve' ? { status: 'approved' } : { status: 'dismissed', input: {} })
|
|
1820
|
+
: null;
|
|
1821
|
+
const action = claimed ? readAction(id, claimed) : null;
|
|
1822
|
+
if (!action)
|
|
1823
|
+
return assistantPage(session, 409, 'That change was already approved or dismissed.');
|
|
1824
|
+
const title = toolTitles.get(action.operation) ?? action.operation;
|
|
1825
|
+
let event;
|
|
1826
|
+
if (decision === 'dismiss') {
|
|
1827
|
+
event = `The member dismissed the proposed change ${id} (${title}); it did not run.`;
|
|
1828
|
+
}
|
|
1829
|
+
else if (SLOTLOCK_ASSISTANT_TOOL_USE[action.operation] !== 'propose') {
|
|
1830
|
+
await store.settleAction(tenantRef, member, id, 'approved', { status: 'failed', outcome: 'tool_not_available', input: {} });
|
|
1831
|
+
event = `The proposed change ${id} (${title}) could not run: tool_not_available.`;
|
|
1832
|
+
}
|
|
1833
|
+
else {
|
|
1834
|
+
const outcome = await dispatch(session, action.operation, action.input, 'run');
|
|
1835
|
+
// Settled, the card keeps what it said, not the arguments: the actions stay small.
|
|
1836
|
+
await store.settleAction(tenantRef, member, id, 'approved', outcome.ok ? { status: 'done', input: {} } : { status: 'failed', outcome: outcome.code, input: {} });
|
|
1837
|
+
const result = JSON.stringify(outcome.ok ? outcome.data : { error: { code: outcome.code } });
|
|
1838
|
+
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}`;
|
|
1839
|
+
}
|
|
1840
|
+
const stored = await store.getConversation(tenantRef, member);
|
|
1841
|
+
if (stored) {
|
|
1842
|
+
const entries = [
|
|
1843
|
+
...readSlotlockAssistantEntries(stored.messages),
|
|
1844
|
+
{ role: 'event', text: event },
|
|
1845
|
+
];
|
|
1846
|
+
const messages = boundSlotlockAssistantEntries(entries);
|
|
1847
|
+
await store.putConversation(tenantRef, member, { messages, added: {}, keep: Object.keys(pruneSlotlockAssistantActions(messages, stored.actions)) }, conversationTtl);
|
|
1848
|
+
}
|
|
1849
|
+
return redirect(303, `${root}/assistant#action-${id}`);
|
|
1850
|
+
}
|
|
1851
|
+
async function resetAssistant(session) {
|
|
1852
|
+
const store = assistantStore();
|
|
1853
|
+
if (!store)
|
|
1854
|
+
return notFound(session);
|
|
1855
|
+
await store.deleteConversation(tenantOf(session), personOf(session));
|
|
1856
|
+
return redirect(303, `${root}/assistant`);
|
|
1857
|
+
}
|
|
1247
1858
|
/** The page a form was sent from, to show what went wrong with it. */
|
|
1248
1859
|
function pageFor(route) {
|
|
1249
1860
|
if (route.startsWith('/keys'))
|
|
@@ -1254,6 +1865,10 @@ ${entries.length === 0
|
|
|
1254
1865
|
return waitlistPage;
|
|
1255
1866
|
if (route.startsWith('/resources') || route === '/hours')
|
|
1256
1867
|
return resourcesPage;
|
|
1868
|
+
if (route.startsWith('/assistant') && assistantStore())
|
|
1869
|
+
return assistantPage;
|
|
1870
|
+
if (route.startsWith('/settings'))
|
|
1871
|
+
return settingsPage;
|
|
1257
1872
|
return overviewPage;
|
|
1258
1873
|
}
|
|
1259
1874
|
function hoursPage(session, state) {
|
|
@@ -1523,8 +2138,9 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1523
2138
|
continue;
|
|
1524
2139
|
}
|
|
1525
2140
|
const id = String(organization.id);
|
|
1526
|
-
if (served?.has(id))
|
|
1527
|
-
found.set(id, { id, login: organization.login });
|
|
2141
|
+
if (served?.has(id)) {
|
|
2142
|
+
found.set(id, { id, login: organization.login, role: membership.role === 'admin' ? 'admin' : 'member' });
|
|
2143
|
+
}
|
|
1528
2144
|
}
|
|
1529
2145
|
return [...found.values()]
|
|
1530
2146
|
.sort((a, b) => a.login.localeCompare(b.login))
|
|
@@ -1615,12 +2231,12 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1615
2231
|
]);
|
|
1616
2232
|
}
|
|
1617
2233
|
/** The form, `'too_large'` past MAX_FORM_BYTES (read no further), or null for another type. */
|
|
1618
|
-
async function readForm(request) {
|
|
2234
|
+
async function readForm(request, maxBytes = MAX_FORM_BYTES) {
|
|
1619
2235
|
const type = request.headers.get('content-type')?.split(';')[0]?.trim();
|
|
1620
2236
|
if (type !== 'application/x-www-form-urlencoded')
|
|
1621
2237
|
return null;
|
|
1622
2238
|
const declared = Number(request.headers.get('content-length'));
|
|
1623
|
-
if (Number.isFinite(declared) && declared >
|
|
2239
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
1624
2240
|
return 'too_large';
|
|
1625
2241
|
if (!request.body)
|
|
1626
2242
|
return new URLSearchParams();
|
|
@@ -1632,7 +2248,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1632
2248
|
if (done)
|
|
1633
2249
|
break;
|
|
1634
2250
|
size += value.byteLength;
|
|
1635
|
-
if (size >
|
|
2251
|
+
if (size > maxBytes) {
|
|
1636
2252
|
await reader.cancel().catch(() => undefined);
|
|
1637
2253
|
return 'too_large';
|
|
1638
2254
|
}
|
|
@@ -1653,6 +2269,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1653
2269
|
name: form.get('name') ?? '',
|
|
1654
2270
|
scopes,
|
|
1655
2271
|
createdBy: personOf(session),
|
|
2272
|
+
origin: 'dashboard',
|
|
1656
2273
|
...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
|
|
1657
2274
|
});
|
|
1658
2275
|
return shownOncePage(session, created.key, created.apiKey, false);
|
|
@@ -1668,17 +2285,23 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1668
2285
|
throw error;
|
|
1669
2286
|
}
|
|
1670
2287
|
}
|
|
2288
|
+
/** A key change in the session's tenant, recorded as the person's, from the dashboard. */
|
|
2289
|
+
const asPerson = (session) => ({
|
|
2290
|
+
tenantRef: tenantOf(session),
|
|
2291
|
+
actor: personOf(session),
|
|
2292
|
+
origin: 'dashboard',
|
|
2293
|
+
});
|
|
1671
2294
|
async function changeKey(session, form, change) {
|
|
1672
2295
|
const id = form.get('id') ?? '';
|
|
1673
2296
|
if (!UUID_PATTERN.test(id))
|
|
1674
2297
|
return keysPage(session, 404, 'That key is not one of yours.');
|
|
1675
2298
|
if (change === 'rotate') {
|
|
1676
|
-
const rotated = await options.keys.rotate({
|
|
2299
|
+
const rotated = await options.keys.rotate({ ...asPerson(session), id });
|
|
1677
2300
|
if (!rotated)
|
|
1678
2301
|
return keysPage(session, 404, 'That key is not an active key of yours.');
|
|
1679
2302
|
return shownOncePage(session, rotated.key, rotated.apiKey, true);
|
|
1680
2303
|
}
|
|
1681
|
-
const revoked = await options.keys.revoke({
|
|
2304
|
+
const revoked = await options.keys.revoke({ ...asPerson(session), id });
|
|
1682
2305
|
if (!revoked)
|
|
1683
2306
|
return keysPage(session, 404, 'That key is not one of yours.');
|
|
1684
2307
|
return redirect(303, `${root}/keys`);
|
|
@@ -1715,7 +2338,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1715
2338
|
const session = await readSession(request);
|
|
1716
2339
|
if (!session)
|
|
1717
2340
|
return redirect(303, root);
|
|
1718
|
-
const form = await readForm(request);
|
|
2341
|
+
const form = await readForm(request, route === '/assistant' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
|
|
1719
2342
|
if (form === 'too_large') {
|
|
1720
2343
|
return respond(413, layout('Refused', html `<p class="notice">That form is too large.</p>`, session));
|
|
1721
2344
|
}
|
|
@@ -1784,6 +2407,20 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1784
2407
|
return changeWebhook(session, form, 'enable');
|
|
1785
2408
|
case '/waitlist/leave':
|
|
1786
2409
|
return leaveWaitlist(session, form);
|
|
2410
|
+
case '/assistant':
|
|
2411
|
+
return sendToAssistant(session, form);
|
|
2412
|
+
case '/assistant/approve':
|
|
2413
|
+
return settleAction(session, form, 'approve');
|
|
2414
|
+
case '/assistant/dismiss':
|
|
2415
|
+
return settleAction(session, form, 'dismiss');
|
|
2416
|
+
case '/assistant/reset':
|
|
2417
|
+
return resetAssistant(session);
|
|
2418
|
+
case '/settings/assistant':
|
|
2419
|
+
return saveAssistantSettings(session, form);
|
|
2420
|
+
case '/settings/assistant/test':
|
|
2421
|
+
return testAssistant(session);
|
|
2422
|
+
case '/settings/assistant/remove':
|
|
2423
|
+
return removeAssistantSettings(session);
|
|
1787
2424
|
default:
|
|
1788
2425
|
return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
1789
2426
|
}
|
|
@@ -1807,6 +2444,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1807
2444
|
case '/resources':
|
|
1808
2445
|
case '/webhooks':
|
|
1809
2446
|
case '/waitlist':
|
|
2447
|
+
case '/assistant':
|
|
1810
2448
|
case '/settings': {
|
|
1811
2449
|
const session = await readSession(request);
|
|
1812
2450
|
if (!session)
|
|
@@ -1819,7 +2457,9 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
1819
2457
|
return await webhooksPage(session);
|
|
1820
2458
|
if (route === '/waitlist')
|
|
1821
2459
|
return await waitlistPage(session);
|
|
1822
|
-
|
|
2460
|
+
if (route === '/assistant')
|
|
2461
|
+
return await assistantPage(session);
|
|
2462
|
+
return await settingsPage(session);
|
|
1823
2463
|
}
|
|
1824
2464
|
case '/style.css':
|
|
1825
2465
|
return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
|
|
@@ -1908,7 +2548,9 @@ a:hover { text-decoration: underline; }
|
|
|
1908
2548
|
code, pre, input[readonly] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
|
|
1909
2549
|
.icon { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
|
|
1910
2550
|
.icon .fill { fill: currentColor; stroke: none; }
|
|
1911
|
-
.mark { width:
|
|
2551
|
+
.mark { width: 20px; height: 18px; flex: none; }
|
|
2552
|
+
.mark-ink { fill: currentColor; }
|
|
2553
|
+
.mark-slot { fill: #FF5B2E; }
|
|
1912
2554
|
.brand { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); font-weight: 650; font-size: 15px; letter-spacing: -0.01em; }
|
|
1913
2555
|
.brand:hover { text-decoration: none; }
|
|
1914
2556
|
.app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; background: linear-gradient(to right, var(--side) 239px, var(--line) 239px, var(--line) 240px, var(--paper) 240px); }
|
|
@@ -2039,6 +2681,23 @@ td[data-label] > .pill { justify-self: start; }
|
|
|
2039
2681
|
td.wrap, .wrap { white-space: normal; overflow-wrap: anywhere; }
|
|
2040
2682
|
form.inline { display: inline; margin: 0; }
|
|
2041
2683
|
form > label { margin-bottom: 4px; }
|
|
2684
|
+
.chat { display: grid; gap: 16px; }
|
|
2685
|
+
.thread { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
|
2686
|
+
.msg { max-width: 78%; padding: 10px 14px; border-radius: 12px; background: var(--wash); border: 1px solid var(--line); }
|
|
2687
|
+
.msg p { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
2688
|
+
.msg.mine { justify-self: end; background: var(--accent-wash); border-color: transparent; }
|
|
2689
|
+
.msg.bot { justify-self: start; }
|
|
2690
|
+
.tool-line { font-size: 12.5px; color: var(--ink-3); padding-left: 4px; }
|
|
2691
|
+
.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; }
|
|
2692
|
+
.action-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
2693
|
+
.action-card .facts { font-size: 13px; }
|
|
2694
|
+
.action-card .facts dd { overflow-wrap: anywhere; }
|
|
2695
|
+
.action-card pre.wrap { white-space: pre-wrap; margin: 0; padding: 8px; font-size: 12px; }
|
|
2696
|
+
.action-summary { margin: 0; color: var(--ink); font-weight: 500; overflow-wrap: anywhere; }
|
|
2697
|
+
.action-buttons, .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
2698
|
+
.composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; margin: 0; }
|
|
2699
|
+
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; }
|
|
2700
|
+
textarea:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-wash); }
|
|
2042
2701
|
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
2043
2702
|
@media (max-width: 1000px) {
|
|
2044
2703
|
.split { grid-template-columns: minmax(0, 1fr); }
|
|
@@ -2066,6 +2725,8 @@ form > label { margin-bottom: 4px; }
|
|
|
2066
2725
|
.actions { text-align: left; }
|
|
2067
2726
|
.act { margin: 4px 12px 0 0; }
|
|
2068
2727
|
.pop { left: 0; right: auto; }
|
|
2728
|
+
.msg { max-width: 92%; }
|
|
2729
|
+
.composer { grid-template-columns: minmax(0, 1fr); }
|
|
2069
2730
|
}
|
|
2070
2731
|
`;
|
|
2071
2732
|
const ASSET_VERSIONS = Object.freeze({
|