@poodle64/librarian 2026.9.21 → 2026.9.23

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.
Files changed (28) hide show
  1. package/README.md +131 -8
  2. package/dist/chat.svelte.d.ts +189 -0
  3. package/dist/chat.svelte.js +554 -0
  4. package/dist/client.d.ts +29 -6
  5. package/dist/client.js +11 -3
  6. package/dist/components/agent-transcript/agent-transcript.svelte +40 -6
  7. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +9 -0
  8. package/dist/components/answer-mark/answer-mark.svelte +223 -0
  9. package/dist/components/answer-mark/answer-mark.svelte.d.ts +11 -0
  10. package/dist/components/answer-mark/index.d.ts +2 -0
  11. package/dist/components/answer-mark/index.js +2 -0
  12. package/dist/components/conversation/conversation.svelte +21 -3
  13. package/dist/components/conversation/conversation.svelte.d.ts +9 -0
  14. package/dist/components/conversation-list/conversation-list.svelte +496 -0
  15. package/dist/components/conversation-list/conversation-list.svelte.d.ts +26 -0
  16. package/dist/components/conversation-list/index.d.ts +2 -0
  17. package/dist/components/conversation-list/index.js +2 -0
  18. package/dist/components/fair-use-notice/fair-use-notice.svelte +97 -0
  19. package/dist/components/fair-use-notice/fair-use-notice.svelte.d.ts +11 -0
  20. package/dist/components/fair-use-notice/index.d.ts +2 -0
  21. package/dist/components/fair-use-notice/index.js +2 -0
  22. package/dist/components/working/working.svelte +8 -3
  23. package/dist/components/working/working.svelte.d.ts +3 -0
  24. package/dist/copy.d.ts +40 -0
  25. package/dist/copy.js +36 -1
  26. package/dist/session.svelte.js +1 -1
  27. package/dist/transcript.svelte.js +1 -1
  28. package/package.json +8 -3
@@ -33,6 +33,8 @@
33
33
  type Citation
34
34
  } from '../../citations';
35
35
  import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
36
+ import type { Verdict } from '../../chat.svelte';
37
+ import AnswerMark from '../answer-mark/answer-mark.svelte';
36
38
  import Working from '../working/working.svelte';
37
39
  import ActivityGroup from '../activity-group/activity-group.svelte';
38
40
  import ArtefactCard from '../artefact-card/artefact-card.svelte';
@@ -76,6 +78,14 @@
76
78
  onopenartefact?: () => void;
77
79
  /** The persona's own words for its tools, and what they read. */
78
80
  describeTool?: DescribeTool;
81
+ /** The question waits behind somebody else's. */
82
+ waiting?: boolean;
83
+ /** When an answer this page did not start began, epoch ms: it is still
84
+ * being written, and the clock counts from then. */
85
+ answering?: number;
86
+ /** Records a verdict on this answer. Omit and no mark renders: a
87
+ * control that records nothing is worse than none. */
88
+ onmark?: (verdict: Verdict) => Promise<boolean>;
79
89
  }
80
90
 
81
91
  let {
@@ -95,7 +105,10 @@
95
105
  artefact,
96
106
  artefactOpen = false,
97
107
  onopenartefact,
98
- describeTool
108
+ describeTool,
109
+ waiting = false,
110
+ answering,
111
+ onmark
99
112
  }: Props = $props();
100
113
 
101
114
  // The prose IS the artefact: it reads in the column, and the transcript
@@ -186,11 +199,16 @@
186
199
  : (outcome?.error ?? (outcome?.isError ? words.answerFailed : null))
187
200
  );
188
201
 
202
+ // A turn nothing is answering any more that said nothing and ended with
203
+ // nothing: stopped before a word came, often while it waited its turn.
204
+ const unanswered = $derived(!running && !hasAnswer && outcome === null && !artefact);
205
+
189
206
  // A run that ended has settled, whatever it said: a failed one, where "Ask
190
207
  // again" is the one thing a reader wants, and one that handed in its answer
191
208
  // under a schema and said nothing at all, whose artefact is the answer. A
192
- // turn read back from history has no outcome and settles on its prose.
193
- const settled = $derived(!running && (hasAnswer || outcome !== null));
209
+ // turn read back from history has no outcome and settles on its prose, and
210
+ // an unanswered one settles on nothing, offering "Ask again".
211
+ const settled = $derived(!running && (hasAnswer || outcome !== null || unanswered));
194
212
 
