@poodle64/librarian 2026.10.4 → 2026.10.6
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 +8 -6
- package/dist/components/activity-group/activity-group.svelte +99 -17
- package/dist/components/activity-group/activity-group.svelte.d.ts +4 -1
- package/dist/components/agent-transcript/agent-transcript.svelte +82 -26
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +4 -3
- package/dist/components/artefact-pane/artefact-pane.svelte +278 -78
- package/dist/components/conversation/conversation.svelte +6 -6
- package/dist/components/conversation/conversation.svelte.d.ts +4 -4
- package/dist/components/markdown/markdown.d.ts +27 -10
- package/dist/components/markdown/markdown.js +99 -22
- package/dist/components/markdown/markdown.svelte +122 -28
- package/dist/components/markdown/markdown.svelte.d.ts +12 -4
- package/dist/components/source-fold/index.d.ts +2 -0
- package/dist/components/source-fold/index.js +2 -0
- package/dist/components/source-fold/source-fold.svelte +225 -0
- package/dist/components/source-fold/source-fold.svelte.d.ts +17 -0
- package/dist/components/source-list/source-list.svelte +5 -3
- package/dist/components/source-list/source-list.svelte.d.ts +2 -0
- package/dist/components/thinking-row/thinking-row.svelte +37 -16
- package/dist/components/thinking-row/thinking-row.svelte.d.ts +2 -0
- package/dist/components/tool-row/tool-row.svelte +130 -38
- 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/components/visual-block/entity-card.svelte +94 -0
- package/dist/components/visual-block/entity-card.svelte.d.ts +7 -0
- package/dist/components/visual-block/timeline.svelte +99 -0
- package/dist/components/visual-block/timeline.svelte.d.ts +7 -0
- package/dist/components/visual-block/visual-block.svelte +53 -0
- package/dist/components/visual-block/visual-block.svelte.d.ts +13 -0
- package/dist/components/written-card/written-card.svelte +97 -49
- package/dist/components/written-card/written-card.svelte.d.ts +1 -1
- package/dist/copy.d.ts +54 -0
- package/dist/copy.js +44 -1
- package/dist/diff.d.ts +8 -0
- package/dist/diff.js +37 -0
- package/dist/steps.d.ts +28 -0
- package/dist/steps.js +168 -0
- package/dist/transcript.svelte.d.ts +4 -0
- package/dist/transcript.svelte.js +13 -2
- package/dist/visuals.d.ts +46 -0
- package/dist/visuals.js +84 -0
- package/dist/web-sources.d.ts +19 -3
- package/dist/web-sources.js +39 -7
- package/dist/written.d.ts +26 -1
- package/dist/written.js +42 -0
- package/package.json +8 -1
- package/dist/components/web-list/index.d.ts +0 -2
- package/dist/components/web-list/index.js +0 -2
- package/dist/components/web-list/web-list.svelte +0 -84
- package/dist/components/web-list/web-list.svelte.d.ts +0 -9
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import DOMPurify from 'isomorphic-dompurify';
|
|
19
19
|
import { marked } from 'marked';
|
|
20
|
-
import {
|
|
20
|
+
import { markChanges } from '../../diff';
|
|
21
|
+
import { webKey } from '../../web-sources';
|
|
21
22
|
marked.setOptions({ gfm: true, breaks: false });
|
|
22
23
|
/** Close anything the stream has opened but not yet finished. */
|
|
23
24
|
export function balance(markdown) {
|
|
@@ -71,17 +72,25 @@ function dropFetchers(root) {
|
|
|
71
72
|
el.remove();
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
/**
|
|
76
|
+
* `markdown` as sanitised HTML. With `previous`, the version before it, each
|
|
77
|
+
* block it changed is marked on the sanitised DOM, before its one serialisation.
|
|
78
|
+
*/
|
|
79
|
+
export function render(markdown, { streaming = false, previous } = {}) {
|
|
80
|
+
const body = sanitised(streaming ? balance(markdown) : markdown);
|
|
81
|
+
if (previous !== undefined && !streaming)
|
|
82
|
+
markChanges(body, sanitised(previous));
|
|
83
|
+
return body.innerHTML;
|
|
84
|
+
}
|
|
85
|
+
function sanitised(source) {
|
|
86
|
+
const body = DOMPurify.sanitize(marked.parse(source, { async: false }), {
|
|
78
87
|
ADD_ATTR: ['target', 'rel', 'data-cite', 'tabindex', 'role'],
|
|
79
88
|
FORBID_TAGS: ['style', 'form', 'input', 'button'],
|
|
80
89
|
FORBID_ATTR: ['style', 'srcset', 'onerror', 'onload'],
|
|
81
90
|
RETURN_DOM: true
|
|
82
91
|
});
|
|
83
92
|
dropFetchers(body);
|
|
84
|
-
return body
|
|
93
|
+
return body;
|
|
85
94
|
}
|
|
86
95
|
let highlighterPromise = null;
|
|
87
96
|
/** Loaded once, lazily — shiki's engine and grammars are not small. */
|
|
@@ -176,26 +185,94 @@ export function markCitations(root, valid) {
|
|
|
176
185
|
text.replaceWith(fragment);
|
|
177
186
|
}
|
|
178
187
|
}
|
|
188
|
+
const OPEN = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)[^`]*$/;
|
|
189
|
+
const CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
|
|
190
|
+
/**
|
|
191
|
+
* Cut `markdown` at each fenced block whose language `has` a renderer for.
|
|
192
|
+
* A fence of another language (or none) is ordinary code and stays in the
|
|
193
|
+
* prose, and so does anything inside one: an example of a visual's fence in a
|
|
194
|
+
* code block is text. A block still open at the end is `closed: false`.
|
|
195
|
+
*/
|
|
196
|
+
export function splitVisuals(markdown, has) {
|
|
197
|
+
const parts = [];
|
|
198
|
+
let prose = [];
|
|
199
|
+
let code = null;
|
|
200
|
+
const flush = () => {
|
|
201
|
+
if (prose.join('').trim())
|
|
202
|
+
parts.push({ kind: 'md', text: prose.join('\n') });
|
|
203
|
+
prose = [];
|
|
204
|
+
};
|
|
205
|
+
for (const line of markdown.split('\n')) {
|
|
206
|
+
if (code) {
|
|
207
|
+
const close = CLOSE.exec(line);
|
|
208
|
+
if (close && close[1][0] === code.fence[0] && close[1].length >= code.fence.length) {
|
|
209
|
+
if (code.visual) {
|
|
210
|
+
parts.push({
|
|
211
|
+
kind: 'visual',
|
|
212
|
+
lang: code.visual.lang,
|
|
213
|
+
source: code.visual.lines.join('\n'),
|
|
214
|
+
closed: true
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
else
|
|
218
|
+
prose.push(line);
|
|
219
|
+
code = null;
|
|
220
|
+
}
|
|
221
|
+
else if (code.visual)
|
|
222
|
+
code.visual.lines.push(line);
|
|
223
|
+
else
|
|
224
|
+
prose.push(line);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const open = OPEN.exec(line);
|
|
228
|
+
if (open) {
|
|
229
|
+
const lang = open[2];
|
|
230
|
+
if (lang && has(lang)) {
|
|
231
|
+
flush();
|
|
232
|
+
code = { fence: open[1], visual: { lang, lines: [] } };
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
code = { fence: open[1] };
|
|
236
|
+
prose.push(line);
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
prose.push(line);
|
|
241
|
+
}
|
|
242
|
+
if (code?.visual)
|
|
243
|
+
parts.push({
|
|
244
|
+
kind: 'visual',
|
|
245
|
+
lang: code.visual.lang,
|
|
246
|
+
source: code.visual.lines.join('\n'),
|
|
247
|
+
closed: false
|
|
248
|
+
});
|
|
249
|
+
else
|
|
250
|
+
flush();
|
|
251
|
+
return parts;
|
|
252
|
+
}
|
|
179
253
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
254
|
+
* Turn each link to a numbered web page into its citation chip: the link's
|
|
255
|
+
* text stays, unlinked, and the chip after it carries the page's number and
|
|
256
|
+
* opens the page. Against the rendered DOM, for the reason `markCitations` is.
|
|
183
257
|
*/
|
|
184
|
-
export function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
258
|
+
export function markWebLinks(root, numbers) {
|
|
259
|
+
if (numbers.size === 0)
|
|
260
|
+
return;
|
|
261
|
+
for (const link of root.querySelectorAll('a[href]')) {
|
|
262
|
+
const href = link.getAttribute('href') ?? '';
|
|
263
|
+
const n = numbers.get(webKey(href));
|
|
264
|
+
if (n === undefined)
|
|
190
265
|
continue;
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
266
|
+
const chip = document.createElement('sup');
|
|
267
|
+
chip.className = 'ds-cite';
|
|
268
|
+
chip.dataset.cite = String(n);
|
|
269
|
+
chip.dataset.web = href;
|
|
270
|
+
chip.setAttribute('role', 'button');
|
|
271
|
+
chip.setAttribute('tabindex', '0');
|
|
272
|
+
chip.setAttribute('aria-label', `Web page ${n}`);
|
|
273
|
+
chip.textContent = String(n);
|
|
274
|
+
link.replaceWith(...link.childNodes, chip);
|
|
194
275
|
}
|
|
195
|
-
return () => {
|
|
196
|
-
for (const one of drawn)
|
|
197
|
-
void unmount(one);
|
|
198
|
-
};
|
|
199
276
|
}
|
|
200
277
|
/**
|
|
201
278
|
* Replace every `<pre><code>` in a rendered fragment with a highlighted one.
|
|
@@ -7,13 +7,16 @@
|
|
|
7
7
|
consuming app.
|
|
8
8
|
-->
|
|
9
9
|
<script lang="ts">
|
|
10
|
+
import { resolveCopy, type ChatCopy } from '../../copy';
|
|
11
|
+
import type { Visual } from '../../visuals';
|
|
12
|
+
import VisualBlock from '../visual-block/visual-block.svelte';
|
|
10
13
|
import {
|
|
11
14
|
highlight,
|
|
12
15
|
markCitations,
|
|
13
16
|
markCollections,
|
|
14
|
-
|
|
17
|
+
markWebLinks,
|
|
15
18
|
render,
|
|
16
|
-
|
|
19
|
+
splitVisuals
|
|
17
20
|
} from './markdown';
|
|
18
21
|
|
|
19
22
|
interface Props {
|
|
@@ -23,13 +26,20 @@
|
|
|
23
26
|
collectionNames?: Set<string>;
|
|
24
27
|
/** `[n]` markers to turn into chips; omit and the markers stay as text. */
|
|
25
28
|
citationNumbers?: Set<number>;
|
|
29
|
+
/** Web pages by `webKey`, numbered after the library's: a link to one
|
|
30
|
+
* renders as its chip. */
|
|
31
|
+
webNumbers?: Map<string, number>;
|
|
26
32
|
oncite?: (n: number) => void;
|
|
27
33
|
/** A step down, for prose that is reference material beside an answer
|
|
28
34
|
* rather than the answer itself — the source pane's sections. */
|
|
29
35
|
dense?: boolean;
|
|
30
|
-
/**
|
|
31
|
-
* Absent, every fence
|
|
32
|
-
|
|
36
|
+
/** Renderers by a fence's language: a block of one is drawn by its
|
|
37
|
+
* component in place of the code. Absent, every fence is code. */
|
|
38
|
+
visuals?: Record<string, Visual>;
|
|
39
|
+
/** The same document's earlier version, as Markdown: what differs here
|
|
40
|
+
* is marked with a bar. */
|
|
41
|
+
previous?: string;
|
|
42
|
+
copy?: Partial<ChatCopy>;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
let {
|
|
@@ -37,53 +47,68 @@
|
|
|
37
47
|
streaming = false,
|
|
38
48
|
collectionNames = new Set(),
|
|
39
49
|
citationNumbers = new Set(),
|
|
50
|
+
webNumbers = new Map(),
|
|
40
51
|
oncite,
|
|
41
52
|
dense = false,
|
|
42
|
-
|
|
53
|
+
visuals,
|
|
54
|
+
previous,
|
|
55
|
+
copy
|
|
43
56
|
}: Props = $props();
|
|
44
57
|
let host = $state<HTMLElement | null>(null);
|
|
45
58
|
|
|
46
|
-
const
|
|
59
|
+
const words = $derived(resolveCopy(copy));
|
|
47
60
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
// A visual is its own component between chunks of prose, so a token that
|
|
62
|
+
// grows the last chunk leaves the charts above it standing.
|
|
63
|
+
const parts = $derived(
|
|
64
|
+
visuals
|
|
65
|
+
? splitVisuals(content, (lang) => Object.hasOwn(visuals, lang))
|
|
66
|
+
: [{ kind: 'md' as const, text: content }]
|
|
67
|
+
);
|
|
68
|
+
const chunked = $derived(parts.some((part) => part.kind === 'visual'));
|
|
69
|
+
const lastPart = $derived(parts.length - 1);
|
|
70
|
+
|
|
71
|
+
const prose = (text: string, last: boolean): string => render(text, { streaming: streaming && last, previous });
|
|
56
72
|
|
|
57
73
|
// Highlight only once the text has settled. Re-running per token would
|
|
58
74
|
// re-parse grammars for a block that changes again milliseconds later.
|
|
59
75
|
$effect(() => {
|
|
60
|
-
|
|
76
|
+
void parts;
|
|
77
|
+
if (!host) return;
|
|
61
78
|
// Chips are cheap and wanted DURING streaming; highlighting is not.
|
|
62
79
|
markCollections(host, collectionNames);
|
|
63
80
|
markCitations(host, citationNumbers);
|
|
81
|
+
markWebLinks(host, webNumbers);
|
|
64
82
|
if (streaming) return;
|
|
65
83
|
void highlight(host);
|
|
66
84
|
});
|
|
67
85
|
|
|
68
86
|
// Delegated, because the chips are created by the sanitiser's output rather
|
|
69
87
|
// than by this template — there is no element here to put a handler on.
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
function chipOf(target: EventTarget | null): HTMLElement | null {
|
|
89
|
+
return ((target as HTMLElement | null)?.closest?.('[data-cite]') as HTMLElement | null) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function press(chip: HTMLElement) {
|
|
93
|
+
if (chip.dataset.web) {
|
|
94
|
+
window.open(chip.dataset.web, '_blank', 'noopener,noreferrer');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const n = Number(chip.dataset.cite);
|
|
98
|
+
if (Number.isFinite(n) && n > 0) oncite?.(n);
|
|
74
99
|
}
|
|
75
100
|
|
|
76
101
|
function click(event: MouseEvent) {
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
102
|
+
const chip = chipOf(event.target);
|
|
103
|
+
if (chip) press(chip);
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
function keydown(event: KeyboardEvent) {
|
|
82
107
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
108
|
+
const chip = chipOf(event.target);
|
|
109
|
+
if (!chip) return;
|
|
85
110
|
event.preventDefault();
|
|
86
|
-
|
|
111
|
+
press(chip);
|
|
87
112
|
}
|
|
88
113
|
</script>
|
|
89
114
|
|
|
@@ -97,9 +122,30 @@
|
|
|
97
122
|
class="agent-prose"
|
|
98
123
|
class:is-dense={dense}
|
|
99
124
|
class:is-streaming={streaming}
|
|
125
|
+
class:is-chunked={chunked}
|
|
100
126
|
>
|
|
101
|
-
|
|
102
|
-
|
|
127
|
+
{#if !chunked}
|
|
128
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in render() -->
|
|
129
|
+
{@html prose(content, true)}
|
|
130
|
+
{:else}
|
|
131
|
+
{#each parts as part, i (i)}
|
|
132
|
+
{#if part.kind === 'md'}
|
|
133
|
+
<div class="ds-lib-chunk">
|
|
134
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in render() -->
|
|
135
|
+
{@html prose(part.text, i === lastPart)}
|
|
136
|
+
</div>
|
|
137
|
+
{:else if visuals}
|
|
138
|
+
<VisualBlock
|
|
139
|
+
lang={part.lang}
|
|
140
|
+
visual={visuals[part.lang]}
|
|
141
|
+
source={part.source}
|
|
142
|
+
closed={part.closed}
|
|
143
|
+
{streaming}
|
|
144
|
+
{words}
|
|
145
|
+
/>
|
|
146
|
+
{/if}
|
|
147
|
+
{/each}
|
|
148
|
+
{/if}
|
|
103
149
|
</div>
|
|
104
150
|
|
|
105
151
|
<style>
|
|
@@ -131,6 +177,31 @@
|
|
|
131
177
|
animation: ds-lib-caret 1.1s steps(1, end) infinite;
|
|
132
178
|
}
|
|
133
179
|
|
|
180
|
+
/* Chunked, the caret rides the last prose chunk's last block, not the chunk. */
|
|
181
|
+
.agent-prose.is-chunked.is-streaming :global(> .ds-lib-chunk:last-child::after) {
|
|
182
|
+
content: none;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.agent-prose.is-chunked.is-streaming :global(> .ds-lib-chunk:last-child > *:last-child::after) {
|
|
186
|
+
content: '';
|
|
187
|
+
display: inline-block;
|
|
188
|
+
width: 0.45em;
|
|
189
|
+
height: 1.05em;
|
|
190
|
+
margin-inline-start: 0.12em;
|
|
191
|
+
border-radius: 1px;
|
|
192
|
+
background: var(--ds-color-primary);
|
|
193
|
+
vertical-align: -0.15em;
|
|
194
|
+
animation: ds-lib-caret 1.1s steps(1, end) infinite;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.agent-prose :global(.ds-lib-chunk > *:first-child) {
|
|
198
|
+
margin-top: 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.agent-prose :global(.ds-lib-chunk > *:last-child) {
|
|
202
|
+
margin-bottom: 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
134
205
|
@keyframes ds-lib-caret {
|
|
135
206
|
50% {
|
|
136
207
|
opacity: 0;
|
|
@@ -138,7 +209,8 @@
|
|
|
138
209
|
}
|
|
139
210
|
|
|
140
211
|
@media (prefers-reduced-motion: reduce) {
|
|
141
|
-
.agent-prose.is-streaming :global(> *:last-child::after)
|
|
212
|
+
.agent-prose.is-streaming :global(> *:last-child::after),
|
|
213
|
+
.agent-prose.is-chunked.is-streaming :global(> .ds-lib-chunk:last-child > *:last-child::after) {
|
|
142
214
|
animation: none;
|
|
143
215
|
}
|
|
144
216
|
}
|
|
@@ -374,6 +446,28 @@
|
|
|
374
446
|
outline-offset: 2px;
|
|
375
447
|
}
|
|
376
448
|
|
|
449
|
+
/* What this version changed: a bar in the margin beside the block. */
|
|
450
|
+
.agent-prose :global([data-changed]) {
|
|
451
|
+
position: relative;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.agent-prose :global([data-changed]::before) {
|
|
455
|
+
content: '';
|
|
456
|
+
position: absolute;
|
|
457
|
+
inset-block: 0;
|
|
458
|
+
inset-inline-start: -0.75rem;
|
|
459
|
+
width: 2px;
|
|
460
|
+
background: var(--ds-color-foreground);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.agent-prose :global(tr[data-changed]::before) {
|
|
464
|
+
content: none;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.agent-prose :global(tr[data-changed] > td:first-child) {
|
|
468
|
+
box-shadow: inset 2px 0 0 var(--ds-color-foreground);
|
|
469
|
+
}
|
|
470
|
+
|
|
377
471
|
.agent-prose :global(hr) {
|
|
378
472
|
border: 0;
|
|
379
473
|
border-top: 1px solid var(--ds-color-border);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ChatCopy } from '../../copy';
|
|
2
|
+
import type { Visual } from '../../visuals';
|
|
2
3
|
interface Props {
|
|
3
4
|
content: string;
|
|
4
5
|
streaming?: boolean;
|
|
@@ -6,13 +7,20 @@ interface Props {
|
|
|
6
7
|
collectionNames?: Set<string>;
|
|
7
8
|
/** `[n]` markers to turn into chips; omit and the markers stay as text. */
|
|
8
9
|
citationNumbers?: Set<number>;
|
|
10
|
+
/** Web pages by `webKey`, numbered after the library's: a link to one
|
|
11
|
+
* renders as its chip. */
|
|
12
|
+
webNumbers?: Map<string, number>;
|
|
9
13
|
oncite?: (n: number) => void;
|
|
10
14
|
/** A step down, for prose that is reference material beside an answer
|
|
11
15
|
* rather than the answer itself — the source pane's sections. */
|
|
12
16
|
dense?: boolean;
|
|
13
|
-
/**
|
|
14
|
-
* Absent, every fence
|
|
15
|
-
|
|
17
|
+
/** Renderers by a fence's language: a block of one is drawn by its
|
|
18
|
+
* component in place of the code. Absent, every fence is code. */
|
|
19
|
+
visuals?: Record<string, Visual>;
|
|
20
|
+
/** The same document's earlier version, as Markdown: what differs here
|
|
21
|
+
* is marked with a bar. */
|
|
22
|
+
previous?: string;
|
|
23
|
+
copy?: Partial<ChatCopy>;
|
|
16
24
|
}
|
|
17
25
|
declare const Markdown: import("svelte").Component<Props, {}, "">;
|
|
18
26
|
type Markdown = ReturnType<typeof Markdown>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
What an answer drew on, folded behind one line: "6 sources · 4 library · 2 web".
|
|
3
|
+
|
|
4
|
+
It opens on the library's numbered sources and then the web pages, numbered
|
|
5
|
+
on from them: the persona cites a page by its link, and the page numbers the
|
|
6
|
+
link with the same chip a library source gets.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
|
10
|
+
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
|
11
|
+
import GlobeIcon from '@lucide/svelte/icons/globe';
|
|
12
|
+
import LibraryIcon from '@lucide/svelte/icons/library';
|
|
13
|
+
import type { Citation } from '../../citations';
|
|
14
|
+
import { fill, type ChatCopy } from '../../copy';
|
|
15
|
+
import type { WebSource } from '../../web-sources';
|
|
16
|
+
import SourceList from '../source-list/source-list.svelte';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
library: Citation[];
|
|
20
|
+
/** Pages, each with the number the answer's chip for it carries. */
|
|
21
|
+
web: Array<WebSource & { n: number }>;
|
|
22
|
+
words: ChatCopy;
|
|
23
|
+
oncite?: (citation: Citation) => void;
|
|
24
|
+
/** When the pages were read, in words ("read today"), for a page the turn read. */
|
|
25
|
+
readWhen?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let { library, web, words, oncite, readWhen = '' }: Props = $props();
|
|
29
|
+
let open = $state(false);
|
|
30
|
+
|
|
31
|
+
const total = $derived(library.length + web.length);
|
|
32
|
+
|
|
33
|
+
function when(page: WebSource): string {
|
|
34
|
+
if (page.date) {
|
|
35
|
+
const date = new Date(page.date);
|
|
36
|
+
return date.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
37
|
+
}
|
|
38
|
+
return page.read ? readWhen : '';
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<section class="ds-lib-fold">
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
class="ds-lib-fold-head"
|
|
46
|
+
onclick={() => (open = !open)}
|
|
47
|
+
aria-expanded={open}
|
|
48
|
+
>
|
|
49
|
+
<span class="ds-lib-fold-icons" aria-hidden="true">
|
|
50
|
+
{#if library.length > 0}<span><LibraryIcon size={11} /></span>{/if}
|
|
51
|
+
{#if web.length > 0}<span><GlobeIcon size={11} /></span>{/if}
|
|
52
|
+
</span>
|
|
53
|
+
<span class="ds-lib-fold-count">
|
|
54
|
+
{total === 1 ? words.sourcesCountOne : fill(words.sourcesCount, { n: total })}
|
|
55
|
+
</span>
|
|
56
|
+
{#if library.length > 0 && web.length > 0}
|
|
57
|
+
<span class="ds-lib-fold-split">
|
|
58
|
+
{fill(words.sourcesLibrary, { n: library.length })} · {fill(words.sourcesWeb, { n: web.length })}
|
|
59
|
+
</span>
|
|
60
|
+
{/if}
|
|
61
|
+
<span class="ds-lib-fold-chevron" aria-hidden="true">
|
|
62
|
+
{#if open}<ChevronUpIcon size={15} />{:else}<ChevronDownIcon size={15} />{/if}
|
|
63
|
+
</span>
|
|
64
|
+
</button>
|
|
65
|
+
|
|
66
|
+
{#if open}
|
|
67
|
+
<div class="ds-lib-fold-body">
|
|
68
|
+
{#if library.length > 0}
|
|
69
|
+
<SourceList sources={library} {words} {oncite} heading={words.fromTheLibrary} />
|
|
70
|
+
{/if}
|
|
71
|
+
{#if web.length > 0}
|
|
72
|
+
<section>
|
|
73
|
+
<h2 class="ds-lib-fold-heading">{words.fromTheWeb}</h2>
|
|
74
|
+
<ol class="ds-lib-fold-pages">
|
|
75
|
+
{#each web as page (page.url)}
|
|
76
|
+
<li>
|
|
77
|
+
<a class="ds-lib-fold-page" href={page.url} target="_blank" rel="noopener noreferrer">
|
|
78
|
+
<span class="ds-lib-fold-n">{page.n}</span>
|
|
79
|
+
<span class="ds-lib-fold-page-text">
|
|
80
|
+
<span class="ds-lib-fold-page-title">{page.title}</span>
|
|
81
|
+
<span class="ds-lib-fold-page-meta">
|
|
82
|
+
{[page.host, when(page)].filter(Boolean).join(' · ')}
|
|
83
|
+
</span>
|
|
84
|
+
</span>
|
|
85
|
+
</a>
|
|
86
|
+
</li>
|
|
87
|
+
{/each}
|
|
88
|
+
</ol>
|
|
89
|
+
</section>
|
|
90
|
+
{/if}
|
|
91
|
+
</div>
|
|
92
|
+
{/if}
|
|
93
|
+
</section>
|
|
94
|
+
|
|
95
|
+
<style>
|
|
96
|
+
.ds-lib-fold-head {
|
|
97
|
+
display: flex;
|
|
98
|
+
min-height: 2.5rem;
|
|
99
|
+
align-items: center;
|
|
100
|
+
gap: 0.625rem;
|
|
101
|
+
border: 0;
|
|
102
|
+
background: none;
|
|
103
|
+
padding: 0;
|
|
104
|
+
color: var(--ds-color-muted-foreground);
|
|
105
|
+
font: inherit;
|
|
106
|
+
font-size: 0.875rem;
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.ds-lib-fold-head:hover {
|
|
111
|
+
color: var(--ds-color-foreground);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.ds-lib-fold-head:focus-visible {
|
|
115
|
+
outline: 2px solid var(--ds-color-ring);
|
|
116
|
+
outline-offset: 2px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.ds-lib-fold-icons {
|
|
120
|
+
display: flex;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.ds-lib-fold-icons span {
|
|
124
|
+
display: flex;
|
|
125
|
+
width: 1.375rem;
|
|
126
|
+
height: 1.375rem;
|
|
127
|
+
align-items: center;
|
|
128
|
+
justify-content: center;
|
|
129
|
+
border: 1px solid var(--ds-color-border);
|
|
130
|
+
border-radius: var(--ds-radius-full);
|
|
131
|
+
background: var(--ds-color-surface-2);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.ds-lib-fold-icons span + span {
|
|
135
|
+
margin-inline-start: -0.375rem;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.ds-lib-fold-count {
|
|
139
|
+
color: var(--ds-color-foreground);
|
|
140
|
+
font-weight: 500;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.ds-lib-fold-split {
|
|
144
|
+
font-size: var(--ds-text-2xs);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.ds-lib-fold-chevron {
|
|
148
|
+
display: flex;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.ds-lib-fold-body {
|
|
152
|
+
display: flex;
|
|
153
|
+
flex-direction: column;
|
|
154
|
+
gap: 1rem;
|
|
155
|
+
margin-top: 0.5rem;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.ds-lib-fold-heading {
|
|
159
|
+
margin: 0 0 0.375rem;
|
|
160
|
+
color: var(--ds-color-muted-foreground);
|
|
161
|
+
font-family: inherit;
|
|
162
|
+
font-size: var(--ds-text-2xs);
|
|
163
|
+
font-weight: 500;
|
|
164
|
+
letter-spacing: 0.05em;
|
|
165
|
+
text-transform: uppercase;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.ds-lib-fold-pages {
|
|
169
|
+
display: flex;
|
|
170
|
+
flex-direction: column;
|
|
171
|
+
gap: 0.125rem;
|
|
172
|
+
margin: 0;
|
|
173
|
+
border-top: 1px solid var(--ds-color-border);
|
|
174
|
+
padding: 0.375rem 0 0;
|
|
175
|
+
list-style: none;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.ds-lib-fold-page {
|
|
179
|
+
display: flex;
|
|
180
|
+
align-items: baseline;
|
|
181
|
+
gap: 0.5rem;
|
|
182
|
+
border-radius: var(--ds-radius-md);
|
|
183
|
+
padding: 0.375rem 0.625rem;
|
|
184
|
+
color: inherit;
|
|
185
|
+
font-size: 0.875rem;
|
|
186
|
+
text-decoration: none;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.ds-lib-fold-page:hover {
|
|
190
|
+
background: var(--ds-color-surface-2);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.ds-lib-fold-page:focus-visible {
|
|
194
|
+
outline: 2px solid var(--ds-color-ring);
|
|
195
|
+
outline-offset: 2px;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.ds-lib-fold-n {
|
|
199
|
+
flex: none;
|
|
200
|
+
border-radius: var(--ds-radius-sm);
|
|
201
|
+
background: color-mix(in oklab, var(--ds-color-primary) 15%, transparent);
|
|
202
|
+
padding-inline: 0.375rem;
|
|
203
|
+
color: var(--ds-color-foreground);
|
|
204
|
+
font-family: var(--ds-font-code);
|
|
205
|
+
font-size: var(--ds-text-2xs);
|
|
206
|
+
font-variant-numeric: tabular-nums;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.ds-lib-fold-page-text {
|
|
210
|
+
display: flex;
|
|
211
|
+
min-width: 0;
|
|
212
|
+
flex-direction: column;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.ds-lib-fold-page-title {
|
|
216
|
+
color: var(--ds-color-foreground);
|
|
217
|
+
font-weight: 500;
|
|
218
|
+
overflow-wrap: anywhere;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.ds-lib-fold-page-meta {
|
|
222
|
+
color: var(--ds-color-muted-foreground);
|
|
223
|
+
font-size: var(--ds-text-2xs);
|
|
224
|
+
}
|
|
225
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Citation } from '../../citations';
|
|
2
|
+
import { type ChatCopy } from '../../copy';
|
|
3
|
+
import type { WebSource } from '../../web-sources';
|
|
4
|
+
interface Props {
|
|
5
|
+
library: Citation[];
|
|
6
|
+
/** Pages, each with the number the answer's chip for it carries. */
|
|
7
|
+
web: Array<WebSource & {
|
|
8
|
+
n: number;
|
|
9
|
+
}>;
|
|
10
|
+
words: ChatCopy;
|
|
11
|
+
oncite?: (citation: Citation) => void;
|
|
12
|
+
/** When the pages were read, in words ("read today"), for a page the turn read. */
|
|
13
|
+
readWhen?: string;
|
|
14
|
+
}
|
|
15
|
+
declare const SourceFold: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type SourceFold = ReturnType<typeof SourceFold>;
|
|
17
|
+
export default SourceFold;
|