@poodle64/librarian 2026.9.8 → 2026.9.9
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 +79 -20
- package/dist/citations.d.ts +35 -0
- package/dist/citations.js +40 -0
- package/dist/client.d.ts +7 -2
- package/dist/components/agent-transcript/agent-transcript.svelte +82 -8
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
- package/dist/components/conversation/conversation.svelte +29 -2
- package/dist/components/conversation/conversation.svelte.d.ts +10 -0
- package/dist/components/document-pane/document-pane.svelte +16 -4
- package/dist/components/document-pane/document-pane.svelte.d.ts +3 -1
- package/dist/components/scope-statement/index.d.ts +2 -0
- package/dist/components/scope-statement/index.js +2 -0
- package/dist/components/scope-statement/scope-statement.svelte +71 -0
- package/dist/components/scope-statement/scope-statement.svelte.d.ts +11 -0
- package/dist/copy.d.ts +44 -0
- package/dist/copy.js +46 -0
- package/dist/transcript.svelte.d.ts +5 -0
- package/dist/transcript.svelte.js +16 -3
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -14,12 +14,14 @@ Change it here, consume it there.
|
|
|
14
14
|
src/lib/
|
|
15
15
|
client.ts ask(): streams Claude Code's OWN events, unaltered
|
|
16
16
|
transcript.svelte.ts Transcript state, the fold/segment/describe helpers
|
|
17
|
-
citations.ts the Citation shape, `[n]` markers,
|
|
17
|
+
citations.ts the Citation shape, `[n]` markers, the trust mark
|
|
18
|
+
copy.ts every word this package says, and the host's overrides
|
|
18
19
|
attachments.ts what a reader may attach, and the limits
|
|
19
20
|
follow-scroll.svelte.ts follow the stream until the reader disagrees
|
|
20
21
|
history.svelte.ts the browser-held conversation list, per caller
|
|
21
22
|
components/
|
|
22
23
|
conversation/ the whole reading surface: scroll, pill, pane, composer slot
|
|
24
|
+
scope-statement/ what this room answers from, and what it does not hold
|
|
23
25
|
agent-transcript/ one question and everything Milton did answering it
|
|
24
26
|
document-pane/ the cited document, open at the cited passage
|
|
25
27
|
composer/ the input box: attachments, scope chips, send/stop
|
|
@@ -99,6 +101,7 @@ gives it a height and a composer and nothing else:
|
|
|
99
101
|
live.blocks = transcript.blocks;
|
|
100
102
|
live.outcome = transcript.outcome;
|
|
101
103
|
live.citations = transcript.citations;
|
|
104
|
+
live.suggestions = transcript.suggestions;
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
} finally {
|
|
@@ -123,6 +126,8 @@ gives it a height and a composer and nothing else:
|
|
|
123
126
|
examples={['How much recreation leave do I get?']}
|
|
124
127
|
onexample={(q) => (question = q)}
|
|
125
128
|
onregenerate={() => run(asked, [])}
|
|
129
|
+
onsuggest={(q) => run(q, [])}
|
|
130
|
+
scope="Milton answers from the ADF Pay and Conditions Manual (PACMAN). He does not hold your own pay records or anything about your individual case."
|
|
126
131
|
{loadDocument}
|
|
127
132
|
>
|
|
128
133
|
{#snippet composer()}
|
|
@@ -152,7 +157,10 @@ on it fires once and never again.
|
|
|
152
157
|
| Attachments | nothing: `ask()` posts multipart (`question`, `resume`, `collections[]`, `files[]`) whenever `files` is non-empty, and JSON when it is not. The route must accept both |
|
|
153
158
|
| Reading a cited document | `loadDocument(document_id) => Promise<{title, sections: [{anchor, heading, text}]}>`, proxied through the app's own authenticated route (cadmus: `GET /api/sources/documents/{id}/content`) |
|
|
154
159
|
| Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself |
|
|
160
|
+
| Asking a follow-up | `onsuggest(question)`: ask it as a NEW turn. Without the handler the chips do not render at all — a chip that does nothing is worse than no chip |
|
|
155
161
|
| The empty state | `welcome` and up to three `examples` |
|
|
162
|
+
| What this surface covers | `scope`: one statement per room, in the host's own words. Rendered above the first turn and folded to a line once the conversation starts |
|
|
163
|
+
| The words themselves | `copy`: a partial of `LibrarianCopy`. Every component resolves it itself, so overriding one line does not mean restating the rest |
|
|
156
164
|
|
|
157
165
|
Nothing here fetches on its own behalf. The library's document read is
|
|
158
166
|
authenticated, and a package that called it directly would be reaching past
|
|
@@ -166,7 +174,15 @@ The library emits one SSE frame after the final assistant text:
|
|
|
166
174
|
{
|
|
167
175
|
"type": "citations",
|
|
168
176
|
"items": [
|
|
169
|
-
{
|
|
177
|
+
{
|
|
178
|
+
"n": 1,
|
|
179
|
+
"document_id": "…",
|
|
180
|
+
"title": "…",
|
|
181
|
+
"section": "…",
|
|
182
|
+
"anchor": "…",
|
|
183
|
+
"snippet": "…",
|
|
184
|
+
"verified_at": "2026-06-23"
|
|
185
|
+
}
|
|
170
186
|
]
|
|
171
187
|
}
|
|
172
188
|
```
|
|
@@ -177,6 +193,57 @@ section. Until that frame ships everywhere, the same chips are DERIVED from
|
|
|
177
193
|
a trailing "## Sources" block in the answer: those render and read, and are
|
|
178
194
|
inert, because a title and a section are not an id.
|
|
179
195
|
|
|
196
|
+
### The trust mark
|
|
197
|
+
|
|
198
|
+
`verified_at` is the date the last recheck found the document unchanged at
|
|
199
|
+
its publisher, `YYYY-MM-DD`, resolved by the library from its own catalogue —
|
|
200
|
+
never from what the model wrote, because a trust mark a model can author is
|
|
201
|
+
not a trust mark. Null means nothing has ever confirmed it.
|
|
202
|
+
|
|
203
|
+
Both the source list and the pane render it in words: **verified 23 Jun 2026**
|
|
204
|
+
or **not verified**, in the same muted register as the section beside it. An
|
|
205
|
+
unverified source is not an error and is not dressed as one — the words carry
|
|
206
|
+
the difference, and a red one would have a colleague discount a document that
|
|
207
|
+
is simply new.
|
|
208
|
+
|
|
209
|
+
A DERIVED citation carries no mark at all. It has no catalogued document
|
|
210
|
+
behind it, so "not verified" would be a claim about a record nothing here
|
|
211
|
+
ever read.
|
|
212
|
+
|
|
213
|
+
An answer that settles having cited nothing says so, once, under the prose:
|
|
214
|
+
"Milton answered this one without a source. He may not hold a document that
|
|
215
|
+
covers it." Only on a turn whose stream this surface actually watched finish
|
|
216
|
+
— a turn read back out of `createHistory()` has no citations because history
|
|
217
|
+
stores none, and labelling it as holding nothing would be a lie about an
|
|
218
|
+
answer that may have cited three documents.
|
|
219
|
+
|
|
220
|
+
### Follow-ups
|
|
221
|
+
|
|
222
|
+
One more frame follows the citations, and only when the librarian named any:
|
|
223
|
+
|
|
224
|
+
```json
|
|
225
|
+
{ "type": "suggestions", "items": ["Can I carry leave over when I post?"] }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
At most three, each short enough to fit a chip. `Transcript.suggestions`
|
|
229
|
+
carries them; `Conversation` offers them under the LAST answer, and clicking
|
|
230
|
+
one asks it through `onsuggest` and takes the whole row with it — the moment
|
|
231
|
+
between the click and the new turn arriving is otherwise long enough to ask a
|
|
232
|
+
second question by mistake.
|
|
233
|
+
|
|
234
|
+
### Changing the words
|
|
235
|
+
|
|
236
|
+
Every user-visible string this package renders lives in
|
|
237
|
+
`@poodle64/librarian/copy`, and every component takes a partial of it:
|
|
238
|
+
|
|
239
|
+
```svelte
|
|
240
|
+
<Conversation {turns} {running} copy={{ notVerified: 'not checked yet' }} />
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Overriding one line leaves the rest as the package wrote them, and a key
|
|
244
|
+
passed as `undefined` — the shape a host produces from state that has not
|
|
245
|
+
loaded — is ignored rather than rendering nothing where a word belongs.
|
|
246
|
+
|
|
180
247
|
### The system preamble
|
|
181
248
|
|
|
182
249
|
A host prepends its own instruction to every question (cadmus sends
|
|
@@ -204,12 +271,15 @@ pnpm run test # build + vitest
|
|
|
204
271
|
pnpm run screenshots # the state grid, real engine (see below)
|
|
205
272
|
```
|
|
206
273
|
|
|
207
|
-
`docs/screenshots/` is
|
|
274
|
+
`docs/screenshots/` is ten states x three widths x both themes, taken by
|
|
208
275
|
`scripts/screenshots.mjs` against the console's `/librarian` lab route
|
|
209
276
|
running from its own static build. The same script asserts what a screenshot
|
|
210
|
-
cannot: that nothing scrolls sideways at any width,
|
|
211
|
-
opens and closes from the keyboard with focus returning to the chip
|
|
212
|
-
|
|
277
|
+
cannot: that nothing scrolls sideways at any width, that the source pane
|
|
278
|
+
opens and closes from the keyboard with focus returning to the chip and is
|
|
279
|
+
really draggable, that the scope statement folds once there is a
|
|
280
|
+
conversation over it and reopens from that line, and that a follow-up chip
|
|
281
|
+
asks its question and takes the rest of the row with it. It exits non-zero
|
|
282
|
+
on any of them.
|
|
213
283
|
|
|
214
284
|
```bash
|
|
215
285
|
pnpm --filter @poodle64/console run build
|
|
@@ -221,17 +291,6 @@ pnpm --filter @poodle64/librarian run screenshots
|
|
|
221
291
|
1. Change a component; bump `version` in `package.json` (CalVer).
|
|
222
292
|
2. `pnpm build`, which runs `svelte-package` then `publint`.
|
|
223
293
|
3. Commit, tag `librarian-v<version>`, push the tag.
|
|
224
|
-
4. `.github/workflows/publish.yaml` runs on that push and
|
|
225
|
-
npm
|
|
226
|
-
`
|
|
227
|
-
`ui-v2026.9.2` fails at the `npm publish` step with
|
|
228
|
-
`E404 Not Found - PUT .../@poodle64%2flibrarian` (an OIDC/trusted-publisher
|
|
229
|
-
binding issue, since `Build and test package` passes first). Until that
|
|
230
|
-
is diagnosed and fixed, publish manually from `packages/librarian/`:
|
|
231
|
-
```bash
|
|
232
|
-
printf '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n' > .npmrc
|
|
233
|
-
signet exec --identity huginn-claude --broker https://portcullis.godswood.au \
|
|
234
|
-
--credential npm-publish-token --env-var NODE_AUTH_TOKEN -- npm publish
|
|
235
|
-
rm -f .npmrc
|
|
236
|
-
```
|
|
237
|
-
Confirm with `npm view @poodle64/librarian version`.
|
|
294
|
+
4. `.github/workflows/publish.yaml` runs on that push and publishes through
|
|
295
|
+
npm's trusted publisher. Watch it: `gh run list --workflow=publish.yaml`,
|
|
296
|
+
then `npm view @poodle64/librarian version`.
|
package/dist/citations.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* a title and a section but no id: those chips render and read, and cannot
|
|
9
9
|
* open a pane. A derived citation is marked `derived` so the UI can tell.
|
|
10
10
|
*/
|
|
11
|
+
import type { LibrarianCopy } from './copy';
|
|
11
12
|
export interface Citation {
|
|
12
13
|
n: number;
|
|
13
14
|
document_id: string;
|
|
@@ -15,6 +16,16 @@ export interface Citation {
|
|
|
15
16
|
section?: string;
|
|
16
17
|
anchor?: string;
|
|
17
18
|
snippet?: string;
|
|
19
|
+
/**
|
|
20
|
+
* The date the last recheck found this document unchanged at its
|
|
21
|
+
* publisher, `YYYY-MM-DD`. Null when nothing has ever confirmed it.
|
|
22
|
+
*
|
|
23
|
+
* The library resolves this from its own catalogue, never from what the
|
|
24
|
+
* model wrote — a trust mark a model can author is not a trust mark — so
|
|
25
|
+
* a DERIVED citation carries none at all, and absent is not the same fact
|
|
26
|
+
* as null.
|
|
27
|
+
*/
|
|
28
|
+
verified_at?: string | null;
|
|
18
29
|
/** True when this came from the prose block rather than the wire event. */
|
|
19
30
|
derived?: boolean;
|
|
20
31
|
}
|
|
@@ -50,6 +61,30 @@ export declare function splitSources(markdown: string): SplitAnswer;
|
|
|
50
61
|
* ids attached — so a turn never shows both.
|
|
51
62
|
*/
|
|
52
63
|
export declare function resolveCitations(fromEvent: Citation[], fromProse: Citation[]): Citation[];
|
|
64
|
+
/**
|
|
65
|
+
* `2026-06-23` as `23 Jun 2026`, or null if it is not a date.
|
|
66
|
+
*
|
|
67
|
+
* Read off the string rather than through `new Date()`: a date-only string is
|
|
68
|
+
* parsed as UTC midnight and then RENDERED in the reader's own zone, so every
|
|
69
|
+
* reader west of Greenwich would be shown the day before the one the library
|
|
70
|
+
* recorded. There is no time here to convert — a date is what was stored.
|
|
71
|
+
*
|
|
72
|
+
* `Intl` is not used either: `en-AU` abbreviates June as "June", so the mark
|
|
73
|
+
* would change length month to month for no reason a reader benefits from.
|
|
74
|
+
*/
|
|
75
|
+
export declare function formatVerified(iso: string): string | null;
|
|
76
|
+
/**
|
|
77
|
+
* How current this source is, in words a reader already has.
|
|
78
|
+
*
|
|
79
|
+
* Three outcomes, and the third is the one worth stating: a citation the
|
|
80
|
+
* package DERIVED from a "## Sources" block gets no mark at all. It has no
|
|
81
|
+
* catalogued document behind it, so "not verified" would be a claim about a
|
|
82
|
+
* record we never read — and a wrong trust mark is worse than none.
|
|
83
|
+
*
|
|
84
|
+
* A `verified_at` that is present but not a date is treated as no date, for
|
|
85
|
+
* the same reason: what is shown must be what is known.
|
|
86
|
+
*/
|
|
87
|
+
export declare function trustMark(citation: Citation, copy: LibrarianCopy): string | null;
|
|
53
88
|
/** One addressable slice of a document — the granularity a citation names. */
|
|
54
89
|
export interface DocumentSection {
|
|
55
90
|
anchor: string;
|
package/dist/citations.js
CHANGED
|
@@ -100,3 +100,43 @@ function parseSource(text) {
|
|
|
100
100
|
export function resolveCitations(fromEvent, fromProse) {
|
|
101
101
|
return fromEvent.length > 0 ? fromEvent : fromProse;
|
|
102
102
|
}
|
|
103
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
104
|
+
/**
|
|
105
|
+
* `2026-06-23` as `23 Jun 2026`, or null if it is not a date.
|
|
106
|
+
*
|
|
107
|
+
* Read off the string rather than through `new Date()`: a date-only string is
|
|
108
|
+
* parsed as UTC midnight and then RENDERED in the reader's own zone, so every
|
|
109
|
+
* reader west of Greenwich would be shown the day before the one the library
|
|
110
|
+
* recorded. There is no time here to convert — a date is what was stored.
|
|
111
|
+
*
|
|
112
|
+
* `Intl` is not used either: `en-AU` abbreviates June as "June", so the mark
|
|
113
|
+
* would change length month to month for no reason a reader benefits from.
|
|
114
|
+
*/
|
|
115
|
+
export function formatVerified(iso) {
|
|
116
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso.trim());
|
|
117
|
+
if (!match)
|
|
118
|
+
return null;
|
|
119
|
+
const [, year, month, day] = match;
|
|
120
|
+
const index = Number(month) - 1;
|
|
121
|
+
const date = Number(day);
|
|
122
|
+
if (index < 0 || index > 11 || date < 1 || date > 31)
|
|
123
|
+
return null;
|
|
124
|
+
return `${date} ${MONTHS[index]} ${year}`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* How current this source is, in words a reader already has.
|
|
128
|
+
*
|
|
129
|
+
* Three outcomes, and the third is the one worth stating: a citation the
|
|
130
|
+
* package DERIVED from a "## Sources" block gets no mark at all. It has no
|
|
131
|
+
* catalogued document behind it, so "not verified" would be a claim about a
|
|
132
|
+
* record we never read — and a wrong trust mark is worse than none.
|
|
133
|
+
*
|
|
134
|
+
* A `verified_at` that is present but not a date is treated as no date, for
|
|
135
|
+
* the same reason: what is shown must be what is known.
|
|
136
|
+
*/
|
|
137
|
+
export function trustMark(citation, copy) {
|
|
138
|
+
if (citation.derived)
|
|
139
|
+
return null;
|
|
140
|
+
const when = citation.verified_at ? formatVerified(citation.verified_at) : null;
|
|
141
|
+
return when ? `${copy.verified} ${when}` : copy.notVerified;
|
|
142
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -40,8 +40,13 @@ export interface AgentEvent {
|
|
|
40
40
|
detail?: string;
|
|
41
41
|
tools?: string[];
|
|
42
42
|
model?: string;
|
|
43
|
-
/**
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The payload of the library's own two trailing frames, which share a key
|
|
45
|
+
* and not a shape: `citations` carries sources, `suggestions` carries
|
|
46
|
+
* follow-up questions as plain strings. Narrowed on `type` by whoever
|
|
47
|
+
* reads it (`Transcript.apply`).
|
|
48
|
+
*/
|
|
49
|
+
items?: Citation[] | string[];
|
|
45
50
|
[key: string]: unknown;
|
|
46
51
|
}
|
|
47
52
|
export interface AskOptions {
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
citationMarkers,
|
|
23
23
|
resolveCitations,
|
|
24
24
|
splitSources,
|
|
25
|
+
trustMark,
|
|
25
26
|
type Citation
|
|
26
27
|
} from '../../citations';
|
|
28
|
+
import { resolveCopy, type LibrarianCopy } from '../../copy';
|
|
27
29
|
import Working from '../working/working.svelte';
|
|
28
30
|
import ActivityGroup from '../activity-group/activity-group.svelte';
|
|
29
31
|
import Markdown from '../markdown/markdown.svelte';
|
|
@@ -38,10 +40,17 @@
|
|
|
38
40
|
citations?: Citation[];
|
|
39
41
|
/** Forwarded to Markdown; omit if the caller has no collections to chip. */
|
|
40
42
|
collectionNames?: Set<string>;
|
|
43
|
+
/** Follow-ups from the library's `suggestions` frame. */
|
|
44
|
+
suggestions?: string[];
|
|
41
45
|
/** Opens the document pane. Omit and chips render but do not open. */
|
|
42
46
|
oncite?: (citation: Citation) => void;
|
|
43
47
|
/** Offered on the LAST answer only — re-asks the same question. */
|
|
44
48
|
onregenerate?: () => void;
|
|
49
|
+
/** Asks a follow-up. Offered on the LAST answer only; omit and the
|
|
50
|
+
* chips do not render, because a chip that does nothing is worse than
|
|
51
|
+
* no chip. */
|
|
52
|
+
onsuggest?: (question: string) => void;
|
|
53
|
+
copy?: Partial<LibrarianCopy>;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
let {
|
|
@@ -51,10 +60,15 @@
|
|
|
51
60
|
running,
|
|
52
61
|
citations = [],
|
|
53
62
|
collectionNames = new Set(),
|
|
63
|
+
suggestions = [],
|
|
54
64
|
oncite,
|
|
55
|
-
onregenerate
|
|
65
|
+
onregenerate,
|
|
66
|
+
onsuggest,
|
|
67
|
+
copy
|
|
56
68
|
}: Props = $props();
|
|
57
69
|
|
|
70
|
+
const words = $derived(resolveCopy(copy));
|
|
71
|
+
|
|
58
72
|
const asked = $derived(readerQuestion(question));
|
|
59
73
|
|
|
60
74
|
// An activity group stays live — and therefore labelled "Working…" — only
|
|
@@ -95,8 +109,31 @@
|
|
|
95
109
|
// from it — there is just nothing to copy.
|
|
96
110
|
const settled = $derived(!running && (hasAnswer || Boolean(outcome?.error)));
|
|
97
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Milton answered and cited nothing, and we WATCHED him do it.
|
|
114
|
+
*
|
|
115
|
+
* `outcome` is the gate, not merely an empty source list: a turn read back
|
|
116
|
+
* out of the conversation history is prose with no citations and no
|
|
117
|
+
* outcome, because history stores neither — and a stored answer that cited
|
|
118
|
+
* three documents would be labelled as holding nothing. Same for an answer
|
|
119
|
+
* a reader stopped: the frame naming its sources never arrived, so nothing
|
|
120
|
+
* here knows whether there were any.
|
|
121
|
+
*/
|
|
122
|
+
const notHeld = $derived(
|
|
123
|
+
settled && hasAnswer && sources.length === 0 && Boolean(outcome) && !outcome?.error
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// A follow-up is offered once. Clicking it asks the question, which puts a
|
|
127
|
+
// new turn below this one — and leaving the row up for the moment before
|
|
128
|
+
// that arrives invites a second click on a third suggestion, asking two
|
|
129
|
+
// questions the reader only meant to ask one of.
|
|
130
|
+
let used = $state(false);
|
|
131
|
+
const followUps = $derived(!used && onsuggest ? suggestions : []);
|
|
132
|
+
|
|
98
133
|
let copied = $state(false);
|
|
99
|
-
|
|
134
|
+
// Not `copy`: the prop of that name is the package's own words, and a
|
|
135
|
+
// function shadowing it here would be a redeclaration, not a shadow.
|
|
136
|
+
async function copyToClipboard() {
|
|
100
137
|
try {
|
|
101
138
|
await navigator.clipboard.writeText(answer);
|
|
102
139
|
copied = true;
|
|
@@ -146,10 +183,11 @@
|
|
|
146
183
|
{#if sources.length > 0}
|
|
147
184
|
<section class="max-w-[72ch]">
|
|
148
185
|
<h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
|
|
149
|
-
|
|
186
|
+
{words.sources}
|
|
150
187
|
</h2>
|
|
151
188
|
<ol class="flex flex-col gap-1">
|
|
152
189
|
{#each sources as source (source.n)}
|
|
190
|
+
{@const mark = trustMark(source, words)}
|
|
153
191
|
<li>
|
|
154
192
|
<button
|
|
155
193
|
type="button"
|
|
@@ -166,6 +204,12 @@
|
|
|
166
204
|
{#if source.section}<span class="text-muted-foreground">
|
|
167
205
|
· {source.section}</span
|
|
168
206
|
>{/if}
|
|
207
|
+
<!-- Same muted register as the section, deliberately. An
|
|
208
|
+
unverified source is not an error and must not be
|
|
209
|
+
dressed as one; the words carry the difference, and a
|
|
210
|
+
red one would have a colleague discount a document
|
|
211
|
+
that is simply new. -->
|
|
212
|
+
{#if mark}<span class="text-muted-foreground/80"> · {mark}</span>{/if}
|
|
169
213
|
</span>
|
|
170
214
|
</button>
|
|
171
215
|
</li>
|
|
@@ -174,6 +218,36 @@
|
|
|
174
218
|
</section>
|
|
175
219
|
{/if}
|
|
176
220
|
|
|
221
|
+
{#if notHeld}
|
|
222
|
+
<p class="text-muted-foreground max-w-[72ch] text-sm">{words.notHeld}</p>
|
|
223
|
+
{/if}
|
|
224
|
+
|
|
225
|
+
{#if followUps.length > 0}
|
|
226
|
+
<section class="max-w-[72ch]">
|
|
227
|
+
<h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
|
|
228
|
+
{words.suggestions}
|
|
229
|
+
</h2>
|
|
230
|
+
<!-- Wrapping, not scrolling: at 390 a question is most of a line, so a
|
|
231
|
+
row of three would clip two of them to fragments. -->
|
|
232
|
+
<ul class="flex flex-wrap gap-2">
|
|
233
|
+
{#each followUps as followUp (followUp)}
|
|
234
|
+
<li class="max-w-full">
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onclick={() => {
|
|
238
|
+
used = true;
|
|
239
|
+
onsuggest?.(followUp);
|
|
240
|
+
}}
|
|
241
|
+
class="border-border hover:border-border-strong hover:bg-surface-2 focus-visible:ring-ring text-foreground max-w-full rounded-full border px-3.5 py-2 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
242
|
+
>
|
|
243
|
+
{followUp}
|
|
244
|
+
</button>
|
|
245
|
+
</li>
|
|
246
|
+
{/each}
|
|
247
|
+
</ul>
|
|
248
|
+
</section>
|
|
249
|
+
{/if}
|
|
250
|
+
|
|
177
251
|
{#if outcome?.error}
|
|
178
252
|
<p
|
|
179
253
|
class="border-status-error/40 bg-status-error/10 text-foreground max-w-[72ch] rounded-lg border px-3 py-2 text-sm"
|
|
@@ -188,23 +262,23 @@
|
|
|
188
262
|
{#if hasAnswer}
|
|
189
263
|
<button
|
|
190
264
|
type="button"
|
|
191
|
-
onclick={
|
|
192
|
-
aria-label={copied ?
|
|
265
|
+
onclick={copyToClipboard}
|
|
266
|
+
aria-label={copied ? words.copiedAnswer : words.copyAnswer}
|
|
193
267
|
class="hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
194
268
|
>
|
|
195
269
|
{#if copied}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
|
|
196
|
-
<span>{copied ?
|
|
270
|
+
<span>{copied ? words.copiedAnswer : words.copyAnswer}</span>
|
|
197
271
|
</button>
|
|
198
272
|
{/if}
|
|
199
273
|
{#if onregenerate}
|
|
200
274
|
<button
|
|
201
275
|
type="button"
|
|
202
276
|
onclick={onregenerate}
|
|
203
|
-
aria-label=
|
|
277
|
+
aria-label={words.askAgain}
|
|
204
278
|
class="hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
205
279
|
>
|
|
206
280
|
<RefreshCwIcon class="size-3.5" />
|
|
207
|
-
<span>
|
|
281
|
+
<span>{words.askAgain}</span>
|
|
208
282
|
</button>
|
|
209
283
|
{/if}
|
|
210
284
|
{#if outcome && !outcome.error}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Block, type Outcome } from '../../transcript.svelte';
|
|
2
2
|
import { type Citation } from '../../citations';
|
|
3
|
+
import { type LibrarianCopy } from '../../copy';
|
|
3
4
|
interface Props {
|
|
4
5
|
question: string;
|
|
5
6
|
blocks: Block[];
|
|
@@ -10,10 +11,17 @@ interface Props {
|
|
|
10
11
|
citations?: Citation[];
|
|
11
12
|
/** Forwarded to Markdown; omit if the caller has no collections to chip. */
|
|
12
13
|
collectionNames?: Set<string>;
|
|
14
|
+
/** Follow-ups from the library's `suggestions` frame. */
|
|
15
|
+
suggestions?: string[];
|
|
13
16
|
/** Opens the document pane. Omit and chips render but do not open. */
|
|
14
17
|
oncite?: (citation: Citation) => void;
|
|
15
18
|
/** Offered on the LAST answer only — re-asks the same question. */
|
|
16
19
|
onregenerate?: () => void;
|
|
20
|
+
/** Asks a follow-up. Offered on the LAST answer only; omit and the
|
|
21
|
+
* chips do not render, because a chip that does nothing is worse than
|
|
22
|
+
* no chip. */
|
|
23
|
+
onsuggest?: (question: string) => void;
|
|
24
|
+
copy?: Partial<LibrarianCopy>;
|
|
17
25
|
}
|
|
18
26
|
declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
|
|
19
27
|
type AgentTranscript = ReturnType<typeof AgentTranscript>;
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
|
14
14
|
import type { Turn } from '../../transcript.svelte';
|
|
15
15
|
import type { Citation, LoadDocument } from '../../citations';
|
|
16
|
+
import { resolveCopy, type LibrarianCopy } from '../../copy';
|
|
16
17
|
import { FollowScroll } from '../../follow-scroll.svelte';
|
|
17
18
|
import AgentTranscript from '../agent-transcript/agent-transcript.svelte';
|
|
18
19
|
import DocumentPane from '../document-pane/document-pane.svelte';
|
|
20
|
+
import ScopeStatement from '../scope-statement/scope-statement.svelte';
|
|
19
21
|
|
|
20
22
|
interface Props {
|
|
21
23
|
turns: Turn[];
|
|
@@ -27,8 +29,17 @@
|
|
|
27
29
|
/** Up to three, shown in the empty state as tappable pills. */
|
|
28
30
|
examples?: string[];
|
|
29
31
|
onexample?: (question: string) => void;
|
|
32
|
+
/** What this surface answers from and what it does not hold, in the
|
|
33
|
+
* host's own words. One statement per room, above the first turn,
|
|
34
|
+
* folded once the conversation starts. */
|
|
35
|
+
scope?: string;
|
|
30
36
|
/** Re-asks the last question. Offered on the last answer only. */
|
|
31
37
|
onregenerate?: () => void;
|
|
38
|
+
/** Asks a follow-up the librarian suggested. Offered on the last answer
|
|
39
|
+
* only; without it the suggestion chips do not render. */
|
|
40
|
+
onsuggest?: (question: string) => void;
|
|
41
|
+
/** Overrides for the package's own words. */
|
|
42
|
+
copy?: Partial<LibrarianCopy>;
|
|
32
43
|
/** Enables the source pane. Without it, chips render but do not open. */
|
|
33
44
|
loadDocument?: LoadDocument;
|
|
34
45
|
collectionNames?: Set<string>;
|
|
@@ -45,12 +56,17 @@
|
|
|
45
56
|
welcome = 'Ask Milton a question about the library.',
|
|
46
57
|
examples = [],
|
|
47
58
|
onexample,
|
|
59
|
+
scope,
|
|
48
60
|
onregenerate,
|
|
61
|
+
onsuggest,
|
|
62
|
+
copy,
|
|
49
63
|
loadDocument,
|
|
50
64
|
collectionNames = new Set(),
|
|
51
65
|
composer
|
|
52
66
|
}: Props = $props();
|
|
53
67
|
|
|
68
|
+
const words = $derived(resolveCopy(copy));
|
|
69
|
+
|
|
54
70
|
let viewport = $state<HTMLElement | null>(null);
|
|
55
71
|
let open = $state<Citation | null>(null);
|
|
56
72
|
const follow = new FollowScroll();
|
|
@@ -139,6 +155,14 @@
|
|
|
139
155
|
</div>
|
|
140
156
|
{/if}
|
|
141
157
|
|
|
158
|
+
<!-- Above the first turn, and in ONE place in the markup: the empty
|
|
159
|
+
state carries the welcome, this carries the boundary, and both
|
|
160
|
+
sit at the top of the same column so a reader meets them in
|
|
161
|
+
the same order whether or not they have asked anything yet. -->
|
|
162
|
+
{#if scope}
|
|
163
|
+
<ScopeStatement statement={scope} expanded={turns.length === 0} {copy} />
|
|
164
|
+
{/if}
|
|
165
|
+
|
|
142
166
|
{#each turns as turn, index (turn.id)}
|
|
143
167
|
<AgentTranscript
|
|
144
168
|
question={turn.question}
|
|
@@ -146,9 +170,12 @@
|
|
|
146
170
|
outcome={turn.outcome}
|
|
147
171
|
running={running && index === turns.length - 1}
|
|
148
172
|
citations={turn.citations ?? []}
|
|
173
|
+
suggestions={turn.suggestions ?? []}
|
|
149
174
|
{collectionNames}
|
|
175
|
+
{copy}
|
|
150
176
|
oncite={cite}
|
|
151
177
|
onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
|
|
178
|
+
onsuggest={index === turns.length - 1 && !running ? onsuggest : undefined}
|
|
152
179
|
/>
|
|
153
180
|
{/each}
|
|
154
181
|
</div>
|
|
@@ -161,7 +188,7 @@
|
|
|
161
188
|
class="border-border bg-surface-1 text-foreground hover:bg-surface-2 focus-visible:ring-ring absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs shadow-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
162
189
|
>
|
|
163
190
|
<ArrowDownIcon class="size-3.5" />
|
|
164
|
-
|
|
191
|
+
{words.jumpToLatest}
|
|
165
192
|
</button>
|
|
166
193
|
{/if}
|
|
167
194
|
</div>
|
|
@@ -175,6 +202,6 @@
|
|
|
175
202
|
</div>
|
|
176
203
|
|
|
177
204
|
{#if open && loadDocument}
|
|
178
|
-
<DocumentPane citation={open} {loadDocument} onclose={() => (open = null)} />
|
|
205
|
+
<DocumentPane citation={open} {loadDocument} {copy} onclose={() => (open = null)} />
|
|
179
206
|
{/if}
|
|
180
207
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { Turn } from '../../transcript.svelte';
|
|
3
3
|
import type { LoadDocument } from '../../citations';
|
|
4
|
+
import { type LibrarianCopy } from '../../copy';
|
|
4
5
|
interface Props {
|
|
5
6
|
turns: Turn[];
|
|
6
7
|
running: boolean;
|
|
@@ -11,8 +12,17 @@ interface Props {
|
|
|
11
12
|
/** Up to three, shown in the empty state as tappable pills. */
|
|
12
13
|
examples?: string[];
|
|
13
14
|
onexample?: (question: string) => void;
|
|
15
|
+
/** What this surface answers from and what it does not hold, in the
|
|
16
|
+
* host's own words. One statement per room, above the first turn,
|
|
17
|
+
* folded once the conversation starts. */
|
|
18
|
+
scope?: string;
|
|
14
19
|
/** Re-asks the last question. Offered on the last answer only. */
|
|
15
20
|
onregenerate?: () => void;
|
|
21
|
+
/** Asks a follow-up the librarian suggested. Offered on the last answer
|
|
22
|
+
* only; without it the suggestion chips do not render. */
|
|
23
|
+
onsuggest?: (question: string) => void;
|
|
24
|
+
/** Overrides for the package's own words. */
|
|
25
|
+
copy?: Partial<LibrarianCopy>;
|
|
16
26
|
/** Enables the source pane. Without it, chips render but do not open. */
|
|
17
27
|
loadDocument?: LoadDocument;
|
|
18
28
|
collectionNames?: Set<string>;
|
|
@@ -9,16 +9,25 @@
|
|
|
9
9
|
-->
|
|
10
10
|
<script lang="ts">
|
|
11
11
|
import XIcon from '@lucide/svelte/icons/x';
|
|
12
|
-
import type
|
|
12
|
+
import { trustMark, type Citation, type LoadDocument, type LoadedDocument } from '../../citations';
|
|
13
|
+
import { resolveCopy, type LibrarianCopy } from '../../copy';
|
|
13
14
|
import Markdown from '../markdown/markdown.svelte';
|
|
14
15
|
|
|
15
16
|
interface Props {
|
|
16
17
|
citation: Citation;
|
|
17
18
|
loadDocument: LoadDocument;
|
|
18
19
|
onclose: () => void;
|
|
20
|
+
copy?: Partial<LibrarianCopy>;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
let { citation, loadDocument, onclose }: Props = $props();
|
|
23
|
+
let { citation, loadDocument, onclose, copy }: Props = $props();
|
|
24
|
+
|
|
25
|
+
const words = $derived(resolveCopy(copy));
|
|
26
|
+
// The same mark the chip carried, repeated where the reader has the
|
|
27
|
+
// document open in front of them: this is the moment they decide whether
|
|
28
|
+
// to act on it, and a currency they had to remember from a chip two
|
|
29
|
+
// scrolls up is one they will not have.
|
|
30
|
+
const mark = $derived(trustMark(citation, words));
|
|
22
31
|
|
|
23
32
|
const MIN_WIDTH = 320;
|
|
24
33
|
/** ~40% of a 1440 desktop, which is the width the pane is designed at. */
|
|
@@ -143,12 +152,15 @@
|
|
|
143
152
|
{#if citation.section}
|
|
144
153
|
<p class="text-muted-foreground truncate text-xs">{citation.section}</p>
|
|
145
154
|
{/if}
|
|
155
|
+
{#if mark}
|
|
156
|
+
<p class="text-muted-foreground/80 text-xs">{mark}</p>
|
|
157
|
+
{/if}
|
|
146
158
|
</div>
|
|
147
159
|
<button
|
|
148
160
|
bind:this={closeButton}
|
|
149
161
|
type="button"
|
|
150
162
|
onclick={onclose}
|
|
151
|
-
aria-label=
|
|
163
|
+
aria-label={words.closeSource}
|
|
152
164
|
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"
|
|
153
165
|
>
|
|
154
166
|
<XIcon class="size-4" />
|
|
@@ -157,7 +169,7 @@
|
|
|
157
169
|
|
|
158
170
|
<div bind:this={scroller} class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
|
|
159
171
|
{#if failed}
|
|
160
|
-
<p class="text-muted-foreground text-sm">
|
|
172
|
+
<p class="text-muted-foreground text-sm">{words.documentUnavailable}</p>
|
|
161
173
|
{:else if !document_}
|
|
162
174
|
<div class="flex flex-col gap-2" aria-hidden="true">
|
|
163
175
|
{#each [0, 1, 2, 3] as row (row)}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Citation, type LoadDocument } from '../../citations';
|
|
2
|
+
import { type LibrarianCopy } from '../../copy';
|
|
2
3
|
interface Props {
|
|
3
4
|
citation: Citation;
|
|
4
5
|
loadDocument: LoadDocument;
|
|
5
6
|
onclose: () => void;
|
|
7
|
+
copy?: Partial<LibrarianCopy>;
|
|
6
8
|
}
|
|
7
9
|
declare const DocumentPane: import("svelte").Component<Props, {}, "">;
|
|
8
10
|
type DocumentPane = ReturnType<typeof DocumentPane>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
What Milton answers from, and what he does not hold.
|
|
3
|
+
|
|
4
|
+
A colleague's first question about a room is not the one they type — it is
|
|
5
|
+
whether the answer they are about to read covers their case at all. Saying it
|
|
6
|
+
once, in the room's own words, is cheaper than every answer hedging.
|
|
7
|
+
|
|
8
|
+
It leads while there is nothing else on the surface, and folds to one line as
|
|
9
|
+
soon as there is: a boundary a reader has already read is a banner in the way
|
|
10
|
+
of the conversation they came for. Folded is not gone — the same line reopens
|
|
11
|
+
it, and a reader who opened it stays opened until the surface itself changes
|
|
12
|
+
state.
|
|
13
|
+
-->
|
|
14
|
+
<script lang="ts">
|
|
15
|
+
import { untrack } from 'svelte';
|
|
16
|
+
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
|
17
|
+
import InfoIcon from '@lucide/svelte/icons/info';
|
|
18
|
+
import { resolveCopy, type LibrarianCopy } from '../../copy';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
/** The statement itself, in the host's own words. */
|
|
22
|
+
statement: string;
|
|
23
|
+
/** Open on arrival. Goes false once the conversation has a first turn. */
|
|
24
|
+
expanded?: boolean;
|
|
25
|
+
copy?: Partial<LibrarianCopy>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let { statement, expanded = true, copy }: Props = $props();
|
|
29
|
+
|
|
30
|
+
const words = $derived(resolveCopy(copy));
|
|
31
|
+
|
|
32
|
+
// `untrack`, not a bare read: capturing the ARRIVING value is the intent —
|
|
33
|
+
// the effect below owns every later change — and Svelte rightly warns
|
|
34
|
+
// about a prop read in a position that will never see one.
|
|
35
|
+
let open = $state(untrack(() => expanded));
|
|
36
|
+
// Plain, not `$state`: the effect below both reads and writes it, and
|
|
37
|
+
// nothing renders it. It exists so the effect fires on a CHANGE of the
|
|
38
|
+
// prop rather than on every re-render — a reader who opened the statement
|
|
39
|
+
// back up mid-conversation keeps it open until the surface itself moves on.
|
|
40
|
+
let followed = untrack(() => expanded);
|
|
41
|
+
$effect(() => {
|
|
42
|
+
if (expanded === followed) return;
|
|
43
|
+
followed = expanded;
|
|
44
|
+
open = expanded;
|
|
45
|
+
});
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<section
|
|
49
|
+
class="border-border bg-surface-2/60 rounded-xl border px-3 py-2"
|
|
50
|
+
aria-label={words.scope}
|
|
51
|
+
>
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
onclick={() => (open = !open)}
|
|
55
|
+
aria-expanded={open}
|
|
56
|
+
class="text-muted-foreground hover:text-foreground focus-visible:ring-ring flex w-full items-center gap-2 rounded-md text-left text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
57
|
+
>
|
|
58
|
+
<InfoIcon class="size-3.5 shrink-0" />
|
|
59
|
+
<span class="min-w-0 flex-1 truncate font-medium tracking-wide uppercase">{words.scope}</span>
|
|
60
|
+
<ChevronDownIcon class="size-3.5 shrink-0 transition-transform {open ? 'rotate-180' : ''}" />
|
|
61
|
+
</button>
|
|
62
|
+
|
|
63
|
+
{#if open}
|
|
64
|
+
<!-- No measure of its own: the card IS the measure, and a paragraph
|
|
65
|
+
capped narrower than the border around it leaves a hand's width of
|
|
66
|
+
empty card down the right at 1440. -->
|
|
67
|
+
<p class="text-muted-foreground mt-1.5 text-sm leading-6 whitespace-pre-line">
|
|
68
|
+
{statement}
|
|
69
|
+
</p>
|
|
70
|
+
{/if}
|
|
71
|
+
</section>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type LibrarianCopy } from '../../copy';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** The statement itself, in the host's own words. */
|
|
4
|
+
statement: string;
|
|
5
|
+
/** Open on arrival. Goes false once the conversation has a first turn. */
|
|
6
|
+
expanded?: boolean;
|
|
7
|
+
copy?: Partial<LibrarianCopy>;
|
|
8
|
+
}
|
|
9
|
+
declare const ScopeStatement: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type ScopeStatement = ReturnType<typeof ScopeStatement>;
|
|
11
|
+
export default ScopeStatement;
|
package/dist/copy.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every word this package puts in front of a reader, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Here rather than inline in each component because these sentences are the
|
|
5
|
+
* package's opinion about how Milton talks to a colleague, and an app that
|
|
6
|
+
* needs a different register — a different name for the librarian, a room
|
|
7
|
+
* whose boundary is worded by its own subject-matter owner — must be able to
|
|
8
|
+
* change one string without forking a component.
|
|
9
|
+
*
|
|
10
|
+
* Every component takes `copy` as a PARTIAL and resolves it itself, so a host
|
|
11
|
+
* overriding one line does not have to restate the other eleven, and a
|
|
12
|
+
* component used directly behaves the same as one reached through
|
|
13
|
+
* `Conversation`.
|
|
14
|
+
*/
|
|
15
|
+
export interface LibrarianCopy {
|
|
16
|
+
/** Prefixes the date on a verified source: "verified 23 Jun 2026". */
|
|
17
|
+
verified: string;
|
|
18
|
+
/** A source the catalogue has never confirmed against its publisher. */
|
|
19
|
+
notVerified: string;
|
|
20
|
+
/** Shown under an answer that finished without citing anything. */
|
|
21
|
+
notHeld: string;
|
|
22
|
+
/** Heading over the list of what an answer cited. */
|
|
23
|
+
sources: string;
|
|
24
|
+
/** Heading over the follow-up questions the librarian offered. */
|
|
25
|
+
suggestions: string;
|
|
26
|
+
/** The collapsed scope statement's own label. */
|
|
27
|
+
scope: string;
|
|
28
|
+
copyAnswer: string;
|
|
29
|
+
copiedAnswer: string;
|
|
30
|
+
askAgain: string;
|
|
31
|
+
jumpToLatest: string;
|
|
32
|
+
closeSource: string;
|
|
33
|
+
documentUnavailable: string;
|
|
34
|
+
}
|
|
35
|
+
export declare const DEFAULT_COPY: LibrarianCopy;
|
|
36
|
+
/**
|
|
37
|
+
* The package's words with a host's overrides on top.
|
|
38
|
+
*
|
|
39
|
+
* An explicitly `undefined` key is dropped rather than spread, because
|
|
40
|
+
* `{...defaults, ...{ sources: undefined }}` leaves a component rendering
|
|
41
|
+
* nothing at all — and that is exactly the shape a host produces by passing a
|
|
42
|
+
* value it computed from state that has not loaded yet.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveCopy(overrides?: Partial<LibrarianCopy>): LibrarianCopy;
|
package/dist/copy.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every word this package puts in front of a reader, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Here rather than inline in each component because these sentences are the
|
|
5
|
+
* package's opinion about how Milton talks to a colleague, and an app that
|
|
6
|
+
* needs a different register — a different name for the librarian, a room
|
|
7
|
+
* whose boundary is worded by its own subject-matter owner — must be able to
|
|
8
|
+
* change one string without forking a component.
|
|
9
|
+
*
|
|
10
|
+
* Every component takes `copy` as a PARTIAL and resolves it itself, so a host
|
|
11
|
+
* overriding one line does not have to restate the other eleven, and a
|
|
12
|
+
* component used directly behaves the same as one reached through
|
|
13
|
+
* `Conversation`.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_COPY = {
|
|
16
|
+
verified: 'verified',
|
|
17
|
+
notVerified: 'not verified',
|
|
18
|
+
// Two sentences, both plain: what happened, and the likeliest reason. It
|
|
19
|
+
// says nothing about collections, indexes or retrieval — a colleague
|
|
20
|
+
// reading it has no model of any of those, and the room's own instruction
|
|
21
|
+
// to Milton forbids him naming them either.
|
|
22
|
+
notHeld: 'Milton answered this one without a source. He may not hold a document that covers it.',
|
|
23
|
+
sources: 'Sources',
|
|
24
|
+
suggestions: 'Ask next',
|
|
25
|
+
scope: 'What Milton answers from',
|
|
26
|
+
copyAnswer: 'Copy',
|
|
27
|
+
copiedAnswer: 'Copied',
|
|
28
|
+
askAgain: 'Ask again',
|
|
29
|
+
jumpToLatest: 'Jump to latest',
|
|
30
|
+
closeSource: 'Close source',
|
|
31
|
+
documentUnavailable: "That document can't be opened right now."
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The package's words with a host's overrides on top.
|
|
35
|
+
*
|
|
36
|
+
* An explicitly `undefined` key is dropped rather than spread, because
|
|
37
|
+
* `{...defaults, ...{ sources: undefined }}` leaves a component rendering
|
|
38
|
+
* nothing at all — and that is exactly the shape a host produces by passing a
|
|
39
|
+
* value it computed from state that has not loaded yet.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveCopy(overrides) {
|
|
42
|
+
if (!overrides)
|
|
43
|
+
return DEFAULT_COPY;
|
|
44
|
+
const given = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined));
|
|
45
|
+
return { ...DEFAULT_COPY, ...given };
|
|
46
|
+
}
|
|
@@ -84,6 +84,8 @@ export declare class Transcript {
|
|
|
84
84
|
outcome: Outcome | null;
|
|
85
85
|
/** Sources for the answer, from the library's own `citations` frame. */
|
|
86
86
|
citations: Citation[];
|
|
87
|
+
/** Follow-ups the librarian named, from the `suggestions` frame after it. */
|
|
88
|
+
suggestions: string[];
|
|
87
89
|
other: AgentEvent[];
|
|
88
90
|
reset(): void;
|
|
89
91
|
apply(event: AgentEvent): void;
|
|
@@ -115,6 +117,9 @@ export interface Turn {
|
|
|
115
117
|
blocks: Block[];
|
|
116
118
|
outcome: Outcome | null;
|
|
117
119
|
citations?: Citation[];
|
|
120
|
+
/** Follow-ups offered after this answer. Absent on a turn read back from
|
|
121
|
+
* history: they belonged to the moment it was asked. */
|
|
122
|
+
suggestions?: string[];
|
|
118
123
|
}
|
|
119
124
|
/**
|
|
120
125
|
* Fold a turn's flat block list into what a reader should actually see.
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
export function readerQuestion(question) {
|
|
21
21
|
const parts = question.split(/\n{2,}/);
|
|
22
22
|
let start = 0;
|
|
23
|
-
while (start < parts.length - 1 &&
|
|
23
|
+
while (start < parts.length - 1 &&
|
|
24
|
+
/^(you are|you're|your role|act as|system:)\b/i.test(parts[start].trim()))
|
|
24
25
|
start += 1;
|
|
25
26
|
return parts.slice(start).join('\n\n').trim();
|
|
26
27
|
}
|
|
@@ -138,6 +139,8 @@ export class Transcript {
|
|
|
138
139
|
outcome = $state(null);
|
|
139
140
|
/** Sources for the answer, from the library's own `citations` frame. */
|
|
140
141
|
citations = $state([]);
|
|
142
|
+
/** Follow-ups the librarian named, from the `suggestions` frame after it. */
|
|
143
|
+
suggestions = $state([]);
|
|
141
144
|
other = $state([]);
|
|
142
145
|
/** Content-block index is per MESSAGE, so it repeats across turns; this
|
|
143
146
|
* maps the live index onto a position in the flat list. Cleared whenever a
|
|
@@ -151,6 +154,7 @@ export class Transcript {
|
|
|
151
154
|
this.blocks = [];
|
|
152
155
|
this.outcome = null;
|
|
153
156
|
this.citations = [];
|
|
157
|
+
this.suggestions = [];
|
|
154
158
|
this.other = [];
|
|
155
159
|
this.#open.clear();
|
|
156
160
|
}
|
|
@@ -171,9 +175,18 @@ export class Transcript {
|
|
|
171
175
|
return;
|
|
172
176
|
}
|
|
173
177
|
// Emitted after the final assistant text, so it lands on a turn that is
|
|
174
|
-
// otherwise complete.
|
|
178
|
+
// otherwise complete. Both frames put their payload on `items`, so each
|
|
179
|
+
// keeps only what its own shape admits: a `citations` frame carrying
|
|
180
|
+
// strings, or a `suggestions` frame carrying objects, is the library
|
|
181
|
+
// having changed under us, and rendering it would be worse than
|
|
182
|
+
// rendering nothing.
|
|
175
183
|
if (event.type === 'citations') {
|
|
176
|
-
this.citations = event.items ?? [];
|
|
184
|
+
this.citations = (event.items ?? []).filter((item) => typeof item === 'object' && item !== null);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Last of the two, and only when the librarian named any.
|
|
188
|
+
if (event.type === 'suggestions') {
|
|
189
|
+
this.suggestions = (event.items ?? []).filter((item) => typeof item === 'string' && item.trim().length > 0);
|
|
177
190
|
return;
|
|
178
191
|
}
|
|
179
192
|
if (event.type === 'library_error') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/librarian",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.9",
|
|
4
4
|
"description": "Milton's conversation surface as a consumable Svelte 5 package: the stream client, transcript state and chat components (transcript, composer, markdown) every household app renders instead of rebuilding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./dist/citations.d.ts",
|
|
35
35
|
"svelte": "./dist/citations.js"
|
|
36
36
|
},
|
|
37
|
+
"./copy": {
|
|
38
|
+
"types": "./dist/copy.d.ts",
|
|
39
|
+
"svelte": "./dist/copy.js"
|
|
40
|
+
},
|
|
37
41
|
"./attachments": {
|
|
38
42
|
"types": "./dist/attachments.d.ts",
|
|
39
43
|
"svelte": "./dist/attachments.js"
|