@poodle64/librarian 2026.10.2 → 2026.10.4
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 +10 -4
- package/dist/chat.svelte.d.ts +40 -0
- package/dist/chat.svelte.js +57 -3
- package/dist/client.d.ts +3 -0
- package/dist/client.js +4 -1
- package/dist/components/activity-group/activity-group.svelte +21 -108
- package/dist/components/activity-group/activity-group.svelte.d.ts +3 -1
- package/dist/components/agent-transcript/agent-transcript.svelte +164 -4
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +18 -0
- package/dist/components/artefact-pane/artefact-pane.svelte +162 -18
- package/dist/components/artefact-pane/artefact-pane.svelte.d.ts +10 -1
- package/dist/components/conversation/conversation.svelte +83 -6
- package/dist/components/conversation/conversation.svelte.d.ts +15 -0
- package/dist/components/fair-use-notice/fair-use-notice.svelte +1 -1
- package/dist/components/markdown/markdown.d.ts +11 -0
- package/dist/components/markdown/markdown.js +62 -2
- package/dist/components/markdown/markdown.svelte +24 -3
- package/dist/components/markdown/markdown.svelte.d.ts +4 -0
- package/dist/components/scope-statement/scope-statement.svelte +17 -20
- package/dist/components/thinking-row/thinking-row.svelte +1 -0
- package/dist/components/thinking-row/thinking-row.svelte.d.ts +1 -0
- package/dist/components/tool-row/tool-row.svelte +40 -20
- package/dist/components/tool-row/tool-row.svelte.d.ts +2 -3
- package/dist/components/web-list/index.d.ts +2 -0
- package/dist/components/web-list/index.js +2 -0
- package/dist/components/web-list/web-list.svelte +84 -0
- package/dist/components/web-list/web-list.svelte.d.ts +9 -0
- package/dist/components/written-card/index.d.ts +2 -0
- package/dist/components/written-card/index.js +2 -0
- package/dist/components/written-card/written-card.svelte +130 -0
- package/dist/components/written-card/written-card.svelte.d.ts +15 -0
- package/dist/copy.d.ts +19 -0
- package/dist/copy.js +13 -0
- package/dist/transcript.svelte.d.ts +15 -34
- package/dist/transcript.svelte.js +52 -55
- package/dist/web-sources.d.ts +18 -0
- package/dist/web-sources.js +58 -0
- package/dist/written.d.ts +46 -0
- package/dist/written.js +41 -0
- package/package.json +5 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
|
|
2
2
|
import { type Citation } from '../../citations';
|
|
3
3
|
import { type ChatCopy } from '../../copy';
|
|
4
|
+
import { type Written, type WrittenUrl } from '../../written';
|
|
4
5
|
import type { Verdict } from '../../chat.svelte';
|
|
6
|
+
import type { Fences } from '../markdown/markdown';
|
|
5
7
|
/** Everything one turn renders from: what `Conversation` hands a host's
|
|
6
8
|
* own `turn` snippet, so it can render this component or its own. */
|
|
7
9
|
export interface AgentTranscriptProps {
|
|
@@ -35,6 +37,22 @@ export interface AgentTranscriptProps {
|
|
|
35
37
|
onopenartefact?: () => void;
|
|
36
38
|
/** The persona's own words for its tools, and what they read. */
|
|
37
39
|
describeTool?: DescribeTool;
|
|
40
|
+
/** The names of the files that went with the question, shown as chips. */
|
|
41
|
+
files?: string[];
|
|
42
|
+
/** Replaces this question with `text` and asks it again from here
|
|
43
|
+
* (`Chat.edit`), resolving false when that did not go through. Omit
|
|
44
|
+
* and no edit control renders: not while an answer is being written. */
|
|
45
|
+
onedit?: (text: string) => Promise<boolean>;
|
|
46
|
+
/** The documents the persona wrote answering it, carded under the answer. */
|
|
47
|
+
written?: Written[];
|
|
48
|
+
/** The version the reading column shows now, by `writtenKey`. */
|
|
49
|
+
showingWritten?: string;
|
|
50
|
+
/** Opens a version in the reading column. Omit and the cards only link. */
|
|
51
|
+
onopenwritten?: (written: Written) => void;
|
|
52
|
+
/** Where a version downloads. Omit and no download links render. */
|
|
53
|
+
writtenUrl?: WrittenUrl;
|
|
54
|
+
/** Languages of fenced code the host draws with its own component. */
|
|
55
|
+
fences?: Fences;
|
|
38
56
|
/** When an answer this page did not start began, epoch ms: it is still
|
|
39
57
|
* being written, and the clock counts from then. */
|
|
40
58
|
answering?: number;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
An artefact that is its answer's own prose,
|
|
2
|
+
An artefact that is its answer's own prose, or one version of a document the
|
|
3
|
+
persona wrote, open in the reading column.
|
|
4
|
+
|
|
5
|
+
A written document is shown from its source: Markdown through the package's
|
|
6
|
+
sanitising renderer, HTML only inside a sandboxed frame with no permissions
|
|
7
|
+
(no scripts, no same origin), never injected into the page.
|
|
3
8
|
|
|
4
9
|
The same shell shape `DocumentPane` uses (a column at 64rem, a bottom sheet
|
|
5
10
|
below it) because decision 2 of the agent surface is that a colleague never
|
|
@@ -8,14 +13,33 @@
|
|
|
8
13
|
-->
|
|
9
14
|
<script lang="ts">
|
|
10
15
|
import XIcon from '@lucide/svelte/icons/x';
|
|
16
|
+
import { untrack } from 'svelte';
|
|
11
17
|
import { segment, type TextBlock, type Turn } from '../../transcript.svelte';
|
|
12
18
|
import { resolveCitations, splitSources, type Citation } from '../../citations';
|
|
13
|
-
import { resolveCopy, type ChatCopy } from '../../copy';
|
|
19
|
+
import { fill, resolveCopy, type ChatCopy } from '../../copy';
|
|
20
|
+
import {
|
|
21
|
+
downloads,
|
|
22
|
+
framed,
|
|
23
|
+
isHtml,
|
|
24
|
+
writtenKey,
|
|
25
|
+
type LoadWritten,
|
|
26
|
+
type Written,
|
|
27
|
+
type WrittenUrl
|
|
28
|
+
} from '../../written';
|
|
14
29
|
import Markdown from '../markdown/markdown.svelte';
|
|
15
30
|
import SourceList from '../source-list/source-list.svelte';
|
|
16
31
|
|
|
32
|
+
|
|
17
33
|
interface Props {
|
|
18
|
-
turn
|
|
34
|
+
/** The turn whose answer is the artefact. Absent when `versions` is given. */
|
|
35
|
+
turn?: Turn;
|
|
36
|
+
/** Every version of one written document, oldest first: the pane shows
|
|
37
|
+
* `version` of them, with a switcher. */
|
|
38
|
+
versions?: Written[];
|
|
39
|
+
version?: number;
|
|
40
|
+
onversion?: (version: number) => void;
|
|
41
|
+
loadWritten?: LoadWritten;
|
|
42
|
+
writtenUrl?: WrittenUrl;
|
|
19
43
|
onclose: () => void;
|
|
20
44
|
/** Opens the cited document instead — swapping the column back, never
|
|
21
45
|
* stacking it beside the artefact. Omit and the sources still list, but
|
|
@@ -24,14 +48,49 @@
|
|
|
24
48
|
copy?: Partial<ChatCopy>;
|
|
25
49
|
}
|
|
26
50
|
|
|
27
|
-
let {
|
|
51
|
+
let {
|
|
52
|
+
turn,
|
|
53
|
+
versions = [],
|
|
54
|
+
version,
|
|
55
|
+
onversion,
|
|
56
|
+
loadWritten,
|
|
57
|
+
writtenUrl,
|
|
58
|
+
onclose,
|
|
59
|
+
oncite,
|
|
60
|
+
copy
|
|
61
|
+
}: Props = $props();
|
|
28
62
|
|
|
29
63
|
const words = $derived(resolveCopy(copy));
|
|
30
64
|
|
|
31
|
-
|
|
65
|
+
// The newest, until the reader picks another.
|
|
66
|
+
const current = $derived(versions.find((v) => v.version === version) ?? versions.at(-1));
|
|
67
|
+
const currentKey = $derived(current ? writtenKey(current) : '');
|
|
68
|
+
let source = $state<{ text: string; mediaType: string } | null>(null);
|
|
69
|
+
let failed = $state(false);
|
|
70
|
+
|
|
71
|
+
// Keyed on the version alone: a new version of the same name arriving in
|
|
72
|
+
// the stream must not read this one again.
|
|
73
|
+
$effect(() => {
|
|
74
|
+
if (!currentKey) return;
|
|
75
|
+
const shown = untrack(() => current);
|
|
76
|
+
const load = untrack(() => loadWritten);
|
|
77
|
+
if (!shown || !load) return;
|
|
78
|
+
let live = true;
|
|
79
|
+
source = null;
|
|
80
|
+
failed = false;
|
|
81
|
+
load(shown.name, shown.version).then(
|
|
82
|
+
(read) => live && (source = read),
|
|
83
|
+
() => live && (failed = true)
|
|
84
|
+
);
|
|
85
|
+
return () => (live = false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const texts = $derived(
|
|
89
|
+
segment(turn?.blocks ?? []).filter((s): s is TextBlock => s.kind === 'text')
|
|
90
|
+
);
|
|
32
91
|
const rawAnswer = $derived(texts.map((t) => t.text).join('\n\n'));
|
|
33
92
|
const split = $derived(splitSources(rawAnswer));
|
|
34
|
-
const sources = $derived(resolveCitations(turn
|
|
93
|
+
const sources = $derived(resolveCitations(turn?.citations ?? [], split.citations));
|
|
35
94
|
|
|
36
95
|
let closeButton = $state<HTMLButtonElement | null>(null);
|
|
37
96
|
|
|
@@ -53,7 +112,7 @@
|
|
|
53
112
|
|
|
54
113
|
<svelte:window onkeydown={keydown} />
|
|
55
114
|
|
|
56
|
-
<aside aria-label=
|
|
115
|
+
<aside aria-label={current?.name ?? 'Study artefact'} class="ds-lib-pane">
|
|
57
116
|
<!-- The sheet's grabber. On a phone this is an overlay a reader has to be
|
|
58
117
|
able to see the top edge of; on a desktop it is a column, so there is
|
|
59
118
|
nothing here to grab. -->
|
|
@@ -61,9 +120,34 @@
|
|
|
61
120
|
|
|
62
121
|
<header class="ds-lib-pane-header">
|
|
63
122
|
<div class="ds-lib-pane-heading">
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
<
|
|
123
|
+
{#if current}
|
|
124
|
+
<h2 class="ds-lib-pane-title">{current.name}</h2>
|
|
125
|
+
<div class="ds-lib-pane-tools">
|
|
126
|
+
<select
|
|
127
|
+
class="ds-lib-pane-versions"
|
|
128
|
+
aria-label={words.writtenVersions}
|
|
129
|
+
value={current.version}
|
|
130
|
+
onchange={(event) => onversion?.(Number(event.currentTarget.value))}
|
|
131
|
+
>
|
|
132
|
+
{#each versions as option (option.version)}
|
|
133
|
+
<option value={option.version}>{fill(words.writtenVersion, { n: option.version })}</option>
|
|
134
|
+
{/each}
|
|
135
|
+
</select>
|
|
136
|
+
{#if writtenUrl}
|
|
137
|
+
<ul class="ds-lib-pane-links" aria-label={words.download}>
|
|
138
|
+
{#each downloads(current, words) as link (link.format)}
|
|
139
|
+
<li>
|
|
140
|
+
<a href={writtenUrl(current.name, current.version, link.format)}>{link.label}</a>
|
|
141
|
+
</li>
|
|
142
|
+
{/each}
|
|
143
|
+
</ul>
|
|
144
|
+
{/if}
|
|
145
|
+
</div>
|
|
146
|
+
{:else}
|
|
147
|
+
<h2 class="ds-lib-pane-title">{turn?.artefact?.title}</h2>
|
|
148
|
+
{#if turn?.artefact?.summary}
|
|
149
|
+
<p class="ds-lib-pane-subtitle">{turn.artefact.summary}</p>
|
|
150
|
+
{/if}
|
|
67
151
|
{/if}
|
|
68
152
|
</div>
|
|
69
153
|
<button
|
|
@@ -77,15 +161,29 @@
|
|
|
77
161
|
</button>
|
|
78
162
|
</header>
|
|
79
163
|
|
|
80
|
-
|
|
81
|
-
|
|
164
|
+
{#if current}
|
|
165
|
+
{@const html = source !== null && isHtml(source.mediaType || current.media_type)}
|
|
166
|
+
<div class="ds-lib-pane-body" class:is-frame={html}>
|
|
167
|
+
{#if failed}
|
|
168
|
+
<p class="ds-lib-pane-subtitle">{words.documentUnavailable}</p>
|
|
169
|
+
{:else if source && html}
|
|
170
|
+
<iframe class="ds-lib-pane-frame" sandbox="" srcdoc={framed(source.text)} title={current.name}
|
|
171
|
+
></iframe>
|
|
172
|
+
{:else if source}
|
|
173
|
+
<Markdown content={source.text} />
|
|
174
|
+
{/if}
|
|
175
|
+
</div>
|
|
176
|
+
{:else}
|
|
177
|
+
<div class="ds-lib-pane-body">
|
|
178
|
+
<Markdown content={split.citations.length > 0 ? split.body : rawAnswer} />
|
|
82
179
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
180
|
+
{#if sources.length > 0}
|
|
181
|
+
<div class="ds-lib-pane-sources">
|
|
182
|
+
<SourceList {sources} {words} {oncite} level={3} />
|
|
183
|
+
</div>
|
|
184
|
+
{/if}
|
|
185
|
+
</div>
|
|
186
|
+
{/if}
|
|
89
187
|
</aside>
|
|
90
188
|
|
|
91
189
|
<style>
|
|
@@ -187,6 +285,52 @@
|
|
|
187
285
|
padding: 0.75rem 1rem;
|
|
188
286
|
}
|
|
189
287
|
|
|
288
|
+
.ds-lib-pane-tools {
|
|
289
|
+
display: flex;
|
|
290
|
+
flex-wrap: wrap;
|
|
291
|
+
align-items: center;
|
|
292
|
+
gap: 0.25rem 0.75rem;
|
|
293
|
+
margin-top: 0.25rem;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.ds-lib-pane-versions {
|
|
297
|
+
border: 1px solid var(--ds-color-border);
|
|
298
|
+
border-radius: var(--ds-radius-md);
|
|
299
|
+
background: var(--ds-color-surface-1);
|
|
300
|
+
padding: 0.125rem 0.25rem;
|
|
301
|
+
color: var(--ds-color-foreground);
|
|
302
|
+
font: inherit;
|
|
303
|
+
font-size: var(--ds-text-2xs);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.ds-lib-pane-links {
|
|
307
|
+
display: flex;
|
|
308
|
+
flex-wrap: wrap;
|
|
309
|
+
gap: 0.75rem;
|
|
310
|
+
margin: 0;
|
|
311
|
+
padding: 0;
|
|
312
|
+
font-size: var(--ds-text-2xs);
|
|
313
|
+
list-style: none;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.ds-lib-pane-links a {
|
|
317
|
+
color: var(--ds-color-muted-foreground);
|
|
318
|
+
text-decoration: underline;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.ds-lib-pane-body.is-frame {
|
|
322
|
+
overflow: hidden;
|
|
323
|
+
padding: 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.ds-lib-pane-frame {
|
|
327
|
+
display: block;
|
|
328
|
+
width: 100%;
|
|
329
|
+
height: 100%;
|
|
330
|
+
border: 0;
|
|
331
|
+
background: #fff;
|
|
332
|
+
}
|
|
333
|
+
|
|
190
334
|
.ds-lib-pane-sources {
|
|
191
335
|
margin-top: 1.5rem;
|
|
192
336
|
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { type Turn } from '../../transcript.svelte';
|
|
2
2
|
import { type Citation } from '../../citations';
|
|
3
3
|
import { type ChatCopy } from '../../copy';
|
|
4
|
+
import { type LoadWritten, type Written, type WrittenUrl } from '../../written';
|
|
4
5
|
interface Props {
|
|
5
|
-
turn
|
|
6
|
+
/** The turn whose answer is the artefact. Absent when `versions` is given. */
|
|
7
|
+
turn?: Turn;
|
|
8
|
+
/** Every version of one written document, oldest first: the pane shows
|
|
9
|
+
* `version` of them, with a switcher. */
|
|
10
|
+
versions?: Written[];
|
|
11
|
+
version?: number;
|
|
12
|
+
onversion?: (version: number) => void;
|
|
13
|
+
loadWritten?: LoadWritten;
|
|
14
|
+
writtenUrl?: WrittenUrl;
|
|
6
15
|
onclose: () => void;
|
|
7
16
|
/** Opens the cited document instead — swapping the column back, never
|
|
8
17
|
* stacking it beside the artefact. Omit and the sources still list, but
|
|
@@ -20,19 +20,32 @@
|
|
|
20
20
|
import type { DescribeTool, Turn } from '../../transcript.svelte';
|
|
21
21
|
import type { Verdict } from '../../chat.svelte';
|
|
22
22
|
import type { Citation, LoadDocument } from '../../citations';
|
|
23
|
+
import {
|
|
24
|
+
writtenKey,
|
|
25
|
+
writtenVersions,
|
|
26
|
+
type LoadWritten,
|
|
27
|
+
type Written,
|
|
28
|
+
type WrittenUrl
|
|
29
|
+
} from '../../written';
|
|
23
30
|
import { DEFAULT_PERSONA, personaName, resolveCopy, type ChatCopy } from '../../copy';
|
|
24
31
|
import { FollowScroll } from '../../follow-scroll.svelte';
|
|
25
32
|
import AgentTranscript, {
|
|
26
33
|
type AgentTranscriptProps
|
|
27
34
|
} from '../agent-transcript/agent-transcript.svelte';
|
|
28
35
|
import ArtefactPane from '../artefact-pane/artefact-pane.svelte';
|
|
36
|
+
import type { Fences } from '../markdown/markdown';
|
|
29
37
|
import DocumentPane from '../document-pane/document-pane.svelte';
|
|
30
38
|
import ScopeStatement from '../scope-statement/scope-statement.svelte';
|
|
31
39
|
|
|
32
|
-
/** What the reading column holds — a cited document
|
|
33
|
-
*
|
|
34
|
-
* replaces whatever was there.
|
|
35
|
-
|
|
40
|
+
/** What the reading column holds — a cited document, a study artefact or a
|
|
41
|
+
* document the persona wrote, never two at once and never two panes:
|
|
42
|
+
* whichever the reader last opened replaces whatever was there. A written
|
|
43
|
+
* document's `version` is null while the reader follows the newest. */
|
|
44
|
+
type Column =
|
|
45
|
+
| { kind: 'document'; citation: Citation }
|
|
46
|
+
| { kind: 'artefact'; turn: Turn }
|
|
47
|
+
| { kind: 'written'; name: string; version: number | null }
|
|
48
|
+
| null;
|
|
36
49
|
|
|
37
50
|
interface Props {
|
|
38
51
|
turns: Turn[];
|
|
@@ -86,6 +99,19 @@
|
|
|
86
99
|
showing?: string;
|
|
87
100
|
/** The persona's own words for its tools, and what they read. */
|
|
88
101
|
describeTool?: DescribeTool;
|
|
102
|
+
/** Replaces the question at `position` (its place among `turns`) with
|
|
103
|
+
* `text` and asks it again from there (`Chat.edit`). Offered on every
|
|
104
|
+
* plain question while no answer is being written; without it no turn
|
|
105
|
+
* can be edited. */
|
|
106
|
+
onedit?: (position: number, text: string) => Promise<boolean>;
|
|
107
|
+
/** Reads one version of a document the persona wrote, to show it in the
|
|
108
|
+
* column. Without it its cards link but do not open. */
|
|
109
|
+
loadWritten?: LoadWritten;
|
|
110
|
+
/** Where a version downloads. Without it no download links render. */
|
|
111
|
+
writtenUrl?: WrittenUrl;
|
|
112
|
+
/** Languages of fenced code the host draws with its own component
|
|
113
|
+
* rather than as code. */
|
|
114
|
+
fences?: Fences;
|
|
89
115
|
/** Prices a turn's `outcome.costUsd` in the host's own currency and
|
|
90
116
|
* words ("about A$0.34"), for the footer's cost line beside a turn's
|
|
91
117
|
* duration. The package never converts currency itself; omitted, the
|
|
@@ -133,6 +159,10 @@
|
|
|
133
159
|
onopenartefact,
|
|
134
160
|
showing,
|
|
135
161
|
describeTool,
|
|
162
|
+
onedit,
|
|
163
|
+
loadWritten,
|
|
164
|
+
writtenUrl,
|
|
165
|
+
fences,
|
|
136
166
|
collectionNames = new Set(),
|
|
137
167
|
composer,
|
|
138
168
|
turn: presentTurn,
|
|
@@ -252,6 +282,24 @@
|
|
|
252
282
|
return undefined;
|
|
253
283
|
}
|
|
254
284
|
|
|
285
|
+
/** Opens a written version, following the newest if it is the newest. */
|
|
286
|
+
function openWritten(written: Written) {
|
|
287
|
+
const newest = writtenVersions(turns, written.name).at(-1)?.version;
|
|
288
|
+
column = {
|
|
289
|
+
kind: 'written',
|
|
290
|
+
name: written.name,
|
|
291
|
+
version: written.version === newest ? null : written.version
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const shownWritten = $derived.by(() => {
|
|
296
|
+
const open = column;
|
|
297
|
+
if (open?.kind !== 'written') return undefined;
|
|
298
|
+
const versions = writtenVersions(turns, open.name);
|
|
299
|
+
const shown = versions.find((v) => v.version === open.version) ?? versions.at(-1);
|
|
300
|
+
return { versions, version: shown?.version, key: shown ? writtenKey(shown) : undefined };
|
|
301
|
+
});
|
|
302
|
+
|
|
255
303
|
function isShowing(turn: Turn): boolean {
|
|
256
304
|
if (onopenartefact) return showing === turn.id;
|
|
257
305
|
return column?.kind === 'artefact' && column.turn.id === turn.id;
|
|
@@ -311,7 +359,7 @@
|
|
|
311
359
|
Centred, both sit under the composer instead. -->
|
|
312
360
|
{#if !centred}
|
|
313
361
|
{#if scope}
|
|
314
|
-
<ScopeStatement statement={scope} expanded={
|
|
362
|
+
<ScopeStatement statement={scope} expanded={false} copy={words} />
|
|
315
363
|
{/if}
|
|
316
364
|
{@render lead?.()}
|
|
317
365
|
{/if}
|
|
@@ -333,6 +381,16 @@
|
|
|
333
381
|
artefactOpen: isShowing(turn),
|
|
334
382
|
onopenartefact: opener(turn),
|
|
335
383
|
describeTool,
|
|
384
|
+
files: turn.files,
|
|
385
|
+
written: turn.written,
|
|
386
|
+
showingWritten: shownWritten?.key,
|
|
387
|
+
onopenwritten: loadWritten ? openWritten : undefined,
|
|
388
|
+
writtenUrl,
|
|
389
|
+
fences,
|
|
390
|
+
onedit:
|
|
391
|
+
onedit && !running && !turn.kind && turn.question
|
|
392
|
+
? (text: string) => onedit(index, text)
|
|
393
|
+
: undefined,
|
|
336
394
|
formatCost,
|
|
337
395
|
oncite: oncite || loadDocument ? cite : undefined,
|
|
338
396
|
onregenerate: last && !running ? onregenerate : undefined,
|
|
@@ -367,7 +425,9 @@
|
|
|
367
425
|
<div class="ds-lib-after">
|
|
368
426
|
{@render suggestions()}
|
|
369
427
|
{#if scope}
|
|
370
|
-
<
|
|
428
|
+
<div class="ds-lib-after-scope">
|
|
429
|
+
<ScopeStatement statement={scope} expanded={false} copy={words} />
|
|
430
|
+
</div>
|
|
371
431
|
{/if}
|
|
372
432
|
{@render lead?.()}
|
|
373
433
|
</div>
|
|
@@ -385,6 +445,18 @@
|
|
|
385
445
|
copy={words}
|
|
386
446
|
onclose={() => (column = null)}
|
|
387
447
|
/>
|
|
448
|
+
{:else if column?.kind === 'written' && shownWritten && shownWritten.versions.length > 0}
|
|
449
|
+
<ArtefactPane
|
|
450
|
+
versions={shownWritten.versions}
|
|
451
|
+
version={shownWritten.version}
|
|
452
|
+
onversion={(version) => {
|
|
453
|
+
if (column?.kind === 'written') column = { ...column, version };
|
|
454
|
+
}}
|
|
455
|
+
{loadWritten}
|
|
456
|
+
{writtenUrl}
|
|
457
|
+
copy={words}
|
|
458
|
+
onclose={() => (column = null)}
|
|
459
|
+
/>
|
|
388
460
|
{:else if column?.kind === 'artefact'}
|
|
389
461
|
<ArtefactPane
|
|
390
462
|
turn={column.turn}
|
|
@@ -532,6 +604,11 @@
|
|
|
532
604
|
padding-top: 1rem;
|
|
533
605
|
}
|
|
534
606
|
|
|
607
|
+
.ds-lib-after-scope {
|
|
608
|
+
display: flex;
|
|
609
|
+
justify-content: center;
|
|
610
|
+
}
|
|
611
|
+
|
|
535
612
|
.ds-lib-after .ds-lib-chips {
|
|
536
613
|
flex-flow: row wrap;
|
|
537
614
|
justify-content: center;
|
|
@@ -2,8 +2,10 @@ import type { Snippet } from 'svelte';
|
|
|
2
2
|
import type { DescribeTool, Turn } from '../../transcript.svelte';
|
|
3
3
|
import type { Verdict } from '../../chat.svelte';
|
|
4
4
|
import type { Citation, LoadDocument } from '../../citations';
|
|
5
|
+
import { type LoadWritten, type WrittenUrl } from '../../written';
|
|
5
6
|
import { type ChatCopy } from '../../copy';
|
|
6
7
|
import { type AgentTranscriptProps } from '../agent-transcript/agent-transcript.svelte';
|
|
8
|
+
import type { Fences } from '../markdown/markdown';
|
|
7
9
|
interface Props {
|
|
8
10
|
turns: Turn[];
|
|
9
11
|
running: boolean;
|
|
@@ -56,6 +58,19 @@ interface Props {
|
|
|
56
58
|
showing?: string;
|
|
57
59
|
/** The persona's own words for its tools, and what they read. */
|
|
58
60
|
describeTool?: DescribeTool;
|
|
61
|
+
/** Replaces the question at `position` (its place among `turns`) with
|
|
62
|
+
* `text` and asks it again from there (`Chat.edit`). Offered on every
|
|
63
|
+
* plain question while no answer is being written; without it no turn
|
|
64
|
+
* can be edited. */
|
|
65
|
+
onedit?: (position: number, text: string) => Promise<boolean>;
|
|
66
|
+
/** Reads one version of a document the persona wrote, to show it in the
|
|
67
|
+
* column. Without it its cards link but do not open. */
|
|
68
|
+
loadWritten?: LoadWritten;
|
|
69
|
+
/** Where a version downloads. Without it no download links render. */
|
|
70
|
+
writtenUrl?: WrittenUrl;
|
|
71
|
+
/** Languages of fenced code the host draws with its own component
|
|
72
|
+
* rather than as code. */
|
|
73
|
+
fences?: Fences;
|
|
59
74
|
/** Prices a turn's `outcome.costUsd` in the host's own currency and
|
|
60
75
|
* words ("about A$0.34"), for the footer's cost line beside a turn's
|
|
61
76
|
* duration. The package never converts currency itself; omitted, the
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* Sanitised on the way out, always. The content is model output rendered as
|
|
16
16
|
* HTML, which is exactly the case DOMPurify exists for.
|
|
17
17
|
*/
|
|
18
|
+
import { type Component } from 'svelte';
|
|
18
19
|
/** Close anything the stream has opened but not yet finished. */
|
|
19
20
|
export declare function balance(markdown: string): string;
|
|
20
21
|
export declare function render(markdown: string, { streaming }?: {
|
|
@@ -42,6 +43,16 @@ export declare function markCollections(root: HTMLElement, collections: Set<stri
|
|
|
42
43
|
* button ROLE and a tab stop, and the component delegates the events.
|
|
43
44
|
*/
|
|
44
45
|
export declare function markCitations(root: HTMLElement, valid: Set<number>): void;
|
|
46
|
+
/** Fenced code blocks of a language a host names, drawn by its own component. */
|
|
47
|
+
export type Fences = Record<string, Component<{
|
|
48
|
+
source: string;
|
|
49
|
+
}>>;
|
|
50
|
+
/**
|
|
51
|
+
* Draw every fenced block whose language the host registered with the host's
|
|
52
|
+
* component, in place of the code block. The source is the block's text, never
|
|
53
|
+
* HTML. Returns what unmounts them.
|
|
54
|
+
*/
|
|
55
|
+
export declare function mountFences(root: HTMLElement, fences: Fences): () => void;
|
|
45
56
|
/**
|
|
46
57
|
* Replace every `<pre><code>` in a rendered fragment with a highlighted one.
|
|
47
58
|
* Runs against the DOM node rather than the HTML string so it can be applied
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import DOMPurify from 'isomorphic-dompurify';
|
|
19
19
|
import { marked } from 'marked';
|
|
20
|
+
import { mount, unmount } from 'svelte';
|
|
20
21
|
marked.setOptions({ gfm: true, breaks: false });
|
|
21
22
|
/** Close anything the stream has opened but not yet finished. */
|
|
22
23
|
export function balance(markdown) {
|
|
@@ -35,14 +36,52 @@ export function balance(markdown) {
|
|
|
35
36
|
}
|
|
36
37
|
return text;
|
|
37
38
|
}
|
|
39
|
+
const FETCHERS = 'img, picture, source, video, audio, track, iframe, object, embed, link, style, image, use';
|
|
40
|
+
const URL_ATTRS = ['src', 'href', 'xlink:href', 'poster', 'data', 'srcset'];
|
|
41
|
+
/** Only what cannot leave the origin: a data URL, a same-origin path, a fragment. */
|
|
42
|
+
function stays(url) {
|
|
43
|
+
const u = url.trim();
|
|
44
|
+
return /^data:/i.test(u) || (u.startsWith('/') && !u.startsWith('//')) || u.startsWith('#');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A rendered answer must not fetch from anywhere: a remote image or url() in
|
|
48
|
+
* agent output is a beacon that carries the conversation out in its query.
|
|
49
|
+
* A remote image degrades to its alt text (or its address as a link).
|
|
50
|
+
*/
|
|
51
|
+
function dropFetchers(root) {
|
|
52
|
+
for (const el of root.querySelectorAll(FETCHERS)) {
|
|
53
|
+
const urls = URL_ATTRS.map((a) => el.getAttribute(a)).filter((v) => v !== null);
|
|
54
|
+
if (urls.every(stays))
|
|
55
|
+
continue;
|
|
56
|
+
if (el.tagName === 'IMG') {
|
|
57
|
+
const alt = el.getAttribute('alt')?.trim();
|
|
58
|
+
const src = el.getAttribute('src')?.trim() ?? '';
|
|
59
|
+
if (alt)
|
|
60
|
+
el.replaceWith(root.ownerDocument.createTextNode(alt));
|
|
61
|
+
else if (/^https?:\/\//i.test(src)) {
|
|
62
|
+
const a = root.ownerDocument.createElement('a');
|
|
63
|
+
a.setAttribute('href', src);
|
|
64
|
+
a.textContent = src;
|
|
65
|
+
el.replaceWith(a);
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
el.remove();
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
el.remove();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
38
74
|
export function render(markdown, { streaming = false } = {}) {
|
|
39
75
|
const source = streaming ? balance(markdown) : markdown;
|
|
40
76
|
const html = marked.parse(source, { async: false });
|
|
41
|
-
|
|
77
|
+
const body = DOMPurify.sanitize(html, {
|
|
42
78
|
ADD_ATTR: ['target', 'rel', 'data-cite', 'tabindex', 'role'],
|
|
43
79
|
FORBID_TAGS: ['style', 'form', 'input', 'button'],
|
|
44
|
-
FORBID_ATTR: ['style', 'onerror', 'onload']
|
|
80
|
+
FORBID_ATTR: ['style', 'srcset', 'onerror', 'onload'],
|
|
81
|
+
RETURN_DOM: true
|
|
45
82
|
});
|
|
83
|
+
dropFetchers(body);
|
|
84
|
+
return body.innerHTML;
|
|
46
85
|
}
|
|
47
86
|
let highlighterPromise = null;
|
|
48
87
|
/** Loaded once, lazily — shiki's engine and grammars are not small. */
|
|
@@ -137,6 +176,27 @@ export function markCitations(root, valid) {
|
|
|
137
176
|
text.replaceWith(fragment);
|
|
138
177
|
}
|
|
139
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Draw every fenced block whose language the host registered with the host's
|
|
181
|
+
* component, in place of the code block. The source is the block's text, never
|
|
182
|
+
* HTML. Returns what unmounts them.
|
|
183
|
+
*/
|
|
184
|
+
export function mountFences(root, fences) {
|
|
185
|
+
const drawn = [];
|
|
186
|
+
for (const code of root.querySelectorAll('pre > code')) {
|
|
187
|
+
const lang = [...code.classList].find((c) => c.startsWith('language-'))?.slice(9);
|
|
188
|
+
const pre = code.parentElement;
|
|
189
|
+
if (!lang || !pre || !Object.hasOwn(fences, lang))
|
|
190
|
+
continue;
|
|
191
|
+
const target = document.createElement('div');
|
|
192
|
+
pre.replaceWith(target);
|
|
193
|
+
drawn.push(mount(fences[lang], { target, props: { source: code.textContent ?? '' } }));
|
|
194
|
+
}
|
|
195
|
+
return () => {
|
|
196
|
+
for (const one of drawn)
|
|
197
|
+
void unmount(one);
|
|
198
|
+
};
|
|
199
|
+
}
|
|
140
200
|
/**
|
|
141
201
|
* Replace every `<pre><code>` in a rendered fragment with a highlighted one.
|
|
142
202
|
* Runs against the DOM node rather than the HTML string so it can be applied
|
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
consuming app.
|
|
8
8
|
-->
|
|
9
9
|
<script lang="ts">
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
highlight,
|
|
12
|
+
markCitations,
|
|
13
|
+
markCollections,
|
|
14
|
+
mountFences,
|
|
15
|
+
render,
|
|
16
|
+
type Fences
|
|
17
|
+
} from './markdown';
|
|
11
18
|
|
|
12
19
|
interface Props {
|
|
13
20
|
content: string;
|
|
@@ -20,6 +27,9 @@
|
|
|
20
27
|
/** A step down, for prose that is reference material beside an answer
|
|
21
28
|
* rather than the answer itself — the source pane's sections. */
|
|
22
29
|
dense?: boolean;
|
|
30
|
+
/** A language to draw with the host's own component instead of as code.
|
|
31
|
+
* Absent, every fence renders as code. */
|
|
32
|
+
fences?: Fences;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
let {
|
|
@@ -28,12 +38,22 @@
|
|
|
28
38
|
collectionNames = new Set(),
|
|
29
39
|
citationNumbers = new Set(),
|
|
30
40
|
oncite,
|
|
31
|
-
dense = false
|
|
41
|
+
dense = false,
|
|
42
|
+
fences
|
|
32
43
|
}: Props = $props();
|
|
33
44
|
let host = $state<HTMLElement | null>(null);
|
|
34
45
|
|
|
35
46
|
const html = $derived(render(content, { streaming }));
|
|
36
47
|
|
|
48
|
+
// Once settled (a half-written fence is not a chart), and before the
|
|
49
|
+
// highlight below, which would otherwise take the block first. Its own
|
|
50
|
+
// effect, so a change to the chips never unmounts what it drew.
|
|
51
|
+
$effect(() => {
|
|
52
|
+
void html;
|
|
53
|
+
if (!host || streaming || !fences) return;
|
|
54
|
+
return mountFences(host, fences);
|
|
55
|
+
});
|
|
56
|
+
|
|
37
57
|
// Highlight only once the text has settled. Re-running per token would
|
|
38
58
|
// re-parse grammars for a block that changes again milliseconds later.
|
|
39
59
|
$effect(() => {
|
|
@@ -214,8 +234,9 @@
|
|
|
214
234
|
color: var(--ds-color-foreground);
|
|
215
235
|
}
|
|
216
236
|
|
|
237
|
+
/* The accent as text: an app whose accent fails as text sets the ink. */
|
|
217
238
|
.agent-prose :global(a) {
|
|
218
|
-
color: var(--ds-color-primary);
|
|
239
|
+
color: var(--ds-color-primary-ink, var(--ds-color-primary));
|
|
219
240
|
text-underline-offset: 0.2em;
|
|
220
241
|
text-decoration-line: underline;
|
|
221
242
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Fences } from './markdown';
|
|
1
2
|
interface Props {
|
|
2
3
|
content: string;
|
|
3
4
|
streaming?: boolean;
|
|
@@ -9,6 +10,9 @@ interface Props {
|
|
|
9
10
|
/** A step down, for prose that is reference material beside an answer
|
|
10
11
|
* rather than the answer itself — the source pane's sections. */
|
|
11
12
|
dense?: boolean;
|
|
13
|
+
/** A language to draw with the host's own component instead of as code.
|
|
14
|
+
* Absent, every fence renders as code. */
|
|
15
|
+
fences?: Fences;
|
|
12
16
|
}
|
|
13
17
|
declare const Markdown: import("svelte").Component<Props, {}, "">;
|
|
14
18
|
type Markdown = ReturnType<typeof Markdown>;
|