slotlock 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/assistant.d.ts +30 -1
- package/assistant.js +369 -56
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +438 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +29 -5
- package/dashboard.js +900 -315
- package/package.json +1 -1
- package/slotlock.manifest.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,50 @@
|
|
|
3
3
|
All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic
|
|
4
4
|
versioning.
|
|
5
5
|
|
|
6
|
+
## 0.8.0 - 2026-11-10
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **Usage** in the dashboard's navigation on hosted Slotlock, with or without Stripe: the plan (or
|
|
11
|
+
the default it moves to, and when) against its limits, this month's operations, bookings and
|
|
12
|
+
holds against the quotas, and which key used what. Without Stripe, an owner asks for another plan
|
|
13
|
+
at `SLOTLOCK_PLAN_CONTACT`. `createSlotlockDashboard` takes `plans` (`plan`, `enrol`,
|
|
14
|
+
`planDefault`, `contact`); `billing` still brings its own.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The Overview leads with what needs a person (approvals waiting, a failed payment, a quota nearly
|
|
19
|
+
or fully used, a coming plan the workspace is over), then the week ahead and the activity as
|
|
20
|
+
sentences ("Booking agent booked room-3", passive for what Slotlock did, repeats counted on one
|
|
21
|
+
line), never a change's type. Get started walks a new workspace from its first resource to an
|
|
22
|
+
agent that has used a key, folds to one line half way and goes once done. The plan's use and the
|
|
23
|
+
MCP and A2A endpoints (with the Claude Code command) sit beside the activity.
|
|
24
|
+
- The Plan page is Usage, and a limit refused says "see Usage".
|
|
25
|
+
|
|
26
|
+
- The dashboard assistant is a chat. A reply streams into the page as the model writes it (the
|
|
27
|
+
dashboard's own script reads `POST <root>/assistant/stream`, one JSON object a line; the CSP
|
|
28
|
+
allows `connect-src 'self'` for it), with a Stop button. Without script, a message still posts
|
|
29
|
+
and the page shows the reply. Replies are Markdown, rendered by one renderer on the server and,
|
|
30
|
+
from its source, in the page as they stream: everything escaped first, links http(s) only. The
|
|
31
|
+
page is one centred column, with the box at the centre when the conversation is empty and five
|
|
32
|
+
things to ask beside it. Each run of reads folds into one line ("Read resources, checked
|
|
33
|
+
availability") that names, in words, a step that could not run and why. A proposed change is a
|
|
34
|
+
card that leads with when, which resource and its title, and keeps every other argument one click
|
|
35
|
+
away; its button says what it does ("Book", "Cancel booking"), and once decided it folds into
|
|
36
|
+
one line. A provider failure ends the conversation with Retry.
|
|
37
|
+
- Settings is a page a section (Assistant, Endpoints, Hours, Account). The assistant's section
|
|
38
|
+
shows what is connected, with Test and its result in place, then one form: the provider as tiles
|
|
39
|
+
(a named one brings its own URL and API format; Custom takes both), the model with Load models,
|
|
40
|
+
and the key as its last four characters with Replace. Disconnect stands apart.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- A message sent twice (a double click, a reload) no longer ends on "That form was already
|
|
45
|
+
sent": the second lands on the conversation, and the turn runs once. The same holds for
|
|
46
|
+
approving, declining and New conversation.
|
|
47
|
+
- A success notice (Saved, Connected) was shown in the error colour: notices are alerts only on
|
|
48
|
+
an error page, and a status otherwise.
|
|
49
|
+
|
|
6
50
|
## 0.7.0 - 2026-11-10
|
|
7
51
|
|
|
8
52
|
### Added
|
package/assistant.d.ts
CHANGED
|
@@ -191,6 +191,11 @@ interface ModelRequest {
|
|
|
191
191
|
force?: string;
|
|
192
192
|
fetch: typeof fetch;
|
|
193
193
|
signal: AbortSignal;
|
|
194
|
+
/**
|
|
195
|
+
* Stream the answer: called with each piece of its text as it arrives, in order; the pieces join
|
|
196
|
+
* to the answer's text. Without it the call asks for one JSON answer.
|
|
197
|
+
*/
|
|
198
|
+
onText?: (delta: string) => void;
|
|
194
199
|
}
|
|
195
200
|
/** One model call. Throws SlotlockAssistantProviderError, whose message never holds the key. */
|
|
196
201
|
export declare function callSlotlockAssistantModel(request: ModelRequest): Promise<SlotlockAssistantModelTurn>;
|
|
@@ -257,7 +262,30 @@ export interface SlotlockAssistantTurnOptions {
|
|
|
257
262
|
ok: false;
|
|
258
263
|
code: string;
|
|
259
264
|
}>;
|
|
265
|
+
/** Stream the turn: each model call's text as it arrives, and each tool call before and after it runs. */
|
|
266
|
+
onEvent?: (event: SlotlockAssistantTurnEvent) => void;
|
|
267
|
+
/** The member's Stop: the turn ends at once, keeping what it has, and says it stopped. */
|
|
268
|
+
signal?: AbortSignal;
|
|
260
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* What a streamed turn tells as it goes, in order: text as the model writes it; then each tool call
|
|
272
|
+
* (`read` runs, `write` is proposed, `unknown` is refused) before it runs, and its result after.
|
|
273
|
+
*/
|
|
274
|
+
export type SlotlockAssistantTurnEvent = {
|
|
275
|
+
type: 'text';
|
|
276
|
+
delta: string;
|
|
277
|
+
} | {
|
|
278
|
+
type: 'call';
|
|
279
|
+
id: string;
|
|
280
|
+
name: string;
|
|
281
|
+
use: 'read' | 'write' | 'unknown';
|
|
282
|
+
} | {
|
|
283
|
+
type: 'result';
|
|
284
|
+
id: string;
|
|
285
|
+
name: string;
|
|
286
|
+
ok: boolean;
|
|
287
|
+
actionId?: string;
|
|
288
|
+
};
|
|
261
289
|
export interface SlotlockAssistantTurnResult {
|
|
262
290
|
/** The entries this turn added. */
|
|
263
291
|
added: SlotlockAssistantEntry[];
|
|
@@ -267,7 +295,8 @@ export interface SlotlockAssistantTurnResult {
|
|
|
267
295
|
/**
|
|
268
296
|
* One member message: call the model, run what it reads, keep what it would write, and call it
|
|
269
297
|
* again with the results, up to `maxSteps` calls. Only the offered tools run, whatever the model
|
|
270
|
-
* names; everything else is answered `tool_not_available`.
|
|
298
|
+
* names; everything else is answered `tool_not_available`. With `onEvent` each model call streams;
|
|
299
|
+
* once `signal` aborts, no model call or tool goes on, and every call kept has its result.
|
|
271
300
|
*/
|
|
272
301
|
export declare function runSlotlockAssistantTurn(options: SlotlockAssistantTurnOptions): Promise<SlotlockAssistantTurnResult>;
|
|
273
302
|
/**
|
package/assistant.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// Nothing the model says changes the list of tools.
|
|
6
6
|
//
|
|
7
7
|
// Request formats as the vendors' official TypeScript SDKs describe them: @anthropic-ai/sdk 1.3.0
|
|
8
|
-
// and openai 7.45.1 (read from the npm registry 2026-10-28).
|
|
8
|
+
// and openai 7.45.1 (read from the npm registry 2026-10-28). Streamed answers as Anthropic's
|
|
9
|
+
// "Streaming messages" page, the Chat Completions chunk object, @anthropic-ai/sdk 1.8.0 and openai
|
|
10
|
+
// 7.55.0 describe them, read as server-sent events by the WHATWG HTML standard (all read 2026-11-10).
|
|
9
11
|
//
|
|
10
12
|
// The API key is sealed with AES-256-GCM (WebCrypto) under a key HKDF-SHA-256-derived from
|
|
11
13
|
// SLOTLOCK_ASSISTANT_SECRET, with the tenant as additional data: a sealed key copied to another
|
|
@@ -510,14 +512,279 @@ async function readBounded(response) {
|
|
|
510
512
|
}
|
|
511
513
|
return Buffer.concat(chunks).toString('utf8');
|
|
512
514
|
}
|
|
515
|
+
/** What an HTTP status from a model call means. */
|
|
516
|
+
function statusReason(status) {
|
|
517
|
+
return status === 401 || status === 403
|
|
518
|
+
? 'refused_key'
|
|
519
|
+
: status === 404
|
|
520
|
+
? 'not_found'
|
|
521
|
+
: status === 429
|
|
522
|
+
? 'rate_limited'
|
|
523
|
+
: status === 408 || status === 504
|
|
524
|
+
? 'timeout'
|
|
525
|
+
: 'unavailable';
|
|
526
|
+
}
|
|
527
|
+
/** The HTTP status each Anthropic error type stands for ("Claude API errors"), for one sent mid-stream. */
|
|
528
|
+
const ANTHROPIC_ERROR_STATUS = new Map([
|
|
529
|
+
['invalid_request_error', 400],
|
|
530
|
+
['authentication_error', 401],
|
|
531
|
+
['billing_error', 402],
|
|
532
|
+
['permission_error', 403],
|
|
533
|
+
['not_found_error', 404],
|
|
534
|
+
['request_too_large', 413],
|
|
535
|
+
['rate_limit_error', 429],
|
|
536
|
+
['api_error', 500],
|
|
537
|
+
['timeout_error', 504],
|
|
538
|
+
['overloaded_error', 529],
|
|
539
|
+
]);
|
|
540
|
+
/**
|
|
541
|
+
* Server-sent events as the WHATWG HTML standard interprets them: lines end at CRLF, LF or CR; a
|
|
542
|
+
* line starting with `:` is a comment; one space after the field's colon is dropped; `data` values
|
|
543
|
+
* join with LF; a blank line dispatches; other fields are ignored. Text comes in any pieces; an
|
|
544
|
+
* event the stream ends inside is never dispatched. `push` is true once `dispatch` was (stop).
|
|
545
|
+
*/
|
|
546
|
+
function sseLines(dispatch) {
|
|
547
|
+
let line = '';
|
|
548
|
+
let afterCR = false;
|
|
549
|
+
let event = '';
|
|
550
|
+
let data = '';
|
|
551
|
+
let done = false;
|
|
552
|
+
const take = (field) => {
|
|
553
|
+
if (field === '') {
|
|
554
|
+
if (data !== '')
|
|
555
|
+
done = dispatch(event || 'message', data.slice(0, -1));
|
|
556
|
+
event = '';
|
|
557
|
+
data = '';
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (field.startsWith(':'))
|
|
561
|
+
return;
|
|
562
|
+
const colon = field.indexOf(':');
|
|
563
|
+
const name = colon === -1 ? field : field.slice(0, colon);
|
|
564
|
+
const value = colon === -1 ? '' : field.slice(field.charCodeAt(colon + 1) === 0x20 ? colon + 2 : colon + 1);
|
|
565
|
+
if (name === 'event')
|
|
566
|
+
event = value;
|
|
567
|
+
else if (name === 'data')
|
|
568
|
+
data += `${value}\n`;
|
|
569
|
+
};
|
|
570
|
+
return (text) => {
|
|
571
|
+
let start = 0;
|
|
572
|
+
for (let index = 0; index < text.length && !done; index += 1) {
|
|
573
|
+
const code = text.charCodeAt(index);
|
|
574
|
+
if (code === 0x0a && afterCR) {
|
|
575
|
+
// The LF of a CRLF whose CR ended the line, perhaps in the previous piece.
|
|
576
|
+
afterCR = false;
|
|
577
|
+
start = index + 1;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
afterCR = code === 0x0d;
|
|
581
|
+
if (code !== 0x0a && code !== 0x0d)
|
|
582
|
+
continue;
|
|
583
|
+
take(line + text.slice(start, index));
|
|
584
|
+
line = '';
|
|
585
|
+
start = index + 1;
|
|
586
|
+
}
|
|
587
|
+
if (!done)
|
|
588
|
+
line += text.slice(start);
|
|
589
|
+
return done;
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* A streamed body, decoded as its bytes arrive (a character split across chunks waits for the
|
|
594
|
+
* rest) and read to `onEvent` until it answers true, within MAX_RESPONSE_BYTES. An abort cancels
|
|
595
|
+
* the read at once and is a timeout, even with events still in hand.
|
|
596
|
+
*/
|
|
597
|
+
async function readEvents(response, signal, onEvent) {
|
|
598
|
+
if (!response.body)
|
|
599
|
+
return;
|
|
600
|
+
const reader = response.body.getReader();
|
|
601
|
+
const cancel = () => {
|
|
602
|
+
reader.cancel().catch(() => undefined);
|
|
603
|
+
};
|
|
604
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
605
|
+
// UTF-8, without a leading byte order mark, as the standard decodes a stream.
|
|
606
|
+
const decoder = new TextDecoder();
|
|
607
|
+
const push = sseLines((event, data) => {
|
|
608
|
+
if (signal.aborted)
|
|
609
|
+
throw new SlotlockAssistantProviderError('timeout');
|
|
610
|
+
return onEvent(event, data);
|
|
611
|
+
});
|
|
612
|
+
let size = 0;
|
|
613
|
+
try {
|
|
614
|
+
for (;;) {
|
|
615
|
+
if (signal.aborted)
|
|
616
|
+
throw new SlotlockAssistantProviderError('timeout');
|
|
617
|
+
let chunk;
|
|
618
|
+
try {
|
|
619
|
+
chunk = await reader.read();
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
throw new SlotlockAssistantProviderError(signal.aborted ? 'timeout' : 'bad_answer');
|
|
623
|
+
}
|
|
624
|
+
if (chunk.done) {
|
|
625
|
+
if (signal.aborted)
|
|
626
|
+
throw new SlotlockAssistantProviderError('timeout');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
size += chunk.value.byteLength;
|
|
630
|
+
if (size > MAX_RESPONSE_BYTES)
|
|
631
|
+
throw new SlotlockAssistantProviderError('bad_answer');
|
|
632
|
+
if (push(decoder.decode(chunk.value, { stream: true })))
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
finally {
|
|
637
|
+
signal.removeEventListener('abort', cancel);
|
|
638
|
+
// Whatever follows the last event, or a body given up on, is not read.
|
|
639
|
+
cancel();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const parsedOr = (json, fallback) => {
|
|
643
|
+
try {
|
|
644
|
+
return JSON.parse(json);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
return fallback;
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* An Anthropic stream: content blocks by index, text from `text_delta`s, a `tool_use`'s input from
|
|
652
|
+
* its `input_json_delta` fragments, parsed once whole; ended by `message_stop`. `ping` and event
|
|
653
|
+
* types this does not know are passed over; `error` names its error as an HTTP status would.
|
|
654
|
+
*/
|
|
655
|
+
function anthropicStream(emit) {
|
|
656
|
+
const blocks = new Map();
|
|
657
|
+
let stopped = false;
|
|
658
|
+
return {
|
|
659
|
+
event(name, data) {
|
|
660
|
+
const body = parsedOr(data, undefined);
|
|
661
|
+
if (name === 'error' || (isRecord(body) && body.type === 'error')) {
|
|
662
|
+
const type = isRecord(body) && isRecord(body.error) ? body.error.type : undefined;
|
|
663
|
+
const status = typeof type === 'string' ? ANTHROPIC_ERROR_STATUS.get(type) : undefined;
|
|
664
|
+
throw new SlotlockAssistantProviderError(status === undefined ? 'unavailable' : statusReason(status));
|
|
665
|
+
}
|
|
666
|
+
if (!isRecord(body)) {
|
|
667
|
+
if (name === 'ping')
|
|
668
|
+
return false;
|
|
669
|
+
throw new SlotlockAssistantProviderError('bad_answer');
|
|
670
|
+
}
|
|
671
|
+
const index = typeof body.index === 'number' ? body.index : undefined;
|
|
672
|
+
if (body.type === 'content_block_start' && index !== undefined && isRecord(body.content_block)) {
|
|
673
|
+
const block = body.content_block;
|
|
674
|
+
if (block.type === 'text') {
|
|
675
|
+
blocks.set(index, 'text');
|
|
676
|
+
if (typeof block.text === 'string')
|
|
677
|
+
emit(block.text);
|
|
678
|
+
}
|
|
679
|
+
else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
|
|
680
|
+
blocks.set(index, { id: block.id, name: block.name, input: block.input, json: '' });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
else if (body.type === 'content_block_delta' && index !== undefined && isRecord(body.delta)) {
|
|
684
|
+
const block = blocks.get(index);
|
|
685
|
+
const delta = body.delta;
|
|
686
|
+
if (block === 'text' && delta.type === 'text_delta' && typeof delta.text === 'string')
|
|
687
|
+
emit(delta.text);
|
|
688
|
+
if (typeof block === 'object' && delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
689
|
+
block.json += delta.partial_json;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
else if (body.type === 'message_stop') {
|
|
693
|
+
stopped = true;
|
|
694
|
+
}
|
|
695
|
+
return stopped;
|
|
696
|
+
},
|
|
697
|
+
complete: () => stopped,
|
|
698
|
+
calls: () => [...blocks.entries()]
|
|
699
|
+
.sort(([a], [b]) => a - b)
|
|
700
|
+
.flatMap(([, block]) => block === 'text'
|
|
701
|
+
? []
|
|
702
|
+
: [{ id: block.id, name: block.name, input: block.json === '' ? block.input : parsedOr(block.json, null) }]),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* A Chat Completions stream: chunks whose first choice's delta carries `content` and `tool_calls`
|
|
707
|
+
* fragments, each call assembled by its `index` (id and name arrive once, arguments in pieces);
|
|
708
|
+
* complete at a `finish_reason`, ended by `data: [DONE]`. A chunk with an `error` is a failure.
|
|
709
|
+
*/
|
|
710
|
+
function openaiStream(emit) {
|
|
711
|
+
const slots = new Map();
|
|
712
|
+
let finished = false;
|
|
713
|
+
return {
|
|
714
|
+
event(name, data) {
|
|
715
|
+
if (data === '[DONE]') {
|
|
716
|
+
finished = true;
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
const body = parsedOr(data, undefined);
|
|
720
|
+
if (!isRecord(body))
|
|
721
|
+
throw new SlotlockAssistantProviderError('bad_answer');
|
|
722
|
+
if (name === 'error' || body.error)
|
|
723
|
+
throw new SlotlockAssistantProviderError('unavailable');
|
|
724
|
+
for (const choice of Array.isArray(body.choices) ? body.choices : []) {
|
|
725
|
+
if (!isRecord(choice) || (choice.index ?? 0) !== 0)
|
|
726
|
+
continue;
|
|
727
|
+
const delta = isRecord(choice.delta) ? choice.delta : {};
|
|
728
|
+
if (typeof delta.content === 'string')
|
|
729
|
+
emit(delta.content);
|
|
730
|
+
for (const [position, fragment] of (Array.isArray(delta.tool_calls) ? delta.tool_calls : []).entries()) {
|
|
731
|
+
if (!isRecord(fragment))
|
|
732
|
+
continue;
|
|
733
|
+
// A server that sends whole calls without an index sends them in order.
|
|
734
|
+
const index = typeof fragment.index === 'number' && Number.isSafeInteger(fragment.index) && fragment.index >= 0
|
|
735
|
+
? fragment.index
|
|
736
|
+
: position;
|
|
737
|
+
const slot = slots.get(index) ?? { args: '' };
|
|
738
|
+
slots.set(index, slot);
|
|
739
|
+
const fn = isRecord(fragment.function) ? fragment.function : {};
|
|
740
|
+
if (typeof fragment.id === 'string' && fragment.id !== '')
|
|
741
|
+
slot.id = fragment.id;
|
|
742
|
+
if (typeof fn.name === 'string' && fn.name !== '')
|
|
743
|
+
slot.name = fn.name;
|
|
744
|
+
if (typeof fn.arguments === 'string')
|
|
745
|
+
slot.args += fn.arguments;
|
|
746
|
+
}
|
|
747
|
+
if (typeof choice.finish_reason === 'string')
|
|
748
|
+
finished = true;
|
|
749
|
+
}
|
|
750
|
+
return false;
|
|
751
|
+
},
|
|
752
|
+
complete: () => finished,
|
|
753
|
+
calls: () => [...slots.entries()]
|
|
754
|
+
.sort(([a], [b]) => a - b)
|
|
755
|
+
.flatMap(([index, slot]) => slot.name === undefined
|
|
756
|
+
? []
|
|
757
|
+
: [{ id: slot.id ?? `call_${index}`, name: slot.name, input: parsedOr(slot.args || '{}', null) }]),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
/** Text to `onText`, within MAX_MODEL_TEXT: what it was given is the answer's text. */
|
|
761
|
+
function textSink(onText) {
|
|
762
|
+
let text = '';
|
|
763
|
+
return {
|
|
764
|
+
emit(delta) {
|
|
765
|
+
const kept = delta.slice(0, MAX_MODEL_TEXT - text.length);
|
|
766
|
+
if (kept === '')
|
|
767
|
+
return;
|
|
768
|
+
text += kept;
|
|
769
|
+
try {
|
|
770
|
+
onText(kept);
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// Showing the answer never changes it.
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
text: () => text,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
const isEventStream = (response) => (response.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase() === 'text/event-stream';
|
|
513
780
|
/** One model call. Throws SlotlockAssistantProviderError, whose message never holds the key. */
|
|
514
781
|
export async function callSlotlockAssistantModel(request) {
|
|
515
|
-
const { model } = request;
|
|
782
|
+
const { model, onText } = request;
|
|
516
783
|
const anthropic = model.provider === 'anthropic';
|
|
517
784
|
const url = anthropic ? `${model.baseUrl}/v1/messages` : `${model.baseUrl}/chat/completions`;
|
|
518
785
|
const headers = {
|
|
519
786
|
'content-type': 'application/json',
|
|
520
|
-
accept: 'application/json',
|
|
787
|
+
accept: onText ? 'text/event-stream' : 'application/json',
|
|
521
788
|
'user-agent': 'slotlock-dashboard-assistant',
|
|
522
789
|
};
|
|
523
790
|
if (anthropic) {
|
|
@@ -528,12 +795,13 @@ export async function callSlotlockAssistantModel(request) {
|
|
|
528
795
|
else if (model.apiKey) {
|
|
529
796
|
headers.authorization = `Bearer ${model.apiKey}`;
|
|
530
797
|
}
|
|
798
|
+
const body = anthropic ? anthropicBody(request) : openaiBody(request);
|
|
531
799
|
let response;
|
|
532
800
|
try {
|
|
533
801
|
response = await request.fetch(url, {
|
|
534
802
|
method: 'POST',
|
|
535
803
|
headers,
|
|
536
|
-
body: JSON.stringify(
|
|
804
|
+
body: JSON.stringify(onText ? { ...body, stream: true } : body),
|
|
537
805
|
// Never follow a redirect: it would carry the key elsewhere.
|
|
538
806
|
redirect: 'manual',
|
|
539
807
|
signal: request.signal,
|
|
@@ -548,20 +816,20 @@ export async function callSlotlockAssistantModel(request) {
|
|
|
548
816
|
}
|
|
549
817
|
if (!response.ok) {
|
|
550
818
|
await response.body?.cancel().catch(() => undefined);
|
|
551
|
-
|
|
552
|
-
throw new SlotlockAssistantProviderError(status === 401 || status === 403
|
|
553
|
-
? 'refused_key'
|
|
554
|
-
: status === 404
|
|
555
|
-
? 'not_found'
|
|
556
|
-
: status === 429
|
|
557
|
-
? 'rate_limited'
|
|
558
|
-
: status === 408 || status === 504
|
|
559
|
-
? 'timeout'
|
|
560
|
-
: 'unavailable', status);
|
|
819
|
+
throw new SlotlockAssistantProviderError(statusReason(response.status), response.status);
|
|
561
820
|
}
|
|
562
|
-
|
|
821
|
+
const sink = onText ? textSink(onText) : undefined;
|
|
822
|
+
if (sink && isEventStream(response)) {
|
|
823
|
+
const answer = anthropic ? anthropicStream(sink.emit) : openaiStream(sink.emit);
|
|
824
|
+
await readEvents(response, request.signal, answer.event);
|
|
825
|
+
if (!answer.complete())
|
|
826
|
+
throw new SlotlockAssistantProviderError('bad_answer');
|
|
827
|
+
return { text: sink.text(), calls: answer.calls() };
|
|
828
|
+
}
|
|
829
|
+
// One JSON answer: asked for, or sent by a server that does not stream.
|
|
830
|
+
let answer;
|
|
563
831
|
try {
|
|
564
|
-
|
|
832
|
+
answer = JSON.parse(await readBounded(response));
|
|
565
833
|
}
|
|
566
834
|
catch (error) {
|
|
567
835
|
if (error instanceof SlotlockAssistantProviderError)
|
|
@@ -570,7 +838,8 @@ export async function callSlotlockAssistantModel(request) {
|
|
|
570
838
|
throw new SlotlockAssistantProviderError('timeout');
|
|
571
839
|
throw new SlotlockAssistantProviderError('bad_answer');
|
|
572
840
|
}
|
|
573
|
-
const turn = anthropic ? readAnthropic(
|
|
841
|
+
const turn = anthropic ? readAnthropic(answer) : readOpenai(answer);
|
|
842
|
+
sink?.emit(turn.text);
|
|
574
843
|
return { text: turn.text.slice(0, MAX_MODEL_TEXT), calls: turn.calls };
|
|
575
844
|
}
|
|
576
845
|
/**
|
|
@@ -692,16 +961,66 @@ const toolText = (value) => {
|
|
|
692
961
|
/**
|
|
693
962
|
* One member message: call the model, run what it reads, keep what it would write, and call it
|
|
694
963
|
* again with the results, up to `maxSteps` calls. Only the offered tools run, whatever the model
|
|
695
|
-
* names; everything else is answered `tool_not_available`.
|
|
964
|
+
* names; everything else is answered `tool_not_available`. With `onEvent` each model call streams;
|
|
965
|
+
* once `signal` aborts, no model call or tool goes on, and every call kept has its result.
|
|
696
966
|
*/
|
|
697
967
|
export async function runSlotlockAssistantTurn(options) {
|
|
698
968
|
const tools = slotlockAssistantTools();
|
|
699
969
|
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
700
970
|
const maxSteps = Math.max(1, Math.min(options.maxSteps ?? SLOTLOCK_ASSISTANT_MAX_STEPS, 20));
|
|
701
971
|
const turnSignal = AbortSignal.timeout(options.turnTimeoutMs);
|
|
972
|
+
const { onEvent, signal: stop } = options;
|
|
702
973
|
const added = [];
|
|
974
|
+
const tell = (event) => {
|
|
975
|
+
try {
|
|
976
|
+
onEvent?.(event);
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
// Showing the turn never changes it.
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
const stopped = () => {
|
|
983
|
+
added.push({ role: 'note', text: 'Stopped.' });
|
|
984
|
+
return { added };
|
|
985
|
+
};
|
|
986
|
+
const run = async (call, tool, index) => {
|
|
987
|
+
if (!tool || index >= MAX_CALLS_PER_STEP) {
|
|
988
|
+
return {
|
|
989
|
+
id: call.id,
|
|
990
|
+
name: call.name,
|
|
991
|
+
content: toolText({ error: { code: tool ? 'too_many_calls' : 'tool_not_available' } }),
|
|
992
|
+
error: true,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
if (tool.use === 'read') {
|
|
996
|
+
const outcome = await options.read(tool.name, call.input);
|
|
997
|
+
return {
|
|
998
|
+
id: call.id,
|
|
999
|
+
name: call.name,
|
|
1000
|
+
content: toolText(outcome.ok ? outcome.data : { error: { code: outcome.code, ...(outcome.details ?? {}) } }),
|
|
1001
|
+
error: !outcome.ok,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
const proposed = await options.propose(tool.name, call.input);
|
|
1005
|
+
return proposed.ok
|
|
1006
|
+
? {
|
|
1007
|
+
id: call.id,
|
|
1008
|
+
name: call.name,
|
|
1009
|
+
content: toolText({
|
|
1010
|
+
status: 'proposed',
|
|
1011
|
+
action_id: proposed.actionId,
|
|
1012
|
+
note: 'Not run. The member sees this change and may approve it; the result comes back as a dashboard note.',
|
|
1013
|
+
}),
|
|
1014
|
+
error: false,
|
|
1015
|
+
action: proposed.actionId,
|
|
1016
|
+
}
|
|
1017
|
+
: { id: call.id, name: call.name, content: toolText({ error: { code: proposed.code } }), error: true };
|
|
1018
|
+
};
|
|
703
1019
|
for (let step = 0; step < maxSteps; step += 1) {
|
|
1020
|
+
if (stop?.aborted)
|
|
1021
|
+
return stopped();
|
|
704
1022
|
let turn;
|
|
1023
|
+
let streamed = '';
|
|
705
1024
|
try {
|
|
706
1025
|
turn = await callSlotlockAssistantModel({
|
|
707
1026
|
model: options.model,
|
|
@@ -709,10 +1028,24 @@ export async function runSlotlockAssistantTurn(options) {
|
|
|
709
1028
|
entries: [...options.entries, ...added],
|
|
710
1029
|
tools,
|
|
711
1030
|
fetch: options.fetch,
|
|
712
|
-
signal: AbortSignal.any([turnSignal, AbortSignal.timeout(options.timeoutMs)]),
|
|
1031
|
+
signal: AbortSignal.any([turnSignal, AbortSignal.timeout(options.timeoutMs), ...(stop ? [stop] : [])]),
|
|
1032
|
+
...(onEvent
|
|
1033
|
+
? {
|
|
1034
|
+
onText: (delta) => {
|
|
1035
|
+
streamed += delta;
|
|
1036
|
+
tell({ type: 'text', delta });
|
|
1037
|
+
},
|
|
1038
|
+
}
|
|
1039
|
+
: {}),
|
|
713
1040
|
});
|
|
714
1041
|
}
|
|
715
1042
|
catch (error) {
|
|
1043
|
+
if (stop?.aborted) {
|
|
1044
|
+
// The text shown so far stays; calls of a reply cut short do not, as none has a result.
|
|
1045
|
+
if (streamed !== '')
|
|
1046
|
+
added.push({ role: 'assistant', text: streamed, calls: [] });
|
|
1047
|
+
return stopped();
|
|
1048
|
+
}
|
|
716
1049
|
added.push({ role: 'note', text: slotlockAssistantProviderMessage(error) });
|
|
717
1050
|
return { added, failure: error };
|
|
718
1051
|
}
|
|
@@ -726,48 +1059,28 @@ export async function runSlotlockAssistantTurn(options) {
|
|
|
726
1059
|
return { added };
|
|
727
1060
|
const results = [];
|
|
728
1061
|
for (const [index, call] of calls.entries()) {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
results.push({
|
|
732
|
-
id: call.id,
|
|
733
|
-
name: call.name,
|
|
734
|
-
content: toolText({ error: { code: tool ? 'too_many_calls' : 'tool_not_available' } }),
|
|
735
|
-
error: true,
|
|
736
|
-
});
|
|
1062
|
+
if (stop?.aborted) {
|
|
1063
|
+
// Not run: answered, so the model is never sent a call without its result.
|
|
1064
|
+
results.push({ id: call.id, name: call.name, content: toolText({ error: { code: 'stopped' } }), error: true });
|
|
737
1065
|
continue;
|
|
738
1066
|
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
? {
|
|
752
|
-
id: call.id,
|
|
753
|
-
name: call.name,
|
|
754
|
-
content: toolText({
|
|
755
|
-
status: 'proposed',
|
|
756
|
-
action_id: proposed.actionId,
|
|
757
|
-
note: 'Not run. The member sees this change and may approve it; the result comes back as a dashboard note.',
|
|
758
|
-
}),
|
|
759
|
-
error: false,
|
|
760
|
-
action: proposed.actionId,
|
|
761
|
-
}
|
|
762
|
-
: {
|
|
763
|
-
id: call.id,
|
|
764
|
-
name: call.name,
|
|
765
|
-
content: toolText({ error: { code: proposed.code } }),
|
|
766
|
-
error: true,
|
|
767
|
-
});
|
|
1067
|
+
const tool = byName.get(call.name);
|
|
1068
|
+
const use = !tool ? 'unknown' : tool.use === 'read' ? 'read' : 'write';
|
|
1069
|
+
tell({ type: 'call', id: call.id, name: call.name, use });
|
|
1070
|
+
const result = await run(call, tool, index);
|
|
1071
|
+
results.push(result);
|
|
1072
|
+
tell({
|
|
1073
|
+
type: 'result',
|
|
1074
|
+
id: result.id,
|
|
1075
|
+
name: result.name,
|
|
1076
|
+
ok: !result.error,
|
|
1077
|
+
...(result.action !== undefined ? { actionId: result.action } : {}),
|
|
1078
|
+
});
|
|
768
1079
|
}
|
|
769
1080
|
added.push({ role: 'tool', results });
|
|
770
1081
|
}
|
|
1082
|
+
if (stop?.aborted)
|
|
1083
|
+
return stopped();
|
|
771
1084
|
added.push({
|
|
772
1085
|
role: 'note',
|
|
773
1086
|
text: `The assistant stopped after ${maxSteps} steps. Ask again, or more narrowly, to go on.`,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** How a step reads while it runs, once it has, and when it could not. */
|
|
2
|
+
export type SlotlockAssistantStepState = 'run' | 'ok' | 'bad';
|
|
3
|
+
/** A tool step in plain words: "Checking availability…", "Checked availability", "Couldn't check availability". */
|
|
4
|
+
export declare function slotlockAssistantStepLabel(name: string, title: string, state: SlotlockAssistantStepState, write?: boolean): string;
|
|
5
|
+
/** The verb a proposed change's card is approved with, and whether it removes something. */
|
|
6
|
+
export declare function slotlockAssistantActionVerb(operation: string): {
|
|
7
|
+
verb: string;
|
|
8
|
+
busy: string;
|
|
9
|
+
done: string;
|
|
10
|
+
danger: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** The chat's styles: one centred column, the composer docked under it, quiet steps and cards. */
|
|
13
|
+
export declare const SLOTLOCK_ASSISTANT_CSS = "\n.assistant-page { display: grid; }\n.assistant { position: relative; display: flex; flex-direction: column; width: min(100%, 720px); margin: 0 auto; min-height: calc(100dvh - 180px); }\n.assistant .thread { flex: 1; }\n.assistant[data-empty] { justify-content: center; padding-bottom: 12vh; }\n.assistant[data-empty] .thread { display: none; }\n.assistant[data-empty] .dock { order: 1; position: static; padding: 0; background: none; }\n.assistant[data-empty] .assistant-start { order: 2; padding-top: var(--space-3); }\n.assistant:not([data-empty]) .assistant-start { display: none; }\n.chips { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-2); }\n.chips form { margin: 0; }\n.chip { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 var(--space-3); border-radius: var(--radius-full); border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); font: inherit; font-size: var(--text-md); cursor: pointer; transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease); }\n.chip:hover { background: var(--hover); border-color: var(--line-strong); color: var(--ink); }\n.thread { list-style: none; margin: 0; padding: var(--space-2) 0 var(--space-6); display: grid; gap: var(--space-6); align-content: start; }\n.turn { margin: 0; min-width: 0; }\n.turn.mine { justify-self: end; max-width: 80%; }\n.bubble { padding: 10px 14px; border-radius: 18px; background: var(--sunken); color: var(--ink); font-size: var(--text-lg); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }\n.turn.bot { justify-self: stretch; }\n.prose { max-width: 68ch; color: var(--ink); font-size: var(--text-lg); line-height: 1.6; overflow-wrap: anywhere; }\n.prose > :first-child { margin-top: 0; }\n.prose > :last-child { margin-bottom: 0; }\n.prose p { margin: 0 0 var(--space-3); color: inherit; }\n.prose strong { font-weight: 600; }\n.prose h4 { margin: var(--space-4) 0 var(--space-2); font-size: var(--text-lg); font-weight: 600; }\n.prose ul, .prose ol { margin: 0 0 var(--space-3); padding-left: 20px; }\n.prose li { margin: 4px 0; }\n.prose li > ul, .prose li > ol { margin: 4px 0 0; }\n.prose a { color: var(--accent); text-underline-offset: 2px; }\n.prose code { font: 400 12.5px/1.4 var(--mono); background: var(--sunken); border-radius: 4px; padding: 1px 5px; }\n.prose pre { margin: 0 0 var(--space-3); padding: var(--space-3); background: var(--sunken); border: 1px solid var(--line); border-radius: var(--radius); overflow-x: auto; }\n.prose pre code { background: none; padding: 0; font-size: 12.5px; }\n.prose blockquote { margin: 0 0 var(--space-3); padding-left: var(--space-3); border-left: 2px solid var(--line-strong); color: var(--ink-2); }\n.prose hr { border: 0; border-top: 1px solid var(--line); margin: var(--space-4) 0; }\n.md-table { overflow-x: auto; margin: 0 0 var(--space-3); }\n.md-table table { border-collapse: collapse; font-size: var(--text-md); min-width: 50%; }\n.md-table table { width: auto; }\n.md-table th { font: 600 var(--text-md)/1.4 var(--font); text-transform: none; letter-spacing: 0; background: none; text-align: left; color: var(--ink-2); padding: 6px 16px 6px 0; border-bottom: 1px solid var(--line); white-space: nowrap; }\n.md-table td { padding: 6px 16px 6px 0; border-bottom: 1px solid var(--line-soft); vertical-align: top; }\n.md-table tr:last-child td { border-bottom: 0; }\n.change details.more { margin: 0; }\n.change details.more > summary { cursor: pointer; color: var(--ink-3); font-size: var(--text-sm); list-style: none; }\n.change details.more > summary::-webkit-details-marker { display: none; }\n.change details.more > summary:hover { color: var(--ink-2); }\n.change details.more[open] > summary { margin-bottom: var(--space-2); }\n.turn.steps { justify-self: start; }\ndetails.steps > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--ink-3); font-size: var(--text-md); padding: 2px 0; }\ndetails.steps > summary::-webkit-details-marker { display: none; }\ndetails.steps > summary::after { content: \"\"; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); transition: transform var(--fast) var(--ease); margin-left: 2px; }\ndetails.steps[open] > summary::after { transform: rotate(45deg); }\ndetails.steps > summary:hover { color: var(--ink-2); }\ndetails.steps.has-bad > summary::before { content: \"\"; width: 6px; height: 6px; border-radius: 50%; background: var(--bad); }\n.step-list { list-style: none; margin: var(--space-2) 0 0; padding: 0 0 0 var(--space-3); border-left: 1px solid var(--line); display: grid; gap: 6px; }\n.step { display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }\n.step::before { content: \"\"; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--good); transform: translateY(-1px); }\n.step.bad::before { background: var(--bad); }\n.step.run::before { background: var(--ink-3); }\n.step .why { color: var(--ink-3); font-size: var(--text-sm); }\n.live-steps { display: grid; gap: 6px; }\n.live-steps .step.run, .thinking { background: linear-gradient(90deg, var(--ink-3) 0%, var(--ink) 50%, var(--ink-3) 100%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: assistant-shimmer 1.6s linear infinite; }\n.thinking { font-size: var(--text-md); justify-self: start; }\n@keyframes assistant-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }\n.prose.streaming > :last-child::after { content: \"\"; display: inline-block; width: 8px; height: 8px; margin-left: 4px; border-radius: 50%; background: var(--ink-3); vertical-align: baseline; animation: assistant-pulse 1s ease-in-out infinite; }\n@keyframes assistant-pulse { 50% { opacity: 0.25; } }\n.turn.note { color: var(--ink-3); font-size: var(--text-md); }\n.turn.error { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 10px 12px; border: 1px solid var(--bad-line); background: var(--bad-wash); border-radius: var(--radius); font-size: var(--text-md); color: var(--ink); }\n.turn.error::before { content: \"\"; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--bad); }\n.turn.error > span { flex: 1; }\n.turn.error form, .turn.note form { margin: 0; }\n.text-button { appearance: none; border: 0; background: none; padding: 0; font: inherit; font-size: var(--text-md); color: var(--accent); cursor: pointer; }\n.text-button:hover { text-decoration: underline; text-underline-offset: 2px; }\n.turn.action { justify-self: start; width: min(100%, 560px); }\n.change { padding: var(--space-4) var(--space-5); background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius-lg); display: grid; gap: var(--space-3); }\n.change-title { margin: 0; font-size: var(--text-lg); font-weight: 600; color: var(--ink); overflow-wrap: anywhere; }\n.change .facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px var(--space-4); margin: 0; font-size: var(--text-md); }\n.change .facts dt { color: var(--ink-3); font-size: var(--text-sm); padding-top: 1px; }\n.change .facts dd { margin: 0; color: var(--ink); overflow-wrap: anywhere; }\n.change .facts dd pre { margin: 0; white-space: pre-wrap; font: 400 12px/1.5 var(--mono); }\n.change .facts .was { color: var(--ink-3); text-decoration: line-through; }\n.change-buttons { display: flex; gap: var(--space-2); flex-wrap: wrap; }\n.change-buttons form { margin: 0; }\n.change-buttons button { height: 32px; }\nbutton.danger { background: var(--bad); color: var(--bad-ink); border-color: var(--bad); }\nbutton.danger:hover { filter: brightness(1.08); }\nbutton.ghost { background: transparent; border-color: transparent; color: var(--ink-2); }\nbutton.ghost:hover { background: var(--hover); color: var(--ink); }\n.receipt { margin: 0; display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }\n.receipt::before { content: \"\"; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--ink-3); transform: translateY(-1px); }\n.receipt.ok { color: var(--ink); }\n.receipt.ok::before { background: var(--good); }\n.receipt.bad::before { background: var(--bad); }\n.receipt .why { color: var(--ink-3); }\n.dock { position: sticky; bottom: 0; padding: var(--space-6) 0 var(--space-6); background: linear-gradient(to bottom, transparent, var(--canvas) 24px); z-index: 2; }\n.composer { display: grid; margin: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; transition: border-color var(--fast) var(--ease), box-shadow var(--fast) var(--ease); }\n.composer:focus-within { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--ring); }\n.composer textarea { display: block; width: 100%; min-height: 26px; max-height: 200px; resize: none; border: 0; outline: 0; background: transparent; padding: 14px 16px 0; color: var(--ink); font: inherit; font-size: var(--text-lg); line-height: 1.5; box-shadow: none; }\n.composer textarea:focus { box-shadow: none; border: 0; }\n.composer-bar { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); padding: var(--space-2); }\n.composer-bar .count { margin-right: auto; padding-left: var(--space-2); font-size: var(--text-sm); color: var(--ink-3); }\n.composer-bar .count.over { color: var(--warn); }\n.send { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 0; background: var(--button); color: var(--button-ink); cursor: pointer; transition: opacity var(--fast) var(--ease), background var(--fast) var(--ease); }\n.send:hover { background: var(--button-hover); }\n.send:disabled { opacity: 0.4; cursor: default; }\n.send svg { width: 16px; height: 16px; }\n.send .stop { display: none; }\n.assistant.streaming .send .arrow { display: none; }\n.assistant.streaming .send .stop { display: block; }\n.jump { position: absolute; left: 50%; transform: translate(-50%, 0); top: -8px; width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); display: grid; place-items: center; cursor: pointer; box-shadow: var(--pop-shadow); }\n.jump[hidden] { display: none; }\n.new-chat svg { width: 14px; height: 14px; }\n.assistant-page[data-empty] .new-chat { display: none; }\n@media (prefers-reduced-motion: reduce) {\n .live-steps .step.run, .thinking { animation: none; color: var(--ink-3); background: none; }\n .prose.streaming > :last-child::after { animation: none; }\n}\n@media (max-width: 760px) {\n .assistant { min-height: calc(100dvh - 140px); }\n .assistant[data-empty] { justify-content: flex-end; padding-bottom: 0; }\n .assistant[data-empty] .assistant-start { order: 1; padding: 0 0 var(--space-3); }\n .assistant[data-empty] .dock { order: 2; position: sticky; padding-bottom: max(12px, env(safe-area-inset-bottom)); }\n .chips { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; max-width: 100%; padding-bottom: 2px; scrollbar-width: none; }\n .chips::-webkit-scrollbar { display: none; }\n .chip { flex: none; }\n .turn.mine { max-width: 88%; }\n .md-table table { display: table; }\n .md-table thead { display: table-header-group; }\n .md-table tbody { display: table-row-group; }\n .md-table tr { display: table-row; padding: 0; border: 0; }\n .md-table th, .md-table td { display: table-cell; padding: 6px 12px 6px 0; }\n .md-table td { border-bottom: 1px solid var(--line-soft); }\n .composer textarea { font-size: 16px; }\n .dock { padding-bottom: max(12px, env(safe-area-inset-bottom)); }\n .change { padding: var(--space-4); }\n .change .facts { grid-template-columns: minmax(0, 1fr); gap: 2px; }\n .change .facts dd { margin-bottom: var(--space-2); }\n .change-buttons form { flex: 1; }\n .change-buttons button { width: 100%; height: 40px; }\n}\n";
|
|
14
|
+
/**
|
|
15
|
+
* The browser side of the chat, appended to app.js: Enter sends, the box grows, a write chip fills
|
|
16
|
+
* the box instead of sending, and a reply streams in from `<root>/assistant/stream` (newline-
|
|
17
|
+
* delimited JSON: text deltas, steps, then the turn as the server renders it). Without it the
|
|
18
|
+
* page still works: every form posts and the page reloads at the latest message.
|
|
19
|
+
* `markdownSource` is renderSlotlockMarkdown's source, so a reply renders as it will once stored.
|
|
20
|
+
*/
|
|
21
|
+
export declare function slotlockAssistantScript(markdownSource: string): string;
|