195
213
  /**
196
214
  * The persona answered and cited nothing, and we WATCHED it happen.
@@ -252,7 +270,15 @@
252
270
  </header>
253
271
 
254
272
  {#if running && segments.length === 0}
255
- <Working copy={words} />
273
+ <Working
274
+ copy={words}
275
+ label={waiting
276
+ ? words.waiting
277
+ : answering !== undefined
278
+ ? words.stillAnswering
279
+ : undefined}
280
+ since={answering}
281
+ />
256
282
  {/if}
257
283
 
258
284
  {#each segments as seg (seg.index)}
@@ -284,6 +310,10 @@
284
310
 
285
311
  <!-- An empty `notHeld` is a host whose persona answers from no shelf,
286
312
  where "without a source" would be a claim about nothing. -->
313
+ {#if unanswered && asked}
314
+ <p class="ds-lib-not-held">{words.noAnswer}</p>
315
+ {/if}
316
+
287
317
  {#if !isArtefact && notHeld && words.notHeld}
288
318
  <p class="ds-lib-not-held">{words.notHeld}</p>
289
319
  {/if}
@@ -316,7 +346,7 @@
316
346
  <p class="ds-lib-failure" role="alert">{failure}</p>
317
347
  {/if}
318
348
 
319
- {#if !isArtefact && settled}
349
+ {#if !isArtefact && settled && (hasAnswer || onregenerate || (outcome && !failure))}
320
350
  <div class="ds-lib-actions">
321
351
  {#if hasAnswer}
322
352
  <button
@@ -329,6 +359,9 @@
329
359
  <span>{copied ? words.copiedAnswer : words.copyAnswer}</span>
330
360
  </button>
331
361
  {/if}
362
+ {#if onmark && hasAnswer && !failure}
363
+ <AnswerMark {onmark} copy={words} />
364
+ {/if}
332
365
  {#if onregenerate}
333
366
  <button
334
367
  type="button"
@@ -360,7 +393,7 @@
360
393
 
361
394
  .ds-lib-ask {
362
395
  display: flex;
363
- max-width: 100%;
396
+ width: 100%;
364
397
  flex-direction: column;
365
398
  align-items: flex-end;
366
399
  gap: 0.25rem;
@@ -515,6 +548,7 @@
515
548
 
516
549
  .ds-lib-actions {
517
550
  display: flex;
551
+ flex-wrap: wrap;
518
552
  align-items: center;
519
553
  gap: 0.25rem;
520
554
  margin-inline-start: -0.375rem;
@@ -1,6 +1,7 @@
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 LibrarianCopy } from '../../copy';
4
+ import type { Verdict } from '../../chat.svelte';
4
5
  interface Props {
5
6
  question: string;
6
7
  blocks: Block[];
@@ -38,6 +39,14 @@ interface Props {
38
39
  onopenartefact?: () => void;
39
40
  /** The persona's own words for its tools, and what they read. */
40
41
  describeTool?: DescribeTool;
42
+ /** The question waits behind somebody else's. */
43
+ waiting?: boolean;
44
+ /** When an answer this page did not start began, epoch ms: it is still
45
+ * being written, and the clock counts from then. */
46
+ answering?: number;
47
+ /** Records a verdict on this answer. Omit and no mark renders: a
48
+ * control that records nothing is worse than none. */
49
+ onmark?: (verdict: Verdict) => Promise<boolean>;
41
50
  }
42
51
  declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
43
52
  type AgentTranscript = ReturnType<typeof AgentTranscript>;
