@poodle64/librarian 2026.9.6 → 2026.9.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 +128 -43
- package/dist/attachments.d.ts +30 -0
- package/dist/attachments.js +80 -0
- package/dist/citations.d.ts +63 -0
- package/dist/citations.js +89 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +38 -12
- package/dist/components/activity-group/activity-group.svelte +36 -44
- package/dist/components/activity-group/activity-group.svelte.d.ts +1 -1
- package/dist/components/agent-transcript/agent-transcript.svelte +166 -26
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
- package/dist/components/composer/composer.svelte +206 -53
- package/dist/components/composer/composer.svelte.d.ts +5 -1
- package/dist/components/conversation/conversation.svelte +179 -0
- package/dist/components/conversation/conversation.svelte.d.ts +26 -0
- package/dist/components/conversation/index.d.ts +2 -0
- package/dist/components/conversation/index.js +2 -0
- package/dist/components/document-pane/document-pane.svelte +177 -0
- package/dist/components/document-pane/document-pane.svelte.d.ts +9 -0
- package/dist/components/document-pane/index.d.ts +2 -0
- package/dist/components/document-pane/index.js +2 -0
- package/dist/components/markdown/index.d.ts +1 -1
- package/dist/components/markdown/index.js +1 -1
- package/dist/components/markdown/markdown.d.ts +13 -0
- package/dist/components/markdown/markdown.js +49 -1
- package/dist/components/markdown/markdown.svelte +85 -3
- package/dist/components/markdown/markdown.svelte.d.ts +6 -0
- package/dist/follow-scroll.svelte.d.ts +34 -0
- package/dist/follow-scroll.svelte.js +46 -0
- package/dist/transcript.svelte.d.ts +27 -0
- package/dist/transcript.svelte.js +27 -0
- package/package.json +16 -2
|
@@ -3,13 +3,17 @@ interface Props {
|
|
|
3
3
|
value: string;
|
|
4
4
|
running: boolean;
|
|
5
5
|
scope: Scope;
|
|
6
|
+
/** Files to send with the next question. Cleared by the caller on send. */
|
|
7
|
+
files?: File[];
|
|
6
8
|
/** Names for the two narrower scopes; absent means that scope is unavailable. */
|
|
7
9
|
documentName?: string;
|
|
8
10
|
collectionName?: string;
|
|
11
|
+
/** Hides the paperclip for a host whose ask route takes no files. */
|
|
12
|
+
attachments?: boolean;
|
|
9
13
|
onscope: (scope: Scope) => void;
|
|
10
14
|
onsubmit: () => void;
|
|
11
15
|
onstop: () => void;
|
|
12
16
|
}
|
|
13
|
-
declare const Composer: import("svelte").Component<Props, {}, "value">;
|
|
17
|
+
declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
|
|
14
18
|
type Composer = ReturnType<typeof Composer>;
|
|
15
19
|
export default Composer;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The whole reading surface: every turn, the scroll that follows the stream,
|
|
3
|
+
and the source pane beside it.
|
|
4
|
+
|
|
5
|
+
This owns the SCROLL CONTAINER, which is the reason it exists rather than the
|
|
6
|
+
host composing `AgentTranscript` in a div of its own. Follow-scroll, the
|
|
7
|
+
jump-to-latest pill and a document pane that narrows the transcript instead
|
|
8
|
+
of covering it are all one question — where the overflow lives — and three
|
|
9
|
+
hosts answering it separately is three chances to answer it differently.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import type { Snippet } from 'svelte';
|
|
13
|
+
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
|
14
|
+
import type { Turn } from '../../transcript.svelte';
|
|
15
|
+
import type { Citation, LoadDocument } from '../../citations';
|
|
16
|
+
import { FollowScroll } from '../../follow-scroll.svelte';
|
|
17
|
+
import AgentTranscript from '../agent-transcript/agent-transcript.svelte';
|
|
18
|
+
import DocumentPane from '../document-pane/document-pane.svelte';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
turns: Turn[];
|
|
22
|
+
running: boolean;
|
|
23
|
+
/** `Transcript.version` — bumped per event. Text grows in place, so a
|
|
24
|
+
* count of turns is not enough to keep the scroll following. */
|
|
25
|
+
version?: number;
|
|
26
|
+
welcome?: string;
|
|
27
|
+
/** Up to three, shown in the empty state as tappable pills. */
|
|
28
|
+
examples?: string[];
|
|
29
|
+
onexample?: (question: string) => void;
|
|
30
|
+
/** Re-asks the last question. Offered on the last answer only. */
|
|
31
|
+
onregenerate?: () => void;
|
|
32
|
+
/** Enables the source pane. Without it, chips render but do not open. */
|
|
33
|
+
loadDocument?: LoadDocument;
|
|
34
|
+
collectionNames?: Set<string>;
|
|
35
|
+
/** The composer, rendered INSIDE the transcript column so the source
|
|
36
|
+
* pane narrows it too — a composer the host places outside slides
|
|
37
|
+
* under the pane the moment one opens. */
|
|
38
|
+
composer?: Snippet;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let {
|
|
42
|
+
turns,
|
|
43
|
+
running,
|
|
44
|
+
version = 0,
|
|
45
|
+
welcome = 'Ask Milton a question about the library.',
|
|
46
|
+
examples = [],
|
|
47
|
+
onexample,
|
|
48
|
+
onregenerate,
|
|
49
|
+
loadDocument,
|
|
50
|
+
collectionNames = new Set(),
|
|
51
|
+
composer
|
|
52
|
+
}: Props = $props();
|
|
53
|
+
|
|
54
|
+
let viewport = $state<HTMLElement | null>(null);
|
|
55
|
+
let open = $state<Citation | null>(null);
|
|
56
|
+
const follow = new FollowScroll();
|
|
57
|
+
|
|
58
|
+
function metrics(el: HTMLElement) {
|
|
59
|
+
return {
|
|
60
|
+
scrollTop: el.scrollTop,
|
|
61
|
+
clientHeight: el.clientHeight,
|
|
62
|
+
scrollHeight: el.scrollHeight
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A new question re-pins: the reader asked for the thing about to arrive,
|
|
67
|
+
// even if they were reading something further up when they asked.
|
|
68
|
+
//
|
|
69
|
+
// Deliberately NOT `$state`: an effect that both reads and writes one piece
|
|
70
|
+
// of reactive state re-triggers itself, which Svelte stops with
|
|
71
|
+
// `effect_update_depth_exceeded` — measured, on this component. Nothing
|
|
72
|
+
// renders this, so a plain variable is both correct and enough.
|
|
73
|
+
let seen = 0;
|
|
74
|
+
$effect(() => {
|
|
75
|
+
if (turns.length > seen) follow.pin();
|
|
76
|
+
seen = turns.length;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
$effect(() => {
|
|
80
|
+
// Both dependencies are deliberate: `version` for text growing inside an
|
|
81
|
+
// existing block, `turns.length` for a whole new turn.
|
|
82
|
+
void version;
|
|
83
|
+
void turns.length;
|
|
84
|
+
if (!viewport || !follow.following) return;
|
|
85
|
+
viewport.scrollTop = viewport.scrollHeight;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function scrolled() {
|
|
89
|
+
if (viewport) follow.measure(metrics(viewport));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function jump() {
|
|
93
|
+
if (!viewport) return;
|
|
94
|
+
follow.pin();
|
|
95
|
+
viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cite(citation: Citation) {
|
|
99
|
+
// A citation derived from the prose has no id to read, so its chip is
|
|
100
|
+
// legible but inert rather than opening an empty pane.
|
|
101
|
+
if (loadDocument && citation.document_id) open = citation;
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<div class="flex min-h-0 flex-1">
|
|
106
|
+
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
107
|
+
<!-- The pill is positioned against the SCROLL region, not the column: the
|
|
108
|
+
composer is in the column too, and a pill over the input is a pill in
|
|
109
|
+
the way. -->
|
|
110
|
+
<div class="relative flex min-h-0 flex-1 flex-col">
|
|
111
|
+
<div
|
|
112
|
+
bind:this={viewport}
|
|
113
|
+
onscroll={scrolled}
|
|
114
|
+
role="log"
|
|
115
|
+
aria-live="polite"
|
|
116
|
+
aria-label="Conversation with Milton"
|
|
117
|
+
class="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
|
118
|
+
>
|
|
119
|
+
<div class="mx-auto flex w-full max-w-[46rem] flex-col gap-8 px-4 py-6">
|
|
120
|
+
{#if turns.length === 0}
|
|
121
|
+
<div class="flex flex-col items-start gap-4 py-8">
|
|
122
|
+
<p class="text-foreground max-w-[46ch] text-lg leading-snug">{welcome}</p>
|
|
123
|
+
{#if examples.length > 0}
|
|
124
|
+
<ul class="flex flex-col items-start gap-2">
|
|
125
|
+
{#each examples.slice(0, 3) as example (example)}
|
|
126
|
+
<li class="max-w-full">
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
onclick={() => onexample?.(example)}
|
|
130
|
+
class="border-border hover:border-border-strong hover:bg-surface-2 focus-visible:ring-ring text-foreground max-w-full rounded-full border px-3.5 py-2 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
131
|
+
>
|
|
132
|
+
{example}
|
|
133
|
+
</button>
|
|
134
|
+
</li>
|
|
135
|
+
{/each}
|
|
136
|
+
</ul>
|
|
137
|
+
{/if}
|
|
138
|
+
</div>
|
|
139
|
+
{/if}
|
|
140
|
+
|
|
141
|
+
{#each turns as turn, index (turn.id)}
|
|
142
|
+
<AgentTranscript
|
|
143
|
+
question={turn.question}
|
|
144
|
+
blocks={turn.blocks}
|
|
145
|
+
outcome={turn.outcome}
|
|
146
|
+
running={running && index === turns.length - 1}
|
|
147
|
+
citations={turn.citations ?? []}
|
|
148
|
+
{collectionNames}
|
|
149
|
+
oncite={cite}
|
|
150
|
+
onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
|
|
151
|
+
/>
|
|
152
|
+
{/each}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{#if !follow.following && turns.length > 0}
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
onclick={jump}
|
|
160
|
+
class="border-border bg-surface-1 text-foreground hover:bg-surface-2 focus-visible:ring-ring absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs shadow-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
161
|
+
>
|
|
162
|
+
<ArrowDownIcon class="size-3.5" />
|
|
163
|
+
Jump to latest
|
|
164
|
+
</button>
|
|
165
|
+
{/if}
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{#if composer}
|
|
169
|
+
<div class="shrink-0 px-4 pb-3">
|
|
170
|
+
<div class="mx-auto w-full max-w-[46rem]">{@render composer()}</div>
|
|
171
|
+
</div>
|
|
172
|
+
{/if}
|
|
173
|
+
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{#if open && loadDocument}
|
|
177
|
+
<DocumentPane citation={open} {loadDocument} onclose={() => (open = null)} />
|
|
178
|
+
{/if}
|
|
179
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { Turn } from '../../transcript.svelte';
|
|
3
|
+
import type { LoadDocument } from '../../citations';
|
|
4
|
+
interface Props {
|
|
5
|
+
turns: Turn[];
|
|
6
|
+
running: boolean;
|
|
7
|
+
/** `Transcript.version` — bumped per event. Text grows in place, so a
|
|
8
|
+
* count of turns is not enough to keep the scroll following. */
|
|
9
|
+
version?: number;
|
|
10
|
+
welcome?: string;
|
|
11
|
+
/** Up to three, shown in the empty state as tappable pills. */
|
|
12
|
+
examples?: string[];
|
|
13
|
+
onexample?: (question: string) => void;
|
|
14
|
+
/** Re-asks the last question. Offered on the last answer only. */
|
|
15
|
+
onregenerate?: () => void;
|
|
16
|
+
/** Enables the source pane. Without it, chips render but do not open. */
|
|
17
|
+
loadDocument?: LoadDocument;
|
|
18
|
+
collectionNames?: Set<string>;
|
|
19
|
+
/** The composer, rendered INSIDE the transcript column so the source
|
|
20
|
+
* pane narrows it too — a composer the host places outside slides
|
|
21
|
+
* under the pane the moment one opens. */
|
|
22
|
+
composer?: Snippet;
|
|
23
|
+
}
|
|
24
|
+
declare const Conversation: import("svelte").Component<Props, {}, "">;
|
|
25
|
+
type Conversation = ReturnType<typeof Conversation>;
|
|
26
|
+
export default Conversation;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The cited document, open at the cited passage.
|
|
3
|
+
|
|
4
|
+
One element, two shapes. On a desktop it is a real column in the flow at
|
|
5
|
+
about 40% of the width and draggable — the transcript narrows beside it and
|
|
6
|
+
nothing is covered. Below `lg` there is no width to give it, so the same
|
|
7
|
+
element becomes a bottom sheet over the conversation. Two components would
|
|
8
|
+
have meant two behaviours to keep honest; a class list is cheaper than that.
|
|
9
|
+
-->
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
import XIcon from '@lucide/svelte/icons/x';
|
|
12
|
+
import type { Citation, LoadDocument, LoadedDocument } from '../../citations';
|
|
13
|
+
import Markdown from '../markdown/markdown.svelte';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
citation: Citation;
|
|
17
|
+
loadDocument: LoadDocument;
|
|
18
|
+
onclose: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let { citation, loadDocument, onclose }: Props = $props();
|
|
22
|
+
|
|
23
|
+
const MIN_WIDTH = 320;
|
|
24
|
+
/** ~40% of a 1440 desktop, which is the width the pane is designed at. */
|
|
25
|
+
const DEFAULT_WIDTH = 560;
|
|
26
|
+
|
|
27
|
+
let width = $state(DEFAULT_WIDTH);
|
|
28
|
+
let document_ = $state<LoadedDocument | null>(null);
|
|
29
|
+
let failed = $state(false);
|
|
30
|
+
let scroller = $state<HTMLElement | null>(null);
|
|
31
|
+
let closeButton = $state<HTMLButtonElement | null>(null);
|
|
32
|
+
|
|
33
|
+
// Keyed on the id, so re-citing the same document while the pane is open
|
|
34
|
+
// moves to the new section without a second fetch and without a flash of
|
|
35
|
+
// the loading state.
|
|
36
|
+
// Plain, not `$state`: the effect below both reads and writes it, and
|
|
37
|
+
// nothing renders it.
|
|
38
|
+
let loadedId: string | null = null;
|
|
39
|
+
|
|
40
|
+
$effect(() => {
|
|
41
|
+
const id = citation.document_id;
|
|
42
|
+
if (!id || id === loadedId) return;
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
document_ = null;
|
|
45
|
+
failed = false;
|
|
46
|
+
loadDocument(id)
|
|
47
|
+
.then((doc) => {
|
|
48
|
+
if (cancelled) return;
|
|
49
|
+
document_ = doc;
|
|
50
|
+
loadedId = id;
|
|
51
|
+
})
|
|
52
|
+
.catch(() => {
|
|
53
|
+
if (!cancelled) failed = true;
|
|
54
|
+
});
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// The anchor is the citation's own address; the section heading is the
|
|
61
|
+
// fallback for a citation that carries only prose-derived words.
|
|
62
|
+
const activeAnchor = $derived.by(() => {
|
|
63
|
+
const sections = document_?.sections ?? [];
|
|
64
|
+
if (citation.anchor && sections.some((s) => s.anchor === citation.anchor))
|
|
65
|
+
return citation.anchor;
|
|
66
|
+
return sections.find((s) => s.heading === citation.section)?.anchor ?? sections[0]?.anchor ?? '';
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
$effect(() => {
|
|
70
|
+
if (!scroller || !activeAnchor) return;
|
|
71
|
+
const target = scroller.querySelector(`[data-anchor="${CSS.escape(activeAnchor)}"]`);
|
|
72
|
+
target?.scrollIntoView({ block: 'center' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Opening a pane a reader reached with the keyboard must move focus into
|
|
76
|
+
// it, or Escape and Tab both act on the transcript behind it.
|
|
77
|
+
$effect(() => {
|
|
78
|
+
const returnTo = globalThis.document?.activeElement as HTMLElement | null;
|
|
79
|
+
closeButton?.focus();
|
|
80
|
+
return () => returnTo?.focus?.();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function keydown(event: KeyboardEvent) {
|
|
84
|
+
if (event.key === 'Escape') {
|
|
85
|
+
event.stopPropagation();
|
|
86
|
+
onclose();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let dragFrom: { x: number; width: number } | null = null;
|
|
91
|
+
|
|
92
|
+
function startDrag(event: PointerEvent) {
|
|
93
|
+
dragFrom = { x: event.clientX, width };
|
|
94
|
+
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function drag(event: PointerEvent) {
|
|
98
|
+
if (!dragFrom) return;
|
|
99
|
+
// The handle is on the pane's LEFT edge, so dragging left widens it.
|
|
100
|
+
const next = dragFrom.width - (event.clientX - dragFrom.x);
|
|
101
|
+
width = Math.max(MIN_WIDTH, Math.min(next, window.innerWidth * 0.7));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function endDrag(event: PointerEvent) {
|
|
105
|
+
dragFrom = null;
|
|
106
|
+
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
|
107
|
+
}
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<svelte:window onkeydown={keydown} />
|
|
111
|
+
|
|
112
|
+
<aside
|
|
113
|
+
style="--pane-width: {width}px"
|
|
114
|
+
aria-label="Source document"
|
|
115
|
+
class="bg-surface-1 border-border fixed inset-x-0 bottom-0 z-40 flex h-[80svh] flex-col rounded-t-2xl border-t shadow-2xl lg:static lg:h-auto lg:w-[var(--pane-width)] lg:shrink-0 lg:rounded-none lg:border-t-0 lg:border-l lg:shadow-none"
|
|
116
|
+
>
|
|
117
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -- a pointer-only
|
|
118
|
+
affordance for a width that has a keyboard-independent default; the
|
|
119
|
+
pane is fully usable without ever touching it. -->
|
|
120
|
+
<div
|
|
121
|
+
onpointerdown={startDrag}
|
|
122
|
+
onpointermove={drag}
|
|
123
|
+
onpointerup={endDrag}
|
|
124
|
+
class="hover:bg-primary/40 absolute inset-y-0 left-0 hidden w-1.5 cursor-col-resize lg:block"
|
|
125
|
+
></div>
|
|
126
|
+
|
|
127
|
+
<!-- The sheet's grabber. Below `lg` this is an overlay a reader has to be
|
|
128
|
+
able to see the top edge of; on a desktop it is a column, and a column
|
|
129
|
+
with a handle on it reads as draggable in the wrong axis. -->
|
|
130
|
+
<div class="flex justify-center pt-2 pb-1 lg:hidden" aria-hidden="true">
|
|
131
|
+
<span class="bg-border h-1 w-9 rounded-full"></span>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<header class="border-border flex items-start gap-2 border-b px-4 py-3 lg:pt-3">
|
|
135
|
+
<div class="min-w-0 flex-1">
|
|
136
|
+
<h2 class="text-foreground truncate text-sm font-semibold">
|
|
137
|
+
{document_?.title ?? citation.title}
|
|
138
|
+
</h2>
|
|
139
|
+
{#if citation.section}
|
|
140
|
+
<p class="text-muted-foreground truncate text-xs">{citation.section}</p>
|
|
141
|
+
{/if}
|
|
142
|
+
</div>
|
|
143
|
+
<button
|
|
144
|
+
bind:this={closeButton}
|
|
145
|
+
type="button"
|
|
146
|
+
onclick={onclose}
|
|
147
|
+
aria-label="Close source"
|
|
148
|
+
class="text-muted-foreground hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring -mt-1 flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
149
|
+
>
|
|
150
|
+
<XIcon class="size-4" />
|
|
151
|
+
</button>
|
|
152
|
+
</header>
|
|
153
|
+
|
|
154
|
+
<div bind:this={scroller} class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
|
|
155
|
+
{#if failed}
|
|
156
|
+
<p class="text-muted-foreground text-sm">That document can't be opened right now.</p>
|
|
157
|
+
{:else if !document_}
|
|
158
|
+
<div class="flex flex-col gap-2" aria-hidden="true">
|
|
159
|
+
{#each [0, 1, 2, 3] as row (row)}
|
|
160
|
+
<div class="bg-muted h-4 animate-pulse rounded" style="width: {90 - row * 12}%"></div>
|
|
161
|
+
{/each}
|
|
162
|
+
</div>
|
|
163
|
+
{:else}
|
|
164
|
+
{#each document_.sections as section (section.anchor)}
|
|
165
|
+
<section
|
|
166
|
+
data-anchor={section.anchor}
|
|
167
|
+
class="scroll-mt-4 rounded-lg px-3 py-2 {section.anchor === activeAnchor
|
|
168
|
+
? 'border-primary/50 bg-primary/8 border-l-2'
|
|
169
|
+
: ''}"
|
|
170
|
+
>
|
|
171
|
+
<h3 class="text-foreground mb-1 text-sm font-semibold">{section.heading}</h3>
|
|
172
|
+
<Markdown content={section.text} dense />
|
|
173
|
+
</section>
|
|
174
|
+
{/each}
|
|
175
|
+
{/if}
|
|
176
|
+
</div>
|
|
177
|
+
</aside>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Citation, LoadDocument } from '../../citations';
|
|
2
|
+
interface Props {
|
|
3
|
+
citation: Citation;
|
|
4
|
+
loadDocument: LoadDocument;
|
|
5
|
+
onclose: () => void;
|
|
6
|
+
}
|
|
7
|
+
declare const DocumentPane: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type DocumentPane = ReturnType<typeof DocumentPane>;
|
|
9
|
+
export default DocumentPane;
|
|
@@ -29,6 +29,19 @@ export declare function render(markdown: string, { streaming }?: {
|
|
|
29
29
|
* cannot drift out of sync with the rendering.
|
|
30
30
|
*/
|
|
31
31
|
export declare function markCollections(root: HTMLElement, collections: Set<string>): void;
|
|
32
|
+
/**
|
|
33
|
+
* Turn every inline `[n]` marker into a citation chip.
|
|
34
|
+
*
|
|
35
|
+
* Done against the rendered DOM rather than the markdown string, for the same
|
|
36
|
+
* reason `markCollections` is: a `[3]` inside a fenced code block or a link
|
|
37
|
+
* label is not a citation, and `closest()` settles that in one call where a
|
|
38
|
+
* string-level regex would need to re-implement the parser to know.
|
|
39
|
+
*
|
|
40
|
+
* `sup` rather than `button`: the sanitiser strips `button` (it is in
|
|
41
|
+
* `FORBID_TAGS`, and rightly — this is model output), so the chip carries the
|
|
42
|
+
* button ROLE and a tab stop, and the component delegates the events.
|
|
43
|
+
*/
|
|
44
|
+
export declare function markCitations(root: HTMLElement, valid: Set<number>): void;
|
|
32
45
|
/**
|
|
33
46
|
* Replace every `<pre><code>` in a rendered fragment with a highlighted one.
|
|
34
47
|
* Runs against the DOM node rather than the HTML string so it can be applied
|
|
@@ -39,7 +39,7 @@ export function render(markdown, { streaming = false } = {}) {
|
|
|
39
39
|
const source = streaming ? balance(markdown) : markdown;
|
|
40
40
|
const html = marked.parse(source, { async: false });
|
|
41
41
|
return DOMPurify.sanitize(html, {
|
|
42
|
-
ADD_ATTR: ['target', 'rel'],
|
|
42
|
+
ADD_ATTR: ['target', 'rel', 'data-cite', 'tabindex', 'role'],
|
|
43
43
|
FORBID_TAGS: ['style', 'form', 'input', 'button'],
|
|
44
44
|
FORBID_ATTR: ['style', 'onerror', 'onload']
|
|
45
45
|
});
|
|
@@ -89,6 +89,54 @@ export function markCollections(root, collections) {
|
|
|
89
89
|
code.dataset.collection = 'true';
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Turn every inline `[n]` marker into a citation chip.
|
|
94
|
+
*
|
|
95
|
+
* Done against the rendered DOM rather than the markdown string, for the same
|
|
96
|
+
* reason `markCollections` is: a `[3]` inside a fenced code block or a link
|
|
97
|
+
* label is not a citation, and `closest()` settles that in one call where a
|
|
98
|
+
* string-level regex would need to re-implement the parser to know.
|
|
99
|
+
*
|
|
100
|
+
* `sup` rather than `button`: the sanitiser strips `button` (it is in
|
|
101
|
+
* `FORBID_TAGS`, and rightly — this is model output), so the chip carries the
|
|
102
|
+
* button ROLE and a tab stop, and the component delegates the events.
|
|
103
|
+
*/
|
|
104
|
+
export function markCitations(root, valid) {
|
|
105
|
+
if (valid.size === 0)
|
|
106
|
+
return;
|
|
107
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
108
|
+
const targets = [];
|
|
109
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
110
|
+
const text = node;
|
|
111
|
+
if (text.parentElement?.closest('pre, code, a, sup'))
|
|
112
|
+
continue;
|
|
113
|
+
if (/\[\d{1,3}\]/.test(text.data))
|
|
114
|
+
targets.push(text);
|
|
115
|
+
}
|
|
116
|
+
for (const text of targets) {
|
|
117
|
+
const fragment = document.createDocumentFragment();
|
|
118
|
+
let cursor = 0;
|
|
119
|
+
for (const match of text.data.matchAll(/\[(\d{1,3})\]/g)) {
|
|
120
|
+
const n = Number(match[1]);
|
|
121
|
+
if (!valid.has(n) || match.index === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
fragment.append(text.data.slice(cursor, match.index));
|
|
124
|
+
const chip = document.createElement('sup');
|
|
125
|
+
chip.className = 'ds-cite';
|
|
126
|
+
chip.dataset.cite = String(n);
|
|
127
|
+
chip.setAttribute('role', 'button');
|
|
128
|
+
chip.setAttribute('tabindex', '0');
|
|
129
|
+
chip.setAttribute('aria-label', `Source ${n}`);
|
|
130
|
+
chip.textContent = String(n);
|
|
131
|
+
fragment.append(chip);
|
|
132
|
+
cursor = match.index + match[0].length;
|
|
133
|
+
}
|
|
134
|
+
if (cursor === 0)
|
|
135
|
+
continue;
|
|
136
|
+
fragment.append(text.data.slice(cursor));
|
|
137
|
+
text.replaceWith(fragment);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
92
140
|
/**
|
|
93
141
|
* Replace every `<pre><code>` in a rendered fragment with a highlighted one.
|
|
94
142
|
* Runs against the DOM node rather than the HTML string so it can be applied
|
|
@@ -7,16 +7,29 @@
|
|
|
7
7
|
consuming app.
|
|
8
8
|
-->
|
|
9
9
|
<script lang="ts">
|
|
10
|
-
import { highlight, markCollections, render } from './markdown';
|
|
10
|
+
import { highlight, markCitations, markCollections, render } from './markdown';
|
|
11
11
|
|
|
12
12
|
interface Props {
|
|
13
13
|
content: string;
|
|
14
14
|
streaming?: boolean;
|
|
15
15
|
/** Collection names to chip in backticks; omit if the caller has none. */
|
|
16
16
|
collectionNames?: Set<string>;
|
|
17
|
+
/** `[n]` markers to turn into chips; omit and the markers stay as text. */
|
|
18
|
+
citationNumbers?: Set<number>;
|
|
19
|
+
oncite?: (n: number) => void;
|
|
20
|
+
/** A step down, for prose that is reference material beside an answer
|
|
21
|
+
* rather than the answer itself — the source pane's sections. */
|
|
22
|
+
dense?: boolean;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
let {
|
|
25
|
+
let {
|
|
26
|
+
content,
|
|
27
|
+
streaming = false,
|
|
28
|
+
collectionNames = new Set(),
|
|
29
|
+
citationNumbers = new Set(),
|
|
30
|
+
oncite,
|
|
31
|
+
dense = false
|
|
32
|
+
}: Props = $props();
|
|
20
33
|
let host = $state<HTMLElement | null>(null);
|
|
21
34
|
|
|
22
35
|
const html = $derived(render(content, { streaming }));
|
|
@@ -27,12 +40,42 @@
|
|
|
27
40
|
if (!host || !html) return;
|
|
28
41
|
// Chips are cheap and wanted DURING streaming; highlighting is not.
|
|
29
42
|
markCollections(host, collectionNames);
|
|
43
|
+
markCitations(host, citationNumbers);
|
|
30
44
|
if (streaming) return;
|
|
31
45
|
void highlight(host);
|
|
32
46
|
});
|
|
47
|
+
|
|
48
|
+
// Delegated, because the chips are created by the sanitiser's output rather
|
|
49
|
+
// than by this template — there is no element here to put a handler on.
|
|
50
|
+
function citeFrom(target: EventTarget | null): number | null {
|
|
51
|
+
const chip = (target as HTMLElement | null)?.closest?.('[data-cite]');
|
|
52
|
+
const n = Number((chip as HTMLElement | undefined)?.dataset.cite);
|
|
53
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function click(event: MouseEvent) {
|
|
57
|
+
const n = citeFrom(event.target);
|
|
58
|
+
if (n !== null) oncite?.(n);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function keydown(event: KeyboardEvent) {
|
|
62
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
63
|
+
const n = citeFrom(event.target);
|
|
64
|
+
if (n === null) return;
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
oncite?.(n);
|
|
67
|
+
}
|
|
33
68
|
</script>
|
|
34
69
|
|
|
35
|
-
|
|
70
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -- the interactive
|
|
71
|
+
elements are the sanitiser-created chips, which carry role and tabindex;
|
|
72
|
+
this element only delegates their events. -->
|
|
73
|
+
<div
|
|
74
|
+
bind:this={host}
|
|
75
|
+
onclick={click}
|
|
76
|
+
onkeydown={keydown}
|
|
77
|
+
class="agent-prose text-foreground {dense ? 'text-sm leading-6' : 'text-base leading-7'}"
|
|
78
|
+
>
|
|
36
79
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in render() -->
|
|
37
80
|
{@html html}
|
|
38
81
|
</div>
|
|
@@ -169,6 +212,10 @@
|
|
|
169
212
|
border-radius: var(--radius);
|
|
170
213
|
padding: 0.85em 1em;
|
|
171
214
|
overflow-x: auto;
|
|
215
|
+
/* A fenced block is the one thing here allowed to be wider than the
|
|
216
|
+
measure, so it must scroll INSIDE itself: without this a 90-column
|
|
217
|
+
line makes the whole transcript scroll sideways at 390px. */
|
|
218
|
+
max-width: 100%;
|
|
172
219
|
}
|
|
173
220
|
|
|
174
221
|
.agent-prose :global(pre code) {
|
|
@@ -190,8 +237,11 @@
|
|
|
190
237
|
color: var(--shiki-dark);
|
|
191
238
|
}
|
|
192
239
|
|
|
240
|
+
/* `display: block` is what makes the overflow scroll: a real `table` box
|
|
241
|
+
ignores overflow-x and widens its container instead. */
|
|
193
242
|
.agent-prose :global(table) {
|
|
194
243
|
width: 100%;
|
|
244
|
+
max-width: 100%;
|
|
195
245
|
border-collapse: collapse;
|
|
196
246
|
font-size: 0.9em;
|
|
197
247
|
display: block;
|
|
@@ -203,6 +253,9 @@
|
|
|
203
253
|
border: 1px solid var(--border);
|
|
204
254
|
padding: 0.4em 0.6em;
|
|
205
255
|
text-align: start;
|
|
256
|
+
/* A cell must not be squeezed to one character per line by a narrow
|
|
257
|
+
viewport; the table scrolls instead. */
|
|
258
|
+
white-space: nowrap;
|
|
206
259
|
}
|
|
207
260
|
|
|
208
261
|
.agent-prose :global(th) {
|
|
@@ -220,6 +273,35 @@
|
|
|
220
273
|
font-weight: 500;
|
|
221
274
|
}
|
|
222
275
|
|
|
276
|
+
/* The citation chip. A superscript number a reader can press: small enough
|
|
277
|
+
to sit inside a sentence without breaking its rhythm, big enough that a
|
|
278
|
+
thumb finds it — the padding, not the glyph, carries the target size. */
|
|
279
|
+
.agent-prose :global(sup.ds-cite) {
|
|
280
|
+
display: inline-block;
|
|
281
|
+
min-width: 1.35em;
|
|
282
|
+
margin-inline: 0.15em;
|
|
283
|
+
padding: 0.1em 0.3em;
|
|
284
|
+
border-radius: 0.4em;
|
|
285
|
+
background: color-mix(in oklab, var(--primary) 16%, transparent);
|
|
286
|
+
color: var(--foreground);
|
|
287
|
+
font-family: var(--ds-font-mono, monospace);
|
|
288
|
+
font-size: 0.7em;
|
|
289
|
+
font-weight: 600;
|
|
290
|
+
line-height: 1.4;
|
|
291
|
+
text-align: center;
|
|
292
|
+
vertical-align: 0.35em;
|
|
293
|
+
cursor: pointer;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.agent-prose :global(sup.ds-cite:hover) {
|
|
297
|
+
background: color-mix(in oklab, var(--primary) 30%, transparent);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.agent-prose :global(sup.ds-cite:focus-visible) {
|
|
301
|
+
outline: 2px solid var(--ring);
|
|
302
|
+
outline-offset: 2px;
|
|
303
|
+
}
|
|
304
|
+
|
|
223
305
|
.agent-prose :global(hr) {
|
|
224
306
|
border: 0;
|
|
225
307
|
border-top: 1px solid var(--border);
|
|
@@ -3,6 +3,12 @@ interface Props {
|
|
|
3
3
|
streaming?: boolean;
|
|
4
4
|
/** Collection names to chip in backticks; omit if the caller has none. */
|
|
5
5
|
collectionNames?: Set<string>;
|
|
6
|
+
/** `[n]` markers to turn into chips; omit and the markers stay as text. */
|
|
7
|
+
citationNumbers?: Set<number>;
|
|
8
|
+
oncite?: (n: number) => void;
|
|
9
|
+
/** A step down, for prose that is reference material beside an answer
|
|
10
|
+
* rather than the answer itself — the source pane's sections. */
|
|
11
|
+
dense?: boolean;
|
|
6
12
|
}
|
|
7
13
|
declare const Markdown: import("svelte").Component<Props, {}, "">;
|
|
8
14
|
type Markdown = ReturnType<typeof Markdown>;
|