@poodle64/librarian 2026.9.5 → 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 +143 -44
- 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 +39 -13
- 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 +212 -59
- 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/components/tool-row/tool-row.svelte +5 -11
- package/dist/components/working/working.svelte +1 -9
- package/dist/follow-scroll.svelte.d.ts +34 -0
- package/dist/follow-scroll.svelte.js +46 -0
- package/dist/transcript.svelte.d.ts +36 -4
- package/dist/transcript.svelte.js +50 -26
- package/package.json +16 -2
|
@@ -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>;
|
|
@@ -5,14 +5,13 @@
|
|
|
5
5
|
dimmed sub-line. Borders and badge pills make a run of five calls read as
|
|
6
6
|
five separate events rather than one train of thought.
|
|
7
7
|
|
|
8
|
-
The row says what
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
nothing is hidden from anyone who wants it.
|
|
8
|
+
The row says what Milton DID, in a reader's own words — never the tool's
|
|
9
|
+
name or the raw command it ran. Expanding a settled row shows what came
|
|
10
|
+
back, never what was typed.
|
|
12
11
|
-->
|
|
13
12
|
<script lang="ts">
|
|
14
13
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
|
15
|
-
import { describe,
|
|
14
|
+
import { describe, type ToolBlock } from '../../transcript.svelte';
|
|
16
15
|
|
|
17
16
|
interface Props {
|
|
18
17
|
block: ToolBlock;
|
|
@@ -53,17 +52,12 @@
|
|
|
53
52
|
<span class="text-muted-foreground truncate">
|
|
54
53
|
<span class="text-foreground font-medium">{said.verb}</span>
|
|
55
54
|
{#if said.object}<span class="text-foreground/80">{said.object}</span>{/if}
|
|
56
|
-
{#if repeats > 1}<span class="text-muted-foreground">· {repeats}
|
|
55
|
+
{#if repeats > 1}<span class="text-muted-foreground">· {repeats} pages</span>{/if}
|
|
57
56
|
</span>
|
|
58
57
|
</button>
|
|
59
58
|
|
|
60
59
|
{#if open}
|
|
61
60
|
<div class="border-border mt-1 ml-5.5 border-l pl-3">
|
|
62
|
-
<!-- The real command, for anyone who wants it. -->
|
|
63
|
-
<p class="text-muted-foreground font-mono text-xs break-all">
|
|
64
|
-
{block.name}
|
|
65
|
-
{summarise(block)}
|
|
66
|
-
</p>
|
|
67
61
|
{#if block.result}
|
|
68
62
|
<pre
|
|
69
63
|
class="text-muted-foreground mt-1 max-h-72 overflow-auto font-mono text-xs whitespace-pre-wrap">{block.result}</pre>
|
|
@@ -18,15 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
let { label }: Props = $props();
|
|
20
20
|
|
|
21
|
-
const WORDS = [
|
|
22
|
-
'Starting',
|
|
23
|
-
'Reading the shelves',
|
|
24
|
-
'Rummaging',
|
|
25
|
-
'Cross-checking',
|
|
26
|
-
'Thumbing pages',
|
|
27
|
-
'Following a reference',
|
|
28
|
-
'Chasing it down'
|
|
29
|
-
];
|
|
21
|
+
const WORDS = ['Milton is looking', 'Milton is reading'];
|
|
30
22
|
|
|
31
23
|
let tick = $state(0);
|
|
32
24
|
let elapsed = $state(0);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow the stream until the reader disagrees.
|
|
3
|
+
*
|
|
4
|
+
* The whole behaviour is one rule stated three ways: a viewport already at the
|
|
5
|
+
* bottom is following; a reader who scrolls UP has stopped following; a reader
|
|
6
|
+
* who comes back to the bottom is following again. Nothing else — no timers,
|
|
7
|
+
* no "was that scroll ours or theirs" bookkeeping — because the auto-scroll
|
|
8
|
+
* this drives always lands AT the bottom, which the first clause re-affirms
|
|
9
|
+
* rather than fights.
|
|
10
|
+
*
|
|
11
|
+
* Growing content moves the bottom away from a parked reader without moving
|
|
12
|
+
* their scrollTop, so distance alone cannot say who moved: the direction of
|
|
13
|
+
* scrollTop can, and that is the only thing `measure` remembers between calls.
|
|
14
|
+
*/
|
|
15
|
+
/** Distance from the bottom, in px, still counted as being at the bottom.
|
|
16
|
+
* A line of prose is ~28px; two lines of slack survives sub-pixel layout
|
|
17
|
+
* rounding and a caret-height change without unpinning the reader. */
|
|
18
|
+
export declare const FOLLOW_THRESHOLD = 64;
|
|
19
|
+
export interface ScrollMetrics {
|
|
20
|
+
scrollTop: number;
|
|
21
|
+
clientHeight: number;
|
|
22
|
+
scrollHeight: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function distanceFromBottom(m: ScrollMetrics): number;
|
|
25
|
+
export declare function atBottom(m: ScrollMetrics, threshold?: number): boolean;
|
|
26
|
+
export declare class FollowScroll {
|
|
27
|
+
#private;
|
|
28
|
+
/** True while the viewport should be dragged along with new content. */
|
|
29
|
+
following: boolean;
|
|
30
|
+
/** Feed every scroll event, and the metrics after every content change. */
|
|
31
|
+
measure(m: ScrollMetrics, threshold?: number): void;
|
|
32
|
+
/** A new question re-pins: the reader asked for the thing about to arrive. */
|
|
33
|
+
pin(): void;
|
|
34
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow the stream until the reader disagrees.
|
|
3
|
+
*
|
|
4
|
+
* The whole behaviour is one rule stated three ways: a viewport already at the
|
|
5
|
+
* bottom is following; a reader who scrolls UP has stopped following; a reader
|
|
6
|
+
* who comes back to the bottom is following again. Nothing else — no timers,
|
|
7
|
+
* no "was that scroll ours or theirs" bookkeeping — because the auto-scroll
|
|
8
|
+
* this drives always lands AT the bottom, which the first clause re-affirms
|
|
9
|
+
* rather than fights.
|
|
10
|
+
*
|
|
11
|
+
* Growing content moves the bottom away from a parked reader without moving
|
|
12
|
+
* their scrollTop, so distance alone cannot say who moved: the direction of
|
|
13
|
+
* scrollTop can, and that is the only thing `measure` remembers between calls.
|
|
14
|
+
*/
|
|
15
|
+
/** Distance from the bottom, in px, still counted as being at the bottom.
|
|
16
|
+
* A line of prose is ~28px; two lines of slack survives sub-pixel layout
|
|
17
|
+
* rounding and a caret-height change without unpinning the reader. */
|
|
18
|
+
export const FOLLOW_THRESHOLD = 64;
|
|
19
|
+
export function distanceFromBottom(m) {
|
|
20
|
+
return m.scrollHeight - m.scrollTop - m.clientHeight;
|
|
21
|
+
}
|
|
22
|
+
export function atBottom(m, threshold = FOLLOW_THRESHOLD) {
|
|
23
|
+
return distanceFromBottom(m) <= threshold;
|
|
24
|
+
}
|
|
25
|
+
export class FollowScroll {
|
|
26
|
+
/** True while the viewport should be dragged along with new content. */
|
|
27
|
+
following = $state(true);
|
|
28
|
+
#lastTop = 0;
|
|
29
|
+
/** Feed every scroll event, and the metrics after every content change. */
|
|
30
|
+
measure(m, threshold = FOLLOW_THRESHOLD) {
|
|
31
|
+
// 1px of tolerance: a trackpad's fractional scrollTop otherwise reads as
|
|
32
|
+
// an upward flick on a viewport that has not actually moved.
|
|
33
|
+
const wentUp = m.scrollTop < this.#lastTop - 1;
|
|
34
|
+
this.#lastTop = m.scrollTop;
|
|
35
|
+
if (atBottom(m, threshold)) {
|
|
36
|
+
this.following = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (wentUp)
|
|
40
|
+
this.following = false;
|
|
41
|
+
}
|
|
42
|
+
/** A new question re-pins: the reader asked for the thing about to arrive. */
|
|
43
|
+
pin() {
|
|
44
|
+
this.following = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* verbatim under `other`, so nothing is silently dropped.
|
|
8
8
|
*/
|
|
9
9
|
import type { AgentEvent } from './client';
|
|
10
|
+
import type { Citation } from './citations';
|
|
10
11
|
export interface TextBlock {
|
|
11
12
|
kind: 'text';
|
|
12
13
|
index: number;
|
|
@@ -34,12 +35,25 @@ export interface Outcome {
|
|
|
34
35
|
isError?: boolean;
|
|
35
36
|
error?: string;
|
|
36
37
|
}
|
|
37
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The question as the READER asked it.
|
|
40
|
+
*
|
|
41
|
+
* A host prepends a system preamble to every question before it goes to the
|
|
42
|
+
* library — cadmus sends `{room.preamble}\n\n{question}` — and echoing that
|
|
43
|
+
* back into the transcript shows a colleague the machine room. Leading
|
|
44
|
+
* blank-line-separated paragraphs addressed to the model are dropped.
|
|
45
|
+
*
|
|
46
|
+
* The LAST paragraph is never dropped, whatever it starts with: someone whose
|
|
47
|
+
* entire question is "You are wrong about the leave rule" must still see it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function readerQuestion(question: string): string;
|
|
50
|
+
/** What Milton DID, in a reader's own words — never the tool's name or the
|
|
51
|
+
* raw command it ran.
|
|
38
52
|
*
|
|
39
53
|
* The collapsed row is read by someone asking a question about documents, not
|
|
40
54
|
* by an engineer: `Bash ls -1 .` tells them nothing and looks like a leak from
|
|
41
|
-
* the machine room.
|
|
42
|
-
*
|
|
55
|
+
* the machine room. Milton never narrates how he searched, so a tool this
|
|
56
|
+
* does not recognise falls back to something that names no mechanism at all.
|
|
43
57
|
*/
|
|
44
58
|
export declare function describe(block: ToolBlock): {
|
|
45
59
|
verb: string;
|
|
@@ -68,6 +82,8 @@ export declare class Transcript {
|
|
|
68
82
|
sessionId: string | null;
|
|
69
83
|
model: string | null;
|
|
70
84
|
outcome: Outcome | null;
|
|
85
|
+
/** Sources for the answer, from the library's own `citations` frame. */
|
|
86
|
+
citations: Citation[];
|
|
71
87
|
other: AgentEvent[];
|
|
72
88
|
reset(): void;
|
|
73
89
|
apply(event: AgentEvent): void;
|
|
@@ -88,6 +104,18 @@ export interface ActivityGroup {
|
|
|
88
104
|
searches: number;
|
|
89
105
|
}
|
|
90
106
|
export type Segment = ActivityGroup | TextBlock;
|
|
107
|
+
/** One question and the answer to it, as the transcript renders it.
|
|
108
|
+
*
|
|
109
|
+
* A finished turn is a plain object the host keeps in a list; the LIVE turn is
|
|
110
|
+
* a `Transcript` spread into the same shape. Both render identically, which is
|
|
111
|
+
* what stops a conversation flickering as the last turn settles. */
|
|
112
|
+
export interface Turn {
|
|
113
|
+
id: string;
|
|
114
|
+
question: string;
|
|
115
|
+
blocks: Block[];
|
|
116
|
+
outcome: Outcome | null;
|
|
117
|
+
citations?: Citation[];
|
|
118
|
+
}
|
|
91
119
|
/**
|
|
92
120
|
* Fold a turn's flat block list into what a reader should actually see.
|
|
93
121
|
*
|
|
@@ -101,5 +129,9 @@ export type Segment = ActivityGroup | TextBlock;
|
|
|
101
129
|
* steps with the same label collapse to one row carrying a count.
|
|
102
130
|
*/
|
|
103
131
|
export declare function segment(blocks: Block[]): Segment[];
|
|
104
|
-
/** One line describing a whole investigation, for the collapsed state.
|
|
132
|
+
/** One line describing a whole investigation, for the collapsed state.
|
|
133
|
+
*
|
|
134
|
+
* Counts of what Milton did are fine ("1 search · 2 documents read"); how he
|
|
135
|
+
* organises what he knows is not — no collection count, no shelf, no corpus.
|
|
136
|
+
*/
|
|
105
137
|
export declare function summariseActivity(group: ActivityGroup): string;
|