@@ -0,0 +1,223 @@
1
+ <!--
2
+ Helpful or not, on the answer a reader has just read, with an optional note.
3
+
4
+ Rendered by `AgentTranscript` inside its action row, between copy and ask
5
+ again, and only when the host passes `onmark`: the Console asks its own
6
+ library, where nothing can take a mark, and a control a host cannot wire is
7
+ worse than none (cadmus#114, where this began).
8
+
9
+ Several roots on purpose: the two thumbs sit in the row, and the note drops
10
+ to a line of its own under it (`order`, `flex-basis`), so the keyboard goes
11
+ verdict, note, ask again, which is the order a reader thinks in.
12
+
13
+ The note is optional and opens only after a verdict, so the fast path is one
14
+ tap. A verdict shows at once and is taken back if it was not recorded; a
15
+ note says "noted" only once the far side confirms it.
16
+ -->
17
+ <script lang="ts">
18
+ import ThumbsDownIcon from '@lucide/svelte/icons/thumbs-down';
19
+ import ThumbsUpIcon from '@lucide/svelte/icons/thumbs-up';
20
+ import type { Verdict } from '../../chat.svelte';
21
+ import { resolveCopy, type LibrarianCopy } from '../../copy';
22
+
23
+ interface Props {
24
+ /** Records a verdict, replacing any before it. Resolves true once it is
25
+ * recorded. */
26
+ onmark: (verdict: Verdict) => Promise<boolean>;
27
+ copy?: Partial<LibrarianCopy>;
28
+ }
29
+
30
+ let { onmark, copy }: Props = $props();
31
+
32
+ const words = $derived(resolveCopy(copy));
33
+
34
+ let helpful = $state<boolean | null>(null);
35
+ let note = $state('');
36
+ /** The note last recorded, sent again when the verdict flips: the far side
37
+ * replaces the whole mark. */
38
+ let recorded = $state<string | null>(null);
39
+ let saving = $state(false);
40
+ let failed = $state(false);
41
+ let form = $state<HTMLFormElement | null>(null);
42
+
43
+ // The note opens under the last answer, which is where the fold is: it
44
+ // brings itself into view, and leaves the keyboard where it was, so a
45
+ // thumb's tap on a phone does not raise one.
46
+ $effect(() => {
47
+ form?.scrollIntoView({ block: 'nearest' });
48
+ });
49
+
50
+ async function send(verdict: Verdict): Promise<boolean> {
51
+ saving = true;
52
+ failed = false;
53
+ try {
54
+ const ok = await onmark(verdict);
55
+ failed = !ok;
56
+ return ok;
57
+ } finally {
58
+ saving = false;
59
+ }
60
+ }
61
+
62
+ async function mark(next: boolean) {
63
+ if (saving || helpful === next) return;
64
+ const previous = helpful;
65
+ helpful = next;
66
+ if (!(await send({ helpful: next, note: recorded }))) helpful = previous;
67
+ }
68
+
69
+ async function submitNote(event: SubmitEvent) {
70
+ event.preventDefault();
71
+ const text = note.trim();
72
+ if (!text || helpful === null || saving) return;
73
+ if (await send({ helpful, note: text })) recorded = text;
74
+ }
75
+ </script>
76
+
77
+ <button
78
+ type="button"
79
+ class="ds-lib-mark"
80
+ class:is-helpful={helpful === true}
81
+ aria-pressed={helpful === true}
82
+ aria-label={words.markHelpful}
83
+ title={words.markHelpful}
84
+ disabled={saving}
85
+ onclick={() => mark(true)}
86
+ >
87
+ <ThumbsUpIcon size={14} />
88
+ </button>
89
+ <button
90
+ type="button"
91
+ class="ds-lib-mark"
92
+ class:is-not-helpful={helpful === false}
93
+ aria-pressed={helpful === false}
94
+ aria-label={words.markNotHelpful}
95
+ title={words.markNotHelpful}
96
+ disabled={saving}
97
+ onclick={() => mark(false)}
98
+ >
99
+ <ThumbsDownIcon size={14} />
100
+ </button>
101
+
102
+ <!-- A note that failed keeps its box, and the words in it, to try again. -->
103
+ {#if recorded !== null && !failed}
104
+ <p class="ds-lib-mark-line" role="status">{words.markNoted}</p>
105
+ {:else if helpful !== null}
106
+ <form bind:this={form} class="ds-lib-mark-line ds-lib-mark-form" onsubmit={submitNote}>
107
+ <input
108
+ bind:value={note}
109
+ maxlength={500}
110
+ placeholder={words.markNote}
111
+ aria-label={words.markNote}
112
+ class="ds-lib-mark-input"
113
+ />
114
+ <button type="submit" class="ds-lib-mark-send" disabled={!note.trim() || saving}>
115
+ {words.markSend}
116
+ </button>
117
+ </form>
118
+ {/if}
119
+ {#if failed}
120
+ <p class="ds-lib-mark-line ds-lib-mark-failed" role="status">{words.markFailed}</p>
121
+ {/if}
122
+
123
+ <style>
124
+ .ds-lib-mark {
125
+ display: flex;
126
+ width: 1.75rem;
127
+ height: 1.75rem;
128
+ align-items: center;
129
+ justify-content: center;
130
+ border: 0;
131
+ border-radius: var(--ds-radius-md);
132
+ background: none;
133
+ color: inherit;
134
+ cursor: pointer;
135
+ transition:
136
+ color 150ms ease,
137
+ background-color 150ms ease;
138
+ }
139
+
140
+ .ds-lib-mark:hover:not(:disabled) {
141
+ background: var(--ds-color-surface-1);
142
+ }
143
+
144
+ /* A verdict keeps its colour under the pointer that just gave it. */
145
+ .ds-lib-mark:hover:not(:disabled, [aria-pressed='true']) {
146
+ color: var(--ds-color-foreground);
147
+ }
148
+
149
+ .ds-lib-mark:disabled {
150
+ cursor: default;
151
+ }
152
+
153
+ .ds-lib-mark:focus-visible,
154
+ .ds-lib-mark-send:focus-visible {
155
+ outline: 2px solid var(--ds-color-ring);
156
+ outline-offset: 2px;
157
+ }
158
+
159
+ .ds-lib-mark.is-helpful {
160
+ color: var(--ds-color-status-success);
161
+ }
162
+
163
+ .ds-lib-mark.is-not-helpful {
164
+ color: var(--ds-color-status-error);
165
+ }
166
+
167
+ /* Its own line under the row, whatever sits after the thumbs in it. */
168
+ .ds-lib-mark-line {
169
+ order: 1;
170
+ flex-basis: 100%;
171
+ margin: 0.25rem 0 0 0.375rem;
172
+ font-size: var(--ds-text-2xs);
173
+ }
174
+
175
+ .ds-lib-mark-failed {
176
+ color: var(--ds-color-status-error);
177
+ }
178
+
179
+ .ds-lib-mark-form {
180
+ display: flex;
181
+ max-width: 28rem;
182
+ min-width: 0;
183
+ gap: 0.5rem;
184
+ }
185
+
186
+ .ds-lib-mark-input {
187
+ min-width: 0;
188
+ flex: 1;
189
+ border: 1px solid var(--ds-color-border);
190
+ border-radius: var(--ds-radius-md);
191
+ background: var(--ds-color-surface-1);
192
+ padding: 0.375rem 0.625rem;
193
+ color: var(--ds-color-foreground);
194
+ font: inherit;
195
+ font-size: 0.875rem;
196
+ }
197
+
198
+ .ds-lib-mark-input::placeholder {
199
+ color: var(--ds-color-muted-foreground);
200
+ }
201
+
202
+ .ds-lib-mark-input:focus {
203
+ border-color: color-mix(in oklab, var(--ds-color-primary) 60%, transparent);
204
+ outline: none;
205
+ }
206
+
207
+ .ds-lib-mark-send {
208
+ flex: none;
209
+ border: 1px solid var(--ds-color-border);
210
+ border-radius: var(--ds-radius-md);
211
+ background: none;
212
+ padding: 0.25rem 0.75rem;
213
+ color: var(--ds-color-foreground);
214
+ font: inherit;
215
+ font-size: 0.8125rem;
216
+ cursor: pointer;
217
+ }
218
+
219
+ .ds-lib-mark-send:disabled {
220
+ cursor: default;
221
+ opacity: 0.4;
222
+ }
223
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { Verdict } from '../../chat.svelte';
2
+ import { type LibrarianCopy } from '../../copy';
3
+ interface Props {
4
+ /** Records a verdict, replacing any before it. Resolves true once it is
5
+ * recorded. */
6
+ onmark: (verdict: Verdict) => Promise<boolean>;
7
+ copy?: Partial<LibrarianCopy>;
8
+ }
9
+ declare const AnswerMark: import("svelte").Component<Props, {}, "">;
10
+ type AnswerMark = ReturnType<typeof AnswerMark>;
11
+ export default AnswerMark;
@@ -0,0 +1,2 @@
1
+ export { default as AnswerMark } from './answer-mark.svelte';
2
+ export { default } from './answer-mark.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as AnswerMark } from './answer-mark.svelte';
2
+ export { default } from './answer-mark.svelte';
@@ -12,6 +12,7 @@
12
12
  import type { Snippet } from 'svelte';
13
13
  import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
14
14
  import type { DescribeTool, Turn } from '../../transcript.svelte';
15
+ import type { Verdict } from '../../chat.svelte';
15
16
  import type { Citation, LoadDocument } from '../../citations';
16
17
  import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
17
18
  import { FollowScroll } from '../../follow-scroll.svelte';
@@ -51,6 +52,14 @@
51
52
  /** Asks a follow-up the librarian suggested. Offered on the last answer
52
53
  * only; without it the suggestion chips do not render. */
53
54
  onsuggest?: (question: string) => void;
55
+ /** Records a verdict on an answer. Offered on the last answer only, as
56
+ * asking again is; without it no mark renders. */
57
+ onmark?: (turn: Turn, verdict: Verdict) => Promise<boolean>;
58
+ /** The last question waits behind somebody else's (`Chat.waiting`). */
59
+ waiting?: boolean;
60
+ /** When the last answer began, epoch ms, where this page did not see it
61
+ * start (`Chat.answering`): it reads as still being written. */
62
+ answering?: number | null;
54
63
  /** Overrides for the package's own words. */
55
64
  copy?: Partial<LibrarianCopy>;
56
65
  /** Enables the source pane. Without it, chips render but do not open. */
@@ -85,6 +94,9 @@
85
94
  scope,
86
95
  onregenerate,
87
96
  onsuggest,
97
+ onmark,
98
+ waiting = false,
99
+ answering = null,
88
100
  copy,
89
101
  loadDocument,
90
102
  oncite,
@@ -256,11 +268,14 @@
256
268
  {/if}
257
269
 
258
270
  {#each turns as turn, index (turn.id)}
271
+ {@const last = index === turns.length - 1}
259
272
  <AgentTranscript
260
273
  question={turn.question}
261
274
  blocks={turn.blocks}
262
275
  outcome={turn.outcome}
263
- running={running && index === turns.length - 1}
276
+ running={running && last}
277
+ waiting={waiting && last}
278
+ answering={last ? (answering ?? undefined) : undefined}
264
279
  citations={turn.citations ?? []}
265
280
  suggestions={turn.suggestions ?? []}
266
281
  {collectionNames}
@@ -272,8 +287,11 @@
272
287
  onopenartefact={opener(turn)}
273
288
  {describeTool}
274
289
  oncite={oncite || loadDocument ? cite : undefined}
275
- onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
276
- onsuggest={index === turns.length - 1 && !running ? onsuggest : undefined}
290
+ onregenerate={last && !running ? onregenerate : undefined}
291
+ onsuggest={last && !running ? onsuggest : undefined}
292
+ onmark={last && !running && onmark
293
+ ? (verdict) => onmark(turn, verdict)
294
+ : undefined}
277
295
  />
278
296
  {/each}
279
297
  </div>
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { DescribeTool, Turn } from '../../transcript.svelte';
3
+ import type { Verdict } from '../../chat.svelte';
3
4
  import type { Citation, LoadDocument } from '../../citations';
4
5
  import { type LibrarianCopy } from '../../copy';
5
6
  interface Props {
@@ -28,6 +29,14 @@ interface Props {
28
29
  /** Asks a follow-up the librarian suggested. Offered on the last answer
29
30
  * only; without it the suggestion chips do not render. */
30
31
  onsuggest?: (question: string) => void;
32
+ /** Records a verdict on an answer. Offered on the last answer only, as
33
+ * asking again is; without it no mark renders. */
34
+ onmark?: (turn: Turn, verdict: Verdict) => Promise<boolean>;
35
+ /** The last question waits behind somebody else's (`Chat.waiting`). */
36
+ waiting?: boolean;
37
+ /** When the last answer began, epoch ms, where this page did not see it
38
+ * start (`Chat.answering`): it reads as still being written. */
39
+ answering?: number | null;
31
40
  /** Overrides for the package's own words. */
32
41
  copy?: Partial<LibrarianCopy>;
33
42
  /** Enables the source pane. Without it, chips render but do not open. */