@poodle64/librarian 2026.9.17 → 2026.9.19
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 +80 -16
- package/dist/citations.d.ts +3 -1
- package/dist/client.d.ts +28 -1
- package/dist/client.js +29 -8
- package/dist/components/activity-group/activity-group.svelte +6 -1
- package/dist/components/agent-transcript/agent-transcript.svelte +51 -33
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +9 -8
- package/dist/components/artefact-card/artefact-card.svelte +100 -39
- package/dist/components/artefact-card/artefact-card.svelte.d.ts +9 -3
- package/dist/components/artefact-pane/artefact-pane.svelte +5 -5
- package/dist/components/composer/composer.svelte +20 -5
- package/dist/components/composer/composer.svelte.d.ts +8 -2
- package/dist/components/conversation/conversation.svelte +46 -9
- package/dist/components/conversation/conversation.svelte.d.ts +14 -2
- package/dist/components/tool-row/tool-row.svelte +16 -9
- package/dist/components/tool-row/tool-row.svelte.d.ts +3 -1
- package/dist/copy.d.ts +3 -0
- package/dist/copy.js +3 -1
- package/dist/session.svelte.d.ts +31 -0
- package/dist/session.svelte.js +105 -0
- package/dist/transcript.svelte.d.ts +104 -17
- package/dist/transcript.svelte.js +209 -140
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -16,8 +16,9 @@ there.
|
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
18
|
src/lib/
|
|
19
|
-
client.ts ask():
|
|
19
|
+
client.ts ask() and watch(): Claude Code's OWN events, unaltered
|
|
20
20
|
transcript.svelte.ts Transcript state, the fold/segment/describe helpers
|
|
21
|
+
session.svelte.ts Session: a whole stream of runs, as turns
|
|
21
22
|
citations.ts the Citation shape, `[n]` markers, the trust mark
|
|
22
23
|
copy.ts every word this package says, and the host's overrides
|
|
23
24
|
attachments.ts what a reader may attach, and the limits
|
|
@@ -28,9 +29,9 @@ src/lib/
|
|
|
28
29
|
scope-statement/ what this room answers from, and what it does not hold
|
|
29
30
|
agent-transcript/ one question and everything Milton did answering it
|
|
30
31
|
document-pane/ the cited document, open at the cited passage
|
|
31
|
-
artefact-card/
|
|
32
|
-
artefact-pane/
|
|
33
|
-
composer/ the input box: attachments, scope chips, send/stop
|
|
32
|
+
artefact-card/ an artefact's card, in the host's words
|
|
33
|
+
artefact-pane/ an artefact that is its answer's prose, in the column
|
|
34
|
+
composer/ the input box: attachments, scope chips, a note, send/stop
|
|
34
35
|
markdown/ sanitised, streaming-safe markdown + highlighting
|
|
35
36
|
activity-group/ a whole investigation, as one quiet line
|
|
36
37
|
tool-row/ one tool call
|
|
@@ -41,12 +42,12 @@ src/lib/
|
|
|
41
42
|
## Installation
|
|
42
43
|
|
|
43
44
|
```bash
|
|
44
|
-
pnpm add @poodle64/librarian @
|
|
45
|
+
pnpm add @poodle64/librarian @lucide/svelte
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
`svelte`, `@
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
`svelte`, `@lucide/svelte`, `marked`, `isomorphic-dompurify` and `shiki` are
|
|
49
|
+
peer dependencies: declare them yourself so Renovate tracks their versions and
|
|
50
|
+
`pnpm ls` shows them.
|
|
50
51
|
|
|
51
52
|
**No Tailwind content-scan line, and deliberately none.** These components
|
|
52
53
|
carry their own CSS, written against the `--ds-*` tokens and compiled by
|
|
@@ -65,9 +66,6 @@ What it does need is the token layer every app already imports:
|
|
|
65
66
|
@import '@poodle64/design-tokens/tokens.css';
|
|
66
67
|
```
|
|
67
68
|
|
|
68
|
-
The composed page chrome this package borrows from `@poodle64/ui` (`Panel`)
|
|
69
|
-
still follows that package's own `@source` line, which every app has.
|
|
70
|
-
|
|
71
69
|
One knob: `--ds-lib-measure` (default `46rem`) sets the transcript's reading
|
|
72
70
|
column. Set it on any ancestor.
|
|
73
71
|
|
|
@@ -153,8 +151,6 @@ gives it a height and a composer and nothing else:
|
|
|
153
151
|
bind:value={question}
|
|
154
152
|
bind:files
|
|
155
153
|
{running}
|
|
156
|
-
scope="library"
|
|
157
|
-
onscope={() => {}}
|
|
158
154
|
onsubmit={submit}
|
|
159
155
|
onstop={() => controller?.abort()}
|
|
160
156
|
/>
|
|
@@ -184,6 +180,71 @@ Nothing here fetches on its own behalf. The library's document read is
|
|
|
184
180
|
authenticated, and a package that called it directly would be reaching past
|
|
185
181
|
the app's proxy with a session it has no business holding.
|
|
186
182
|
|
|
183
|
+
### Watching a session somebody else started
|
|
184
|
+
|
|
185
|
+
A session the APP started — a persona reading a document with nobody asking —
|
|
186
|
+
is watched rather than asked. `watch()` GETs its stream; `Session` folds it
|
|
187
|
+
into turns: the prompt and every later message on the reader's side (the CLI
|
|
188
|
+
echoes each one back under `--replay-user-messages`), each run's answer on the
|
|
189
|
+
persona's, each settled by its own `result`. A stream without deltas is folded
|
|
190
|
+
from its whole messages. Events are deduped by `uuid`, so opening the watch
|
|
191
|
+
again after sending a message folds only what is new.
|
|
192
|
+
|
|
193
|
+
```svelte
|
|
194
|
+
<script lang="ts">
|
|
195
|
+
import { watch } from '@poodle64/librarian/client';
|
|
196
|
+
import { Session } from '@poodle64/librarian/session';
|
|
197
|
+
|
|
198
|
+
const session = new Session();
|
|
199
|
+
let watching = $state(false);
|
|
200
|
+
|
|
201
|
+
async function follow(signal: AbortSignal) {
|
|
202
|
+
watching = true;
|
|
203
|
+
try {
|
|
204
|
+
for await (const event of watch({ endpoint: `/api/agent/${persona}/jobs/${id}/watch`, signal }))
|
|
205
|
+
session.apply(event);
|
|
206
|
+
} finally {
|
|
207
|
+
watching = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The host's own additions: what a run handed in, carded under its answer.
|
|
212
|
+
const turns = $derived(
|
|
213
|
+
session.turns.map((turn) =>
|
|
214
|
+
turn.outcome?.structuredOutput ? { ...turn, artefact: artefactFrom(turn) } : turn
|
|
215
|
+
)
|
|
216
|
+
);
|
|
217
|
+
</script>
|
|
218
|
+
|
|
219
|
+
<Conversation
|
|
220
|
+
{turns}
|
|
221
|
+
running={watching && session.working}
|
|
222
|
+
version={session.version}
|
|
223
|
+
{name}
|
|
224
|
+
{describeTool}
|
|
225
|
+
copy={{ sources: 'Read from', notHeld: '' }}
|
|
226
|
+
onopenartefact={(turn) => (showing = turn.id)}
|
|
227
|
+
{showing}
|
|
228
|
+
oncite={(citation) => showPage(citation.document_id)}
|
|
229
|
+
>
|
|
230
|
+
{#snippet composer()}
|
|
231
|
+
<Composer bind:value {running} {name} note="It carries on from where it stopped." {onsubmit} {onstop} />
|
|
232
|
+
{/snippet}
|
|
233
|
+
</Conversation>
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`watch()` differs from `ask()` in one way: a clean close with no `result` is
|
|
237
|
+
not a failure, because a stopped run and a session waiting between runs both
|
|
238
|
+
end that way.
|
|
239
|
+
|
|
240
|
+
| Concern | How |
|
|
241
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
242
|
+
| The persona's tools | `describeTool(block) => ToolWords \| undefined`: the row (`verb`, `object`, `detail`), the activity line's counts (`tally`), and what it read (`source`). `undefined` leaves a call to the package's own words |
|
|
243
|
+
| What an answer read | `ToolWords.source` lists under the answer, under `copy.sources`; tapping one calls `oncite`. Nothing checked it, so it carries no trust mark |
|
|
244
|
+
| An artefact | `turn.artefact = { title, summary }` cards under the prose once the turn settles; `isAnswer: true` is an artefact that IS the prose, and replaces it |
|
|
245
|
+
| Opening it | `onopenartefact(turn)` and `showing` (the turn id the host shows). Without a handler, an `isAnswer` artefact opens in `ArtefactPane` and any other offers no action |
|
|
246
|
+
| "Answered without a source" | `copy.notHeld: ''` for a persona that answers from no shelf |
|
|
247
|
+
|
|
187
248
|
### Citations
|
|
188
249
|
|
|
189
250
|
The library emits one SSE frame after the final assistant text:
|
|
@@ -343,7 +404,7 @@ pnpm run test # build + vitest
|
|
|
343
404
|
pnpm run screenshots # the state grid, real engine (see below)
|
|
344
405
|
```
|
|
345
406
|
|
|
346
|
-
`docs/screenshots/` is
|
|
407
|
+
`docs/screenshots/` is sixteen states x three widths x both themes, taken by
|
|
347
408
|
`scripts/screenshots.mjs` against the console's `/librarian` lab route
|
|
348
409
|
running from its own static build. The same script asserts what a screenshot
|
|
349
410
|
cannot: that nothing scrolls sideways at any width, that the source pane
|
|
@@ -352,8 +413,11 @@ really draggable, that the scope statement folds once there is a
|
|
|
352
413
|
conversation over it and reopens from that line, that a follow-up chip asks
|
|
353
414
|
its question and takes the rest of the row with it, that the persona's
|
|
354
415
|
between-tool narration is nowhere in the answer prose before the activity
|
|
355
|
-
line is opened,
|
|
356
|
-
|
|
416
|
+
line is opened, that a job's artefact and the pages it read open in the
|
|
417
|
+
host's own columns and never in a pane of the package's, and that the reading
|
|
418
|
+
column holds its 46rem measure and stays centred at every width. It exits
|
|
419
|
+
non-zero on any of them. The job states fold a stream captured from the real
|
|
420
|
+
CLI (`src/test/fixtures/`).
|
|
357
421
|
|
|
358
422
|
The console's `app.css` deliberately does NOT scan this package's `dist`, so
|
|
359
423
|
the grid is taken in a consumer that compiles none of its Tailwind classes —
|
package/dist/citations.d.ts
CHANGED
|
@@ -26,7 +26,9 @@ export interface Citation {
|
|
|
26
26
|
* as null.
|
|
27
27
|
*/
|
|
28
28
|
verified_at?: string | null;
|
|
29
|
-
/** True when
|
|
29
|
+
/** True when read off the stream — the prose's Sources block, or a call
|
|
30
|
+
* that read it — rather than the library's own frame. Nothing checked it,
|
|
31
|
+
* so it carries no trust mark. */
|
|
30
32
|
derived?: boolean;
|
|
31
33
|
}
|
|
32
34
|
/** Inline `[n]` markers, in order of first appearance.
|
package/dist/client.d.ts
CHANGED
|
@@ -27,15 +27,25 @@ export interface AgentEvent {
|
|
|
27
27
|
id?: string;
|
|
28
28
|
};
|
|
29
29
|
};
|
|
30
|
+
/** A whole message; `content` is a plain string only on a user frame. */
|
|
30
31
|
message?: {
|
|
31
|
-
|
|
32
|
+
id?: string;
|
|
33
|
+
content?: Array<Record<string, unknown>> | string;
|
|
32
34
|
};
|
|
35
|
+
/** A user frame the CLI echoed back as the turn that consumes it starts
|
|
36
|
+
* (`--replay-user-messages`): the reader's side of a session. */
|
|
37
|
+
isReplay?: boolean;
|
|
38
|
+
uuid?: string;
|
|
39
|
+
/** ISO 8601, on every user and assistant frame. */
|
|
40
|
+
timestamp?: string;
|
|
33
41
|
session_id?: string;
|
|
34
42
|
result?: string;
|
|
35
43
|
num_turns?: number;
|
|
36
44
|
total_cost_usd?: number;
|
|
37
45
|
duration_ms?: number;
|
|
38
46
|
is_error?: boolean;
|
|
47
|
+
/** What a run returned under `--json-schema`, on its `result`. */
|
|
48
|
+
structured_output?: unknown;
|
|
39
49
|
error?: string;
|
|
40
50
|
detail?: string;
|
|
41
51
|
tools?: string[];
|
|
@@ -85,3 +95,20 @@ export interface AskOptions {
|
|
|
85
95
|
* where the turn is rendered.
|
|
86
96
|
*/
|
|
87
97
|
export declare function ask(options: AskOptions): AsyncGenerator<AgentEvent>;
|
|
98
|
+
export interface WatchOptions {
|
|
99
|
+
/** The session's watch route, e.g. `/api/agent/{persona}/jobs/{id}/watch`. */
|
|
100
|
+
endpoint: string;
|
|
101
|
+
signal?: AbortSignal;
|
|
102
|
+
fetch?: typeof fetch;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Async-iterate a session somebody else started: every event from the first,
|
|
106
|
+
* then live while it runs.
|
|
107
|
+
*
|
|
108
|
+
* The same frames and the same never-throw contract as `ask()`, with one
|
|
109
|
+
* difference: a clean close with no `result` is not a failure. A run the
|
|
110
|
+
* reader stopped ends exactly that way, and so does a session that is
|
|
111
|
+
* waiting between runs, so only a stream that would not open, or broke,
|
|
112
|
+
* yields `library_error`. Fold the events with `Session`.
|
|
113
|
+
*/
|
|
114
|
+
export declare function watch(options: WatchOptions): AsyncGenerator<AgentEvent>;
|
package/dist/client.js
CHANGED
|
@@ -64,14 +64,35 @@ function requestInit(options, signal) {
|
|
|
64
64
|
* where the turn is rendered.
|
|
65
65
|
*/
|
|
66
66
|
export async function* ask(options) {
|
|
67
|
-
|
|
67
|
+
yield* read(() => (options.fetch ?? fetch)(options.endpoint ?? '/api/agent/ask', requestInit(options, options.signal)), options.signal, true);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Async-iterate a session somebody else started: every event from the first,
|
|
71
|
+
* then live while it runs.
|
|
72
|
+
*
|
|
73
|
+
* The same frames and the same never-throw contract as `ask()`, with one
|
|
74
|
+
* difference: a clean close with no `result` is not a failure. A run the
|
|
75
|
+
* reader stopped ends exactly that way, and so does a session that is
|
|
76
|
+
* waiting between runs, so only a stream that would not open, or broke,
|
|
77
|
+
* yields `library_error`. Fold the events with `Session`.
|
|
78
|
+
*/
|
|
79
|
+
export async function* watch(options) {
|
|
80
|
+
yield* read(() => (options.fetch ?? fetch)(options.endpoint, {
|
|
81
|
+
headers: { Accept: 'text/event-stream' },
|
|
82
|
+
credentials: 'include',
|
|
83
|
+
signal: options.signal
|
|
84
|
+
}), options.signal, false);
|
|
85
|
+
}
|
|
86
|
+
/** One SSE response, as Claude Code's own events. `terminal` demands a
|
|
87
|
+
* `result` before the stream closes. */
|
|
88
|
+
async function* read(open, signal, terminal) {
|
|
68
89
|
let response;
|
|
69
90
|
try {
|
|
70
|
-
response = await
|
|
91
|
+
response = await open();
|
|
71
92
|
}
|
|
72
93
|
catch {
|
|
73
94
|
// A reader who pressed stop asked for this one; it is not a failure.
|
|
74
|
-
if (
|
|
95
|
+
if (signal?.aborted)
|
|
75
96
|
return;
|
|
76
97
|
yield { type: 'library_error' };
|
|
77
98
|
return;
|
|
@@ -97,7 +118,7 @@ export async function* ask(options) {
|
|
|
97
118
|
// A turn that ends with no terminal frame ended by accident. Tracked here
|
|
98
119
|
// rather than left to the renderer, which cannot tell a stream that died
|
|
99
120
|
// from one still arriving.
|
|
100
|
-
let
|
|
121
|
+
let ended = false;
|
|
101
122
|
/** One frame's `data:` lines, as the event they carry. */
|
|
102
123
|
function parse(frame) {
|
|
103
124
|
// `data:` only — the type lives inside the payload where Claude Code
|
|
@@ -123,7 +144,7 @@ export async function* ask(options) {
|
|
|
123
144
|
chunk = await reader.read();
|
|
124
145
|
}
|
|
125
146
|
catch {
|
|
126
|
-
if (
|
|
147
|
+
if (signal?.aborted)
|
|
127
148
|
return;
|
|
128
149
|
yield { type: 'library_error' };
|
|
129
150
|
return;
|
|
@@ -140,7 +161,7 @@ export async function* ask(options) {
|
|
|
140
161
|
if (!event)
|
|
141
162
|
continue;
|
|
142
163
|
if (event.type === 'result' || event.type === 'library_error')
|
|
143
|
-
|
|
164
|
+
ended = true;
|
|
144
165
|
yield event;
|
|
145
166
|
}
|
|
146
167
|
}
|
|
@@ -151,9 +172,9 @@ export async function* ask(options) {
|
|
|
151
172
|
const last = parse(buffered);
|
|
152
173
|
if (last) {
|
|
153
174
|
if (last.type === 'result' || last.type === 'library_error')
|
|
154
|
-
|
|
175
|
+
ended = true;
|
|
155
176
|
yield last;
|
|
156
177
|
}
|
|
157
|
-
if (
|
|
178
|
+
if (terminal && !ended && !signal?.aborted)
|
|
158
179
|
yield { type: 'library_error' };
|
|
159
180
|
}
|
|
@@ -52,7 +52,12 @@
|
|
|
52
52
|
<div class="ds-lib-activity-steps">
|
|
53
53
|
{#each group.steps as step (step.block.index)}
|
|
54
54
|
{#if step.block.kind === 'tool'}
|
|
55
|
-
<ToolRow
|
|
55
|
+
<ToolRow
|
|
56
|
+
block={step.block}
|
|
57
|
+
repeats={step.repeats}
|
|
58
|
+
words={step.words}
|
|
59
|
+
running={live}
|
|
60
|
+
/>
|
|
56
61
|
{:else}
|
|
57
62
|
<ThinkingRow block={step.block} active={false} />
|
|
58
63
|
{/if}
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
|
19
19
|
import {
|
|
20
20
|
readerQuestion,
|
|
21
|
+
readFrom,
|
|
21
22
|
segment,
|
|
23
|
+
type Artefact,
|
|
22
24
|
type Block,
|
|
25
|
+
type DescribeTool,
|
|
23
26
|
type Outcome,
|
|
24
27
|
type TextBlock
|
|
25
28
|
} from '../../transcript.svelte';
|
|
@@ -64,14 +67,15 @@
|
|
|
64
67
|
* and a reader scanning back for "what did I ask after lunch" needs the
|
|
65
68
|
* first one. Absent renders no time rather than a guess. */
|
|
66
69
|
at?: number;
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
/** The artefact
|
|
71
|
-
|
|
72
|
-
/** Opens
|
|
73
|
-
* is `'artefact'`. */
|
|
70
|
+
/** What this turn produced for the reader to open, carded once it
|
|
71
|
+
* settles: under the prose, or in its place when it IS the prose. */
|
|
72
|
+
artefact?: Artefact;
|
|
73
|
+
/** The artefact is open now. */
|
|
74
|
+
artefactOpen?: boolean;
|
|
75
|
+
/** Opens it. Omit and the card informs but does not open. */
|
|
74
76
|
onopenartefact?: () => void;
|
|
77
|
+
/** The persona's own words for its tools, and what they read. */
|
|
78
|
+
describeTool?: DescribeTool;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
let {
|
|
@@ -88,12 +92,15 @@
|
|
|
88
92
|
copy,
|
|
89
93
|
name = DEFAULT_PERSONA,
|
|
90
94
|
at,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
onopenartefact
|
|
95
|
+
artefact,
|
|
96
|
+
artefactOpen = false,
|
|
97
|
+
onopenartefact,
|
|
98
|
+
describeTool
|
|
94
99
|
}: Props = $props();
|
|
95
100
|
|
|
96
|
-
|
|
101
|
+
// The prose IS the artefact: it reads in the column, and the transcript
|
|
102
|
+
// shows the card alone.
|
|
103
|
+
const isArtefact = $derived(artefact?.isAnswer === true);
|
|
97
104
|
|
|
98
105
|
const words = $derived(resolveCopy(copy, name));
|
|
99
106
|
const who = $derived(personaName(name));
|
|
@@ -120,7 +127,7 @@
|
|
|
120
127
|
// An activity group stays live — and therefore labelled "Working…" — only
|
|
121
128
|
// while it is the last thing in the turn. As soon as prose arrives, it
|
|
122
129
|
// settles into its count.
|
|
123
|
-
const segments = $derived(segment(blocks));
|
|
130
|
+
const segments = $derived(segment(blocks, describeTool));
|
|
124
131
|
const lastIndex = $derived(segments.at(-1)?.index ?? -1);
|
|
125
132
|
|
|
126
133
|
// An answer can arrive as SEVERAL text blocks with tool calls between them,
|
|
@@ -135,7 +142,11 @@
|
|
|
135
142
|
const split = $derived(
|
|
136
143
|
running ? { body: '', citations: [] } : splitSources(texts.at(-1)?.text ?? '')
|
|
137
144
|
);
|
|
138
|
-
|
|
145
|
+
// What the calls read, when the answer names no source of its own.
|
|
146
|
+
const read = $derived(running ? [] : readFrom(blocks, describeTool));
|
|
147
|
+
const sources = $derived(
|
|
148
|
+
resolveCitations(citations, split.citations.length > 0 ? split.citations : read)
|
|
149
|
+
);
|
|
139
150
|
|
|
140
151
|
/** The answer as a reader would paste it: every block, Sources stripped. */
|
|
141
152
|
const answer = $derived(
|
|
@@ -218,13 +229,16 @@
|
|
|
218
229
|
<article class="ds-lib-turn">
|
|
219
230
|
<!-- Trailing-edge bubble, following Claude and ChatGPT rather than a
|
|
220
231
|
VS Code-style panel: this surface is read by people who arrive with
|
|
221
|
-
those two as their model of what a chat looks like.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
232
|
+
those two as their model of what a chat looks like. A session whose
|
|
233
|
+
stream never carried its prompt has no words to put here. -->
|
|
234
|
+
{#if asked}
|
|
235
|
+
<div class="ds-lib-ask">
|
|
236
|
+
<div class="ds-lib-bubble">{asked}</div>
|
|
237
|
+
{#if clock}
|
|
238
|
+
<time class="ds-lib-ask-time" datetime={clock.iso}>{clock.text}</time>
|
|
239
|
+
{/if}
|
|
240
|
+
</div>
|
|
241
|
+
{/if}
|
|
228
242
|
|
|
229
243
|
<div class="ds-lib-answer">
|
|
230
244
|
<header class="ds-lib-answer-head">
|
|
@@ -258,19 +272,17 @@
|
|
|
258
272
|
{/if}
|
|
259
273
|
{/each}
|
|
260
274
|
|
|
261
|
-
{#if
|
|
262
|
-
<ArtefactCard
|
|
263
|
-
title={title ?? 'Briefing'}
|
|
264
|
-
citationCount={sources.length}
|
|
265
|
-
onopen={() => onopenartefact?.()}
|
|
266
|
-
/>
|
|
275
|
+
{#if artefact && settled}
|
|
276
|
+
<ArtefactCard {artefact} open={artefactOpen} onopen={onopenartefact} copy={words} />
|
|
267
277
|
{/if}
|
|
268
278
|
|
|
269
279
|
{#if !isArtefact && sources.length > 0}
|
|
270
280
|
<SourceList {sources} {words} {oncite} />
|
|
271
281
|
{/if}
|
|
272
282
|
|
|
273
|
-
|
|
283
|
+
<!-- An empty `notHeld` is a host whose persona answers from no shelf,
|
|
284
|
+
where "without a source" would be a claim about nothing. -->
|
|
285
|
+
{#if !isArtefact && notHeld && words.notHeld}
|
|
274
286
|
<p class="ds-lib-not-held">{words.notHeld}</p>
|
|
275
287
|
{/if}
|
|
276
288
|
|
|
@@ -356,8 +368,14 @@
|
|
|
356
368
|
/* The reader's own words carry the consuming app's hue, the way every chat
|
|
357
369
|
a person already uses colours THEIR side and leaves the other party
|
|
358
370
|
neutral. It is an identity mark, not decoration: scanning back for "what
|
|
359
|
-
did I ask" is a colour search, not a reading task.
|
|
360
|
-
|
|
371
|
+
did I ask" is a colour search, not a reading task.
|
|
372
|
+
|
|
373
|
+
--ds-color-primary-solid, falling back to --ds-color-primary, because a
|
|
374
|
+
dark-mode palette lightens its primary so the SAME token can serve as
|
|
375
|
+
coloured text on a dark page. A fill behind light text and text on a dark
|
|
376
|
+
ground want opposite lightness, and a brand asked to be both comes out
|
|
377
|
+
washed — a deep red reads as salmon. An app with a fill-grade brand
|
|
378
|
+
colour declares the solid pair; one that has not is unaffected.
|
|
361
379
|
It stays on the SHORT side of the transcript on purpose — a question is
|
|
362
380
|
a line or two, an answer is paragraphs, so filling the question is an
|
|
363
381
|
accent and filling the answer would be a wall. */
|
|
@@ -365,9 +383,9 @@
|
|
|
365
383
|
max-width: 85%;
|
|
366
384
|
border: 1px solid transparent;
|
|
367
385
|
border-radius: var(--ds-radius-xl);
|
|
368
|
-
background: var(--ds-color-primary);
|
|
386
|
+
background: var(--ds-color-primary-solid, var(--ds-color-primary));
|
|
369
387
|
padding: 0.625rem 1rem;
|
|
370
|
-
color: var(--ds-color-primary-foreground);
|
|
388
|
+
color: var(--ds-color-primary-solid-foreground, var(--ds-color-primary-foreground));
|
|
371
389
|
font-size: 1rem;
|
|
372
390
|
line-height: 1.5rem;
|
|
373
391
|
overflow-wrap: break-word;
|
|
@@ -414,8 +432,8 @@
|
|
|
414
432
|
under the 3:1 floor for a non-text graphic and read as a smudge —
|
|
415
433
|
measured 2.55:1 on a dark-mode host. A solid fill carries the hue AND
|
|
416
434
|
the contrast. */
|
|
417
|
-
background: var(--ds-color-primary);
|
|
418
|
-
color: var(--ds-color-primary-foreground);
|
|
435
|
+
background: var(--ds-color-primary-solid, var(--ds-color-primary));
|
|
436
|
+
color: var(--ds-color-primary-solid-foreground, var(--ds-color-primary-foreground));
|
|
419
437
|
font-size: var(--ds-text-2xs);
|
|
420
438
|
font-weight: 600;
|
|
421
439
|
text-transform: uppercase;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Block, type Outcome } from '../../transcript.svelte';
|
|
1
|
+
import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
|
|
2
2
|
import { type Citation } from '../../citations';
|
|
3
3
|
import { type LibrarianCopy } from '../../copy';
|
|
4
4
|
interface Props {
|
|
@@ -29,14 +29,15 @@ interface Props {
|
|
|
29
29
|
* and a reader scanning back for "what did I ask after lunch" needs the
|
|
30
30
|
* first one. Absent renders no time rather than a guess. */
|
|
31
31
|
at?: number;
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
/** The artefact
|
|
36
|
-
|
|
37
|
-
/** Opens
|
|
38
|
-
* is `'artefact'`. */
|
|
32
|
+
/** What this turn produced for the reader to open, carded once it
|
|
33
|
+
* settles: under the prose, or in its place when it IS the prose. */
|
|
34
|
+
artefact?: Artefact;
|
|
35
|
+
/** The artefact is open now. */
|
|
36
|
+
artefactOpen?: boolean;
|
|
37
|
+
/** Opens it. Omit and the card informs but does not open. */
|
|
39
38
|
onopenartefact?: () => void;
|
|
39
|
+
/** The persona's own words for its tools, and what they read. */
|
|
40
|
+
describeTool?: DescribeTool;
|
|
40
41
|
}
|
|
41
42
|
declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
|
|
42
43
|
type AgentTranscript = ReturnType<typeof AgentTranscript>;
|
|
@@ -1,68 +1,129 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
An artefact's card in the transcript: what a reader taps to open it.
|
|
3
|
+
|
|
4
|
+
It is not the artefact itself. Every word on it is the host's — what the
|
|
5
|
+
thing is called and what it holds — and opening it is the host's too, in
|
|
6
|
+
whatever column the host keeps for it; the package's own `ArtefactPane` is
|
|
7
|
+
one such column, for an artefact that is the answer's own prose.
|
|
8
|
+
|
|
9
|
+
One button, not a card with a button in it: the whole row is the target,
|
|
10
|
+
so a thumb that lands on the summary still opens it.
|
|
7
11
|
-->
|
|
8
12
|
<script lang="ts">
|
|
9
13
|
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
|
10
|
-
import
|
|
14
|
+
import type { Artefact } from '../../transcript.svelte';
|
|
15
|
+
import { resolveCopy, type LibrarianCopy } from '../../copy';
|
|
11
16
|
|
|
12
17
|
interface Props {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
artefact: Artefact;
|
|
19
|
+
/** The host is showing it now. */
|
|
20
|
+
open?: boolean;
|
|
21
|
+
/** Omit and the card informs but does not open: a host with nowhere
|
|
22
|
+
* to show the artefact offers no action rather than a dead one. */
|
|
23
|
+
onopen?: () => void;
|
|
24
|
+
copy?: Partial<LibrarianCopy>;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
|
-
let {
|
|
27
|
+
let { artefact, open = false, onopen, copy }: Props = $props();
|
|
28
|
+
|
|
29
|
+
const words = $derived(resolveCopy(copy));
|
|
19
30
|
</script>
|
|
20
31
|
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
onclick={onopen}
|
|
28
|
-
onkeydown={(event: KeyboardEvent) => {
|
|
29
|
-
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
30
|
-
event.preventDefault();
|
|
31
|
-
onopen();
|
|
32
|
-
}}
|
|
33
|
-
class="ds-lib-artefact-card"
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
class="ds-lib-artefact"
|
|
35
|
+
class:is-open={open}
|
|
36
|
+
onclick={() => onopen?.()}
|
|
37
|
+
disabled={!onopen}
|
|
34
38
|
>
|
|
35
|
-
{
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
</
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
<span class="ds-lib-artefact-icon" aria-hidden="true"><FileTextIcon size={16} /></span>
|
|
40
|
+
<span class="ds-lib-artefact-text">
|
|
41
|
+
<span class="ds-lib-artefact-title">{artefact.title}</span>
|
|
42
|
+
{#if artefact.summary}<span class="ds-lib-artefact-summary">{artefact.summary}</span>{/if}
|
|
43
|
+
</span>
|
|
44
|
+
{#if onopen}
|
|
45
|
+
<span class="ds-lib-artefact-action">{open ? words.showingArtefact : words.openArtefact}</span>
|
|
46
|
+
{/if}
|
|
47
|
+
</button>
|
|
41
48
|
|
|
42
49
|
<style>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
Svelte's scoping hash never reaches. */
|
|
46
|
-
:global(.ds-lib-artefact-card) {
|
|
50
|
+
.ds-lib-artefact {
|
|
51
|
+
display: flex;
|
|
47
52
|
width: 100%;
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: 0.75rem;
|
|
55
|
+
border: 1px solid var(--ds-color-border);
|
|
56
|
+
border-radius: var(--ds-radius-lg);
|
|
57
|
+
background: var(--ds-color-surface-1);
|
|
58
|
+
padding: 0.625rem 0.75rem;
|
|
59
|
+
color: var(--ds-color-foreground);
|
|
60
|
+
font: inherit;
|
|
50
61
|
text-align: start;
|
|
62
|
+
cursor: pointer;
|
|
51
63
|
transition: border-color 150ms ease;
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
|
|
66
|
+
.ds-lib-artefact:hover:not(:disabled) {
|
|
55
67
|
border-color: var(--ds-color-border-strong);
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
|
|
70
|
+
.ds-lib-artefact:disabled {
|
|
71
|
+
cursor: default;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.ds-lib-artefact:focus-visible {
|
|
59
75
|
outline: 2px solid var(--ds-color-ring);
|
|
60
76
|
outline-offset: 2px;
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
|
|
79
|
+
/* The primary edge, not a fill: the card says "this is what is open" and
|
|
80
|
+
the column beside it carries the weight. */
|
|
81
|
+
.ds-lib-artefact.is-open {
|
|
82
|
+
border-color: var(--ds-color-primary);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.ds-lib-artefact-icon {
|
|
86
|
+
display: flex;
|
|
87
|
+
width: 2rem;
|
|
88
|
+
height: 2rem;
|
|
89
|
+
flex: none;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
border: 1px solid var(--ds-color-border);
|
|
93
|
+
border-radius: var(--ds-radius-md);
|
|
94
|
+
color: var(--ds-color-muted-foreground);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.ds-lib-artefact-text {
|
|
98
|
+
display: flex;
|
|
99
|
+
min-width: 0;
|
|
100
|
+
flex: 1;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.ds-lib-artefact-title {
|
|
105
|
+
font-size: 0.875rem;
|
|
106
|
+
font-weight: 500;
|
|
107
|
+
line-height: 1.25rem;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.ds-lib-artefact-summary {
|
|
65
111
|
color: var(--ds-color-muted-foreground);
|
|
66
112
|
font-size: var(--ds-text-2xs);
|
|
113
|
+
overflow-wrap: anywhere;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.ds-lib-artefact-action {
|
|
117
|
+
flex: none;
|
|
118
|
+
border: 1px solid var(--ds-color-border);
|
|
119
|
+
border-radius: var(--ds-radius-md);
|
|
120
|
+
padding: 0.25rem 0.625rem;
|
|
121
|
+
font-size: 0.8125rem;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@media (prefers-reduced-motion: reduce) {
|
|
125
|
+
.ds-lib-artefact {
|
|
126
|
+
transition: none;
|
|
127
|
+
}
|
|
67
128
|
}
|
|
68
129
|
</style>
|