@poodle64/librarian 2026.10.5 → 2026.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/chat.svelte.d.ts +6 -4
- package/dist/chat.svelte.js +21 -11
- package/dist/components/agent-transcript/agent-transcript.svelte +18 -6
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +7 -0
- package/dist/components/composer/composer.svelte +25 -14
- package/dist/components/composer/scope-picker.svelte +3 -2
- package/dist/components/conversation/conversation.svelte +6 -1
- package/dist/components/conversation/conversation.svelte.d.ts +3 -0
- package/dist/components/visual-block/chart-draw.svelte +26 -0
- package/dist/components/visual-block/chart-draw.svelte.d.ts +7 -0
- package/dist/components/visual-block/chart.svelte +73 -0
- package/dist/components/visual-block/chart.svelte.d.ts +8 -0
- package/dist/visuals.d.ts +9 -1
- package/dist/visuals.js +31 -29
- package/package.json +2 -4
- package/dist/components/visual-block/vega-chart.svelte +0 -110
- package/dist/components/visual-block/vega-chart.svelte.d.ts +0 -7
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ on it fires once and never again.
|
|
|
179
179
|
| Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself |
|
|
180
180
|
| Choosing a collection | `Composer`'s `collections` (`{ name, title?, description? }[]`, the ones the person may read) and `collection` (the chosen name; undefined is all of them), with `oncollection(name \| undefined)`. From two collections up it is ONE control naming the choice, opening a listbox (searchable from eight) where each shows by its title (the name made readable where there is none) over its description, never a retrieval summary |
|
|
181
181
|
| The empty state | `welcome` and up to three `examples`. With a composer, the opening and the composer sit together in the middle of the panel, the examples, the folded scope and `lead` under it; the first turn docks the composer at the foot |
|
|
182
|
-
| Conversation controls | `toolbar`: a snippet at the measure's trailing edge above the transcript, outside its scroll (the
|
|
182
|
+
| Conversation controls | `toolbar`: a snippet at the measure's trailing edge above the transcript, outside its scroll (the chat page's Past questions and New question) |
|
|
183
183
|
| What this surface covers | `scope`: one statement per persona, in the host's own words. Rendered above the first turn and folded to a line once the conversation starts |
|
|
184
184
|
| The words themselves | `copy`: a partial of `ChatCopy`. Every component resolves it itself, so overriding one line does not mean restating the rest |
|
|
185
185
|
| An answer its own way | `turn`: a snippet given each turn's `AgentTranscriptProps` in place of `AgentTranscript`. Render your own from the parts, or `AgentTranscript` inside your own to add to it |
|
|
@@ -405,7 +405,7 @@ end that way.
|
|
|
405
405
|
| Documents it wrote | `artefact` frames and a stored turn's `artefacts` (each with `title`, `kind`, `changes`, `pages`) become `turn.written`: the newest version of each is a card with Open and a download chip (Word where `formats` has it, else the source). The pane marks what a version changed since the one before: a "Changed since vN" line from `changes`, and a bar beside each changed block. `loadWritten(name, version)` and `writtenUrl(name, version, format)` (`Chat.loadArtefact` / `Chat.artefactUrl` over the transport) let the pane show a version: Markdown sanitised, HTML only in a `sandbox=""` frame |
|
|
406
406
|
| The turn's steps | One fold, "Read your attachment, searched the library and the web, and wrote a minute · 6 steps", composed from the steps (`stepsSummary`); it says what is happening now while the turn runs, and opens on one row per step (a search row counts what it found, a web search opens on the pages it found) |
|
|
407
407
|
| Web sources | Pages from the turn's supacrawl calls and the answer's Markdown links fold with the library's sources ("6 sources · 4 library · 2 web"), numbered on from the library's, and a link to one in the answer reads as that number's chip |
|
|
408
|
-
| Drawing a fence | `visuals` on `Conversation` is a registry keyed on a fence's language, `{ component, parse }` each; `defaultVisuals` (from `@poodle64/librarian/visuals`) draws `
|
|
408
|
+
| Drawing a fence | `visuals` on `Conversation` is a registry keyed on a fence's language, `{ component, parse }` each; `defaultVisuals` (from `@poodle64/librarian/visuals`) draws `chart` (`{title, type: bar | line | area, x, y: [fields], data}` on LayerChart, in the app's `--chart-1`…`--chart-5` tokens, loaded when an answer carries one), `timeline` and `card`. An app passes `{ ...defaultVisuals, openui }`. A block whose closing fence has not arrived is "Drawing…", one that does not parse is its code |
|
|
409
409
|
| "Answered without a source" | `copy.notHeld: ''` for a persona that answers from no sources |
|
|
410
410
|
| Saying something mid-run | `Composer`'s `sendWhileRunning`: while the session works the box stays open and Send sits beside Stop. For a route that reads a message while the run works; a chat leaves it off and waits for its answer |
|
|
411
411
|
|
package/dist/chat.svelte.d.ts
CHANGED
|
@@ -205,11 +205,13 @@ export declare class Chat {
|
|
|
205
205
|
new(): void;
|
|
206
206
|
/**
|
|
207
207
|
* Ask `question`, or what is in the box, as a `kind` of turn when it is not
|
|
208
|
-
* a plain question, narrowed to one `collection` by name when given.
|
|
209
|
-
*
|
|
210
|
-
*
|
|
208
|
+
* a plain question, narrowed to one `collection` by name when given. A
|
|
209
|
+
* question handed in carries `files` with it, the box's own going with a
|
|
210
|
+
* typed one. Resolves once the turn settles: true if it was asked, false if
|
|
211
|
+
* it was refused, here or by the server, in which case the question and its
|
|
212
|
+
* files go back into the box, handed or typed.
|
|
211
213
|
*/
|
|
212
|
-
ask(question?: string, kind?: string, collection?: string): Promise<boolean>;
|
|
214
|
+
ask(question?: string, kind?: string, collection?: string, files?: File[]): Promise<boolean>;
|
|
213
215
|
/** The last question again, as a new turn: the conversation is append-only,
|
|
214
216
|
* because the agent's transcript is. Same kind, same collection. */
|
|
215
217
|
again(): Promise<boolean>;
|
package/dist/chat.svelte.js
CHANGED
|
@@ -144,25 +144,31 @@ export class Chat {
|
|
|
144
144
|
}
|
|
145
145
|
/**
|
|
146
146
|
* Ask `question`, or what is in the box, as a `kind` of turn when it is not
|
|
147
|
-
* a plain question, narrowed to one `collection` by name when given.
|
|
148
|
-
*
|
|
149
|
-
*
|
|
147
|
+
* a plain question, narrowed to one `collection` by name when given. A
|
|
148
|
+
* question handed in carries `files` with it, the box's own going with a
|
|
149
|
+
* typed one. Resolves once the turn settles: true if it was asked, false if
|
|
150
|
+
* it was refused, here or by the server, in which case the question and its
|
|
151
|
+
* files go back into the box, handed or typed.
|
|
150
152
|
*/
|
|
151
|
-
async ask(question, kind, collection) {
|
|
153
|
+
async ask(question, kind, collection, files = []) {
|
|
152
154
|
const typed = question === undefined;
|
|
153
155
|
const text = (question ?? this.draft).trim();
|
|
154
156
|
if (!text)
|
|
155
157
|
return false;
|
|
156
158
|
if (this.#job)
|
|
157
159
|
return this.#message(text, typed);
|
|
158
|
-
if (this.busy || this.quota?.reached)
|
|
160
|
+
if (this.busy || this.quota?.reached) {
|
|
161
|
+
// A handed question has no other home: the box takes it, as it takes a refused one.
|
|
162
|
+
if (!typed)
|
|
163
|
+
this.#restore(text, files);
|
|
159
164
|
return false;
|
|
160
|
-
|
|
165
|
+
}
|
|
166
|
+
const sent = typed ? this.files : files;
|
|
161
167
|
if (typed) {
|
|
162
168
|
this.draft = '';
|
|
163
169
|
this.files = [];
|
|
164
170
|
}
|
|
165
|
-
return this.#ask(text,
|
|
171
|
+
return this.#ask(text, sent, typed, kind, collection);
|
|
166
172
|
}
|
|
167
173
|
/** The last question again, as a new turn: the conversation is append-only,
|
|
168
174
|
* because the agent's transcript is. Same kind, same collection. */
|
|
@@ -419,10 +425,7 @@ export class Chat {
|
|
|
419
425
|
const at = this.#turns.findIndex((t) => t.id === live.id);
|
|
420
426
|
if (at >= 0)
|
|
421
427
|
this.#turns.splice(at, 1);
|
|
422
|
-
|
|
423
|
-
this.draft = text;
|
|
424
|
-
this.files = files;
|
|
425
|
-
}
|
|
428
|
+
this.#restore(text, files);
|
|
426
429
|
this.#finish();
|
|
427
430
|
// Asked elsewhere and still being answered: show that instead.
|
|
428
431
|
if (ending === 'answering' && id)
|
|
@@ -564,6 +567,11 @@ export class Chat {
|
|
|
564
567
|
return;
|
|
565
568
|
}
|
|
566
569
|
}
|
|
570
|
+
/** A question that was not asked goes back into the box with its files, ahead of anything typed since. */
|
|
571
|
+
#restore(text, files) {
|
|
572
|
+
this.draft = this.draft ? `${text}\n\n${this.draft}` : text;
|
|
573
|
+
this.files = [...files, ...this.files];
|
|
574
|
+
}
|
|
567
575
|
async #message(text, typed) {
|
|
568
576
|
const id = this.conversationId;
|
|
569
577
|
if (!id)
|
|
@@ -576,6 +584,8 @@ export class Chat {
|
|
|
576
584
|
catch {
|
|
577
585
|
if (typed)
|
|
578
586
|
this.draft = text;
|
|
587
|
+
else
|
|
588
|
+
this.#restore(text, []);
|
|
579
589
|
return false;
|
|
580
590
|
}
|
|
581
591
|
// A settled job takes it as a new run, already started when the
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import PencilIcon from '@lucide/svelte/icons/pencil';
|
|
19
19
|
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
|
20
20
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
|
21
|
+
import type { Snippet } from 'svelte';
|
|
21
22
|
import {
|
|
22
23
|
outcomeLine,
|
|
23
24
|
readerQuestion,
|
|
@@ -116,6 +117,9 @@
|
|
|
116
117
|
* ("about A$0.34"). The package never converts currency itself,
|
|
117
118
|
* and a zero-cost run (a local model) shows no cost regardless. */
|
|
118
119
|
formatCost?: (usd: number) => string;
|
|
120
|
+
/** Drawn in place of the package's own "working" line while the answer has
|
|
121
|
+
* shown nothing yet; given the line's `label` and `since`. */
|
|
122
|
+
working?: Snippet<[{ label?: string; since?: number }]>;
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
let {
|
|
@@ -145,7 +149,8 @@
|
|
|
145
149
|
onmark,
|
|
146
150
|
shared,
|
|
147
151
|
onshare,
|
|
148
|
-
formatCost
|
|
152
|
+
formatCost,
|
|
153
|
+
working
|
|
149
154
|
}: AgentTranscriptProps = $props();
|
|
150
155
|
|
|
151
156
|
// The prose IS the artefact: it reads in the column, and the transcript
|
|
@@ -390,11 +395,18 @@
|
|
|
390
395
|
</header>
|
|
391
396
|
|
|
392
397
|
{#if running && segments.length === 0}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
+
{#if working}
|
|
399
|
+
{@render working({
|
|
400
|
+
label: answering !== undefined ? words.stillAnswering : undefined,
|
|
401
|
+
since: answering
|
|
402
|
+
})}
|
|
403
|
+
{:else}
|
|
404
|
+
<Working
|
|
405
|
+
copy={words}
|
|
406
|
+
label={answering !== undefined ? words.stillAnswering : undefined}
|
|
407
|
+
since={answering}
|
|
408
|
+
/>
|
|
409
|
+
{/if}
|
|
398
410
|
{/if}
|
|
399
411
|
|
|
400
412
|
{#each segments as seg (seg.index)}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
1
2
|
import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
|
|
2
3
|
import { type Citation } from '../../citations';
|
|
3
4
|
import { type ChatCopy } from '../../copy';
|
|
@@ -71,6 +72,12 @@ export interface AgentTranscriptProps {
|
|
|
71
72
|
* ("about A$0.34"). The package never converts currency itself,
|
|
72
73
|
* and a zero-cost run (a local model) shows no cost regardless. */
|
|
73
74
|
formatCost?: (usd: number) => string;
|
|
75
|
+
/** Drawn in place of the package's own "working" line while the answer has
|
|
76
|
+
* shown nothing yet; given the line's `label` and `since`. */
|
|
77
|
+
working?: Snippet<[{
|
|
78
|
+
label?: string;
|
|
79
|
+
since?: number;
|
|
80
|
+
}]>;
|
|
74
81
|
}
|
|
75
82
|
declare const AgentTranscript: import("svelte").Component<AgentTranscriptProps, {}, "">;
|
|
76
83
|
type AgentTranscript = ReturnType<typeof AgentTranscript>;
|
|
@@ -120,8 +120,13 @@
|
|
|
120
120
|
|
|
121
121
|
// Who the question is put to: every collection the person may read, or one
|
|
122
122
|
// of them. A person with one collection (or none) has nothing to pick, so
|
|
123
|
-
// nothing renders rather than a control that only reselects itself
|
|
124
|
-
|
|
123
|
+
// nothing renders rather than a control that only reselects itself, unless
|
|
124
|
+
// the question is already narrowed to the one held: a page that has not yet
|
|
125
|
+
// had the list shows the collection its address names, and the control stays
|
|
126
|
+
// put as the rest of the list arrives.
|
|
127
|
+
const hasChoice = $derived(
|
|
128
|
+
collections.length > 1 || (collection !== undefined && collections.length > 0)
|
|
129
|
+
);
|
|
125
130
|
|
|
126
131
|
let box = $state<HTMLElement | null>(null);
|
|
127
132
|
let wrapper = $state<HTMLElement | null>(null);
|
|
@@ -137,8 +142,10 @@
|
|
|
137
142
|
const el = textarea;
|
|
138
143
|
if (!el) return;
|
|
139
144
|
el.style.height = 'auto';
|
|
140
|
-
const
|
|
141
|
-
|
|
145
|
+
const style = getComputedStyle(el);
|
|
146
|
+
const line = parseFloat(style.lineHeight) || 27;
|
|
147
|
+
const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
|
|
148
|
+
el.style.height = `${Math.min(el.scrollHeight, line * 6 + padding)}px`;
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
$effect(() => {
|
|
@@ -280,7 +287,7 @@
|
|
|
280
287
|
bind:value
|
|
281
288
|
oninput={grow}
|
|
282
289
|
onkeydown={keydown}
|
|
283
|
-
rows="
|
|
290
|
+
rows="3"
|
|
284
291
|
disabled={waiting}
|
|
285
292
|
placeholder={waiting ? words.answeringPlaceholder : words.askPlaceholder}
|
|
286
293
|
class="ds-lib-input"
|
|
@@ -305,7 +312,7 @@
|
|
|
305
312
|
disabled={running || files.length >= MAX_FILES}
|
|
306
313
|
aria-label="Attach a file"
|
|
307
314
|
>
|
|
308
|
-
<PaperclipIcon size={
|
|
315
|
+
<PaperclipIcon size={20} />
|
|
309
316
|
</button>
|
|
310
317
|
{/if}
|
|
311
318
|
|
|
@@ -318,7 +325,7 @@
|
|
|
318
325
|
aria-label="Ask for a briefing"
|
|
319
326
|
title="Ask for a briefing"
|
|
320
327
|
>
|
|
321
|
-
<FileTextIcon size={
|
|
328
|
+
<FileTextIcon size={20} />
|
|
322
329
|
</button>
|
|
323
330
|
{/if}
|
|
324
331
|
|
|
@@ -339,7 +346,7 @@
|
|
|
339
346
|
<span class="ds-lib-spacer"></span>
|
|
340
347
|
{#if running}
|
|
341
348
|
<button type="button" class="ds-lib-stop" onclick={onstop} aria-label="Stop">
|
|
342
|
-
<SquareIcon size={
|
|
349
|
+
<SquareIcon size={16} fill="currentColor" />
|
|
343
350
|
</button>
|
|
344
351
|
{/if}
|
|
345
352
|
{#if !waiting}
|
|
@@ -350,7 +357,7 @@
|
|
|
350
357
|
disabled={!value.trim()}
|
|
351
358
|
aria-label="Send"
|
|
352
359
|
>
|
|
353
|
-
<ArrowUpIcon size={
|
|
360
|
+
<ArrowUpIcon size={20} />
|
|
354
361
|
</button>
|
|
355
362
|
{/if}
|
|
356
363
|
</div>
|
|
@@ -441,10 +448,14 @@
|
|
|
441
448
|
resize: none;
|
|
442
449
|
border: 0;
|
|
443
450
|
background: transparent;
|
|
444
|
-
padding:
|
|
451
|
+
padding: var(--ds-spacing-md) var(--ds-spacing-md) var(--ds-spacing-sm);
|
|
445
452
|
color: var(--ds-color-foreground);
|
|
446
453
|
font: inherit;
|
|
447
|
-
|
|
454
|
+
/* No token carries a body size or a control size, so these are raw:
|
|
455
|
+
a resting box that reads as a chat composer on every page (an app
|
|
456
|
+
overriding them is a defect here). Three lines, 18px text. */
|
|
457
|
+
font-size: 1.125rem;
|
|
458
|
+
line-height: 1.5;
|
|
448
459
|
outline: none;
|
|
449
460
|
}
|
|
450
461
|
|
|
@@ -462,7 +473,7 @@
|
|
|
462
473
|
align-items: center;
|
|
463
474
|
gap: 0.5rem;
|
|
464
475
|
row-gap: 0.375rem;
|
|
465
|
-
padding: 0
|
|
476
|
+
padding: 0 var(--ds-spacing-sm) var(--ds-spacing-sm);
|
|
466
477
|
}
|
|
467
478
|
|
|
468
479
|
.ds-lib-picker {
|
|
@@ -473,8 +484,8 @@
|
|
|
473
484
|
.ds-lib-stop,
|
|
474
485
|
.ds-lib-send {
|
|
475
486
|
display: flex;
|
|
476
|
-
width:
|
|
477
|
-
height:
|
|
487
|
+
width: 2.75rem;
|
|
488
|
+
height: 2.75rem;
|
|
478
489
|
flex: none;
|
|
479
490
|
align-items: center;
|
|
480
491
|
justify-content: center;
|
|
@@ -292,10 +292,11 @@
|
|
|
292
292
|
border: 1px solid transparent;
|
|
293
293
|
border-radius: var(--ds-radius-full);
|
|
294
294
|
background: none;
|
|
295
|
-
|
|
295
|
+
min-height: 2.75rem;
|
|
296
|
+
padding: 0 var(--ds-spacing-sm) 0 0.75rem;
|
|
296
297
|
color: var(--ds-color-muted-foreground);
|
|
297
298
|
font: inherit;
|
|
298
|
-
font-size:
|
|
299
|
+
font-size: 0.875rem;
|
|
299
300
|
cursor: pointer;
|
|
300
301
|
transition:
|
|
301
302
|
color 150ms ease,
|
|
@@ -137,6 +137,9 @@
|
|
|
137
137
|
* transcript. It stays put while the transcript scrolls and narrows
|
|
138
138
|
* with it when a pane opens. */
|
|
139
139
|
toolbar?: Snippet;
|
|
140
|
+
/** Drawn in place of each answer's "working" line before it shows anything
|
|
141
|
+
* (`AgentTranscriptProps.working`). */
|
|
142
|
+
working?: AgentTranscriptProps['working'];
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
let {
|
|
@@ -168,7 +171,8 @@
|
|
|
168
171
|
turn: presentTurn,
|
|
169
172
|
lead,
|
|
170
173
|
toolbar,
|
|
171
|
-
formatCost
|
|
174
|
+
formatCost,
|
|
175
|
+
working
|
|
172
176
|
}: Props = $props();
|
|
173
177
|
|
|
174
178
|
// Resolved ONCE, here, and handed down whole: every child takes `copy` and
|
|
@@ -392,6 +396,7 @@
|
|
|
392
396
|
? (text: string) => onedit(index, text)
|
|
393
397
|
: undefined,
|
|
394
398
|
formatCost,
|
|
399
|
+
working,
|
|
395
400
|
oncite: oncite || loadDocument ? cite : undefined,
|
|
396
401
|
onregenerate: last && !running ? onregenerate : undefined,
|
|
397
402
|
onmark:
|
|
@@ -96,6 +96,9 @@ interface Props {
|
|
|
96
96
|
* transcript. It stays put while the transcript scrolls and narrows
|
|
97
97
|
* with it when a pane opens. */
|
|
98
98
|
toolbar?: Snippet;
|
|
99
|
+
/** Drawn in place of each answer's "working" line before it shows anything
|
|
100
|
+
* (`AgentTranscriptProps.working`). */
|
|
101
|
+
working?: AgentTranscriptProps['working'];
|
|
99
102
|
}
|
|
100
103
|
declare const Conversation: import("svelte").Component<Props, {}, "">;
|
|
101
104
|
type Conversation = ReturnType<typeof Conversation>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { AreaChart, BarChart, LineChart } from 'layerchart';
|
|
3
|
+
import type { ChartValue } from '../../visuals';
|
|
4
|
+
|
|
5
|
+
let { value }: { value: ChartValue } = $props();
|
|
6
|
+
|
|
7
|
+
// `--chart-1` to `--chart-5` are the app's own series colours.
|
|
8
|
+
const series = $derived(
|
|
9
|
+
value.y.map((key, index) => ({ key, label: key, color: `var(--chart-${index + 1})` }))
|
|
10
|
+
);
|
|
11
|
+
const legend = $derived(series.length > 1);
|
|
12
|
+
// A line or area over ISO dates is a time axis; anything else is the label it says.
|
|
13
|
+
const data = $derived(
|
|
14
|
+
value.type !== 'bar' && value.data.every((row) => /^\d{4}-\d{2}(-\d{2})?$/.test(String(row[value.x])))
|
|
15
|
+
? value.data.map((row) => ({ ...row, [value.x]: new Date(String(row[value.x])) }))
|
|
16
|
+
: value.data
|
|
17
|
+
);
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
{#if value.type === 'bar'}
|
|
21
|
+
<BarChart {data} x={value.x} {series} {legend} seriesLayout="group" props={{ bars: { strokeWidth: 0, radius: 2 } }} />
|
|
22
|
+
{:else if value.type === 'line'}
|
|
23
|
+
<LineChart {data} x={value.x} {series} {legend} />
|
|
24
|
+
{:else}
|
|
25
|
+
<AreaChart {data} x={value.x} {series} {legend} />
|
|
26
|
+
{/if}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
One chart block, drawn with LayerChart in the app's chart tokens. The persona
|
|
3
|
+
writes the data and the kind; colour, type, axes and size come from here.
|
|
4
|
+
LayerChart is loaded when an answer carries a chart, never with the page.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import type { Component } from 'svelte';
|
|
8
|
+
import type { ChartValue } from '../../visuals';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
value: unknown;
|
|
12
|
+
source: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { value, source }: Props = $props();
|
|
16
|
+
const chart = $derived(value as ChartValue);
|
|
17
|
+
let Draw = $state<Component<{ value: ChartValue }> | null>(null);
|
|
18
|
+
let failed = $state(false);
|
|
19
|
+
|
|
20
|
+
$effect(() => {
|
|
21
|
+
if (Draw || failed) return;
|
|
22
|
+
import('./chart-draw.svelte').then(
|
|
23
|
+
(module) => (Draw = module.default),
|
|
24
|
+
() => (failed = true)
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
{#if failed}
|
|
30
|
+
<pre data-highlighted="true"><code>{source}</code></pre>
|
|
31
|
+
{:else}
|
|
32
|
+
<figure class="ds-lib-chart">
|
|
33
|
+
{#if chart.title}<figcaption class="ds-lib-chart-title">{chart.title}</figcaption>{/if}
|
|
34
|
+
<div class="ds-lib-chart-host">
|
|
35
|
+
{#if Draw}<Draw value={chart} />{/if}
|
|
36
|
+
</div>
|
|
37
|
+
</figure>
|
|
38
|
+
{/if}
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
.ds-lib-chart {
|
|
42
|
+
margin: 0.75em 0;
|
|
43
|
+
border: 1px solid var(--ds-color-border);
|
|
44
|
+
border-radius: var(--ds-radius-lg);
|
|
45
|
+
background: var(--ds-color-surface-1);
|
|
46
|
+
padding: 0.75rem 0.875rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.ds-lib-chart-title {
|
|
50
|
+
margin-bottom: 0.5rem;
|
|
51
|
+
color: var(--ds-color-foreground);
|
|
52
|
+
font-size: 0.875rem;
|
|
53
|
+
font-weight: 600;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ds-lib-chart-host {
|
|
57
|
+
width: 100%;
|
|
58
|
+
height: 16rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.ds-lib-chart-host :global(text) {
|
|
62
|
+
fill: var(--ds-color-muted-foreground);
|
|
63
|
+
stroke: none;
|
|
64
|
+
font-size: 12px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.ds-lib-chart-host :global(.lc-rule),
|
|
68
|
+
.ds-lib-chart-host :global(.lc-axis-tick),
|
|
69
|
+
.ds-lib-chart-host :global(.lc-grid-x-line),
|
|
70
|
+
.ds-lib-chart-host :global(.lc-grid-y-line) {
|
|
71
|
+
stroke: var(--ds-color-border-strong);
|
|
72
|
+
}
|
|
73
|
+
</style>
|
package/dist/visuals.d.ts
CHANGED
|
@@ -17,6 +17,14 @@ export interface Visual {
|
|
|
17
17
|
/** The block's text as the value its component draws; throws on a block that is invalid. */
|
|
18
18
|
parse: (source: string) => unknown;
|
|
19
19
|
}
|
|
20
|
+
export interface ChartValue {
|
|
21
|
+
title: string;
|
|
22
|
+
type: 'bar' | 'line' | 'area';
|
|
23
|
+
/** The field on the x axis; each y is a field drawn as a series. */
|
|
24
|
+
x: string;
|
|
25
|
+
y: string[];
|
|
26
|
+
data: Array<Record<string, string | number>>;
|
|
27
|
+
}
|
|
20
28
|
export interface TimelineValue {
|
|
21
29
|
title: string;
|
|
22
30
|
events: Array<{
|
|
@@ -34,5 +42,5 @@ export interface CardValue {
|
|
|
34
42
|
value: string;
|
|
35
43
|
}>;
|
|
36
44
|
}
|
|
37
|
-
/** A chart, a timeline and an entity card. An app adds to these or replaces one through `ChatExtensions.visuals`. */
|
|
45
|
+
/** A chart (LayerChart), a timeline and an entity card. An app adds to these or replaces one through `ChatExtensions.visuals`. */
|
|
38
46
|
export declare const defaultVisuals: Record<string, Visual>;
|
package/dist/visuals.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import EntityCard from './components/visual-block/entity-card.svelte';
|
|
12
12
|
import Timeline from './components/visual-block/timeline.svelte';
|
|
13
|
-
import
|
|
13
|
+
import Chart from './components/visual-block/chart.svelte';
|
|
14
14
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
15
|
const text = (value) => typeof value === 'string' ? value : typeof value === 'number' ? String(value) : '';
|
|
16
16
|
const object = (source) => {
|
|
@@ -19,32 +19,34 @@ const object = (source) => {
|
|
|
19
19
|
throw new Error('not an object');
|
|
20
20
|
return value;
|
|
21
21
|
};
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
22
|
+
const KINDS = ['bar', 'line', 'area'];
|
|
23
|
+
const MAX_SERIES = 5;
|
|
24
|
+
const MAX_ROWS = 500;
|
|
25
|
+
function parseChart(source) {
|
|
26
|
+
const value = object(source);
|
|
27
|
+
const type = KINDS.find((kind) => kind === value.type);
|
|
28
|
+
if (!type)
|
|
29
|
+
throw new Error('not a bar, line or area');
|
|
30
|
+
const x = text(value.x);
|
|
31
|
+
const y = Array.isArray(value.y) ? value.y.map(text) : [];
|
|
32
|
+
if (!x || y.length === 0 || y.length > MAX_SERIES || y.some((key) => !key))
|
|
33
|
+
throw new Error('no x or y');
|
|
34
|
+
const rows = Array.isArray(value.data) ? value.data : [];
|
|
35
|
+
if (rows.length === 0 || rows.length > MAX_ROWS)
|
|
36
|
+
throw new Error('no data');
|
|
37
|
+
const data = rows.map((row) => {
|
|
38
|
+
if (!isRecord(row) || !text(row[x]))
|
|
39
|
+
throw new Error('a row with no x');
|
|
40
|
+
const point = { [x]: typeof row[x] === 'number' ? row[x] : text(row[x]) };
|
|
41
|
+
for (const key of y) {
|
|
42
|
+
const number = row[key];
|
|
43
|
+
if (typeof number !== 'number' || !Number.isFinite(number))
|
|
44
|
+
throw new Error('a y that is not a number');
|
|
45
|
+
point[key] = number;
|
|
46
|
+
}
|
|
47
|
+
return point;
|
|
48
|
+
});
|
|
49
|
+
return { title: text(value.title), type, x, y, data };
|
|
48
50
|
}
|
|
49
51
|
function parseTimeline(source) {
|
|
50
52
|
const value = object(source);
|
|
@@ -74,9 +76,9 @@ function parseCard(source) {
|
|
|
74
76
|
.filter((fact) => fact.label || fact.value)
|
|
75
77
|
};
|
|
76
78
|
}
|
|
77
|
-
/** A chart, a timeline and an entity card. An app adds to these or replaces one through `ChatExtensions.visuals`. */
|
|
79
|
+
/** A chart (LayerChart), a timeline and an entity card. An app adds to these or replaces one through `ChatExtensions.visuals`. */
|
|
78
80
|
export const defaultVisuals = {
|
|
79
|
-
|
|
81
|
+
chart: { component: Chart, parse: parseChart },
|
|
80
82
|
timeline: { component: Timeline, parse: parseTimeline },
|
|
81
83
|
card: { component: EntityCard, parse: parseCard }
|
|
82
84
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/librarian",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.7",
|
|
4
4
|
"description": "An agent's conversation surface as a consumable Svelte 5 package: the stream client, transcript state and self-styling chat components (transcript, composer, markdown) every household app renders instead of rebuilding, speaking as whichever persona the app names.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -73,9 +73,7 @@
|
|
|
73
73
|
"shiki": "^4.4.3"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
|
-
"
|
|
77
|
-
"vega-embed": "7.3.0",
|
|
78
|
-
"vega-lite": "6.4.3"
|
|
76
|
+
"layerchart": "2.6.0"
|
|
79
77
|
},
|
|
80
78
|
"devDependencies": {
|
|
81
79
|
"@lucide/svelte": "^1.7.0",
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
One Vega-Lite v5 spec, drawn with the app's own tokens. The persona writes
|
|
3
|
-
the data and the marks; colour, type, axes and size come from here and
|
|
4
|
-
override whatever the spec says. Loaded on demand: vega is not small.
|
|
5
|
-
-->
|
|
6
|
-
<script lang="ts">
|
|
7
|
-
import { onMount } from 'svelte';
|
|
8
|
-
|
|
9
|
-
interface Props {
|
|
10
|
-
value: unknown;
|
|
11
|
-
source: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
let { value, source }: Props = $props();
|
|
15
|
-
let host = $state<HTMLDivElement | null>(null);
|
|
16
|
-
let failed = $state(false);
|
|
17
|
-
|
|
18
|
-
const css = (name: string): string =>
|
|
19
|
-
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
20
|
-
|
|
21
|
-
function config() {
|
|
22
|
-
const ink = css('--ds-color-foreground') || '#231f20';
|
|
23
|
-
const muted = css('--ds-color-muted-foreground') || '#57595c';
|
|
24
|
-
const line = css('--ds-color-border-strong') || '#00000038';
|
|
25
|
-
const font = getComputedStyle(document.body).fontFamily;
|
|
26
|
-
const series = [1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => css(`--chart-${i}`)).filter(Boolean);
|
|
27
|
-
return {
|
|
28
|
-
background: null,
|
|
29
|
-
font,
|
|
30
|
-
...(series.length > 0 ? { range: { category: series } } : {}),
|
|
31
|
-
mark: { color: series[0] ?? ink },
|
|
32
|
-
bar: { cornerRadius: 1 },
|
|
33
|
-
view: { stroke: null },
|
|
34
|
-
axis: {
|
|
35
|
-
labelColor: muted,
|
|
36
|
-
titleColor: muted,
|
|
37
|
-
domainColor: line,
|
|
38
|
-
tickColor: line,
|
|
39
|
-
gridColor: line,
|
|
40
|
-
gridOpacity: 0.5,
|
|
41
|
-
labelFont: font,
|
|
42
|
-
titleFont: font,
|
|
43
|
-
labelFontSize: 12,
|
|
44
|
-
titleFontSize: 12,
|
|
45
|
-
titleFontWeight: 600
|
|
46
|
-
},
|
|
47
|
-
legend: { labelColor: ink, titleColor: muted, labelFont: font, titleFont: font },
|
|
48
|
-
title: { color: ink, font, fontSize: 14, fontWeight: 600, anchor: 'start' }
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
let latest = 0;
|
|
53
|
-
// The view drawn last, ended before the next is drawn and when the chart goes.
|
|
54
|
-
let drawn: { finalize: () => void } | undefined;
|
|
55
|
-
async function draw() {
|
|
56
|
-
if (!host) return;
|
|
57
|
-
const mine = ++latest;
|
|
58
|
-
try {
|
|
59
|
-
const { default: embed } = await import('vega-embed');
|
|
60
|
-
if (mine !== latest) return;
|
|
61
|
-
const { $schema: _schema, config: _config, usermeta: _usermeta, ...spec } = value as Record<string, unknown>;
|
|
62
|
-
drawn?.finalize();
|
|
63
|
-
drawn = await embed(
|
|
64
|
-
host,
|
|
65
|
-
{ ...spec, width: 'container', autosize: { type: 'fit', contains: 'padding' } } as never,
|
|
66
|
-
{ actions: false, renderer: 'svg', config: config() as never }
|
|
67
|
-
);
|
|
68
|
-
failed = false;
|
|
69
|
-
} catch {
|
|
70
|
-
if (mine === latest) failed = true;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
onMount(() => {
|
|
75
|
-
// The palette follows the theme, and the theme is a class on the root.
|
|
76
|
-
const watch = new MutationObserver(() => void draw());
|
|
77
|
-
watch.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
78
|
-
return () => {
|
|
79
|
-
watch.disconnect();
|
|
80
|
-
drawn?.finalize();
|
|
81
|
-
};
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
$effect(() => {
|
|
85
|
-
void value;
|
|
86
|
-
void host;
|
|
87
|
-
void draw();
|
|
88
|
-
});
|
|
89
|
-
</script>
|
|
90
|
-
|
|
91
|
-
{#if failed}
|
|
92
|
-
<pre data-highlighted="true"><code>{source}</code></pre>
|
|
93
|
-
{:else}
|
|
94
|
-
<figure class="ds-lib-chart"><div bind:this={host} class="ds-lib-chart-host"></div></figure>
|
|
95
|
-
{/if}
|
|
96
|
-
|
|
97
|
-
<style>
|
|
98
|
-
.ds-lib-chart {
|
|
99
|
-
margin: 0.75em 0;
|
|
100
|
-
border: 1px solid var(--ds-color-border);
|
|
101
|
-
border-radius: var(--ds-radius-lg);
|
|
102
|
-
background: var(--ds-color-surface-1);
|
|
103
|
-
padding: 0.75rem 0.875rem;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
.ds-lib-chart-host {
|
|
107
|
-
width: 100%;
|
|
108
|
-
min-height: 6rem;
|
|
109
|
-
}
|
|
110
|
-
</style>
|