@poodle64/librarian 2026.9.25 → 2026.9.27
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 +44 -0
- package/dist/chat.svelte.d.ts +22 -6
- package/dist/chat.svelte.js +41 -13
- package/dist/client.d.ts +13 -0
- package/dist/client.js +4 -1
- package/dist/components/agent-transcript/agent-transcript.svelte +32 -4
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +16 -1
- package/dist/components/answer-share/answer-share.svelte +129 -0
- package/dist/components/answer-share/answer-share.svelte.d.ts +13 -0
- package/dist/components/answer-share/index.d.ts +2 -0
- package/dist/components/answer-share/index.js +2 -0
- package/dist/components/composer/composer.svelte +155 -1
- package/dist/components/composer/composer.svelte.d.ts +17 -1
- package/dist/components/composer/index.d.ts +1 -0
- package/dist/components/conversation/conversation.svelte +17 -2
- package/dist/components/conversation/conversation.svelte.d.ts +8 -0
- package/dist/copy.d.ts +20 -0
- package/dist/copy.js +12 -0
- package/dist/transcript.svelte.d.ts +26 -1
- package/dist/transcript.svelte.js +25 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ src/lib/
|
|
|
29
29
|
conversation/ the whole reading surface: scroll, pill, pane, composer slot
|
|
30
30
|
conversation-list/ past conversations, newest first: reopen, rename, download, delete
|
|
31
31
|
answer-mark/ helpful or not, with a note, on the last answer
|
|
32
|
+
answer-share/ share an answer read-only, and stop sharing it
|
|
32
33
|
fair-use-notice/ today's allowance, beside the composer
|
|
33
34
|
scope-statement/ what this room answers from, and what it does not hold
|
|
34
35
|
agent-transcript/ one question and everything Milton did answering it
|
|
@@ -252,6 +253,46 @@ artefact })` says that kind opens, on a live turn and a reopened one alike:
|
|
|
252
253
|
`{ title: 'Briefing', isAnswer: true }` cards it and opens its prose in the
|
|
253
254
|
column. `again()` asks it as the same kind.
|
|
254
255
|
|
|
256
|
+
### Depth: Quick or Thorough, and what it cost
|
|
257
|
+
|
|
258
|
+
`Composer`'s `depth` is optional and bindable, and entirely opt-in: omit it
|
|
259
|
+
and the composer renders exactly as it always has. Bind it, the host's own
|
|
260
|
+
`$state<Depth>('quick')`, and the switch always renders, both sides always
|
|
261
|
+
enabled. The operator's ruling (30/09/2026) is that once a room offers the
|
|
262
|
+
choice, it is never disabled, hidden, greyed out, or explained by naming a
|
|
263
|
+
model. `depth` is `'quick' | 'thorough'`, exported from
|
|
264
|
+
`@poodle64/librarian/composer` and `@poodle64/librarian/client`.
|
|
265
|
+
|
|
266
|
+
```svelte
|
|
267
|
+
<Composer bind:value={chat.draft} bind:depth onsubmit={() => chat.ask(undefined, undefined, depth)} ... />
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`chat.ask(question?, kind?, depth?)` carries it to `client.ask()`'s `depth`,
|
|
271
|
+
`StoredTurn.depth` reads it back, and `again()` keeps it: the same shape as
|
|
272
|
+
`kind`, alongside it rather than replacing it. A host whose route does not
|
|
273
|
+
read `depth` yet is unaffected, since the field rides along and is ignored.
|
|
274
|
+
|
|
275
|
+
Under a settled answer, `AgentTranscript`'s footer folds the turn's `depth`
|
|
276
|
+
and `outcome.costUsd` into the ONE line that already showed the duration:
|
|
277
|
+
`Quick · 56 s · about A$0.34`. Both are opt-in the same way: pass `depth` to
|
|
278
|
+
`Conversation`/`AgentTranscript` (it already has it, from `Turn.depth`) and a
|
|
279
|
+
`formatCost(usd) => string` to price it in the host's own currency and words
|
|
280
|
+
("about A$0.34"; cadmus converts USD to AUD with its own setting). Without
|
|
281
|
+
`depth` the line renders exactly as before (`56.3s`). Without `formatCost`,
|
|
282
|
+
or where `costUsd` is `0` (a local model), it shows depth and duration only,
|
|
283
|
+
never a raw USD figure and never which model answered.
|
|
284
|
+
|
|
285
|
+
Past `turnLimit` turns (`Composer`'s prop, 8 by default), a quiet banner
|
|
286
|
+
appears above the box, "This conversation is getting long. A new question
|
|
287
|
+
keeps Milton quick." with a "New question" button, because a follow-up
|
|
288
|
+
re-reads the whole conversation, so each one costs more than the last.
|
|
289
|
+
`turnCount` and `onnewquestion` are both required for it to render; either
|
|
290
|
+
missing and the composer stays as it is today.
|
|
291
|
+
|
|
292
|
+
None of this prices anything before asking, and none of it names a model.
|
|
293
|
+
That is the operator's ruling on what a colleague sees, and the package
|
|
294
|
+
never puts a control in front of it.
|
|
295
|
+
|
|
255
296
|
The transport is the app's own client over its routes, and the shapes are
|
|
256
297
|
the routes' own bodies (`ConversationSummary`, `ConversationRead`,
|
|
257
298
|
`StoredTurn`, `Quota`), so the stamped slice returns them as they are and
|
|
@@ -268,6 +309,7 @@ the transport passes them through:
|
|
|
268
309
|
| `remove` | `DELETE conversations/{id}` |
|
|
269
310
|
| `download` | `GET conversations/{id}/export`, as `{ name, body }` |
|
|
270
311
|
| `mark` | its answer mark, where the agent takes one (optional) |
|
|
312
|
+
| `share` | share an answer read-only, or stop (optional) |
|
|
271
313
|
|
|
272
314
|
What the controller relies on the routes to say:
|
|
273
315
|
|
|
@@ -299,6 +341,8 @@ the job may go on by itself; `sendWhileRunning` is true.
|
|
|
299
341
|
| Concern | How |
|
|
300
342
|
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
301
343
|
| The answer mark | `onmark(turn, verdict)` on `Conversation`: resolve true once recorded. Offered on the last settled answer only; without it no mark renders |
|
|
344
|
+
| Sharing an answer | `onshare(turn, share)` on `Conversation`, over `chat.share`: every settled answer offers Share, which copies the link `StoredTurn.shared` names; a shared one offers Copy link and Stop sharing. Nothing is shared unless the reader asks. A host's own `turn` places `AnswerShare` (`./answer-share`) with the `onshare` and `shared` it is handed |
|
|
345
|
+
| A shared answer, read | `Conversation` given `[storedTurn(id, 0, answer)]` from `./chat` and no handlers, no composer: the question, the answer and its sources, read-only, citations opening as in the room |
|
|
302
346
|
| Waiting, still answering | `waiting` and `answering` on `Conversation`, from the controller |
|
|
303
347
|
| The allowance | `FairUseNotice quota={…}` in the composer snippet: a count while there are questions left, a notice with `support_url` once there are none. Nothing for `exempt`, or a `limit` of 0 |
|
|
304
348
|
| Past conversations | `ConversationList`: newest first, `href` makes each row a link, and each act renders only when its handler is given; a delete asks first |
|
package/dist/chat.svelte.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* routes, and its shapes are the routes' own bodies, so a stamped app passes
|
|
20
20
|
* what its client returns straight through.
|
|
21
21
|
*/
|
|
22
|
-
import { type AgentEvent } from './client';
|
|
22
|
+
import { type AgentEvent, type Depth } from './client';
|
|
23
23
|
import type { Citation } from './citations';
|
|
24
24
|
import { type Artefact, type Turn } from './transcript.svelte';
|
|
25
25
|
/** One conversation in the list. */
|
|
@@ -43,6 +43,10 @@ export interface StoredTurn {
|
|
|
43
43
|
citations?: Citation[];
|
|
44
44
|
/** What the host asked for, when it was not a plain question. */
|
|
45
45
|
kind?: string | null;
|
|
46
|
+
/** How hard the persona worked on this one. */
|
|
47
|
+
depth?: Depth | null;
|
|
48
|
+
/** Where this answer's read-only view lives while it is shared. */
|
|
49
|
+
shared?: string | null;
|
|
46
50
|
}
|
|
47
51
|
/** A conversation reopened. */
|
|
48
52
|
export interface ConversationRead extends ConversationSummary {
|
|
@@ -73,6 +77,8 @@ export interface AskRequest {
|
|
|
73
77
|
signal: AbortSignal;
|
|
74
78
|
/** What the host asked for, when it is not a plain question: `client.ask()`'s `kind`. */
|
|
75
79
|
kind?: string;
|
|
80
|
+
/** How hard the persona should work on this one: `client.ask()`'s `depth`. */
|
|
81
|
+
depth?: Depth;
|
|
76
82
|
}
|
|
77
83
|
/**
|
|
78
84
|
* A room's routes, as the host's own client reaches them. Every method but
|
|
@@ -103,6 +109,10 @@ export interface RoomTransport {
|
|
|
103
109
|
/** The answer mark, where the agent takes one. `turn` is the answer's
|
|
104
110
|
* zero-based position in the conversation. */
|
|
105
111
|
mark?(id: string, turn: number, verdict: Verdict): Promise<void>;
|
|
112
|
+
/** Shares answer `turn` read-only with whoever may enter the room, or
|
|
113
|
+
* stops sharing it. Resolves to where it is read while shared, null once
|
|
114
|
+
* it is not. */
|
|
115
|
+
share?(id: string, turn: number, share: boolean): Promise<string | null>;
|
|
106
116
|
}
|
|
107
117
|
/** A job's routes. */
|
|
108
118
|
export interface JobTransport {
|
|
@@ -171,13 +181,13 @@ export declare class Chat {
|
|
|
171
181
|
new(): void;
|
|
172
182
|
/**
|
|
173
183
|
* Ask `question`, or what is in the box, as a `kind` of turn when it is not
|
|
174
|
-
* a plain question
|
|
175
|
-
*
|
|
176
|
-
* go back into the box.
|
|
184
|
+
* a plain question, at a `depth` when the host offers the choice. Resolves
|
|
185
|
+
* once the turn settles: true if it was asked, false if it was refused,
|
|
186
|
+
* here or by the server, in which case typed words go back into the box.
|
|
177
187
|
*/
|
|
178
|
-
ask(question?: string, kind?: string): Promise<boolean>;
|
|
188
|
+
ask(question?: string, kind?: string, depth?: Depth): Promise<boolean>;
|
|
179
189
|
/** The last question again, as a new turn: the conversation is append-only,
|
|
180
|
-
* because the agent's transcript is. */
|
|
190
|
+
* because the agent's transcript is. Same kind, same depth. */
|
|
181
191
|
again(): Promise<boolean>;
|
|
182
192
|
/** Stop the answer being written, wherever it is being written. */
|
|
183
193
|
stop(): Promise<void>;
|
|
@@ -195,4 +205,10 @@ export declare class Chat {
|
|
|
195
205
|
download(id: string): Promise<boolean>;
|
|
196
206
|
/** Mark an answer the agent holds. False when it could not be recorded. */
|
|
197
207
|
mark(turn: Turn, verdict: Verdict): Promise<boolean>;
|
|
208
|
+
/** Share an answer the agent holds, or stop sharing it. False when that
|
|
209
|
+
* was not recorded. */
|
|
210
|
+
share(turn: Turn, share: boolean): Promise<boolean>;
|
|
198
211
|
}
|
|
212
|
+
/** A turn as read back, ready for `Conversation`: given one and no handlers,
|
|
213
|
+
* that is the read-only view of a shared answer. */
|
|
214
|
+
export declare function storedTurn(conversation: string, index: number, stored: StoredTurn): Turn;
|
package/dist/chat.svelte.js
CHANGED
|
@@ -62,6 +62,8 @@ export class Chat {
|
|
|
62
62
|
#watch = null;
|
|
63
63
|
#woken = false;
|
|
64
64
|
#wake = null;
|
|
65
|
+
// The turns the server recorded, which its routes count by place; a mark
|
|
66
|
+
// or a share on one still being written is the server's to refuse.
|
|
65
67
|
// Plain, as `Session`'s seen-set: nothing renders it.
|
|
66
68
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
67
69
|
#held = new Set();
|
|
@@ -145,11 +147,11 @@ export class Chat {
|
|
|
145
147
|
}
|
|
146
148
|
/**
|
|
147
149
|
* Ask `question`, or what is in the box, as a `kind` of turn when it is not
|
|
148
|
-
* a plain question
|
|
149
|
-
*
|
|
150
|
-
* go back into the box.
|
|
150
|
+
* a plain question, at a `depth` when the host offers the choice. Resolves
|
|
151
|
+
* once the turn settles: true if it was asked, false if it was refused,
|
|
152
|
+
* here or by the server, in which case typed words go back into the box.
|
|
151
153
|
*/
|
|
152
|
-
async ask(question, kind) {
|
|
154
|
+
async ask(question, kind, depth) {
|
|
153
155
|
const typed = question === undefined;
|
|
154
156
|
const text = (question ?? this.draft).trim();
|
|
155
157
|
if (!text)
|
|
@@ -163,13 +165,15 @@ export class Chat {
|
|
|
163
165
|
this.draft = '';
|
|
164
166
|
this.files = [];
|
|
165
167
|
}
|
|
166
|
-
return this.#ask(text, files, typed, kind);
|
|
168
|
+
return this.#ask(text, files, typed, kind, depth);
|
|
167
169
|
}
|
|
168
170
|
/** The last question again, as a new turn: the conversation is append-only,
|
|
169
|
-
* because the agent's transcript is. */
|
|
171
|
+
* because the agent's transcript is. Same kind, same depth. */
|
|
170
172
|
again() {
|
|
171
173
|
const last = this.turns.at(-1);
|
|
172
|
-
return last?.question
|
|
174
|
+
return last?.question
|
|
175
|
+
? this.ask(last.question, last.kind, last.depth)
|
|
176
|
+
: Promise.resolve(false);
|
|
173
177
|
}
|
|
174
178
|
/** Stop the answer being written, wherever it is being written. */
|
|
175
179
|
async stop() {
|
|
@@ -277,15 +281,33 @@ export class Chat {
|
|
|
277
281
|
const id = this.conversationId;
|
|
278
282
|
if (!id || !this.#room?.mark || !this.#held.has(turn.id))
|
|
279
283
|
return false;
|
|
280
|
-
const position = this.turns.filter((t) => this.#held.has(t.id)).findIndex((t) => t.id === turn.id);
|
|
281
284
|
try {
|
|
282
|
-
await this.#room.mark(id, position, verdict);
|
|
285
|
+
await this.#room.mark(id, this.#position(turn), verdict);
|
|
283
286
|
return true;
|
|
284
287
|
}
|
|
285
288
|
catch {
|
|
286
289
|
return false;
|
|
287
290
|
}
|
|
288
291
|
}
|
|
292
|
+
/** Share an answer the agent holds, or stop sharing it. False when that
|
|
293
|
+
* was not recorded. */
|
|
294
|
+
async share(turn, share) {
|
|
295
|
+
const id = this.conversationId;
|
|
296
|
+
if (!id || !this.#room?.share || !this.#held.has(turn.id))
|
|
297
|
+
return false;
|
|
298
|
+
try {
|
|
299
|
+
turn.shared = (await this.#room.share(id, this.#position(turn), share)) ?? undefined;
|
|
300
|
+
this.#version += 1;
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** A turn's place among the questions the agent was asked: the server's count. */
|
|
308
|
+
#position(turn) {
|
|
309
|
+
return this.turns.filter((t) => this.#held.has(t.id)).findIndex((t) => t.id === turn.id);
|
|
310
|
+
}
|
|
289
311
|
#leave() {
|
|
290
312
|
this.#generation += 1;
|
|
291
313
|
// A stop still waiting to be addressed is carried on the stream it
|
|
@@ -301,7 +323,7 @@ export class Chat {
|
|
|
301
323
|
this.answering = null;
|
|
302
324
|
this.waiting = false;
|
|
303
325
|
}
|
|
304
|
-
async #ask(text, files, typed, kind) {
|
|
326
|
+
async #ask(text, files, typed, kind, depth) {
|
|
305
327
|
const room = this.#room;
|
|
306
328
|
const mine = this.#generation;
|
|
307
329
|
const stream = { controller: new AbortController(), stopWanted: false };
|
|
@@ -315,6 +337,7 @@ export class Chat {
|
|
|
315
337
|
citations: [],
|
|
316
338
|
suggestions: [],
|
|
317
339
|
kind,
|
|
340
|
+
depth,
|
|
318
341
|
artefact: this.#card(kind)
|
|
319
342
|
});
|
|
320
343
|
const live = this.#turns[this.#turns.length - 1];
|
|
@@ -329,7 +352,8 @@ export class Chat {
|
|
|
329
352
|
files,
|
|
330
353
|
resume: this.conversationId,
|
|
331
354
|
signal: stream.controller.signal,
|
|
332
|
-
kind
|
|
355
|
+
kind,
|
|
356
|
+
depth
|
|
333
357
|
})) {
|
|
334
358
|
const named = event.type === 'system' && event.subtype === 'init' ? event.session_id : undefined;
|
|
335
359
|
if (stream.stopWanted) {
|
|
@@ -543,7 +567,9 @@ export class Chat {
|
|
|
543
567
|
return true;
|
|
544
568
|
}
|
|
545
569
|
}
|
|
546
|
-
|
|
570
|
+
/** A turn as read back, ready for `Conversation`: given one and no handlers,
|
|
571
|
+
* that is the read-only view of a shared answer. */
|
|
572
|
+
export function storedTurn(conversation, index, stored) {
|
|
547
573
|
return {
|
|
548
574
|
id: `${conversation}:${index}`,
|
|
549
575
|
question: stored.question,
|
|
@@ -551,7 +577,9 @@ function storedTurn(conversation, index, stored) {
|
|
|
551
577
|
blocks: stored.answer ? [{ kind: 'text', index: 0, text: stored.answer }] : [],
|
|
552
578
|
outcome: null,
|
|
553
579
|
citations: stored.citations ?? [],
|
|
554
|
-
kind: stored.kind ?? undefined
|
|
580
|
+
kind: stored.kind ?? undefined,
|
|
581
|
+
depth: stored.depth ?? undefined,
|
|
582
|
+
shared: stored.shared ?? undefined
|
|
555
583
|
};
|
|
556
584
|
}
|
|
557
585
|
function epoch(value) {
|
package/dist/client.d.ts
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
* event type needs no change on either side.
|
|
9
9
|
*/
|
|
10
10
|
import type { Citation } from './citations';
|
|
11
|
+
/**
|
|
12
|
+
* How hard the persona works on one question: a reading budget and a model,
|
|
13
|
+
* both the operator's to set per deployment, never the colleague's concern.
|
|
14
|
+
* `quick` is always the default; `thorough` always means something — a
|
|
15
|
+
* bigger budget on the strongest model the room allows, even where that
|
|
16
|
+
* model is a local one — so a host never has a state where the choice is
|
|
17
|
+
* greyed out or explained away.
|
|
18
|
+
*/
|
|
19
|
+
export type Depth = 'quick' | 'thorough';
|
|
11
20
|
/** A Claude Code message, whole. `content` is a plain string only on a user frame. */
|
|
12
21
|
export interface AgentMessage {
|
|
13
22
|
id?: string;
|
|
@@ -95,6 +104,10 @@ export interface AskOptions {
|
|
|
95
104
|
* (`'briefing'`) and interprets what comes back accordingly — the wire
|
|
96
105
|
* vocabulary is the host's own, never a fixed set here. */
|
|
97
106
|
kind?: string;
|
|
107
|
+
/** How hard the persona should work on this one. Absent asks for
|
|
108
|
+
* whatever the route does by default, so a host that never reads
|
|
109
|
+
* `Composer`'s `depth` sends exactly what it always has. */
|
|
110
|
+
depth?: Depth;
|
|
98
111
|
signal?: AbortSignal;
|
|
99
112
|
/** Where the ask lands. Each app mounts its own ask route. */
|
|
100
113
|
endpoint?: string;
|
package/dist/client.js
CHANGED
|
@@ -34,7 +34,8 @@ function requestInit(options, signal) {
|
|
|
34
34
|
resume: options.resume ?? null,
|
|
35
35
|
subtree: options.subtree ?? '',
|
|
36
36
|
collections: options.collections ?? [],
|
|
37
|
-
kind: options.kind
|
|
37
|
+
kind: options.kind,
|
|
38
|
+
depth: options.depth
|
|
38
39
|
}),
|
|
39
40
|
signal
|
|
40
41
|
};
|
|
@@ -49,6 +50,8 @@ function requestInit(options, signal) {
|
|
|
49
50
|
form.append('files[]', file, file.name);
|
|
50
51
|
if (options.kind)
|
|
51
52
|
form.append('kind', options.kind);
|
|
53
|
+
if (options.depth)
|
|
54
|
+
form.append('depth', options.depth);
|
|
52
55
|
return { method: 'POST', credentials: 'include', body: form, signal };
|
|
53
56
|
}
|
|
54
57
|
/**
|
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
import CopyIcon from '@lucide/svelte/icons/copy';
|
|
18
18
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
|
19
19
|
import {
|
|
20
|
+
outcomeLine,
|
|
20
21
|
readerQuestion,
|
|
21
22
|
readFrom,
|
|
22
23
|
segment,
|
|
23
24
|
type Artefact,
|
|
24
25
|
type Block,
|
|
25
26
|
type DescribeTool,
|
|
27
|
+
type Depth,
|
|
26
28
|
type Outcome,
|
|
27
29
|
type TextBlock
|
|
28
30
|
} from '../../transcript.svelte';
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
|
|
36
38
|
import type { Verdict } from '../../chat.svelte';
|
|
37
39
|
import AnswerMark from '../answer-mark/answer-mark.svelte';
|
|
40
|
+
import AnswerShare from '../answer-share/answer-share.svelte';
|
|
38
41
|
import Working from '../working/working.svelte';
|
|
39
42
|
import ActivityGroup from '../activity-group/activity-group.svelte';
|
|
40
43
|
import ArtefactCard from '../artefact-card/artefact-card.svelte';
|
|
@@ -88,6 +91,21 @@
|
|
|
88
91
|
/** Records a verdict on this answer. Omit and no mark renders: a
|
|
89
92
|
* control that records nothing is worse than none. */
|
|
90
93
|
onmark?: (verdict: Verdict) => Promise<boolean>;
|
|
94
|
+
/** Where this answer's read-only view lives while it is shared, as the
|
|
95
|
+
* host's own address. */
|
|
96
|
+
shared?: string;
|
|
97
|
+
/** Shares this answer (true) or stops sharing it (false), resolving
|
|
98
|
+
* true once recorded. Omit and nothing about sharing renders: nothing
|
|
99
|
+
* is shared unless the reader asks. */
|
|
100
|
+
onshare?: (share: boolean) => Promise<boolean>;
|
|
101
|
+
/** How hard the persona worked on this one, for the footer's cost line
|
|
102
|
+
* (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
|
|
103
|
+
* as it always has: the duration alone. */
|
|
104
|
+
depth?: Depth;
|
|
105
|
+
/** Prices `outcome.costUsd` in the host's own currency and words
|
|
106
|
+
* ("about A$0.34"). The package never converts currency itself,
|
|
107
|
+
* and a zero-cost run (a local model) shows no cost regardless. */
|
|
108
|
+
formatCost?: (usd: number) => string;
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
let {
|
|
@@ -110,7 +128,11 @@
|
|
|
110
128
|
describeTool,
|
|
111
129
|
waiting = false,
|
|
112
130
|
answering,
|
|
113
|
-
onmark
|
|
131
|
+
onmark,
|
|
132
|
+
shared,
|
|
133
|
+
onshare,
|
|
134
|
+
depth,
|
|
135
|
+
formatCost
|
|
114
136
|
}: AgentTranscriptProps = $props();
|
|
115
137
|
|
|
116
138
|
// The prose IS the artefact: it reads in the column, and the transcript
|
|
@@ -361,6 +383,9 @@
|
|
|
361
383
|
<span>{copied ? words.copiedAnswer : words.copyAnswer}</span>
|
|
362
384
|
</button>
|
|
363
385
|
{/if}
|
|
386
|
+
{#if onshare && hasAnswer && !failure}
|
|
387
|
+
<AnswerShare {onshare} {shared} copy={words} />
|
|
388
|
+
{/if}
|
|
364
389
|
{#if onmark && hasAnswer && !failure}
|
|
365
390
|
<AnswerMark {onmark} copy={words} />
|
|
366
391
|
{/if}
|
|
@@ -375,11 +400,12 @@
|
|
|
375
400
|
<span>{words.askAgain}</span>
|
|
376
401
|
</button>
|
|
377
402
|
{/if}
|
|
378
|
-
<!--
|
|
403
|
+
<!-- What it took, which is not the same fact as when it was
|
|
379
404
|
asked; both are on the card and neither stands in for the
|
|
380
|
-
other.
|
|
405
|
+
other. Depth and cost fold into the SAME line, never a
|
|
406
|
+
second one, once the host offers either. -->
|
|
381
407
|
{#if outcome && !failure}
|
|
382
|
-
<span class="ds-lib-duration">{(
|
|
408
|
+
<span class="ds-lib-duration">{outcomeLine(outcome, depth, words, formatCost)}</span>
|
|
383
409
|
{/if}
|
|
384
410
|
</div>
|
|
385
411
|
{/if}
|
|
@@ -557,8 +583,10 @@
|
|
|
557
583
|
color: var(--ds-color-muted-foreground);
|
|
558
584
|
}
|
|
559
585
|
|
|
586
|
+
/* 44px tall: each is a thumb's target on a phone. */
|
|
560
587
|
.ds-lib-action {
|
|
561
588
|
display: flex;
|
|
589
|
+
min-height: 2.75rem;
|
|
562
590
|
align-items: center;
|
|
563
591
|
gap: 0.375rem;
|
|
564
592
|
border: 0;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
|
|
1
|
+
import { type Artefact, type Block, type DescribeTool, type Depth, type Outcome } from '../../transcript.svelte';
|
|
2
2
|
import { type Citation } from '../../citations';
|
|
3
3
|
import { type LibrarianCopy } from '../../copy';
|
|
4
4
|
import type { Verdict } from '../../chat.svelte';
|
|
@@ -49,6 +49,21 @@ export interface AgentTranscriptProps {
|
|
|
49
49
|
/** Records a verdict on this answer. Omit and no mark renders: a
|
|
50
50
|
* control that records nothing is worse than none. */
|
|
51
51
|
onmark?: (verdict: Verdict) => Promise<boolean>;
|
|
52
|
+
/** Where this answer's read-only view lives while it is shared, as the
|
|
53
|
+
* host's own address. */
|
|
54
|
+
shared?: string;
|
|
55
|
+
/** Shares this answer (true) or stops sharing it (false), resolving
|
|
56
|
+
* true once recorded. Omit and nothing about sharing renders: nothing
|
|
57
|
+
* is shared unless the reader asks. */
|
|
58
|
+
onshare?: (share: boolean) => Promise<boolean>;
|
|
59
|
+
/** How hard the persona worked on this one, for the footer's cost line
|
|
60
|
+
* (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
|
|
61
|
+
* as it always has: the duration alone. */
|
|
62
|
+
depth?: Depth;
|
|
63
|
+
/** Prices `outcome.costUsd` in the host's own currency and words
|
|
64
|
+
* ("about A$0.34"). The package never converts currency itself,
|
|
65
|
+
* and a zero-cost run (a local model) shows no cost regardless. */
|
|
66
|
+
formatCost?: (usd: number) => string;
|
|
52
67
|
}
|
|
53
68
|
declare const AgentTranscript: import("svelte").Component<AgentTranscriptProps, {}, "">;
|
|
54
69
|
type AgentTranscript = ReturnType<typeof AgentTranscript>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Share one answer read-only with whoever may ask here, and stop sharing it.
|
|
3
|
+
|
|
4
|
+
Rendered by `AgentTranscript` in its action row, and by a host's own turn
|
|
5
|
+
beside its own presentation, only when the host passes `onshare`: nothing
|
|
6
|
+
is shared unless the reader asks. A share copies its link at once; a
|
|
7
|
+
browser that refuses a copy after the round trip (Safari) still has "Copy
|
|
8
|
+
link". Several roots, as `AnswerMark`: the buttons sit in the row, and the
|
|
9
|
+
line saying who can open it drops under it.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { tick } from 'svelte';
|
|
13
|
+
import CheckIcon from '@lucide/svelte/icons/check';
|
|
14
|
+
import LinkIcon from '@lucide/svelte/icons/link';
|
|
15
|
+
import Share2Icon from '@lucide/svelte/icons/share-2';
|
|
16
|
+
import { resolveCopy, type LibrarianCopy } from '../../copy';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
/** Shares the answer (true) or stops sharing it (false), resolving true
|
|
20
|
+
* once recorded. */
|
|
21
|
+
onshare: (share: boolean) => Promise<boolean>;
|
|
22
|
+
/** Where the read-only view lives while the answer is shared, as the
|
|
23
|
+
* host's own address. */
|
|
24
|
+
shared?: string;
|
|
25
|
+
copy?: Partial<LibrarianCopy>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let { onshare, shared, copy }: Props = $props();
|
|
29
|
+
|
|
30
|
+
const words = $derived(resolveCopy(copy));
|
|
31
|
+
|
|
32
|
+
let saving = $state(false);
|
|
33
|
+
let failed = $state(false);
|
|
34
|
+
let copied = $state(false);
|
|
35
|
+
|
|
36
|
+
async function send(share: boolean) {
|
|
37
|
+
if (saving) return;
|
|
38
|
+
saving = true;
|
|
39
|
+
failed = false;
|
|
40
|
+
try {
|
|
41
|
+
failed = !(await onshare(share));
|
|
42
|
+
} finally {
|
|
43
|
+
saving = false;
|
|
44
|
+
}
|
|
45
|
+
if (share && !failed) {
|
|
46
|
+
await tick();
|
|
47
|
+
await copyLink();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function copyLink() {
|
|
52
|
+
if (!shared) return;
|
|
53
|
+
try {
|
|
54
|
+
await navigator.clipboard.writeText(new URL(shared, location.href).href);
|
|
55
|
+
copied = true;
|
|
56
|
+
setTimeout(() => (copied = false), 1600);
|
|
57
|
+
} catch {
|
|
58
|
+
// Refused; "Copy link" is still there to tap.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
{#if shared}
|
|
64
|
+
<button type="button" class="ds-lib-share" onclick={copyLink} disabled={saving}>
|
|
65
|
+
{#if copied}<CheckIcon size={14} />{:else}<LinkIcon size={14} />{/if}
|
|
66
|
+
<span>{copied ? words.linkCopied : words.copyLink}</span>
|
|
67
|
+
</button>
|
|
68
|
+
<button type="button" class="ds-lib-share" onclick={() => send(false)} disabled={saving}>
|
|
69
|
+
<span>{words.stopSharing}</span>
|
|
70
|
+
</button>
|
|
71
|
+
{:else}
|
|
72
|
+
<button type="button" class="ds-lib-share" onclick={() => send(true)} disabled={saving}>
|
|
73
|
+
<Share2Icon size={14} />
|
|
74
|
+
<span>{words.share}</span>
|
|
75
|
+
</button>
|
|
76
|
+
{/if}
|
|
77
|
+
{#if shared || failed}
|
|
78
|
+
<p class="ds-lib-share-line" class:is-failed={failed} role="status">
|
|
79
|
+
{failed ? words.shareFailed : words.sharedWith}
|
|
80
|
+
</p>
|
|
81
|
+
{/if}
|
|
82
|
+
|
|
83
|
+
<style>
|
|
84
|
+
/* 44px tall: a thumb's target on a phone. */
|
|
85
|
+
.ds-lib-share {
|
|
86
|
+
display: flex;
|
|
87
|
+
min-height: 2.75rem;
|
|
88
|
+
align-items: center;
|
|
89
|
+
gap: 0.375rem;
|
|
90
|
+
border: 0;
|
|
91
|
+
border-radius: var(--ds-radius-md);
|
|
92
|
+
background: none;
|
|
93
|
+
padding: 0.25rem 0.375rem;
|
|
94
|
+
color: inherit;
|
|
95
|
+
font: inherit;
|
|
96
|
+
font-size: var(--ds-text-2xs);
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
transition:
|
|
99
|
+
color 150ms ease,
|
|
100
|
+
background-color 150ms ease;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.ds-lib-share:hover:not(:disabled) {
|
|
104
|
+
background: var(--ds-color-surface-1);
|
|
105
|
+
color: var(--ds-color-foreground);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.ds-lib-share:disabled {
|
|
109
|
+
cursor: default;
|
|
110
|
+
opacity: 0.6;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.ds-lib-share:focus-visible {
|
|
114
|
+
outline: 2px solid var(--ds-color-ring);
|
|
115
|
+
outline-offset: 2px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Its own line under the row, whatever sits after the buttons in it. */
|
|
119
|
+
.ds-lib-share-line {
|
|
120
|
+
order: 1;
|
|
121
|
+
flex-basis: 100%;
|
|
122
|
+
margin: 0 0 0 0.375rem;
|
|
123
|
+
font-size: var(--ds-text-2xs);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.ds-lib-share-line.is-failed {
|
|
127
|
+
color: var(--ds-color-status-error);
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type LibrarianCopy } from '../../copy';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Shares the answer (true) or stops sharing it (false), resolving true
|
|
4
|
+
* once recorded. */
|
|
5
|
+
onshare: (share: boolean) => Promise<boolean>;
|
|
6
|
+
/** Where the read-only view lives while the answer is shared, as the
|
|
7
|
+
* host's own address. */
|
|
8
|
+
shared?: string;
|
|
9
|
+
copy?: Partial<LibrarianCopy>;
|
|
10
|
+
}
|
|
11
|
+
declare const AnswerShare: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type AnswerShare = ReturnType<typeof AnswerShare>;
|
|
13
|
+
export default AnswerShare;
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
-->
|
|
15
15
|
<script lang="ts">
|
|
16
16
|
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
|
17
|
+
import ClockIcon from '@lucide/svelte/icons/clock';
|
|
17
18
|
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
|
18
19
|
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
|
|
19
20
|
import SquareIcon from '@lucide/svelte/icons/square';
|
|
@@ -25,8 +26,14 @@
|
|
|
25
26
|
MAX_FILES,
|
|
26
27
|
rejectionMessage
|
|
27
28
|
} from '../../attachments';
|
|
29
|
+
import type { Depth } from '../../client';
|
|
28
30
|
import { DEFAULT_PERSONA, resolveCopy, type LibrarianCopy } from '../../copy';
|
|
29
31
|
|
|
32
|
+
/** Past this many turns, the long-conversation banner offers to start
|
|
33
|
+
* over: a follow-up re-reads the whole conversation, so each one costs
|
|
34
|
+
* more than the last (measured — the longest ran 2.05M cached tokens). */
|
|
35
|
+
const DEFAULT_TURN_LIMIT = 8;
|
|
36
|
+
|
|
30
37
|
export type Scope = 'document' | 'collection' | 'library';
|
|
31
38
|
|
|
32
39
|
interface Props {
|
|
@@ -63,6 +70,21 @@
|
|
|
63
70
|
* sit, in the host's words: what sending does here ("It carries on
|
|
64
71
|
* from where it stopped."). */
|
|
65
72
|
note?: string;
|
|
73
|
+
/** Quick or Thorough for the next question, as a two-way switch with a
|
|
74
|
+
* one-line hint. Bind it and pass it to `ask()`; omit it and nothing
|
|
75
|
+
* renders. Once shown it is never disabled or hidden, and never names
|
|
76
|
+
* the model behind it. */
|
|
77
|
+
depth?: Depth;
|
|
78
|
+
/** How many turns are in the conversation so far. Past `turnLimit`
|
|
79
|
+
* (8 by default), a quiet banner appears above the box offering to
|
|
80
|
+
* start over. Omit and no banner ever renders — a host that does not
|
|
81
|
+
* track turns keeps today's composer. */
|
|
82
|
+
turnCount?: number;
|
|
83
|
+
turnLimit?: number;
|
|
84
|
+
/** Starts a new conversation, from the banner's own button. Without it
|
|
85
|
+
* the banner never renders, past the limit or not — an offer with no
|
|
86
|
+
* action is worse than none. */
|
|
87
|
+
onnewquestion?: () => void;
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
let {
|
|
@@ -80,7 +102,11 @@
|
|
|
80
102
|
name = DEFAULT_PERSONA,
|
|
81
103
|
copy,
|
|
82
104
|
note,
|
|
83
|
-
sendWhileRunning = false
|
|
105
|
+
sendWhileRunning = false,
|
|
106
|
+
depth = $bindable(),
|
|
107
|
+
turnCount,
|
|
108
|
+
turnLimit = DEFAULT_TURN_LIMIT,
|
|
109
|
+
onnewquestion
|
|
84
110
|
}: Props = $props();
|
|
85
111
|
|
|
86
112
|
/** Nothing can be said right now: a chat whose answer is still coming. */
|
|
@@ -88,6 +114,12 @@
|
|
|
88
114
|
|
|
89
115
|
const words = $derived(resolveCopy(copy, name));
|
|
90
116
|
|
|
117
|
+
// The banner needs somewhere to send a reader AND a reason to show at
|
|
118
|
+
// all: a host that never counts turns gets today's composer, unchanged.
|
|
119
|
+
const longConversation = $derived(
|
|
120
|
+
turnCount !== undefined && turnCount > turnLimit && Boolean(onnewquestion)
|
|
121
|
+
);
|
|
122
|
+
|
|
91
123
|
// What Milton is being asked about — this document, this collection, or
|
|
92
124
|
// the whole library. Callers that have no narrower scope pass neither
|
|
93
125
|
// name, leaving "The whole library" the sole entry — and a sole entry is
|
|
@@ -212,6 +244,16 @@
|
|
|
212
244
|
? '0px'
|
|
213
245
|
: 'env(safe-area-inset-bottom, 0px)'}"
|
|
214
246
|
>
|
|
247
|
+
{#if longConversation}
|
|
248
|
+
<div class="ds-lib-long" role="status">
|
|
249
|
+
<ClockIcon size={16} />
|
|
250
|
+
<span>{words.longConversation}</span>
|
|
251
|
+
<button type="button" class="ds-lib-long-new" onclick={onnewquestion}>
|
|
252
|
+
{words.newQuestion}
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
{/if}
|
|
256
|
+
|
|
215
257
|
<div class="ds-lib-box">
|
|
216
258
|
{#if files.length > 0}
|
|
217
259
|
<ul class="ds-lib-files">
|
|
@@ -279,6 +321,31 @@
|
|
|
279
321
|
</button>
|
|
280
322
|
{/if}
|
|
281
323
|
|
|
324
|
+
{#if depth !== undefined}
|
|
325
|
+
<div role="group" aria-label={words.depthGroupLabel} class="ds-lib-depth">
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
aria-pressed={depth === 'quick'}
|
|
329
|
+
class="ds-lib-depth-side"
|
|
330
|
+
class:is-chosen={depth === 'quick'}
|
|
331
|
+
onclick={() => (depth = 'quick')}
|
|
332
|
+
>
|
|
333
|
+
{words.depthQuick}
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
aria-pressed={depth === 'thorough'}
|
|
338
|
+
class="ds-lib-depth-side"
|
|
339
|
+
class:is-chosen={depth === 'thorough'}
|
|
340
|
+
onclick={() => (depth = 'thorough')}
|
|
341
|
+
>
|
|
342
|
+
{words.depthThorough}
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
<span class="ds-lib-note">
|
|
346
|
+
{depth === 'quick' ? words.depthQuickHint : words.depthThoroughHint}
|
|
347
|
+
</span>
|
|
348
|
+
{/if}
|
|
282
349
|
{#if hasChoice}
|
|
283
350
|
<div class="ds-lib-scopes">
|
|
284
351
|
{#each choices as choice (choice.id)}
|
|
@@ -415,8 +482,10 @@
|
|
|
415
482
|
|
|
416
483
|
.ds-lib-controls {
|
|
417
484
|
display: flex;
|
|
485
|
+
flex-wrap: wrap;
|
|
418
486
|
align-items: center;
|
|
419
487
|
gap: 0.5rem;
|
|
488
|
+
row-gap: 0.375rem;
|
|
420
489
|
padding: 0 0.75rem 0.625rem;
|
|
421
490
|
}
|
|
422
491
|
|
|
@@ -538,6 +607,91 @@
|
|
|
538
607
|
font-size: var(--ds-text-2xs);
|
|
539
608
|
}
|
|
540
609
|
|
|
610
|
+
/* A labelled button group, never a select: two states a reader compares
|
|
611
|
+
at a glance, not a list to open. 44px touch targets throughout — the
|
|
612
|
+
one row here a colleague reaches for on every single question. */
|
|
613
|
+
.ds-lib-depth {
|
|
614
|
+
display: flex;
|
|
615
|
+
flex: none;
|
|
616
|
+
gap: 0.125rem;
|
|
617
|
+
border-radius: var(--ds-radius-lg);
|
|
618
|
+
background: var(--ds-color-surface-3);
|
|
619
|
+
padding: 0.1875rem;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.ds-lib-depth-side {
|
|
623
|
+
min-width: 2.75rem;
|
|
624
|
+
min-height: 2.75rem;
|
|
625
|
+
border: 0;
|
|
626
|
+
border-radius: var(--ds-radius-md);
|
|
627
|
+
background: none;
|
|
628
|
+
padding: 0 0.75rem;
|
|
629
|
+
color: var(--ds-color-muted-foreground);
|
|
630
|
+
font: inherit;
|
|
631
|
+
font-size: var(--ds-text-2xs);
|
|
632
|
+
cursor: pointer;
|
|
633
|
+
transition:
|
|
634
|
+
color 150ms ease,
|
|
635
|
+
background-color 150ms ease;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.ds-lib-depth-side:hover {
|
|
639
|
+
color: var(--ds-color-foreground);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.ds-lib-depth-side.is-chosen {
|
|
643
|
+
background: var(--ds-color-primary);
|
|
644
|
+
color: var(--ds-color-primary-foreground);
|
|
645
|
+
font-weight: 600;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.ds-lib-depth-side:focus-visible {
|
|
649
|
+
outline: 2px solid var(--ds-color-ring);
|
|
650
|
+
outline-offset: 2px;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* Above the box, never inside it: it is about the conversation so far,
|
|
654
|
+
not this one question. Warning-tinted, never error — nothing has gone
|
|
655
|
+
wrong, a follow-up just costs more than the last one did. */
|
|
656
|
+
.ds-lib-long {
|
|
657
|
+
display: flex;
|
|
658
|
+
align-items: center;
|
|
659
|
+
gap: 0.625rem;
|
|
660
|
+
border: 1px solid color-mix(in oklab, var(--ds-color-status-warning) 40%, transparent);
|
|
661
|
+
border-radius: var(--ds-radius-lg);
|
|
662
|
+
background: color-mix(in oklab, var(--ds-color-status-warning) 12%, transparent);
|
|
663
|
+
padding: 0.5rem 0.5rem 0.5rem 0.875rem;
|
|
664
|
+
color: var(--ds-color-foreground);
|
|
665
|
+
font-size: 0.875rem;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.ds-lib-long span {
|
|
669
|
+
flex: 1;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.ds-lib-long-new {
|
|
673
|
+
min-height: 2.75rem;
|
|
674
|
+
flex: none;
|
|
675
|
+
border: 1px solid var(--ds-color-border);
|
|
676
|
+
border-radius: var(--ds-radius-md);
|
|
677
|
+
background: transparent;
|
|
678
|
+
padding: 0 0.875rem;
|
|
679
|
+
color: var(--ds-color-foreground);
|
|
680
|
+
font: inherit;
|
|
681
|
+
font-size: var(--ds-text-2xs);
|
|
682
|
+
cursor: pointer;
|
|
683
|
+
transition: background-color 150ms ease;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.ds-lib-long-new:hover {
|
|
687
|
+
background: var(--ds-color-surface-2);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.ds-lib-long-new:focus-visible {
|
|
691
|
+
outline: 2px solid var(--ds-color-ring);
|
|
692
|
+
outline-offset: 2px;
|
|
693
|
+
}
|
|
694
|
+
|
|
541
695
|
@media (prefers-reduced-motion: reduce) {
|
|
542
696
|
.ds-lib-composer-lift {
|
|
543
697
|
transition: none;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Depth } from '../../client';
|
|
1
2
|
import { type LibrarianCopy } from '../../copy';
|
|
2
3
|
export type Scope = 'document' | 'collection' | 'library';
|
|
3
4
|
interface Props {
|
|
@@ -34,7 +35,22 @@ interface Props {
|
|
|
34
35
|
* sit, in the host's words: what sending does here ("It carries on
|
|
35
36
|
* from where it stopped."). */
|
|
36
37
|
note?: string;
|
|
38
|
+
/** Quick or Thorough for the next question, as a two-way switch with a
|
|
39
|
+
* one-line hint. Bind it and pass it to `ask()`; omit it and nothing
|
|
40
|
+
* renders. Once shown it is never disabled or hidden, and never names
|
|
41
|
+
* the model behind it. */
|
|
42
|
+
depth?: Depth;
|
|
43
|
+
/** How many turns are in the conversation so far. Past `turnLimit`
|
|
44
|
+
* (8 by default), a quiet banner appears above the box offering to
|
|
45
|
+
* start over. Omit and no banner ever renders — a host that does not
|
|
46
|
+
* track turns keeps today's composer. */
|
|
47
|
+
turnCount?: number;
|
|
48
|
+
turnLimit?: number;
|
|
49
|
+
/** Starts a new conversation, from the banner's own button. Without it
|
|
50
|
+
* the banner never renders, past the limit or not — an offer with no
|
|
51
|
+
* action is worse than none. */
|
|
52
|
+
onnewquestion?: () => void;
|
|
37
53
|
}
|
|
38
|
-
declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
|
|
54
|
+
declare const Composer: import("svelte").Component<Props, {}, "value" | "depth" | "files">;
|
|
39
55
|
type Composer = ReturnType<typeof Composer>;
|
|
40
56
|
export default Composer;
|
|
@@ -57,6 +57,9 @@
|
|
|
57
57
|
/** Records a verdict on an answer. Offered on the last answer only, as
|
|
58
58
|
* asking again is; without it no mark renders. */
|
|
59
59
|
onmark?: (turn: Turn, verdict: Verdict) => Promise<boolean>;
|
|
60
|
+
/** Shares an answer read-only, or stops sharing it (`Chat.share`).
|
|
61
|
+
* Offered on every settled answer; without it nothing is shareable. */
|
|
62
|
+
onshare?: (turn: Turn, share: boolean) => Promise<boolean>;
|
|
60
63
|
/** The last question waits behind somebody else's (`Chat.waiting`). */
|
|
61
64
|
waiting?: boolean;
|
|
62
65
|
/** When the last answer began, epoch ms, where this page did not see it
|
|
@@ -78,6 +81,11 @@
|
|
|
78
81
|
showing?: string;
|
|
79
82
|
/** The persona's own words for its tools, and what they read. */
|
|
80
83
|
describeTool?: DescribeTool;
|
|
84
|
+
/** Prices a turn's `outcome.costUsd` in the host's own currency and
|
|
85
|
+
* words ("about A$0.34"), for the footer's cost line beside a turn's
|
|
86
|
+
* `depth`. The package never converts currency itself; omitted, the
|
|
87
|
+
* line shows depth and duration only. */
|
|
88
|
+
formatCost?: (usd: number) => string;
|
|
81
89
|
collectionNames?: Set<string>;
|
|
82
90
|
/** The composer, rendered INSIDE the transcript column so the source
|
|
83
91
|
* pane narrows it too — a composer the host places outside slides
|
|
@@ -106,6 +114,7 @@
|
|
|
106
114
|
onregenerate,
|
|
107
115
|
onsuggest,
|
|
108
116
|
onmark,
|
|
117
|
+
onshare,
|
|
109
118
|
waiting = false,
|
|
110
119
|
answering = null,
|
|
111
120
|
copy,
|
|
@@ -117,7 +126,8 @@
|
|
|
117
126
|
collectionNames = new Set(),
|
|
118
127
|
composer,
|
|
119
128
|
turn: presentTurn,
|
|
120
|
-
lead
|
|
129
|
+
lead,
|
|
130
|
+
formatCost
|
|
121
131
|
}: Props = $props();
|
|
122
132
|
|
|
123
133
|
// Resolved ONCE, here, and handed down whole: every child takes `copy` and
|
|
@@ -311,11 +321,16 @@
|
|
|
311
321
|
artefactOpen: isShowing(turn),
|
|
312
322
|
onopenartefact: opener(turn),
|
|
313
323
|
describeTool,
|
|
324
|
+
depth: turn.depth,
|
|
325
|
+
formatCost,
|
|
314
326
|
oncite: oncite || loadDocument ? cite : undefined,
|
|
315
327
|
onregenerate: last && !running ? onregenerate : undefined,
|
|
316
328
|
onsuggest: last && !running ? onsuggest : undefined,
|
|
317
329
|
onmark:
|
|
318
|
-
last && !running && onmark ? (verdict: Verdict) => onmark(turn, verdict) : undefined
|
|
330
|
+
last && !running && onmark ? (verdict: Verdict) => onmark(turn, verdict) : undefined,
|
|
331
|
+
shared: turn.shared,
|
|
332
|
+
onshare:
|
|
333
|
+
onshare && !(last && running) ? (share: boolean) => onshare(turn, share) : undefined
|
|
319
334
|
} satisfies AgentTranscriptProps}
|
|
320
335
|
{#if presentTurn}
|
|
321
336
|
{@render presentTurn(props)}
|
|
@@ -33,6 +33,9 @@ interface Props {
|
|
|
33
33
|
/** Records a verdict on an answer. Offered on the last answer only, as
|
|
34
34
|
* asking again is; without it no mark renders. */
|
|
35
35
|
onmark?: (turn: Turn, verdict: Verdict) => Promise<boolean>;
|
|
36
|
+
/** Shares an answer read-only, or stops sharing it (`Chat.share`).
|
|
37
|
+
* Offered on every settled answer; without it nothing is shareable. */
|
|
38
|
+
onshare?: (turn: Turn, share: boolean) => Promise<boolean>;
|
|
36
39
|
/** The last question waits behind somebody else's (`Chat.waiting`). */
|
|
37
40
|
waiting?: boolean;
|
|
38
41
|
/** When the last answer began, epoch ms, where this page did not see it
|
|
@@ -54,6 +57,11 @@ interface Props {
|
|
|
54
57
|
showing?: string;
|
|
55
58
|
/** The persona's own words for its tools, and what they read. */
|
|
56
59
|
describeTool?: DescribeTool;
|
|
60
|
+
/** Prices a turn's `outcome.costUsd` in the host's own currency and
|
|
61
|
+
* words ("about A$0.34"), for the footer's cost line beside a turn's
|
|
62
|
+
* `depth`. The package never converts currency itself; omitted, the
|
|
63
|
+
* line shows depth and duration only. */
|
|
64
|
+
formatCost?: (usd: number) => string;
|
|
57
65
|
collectionNames?: Set<string>;
|
|
58
66
|
/** The composer, rendered INSIDE the transcript column so the source
|
|
59
67
|
* pane narrows it too — a composer the host places outside slides
|
package/dist/copy.d.ts
CHANGED
|
@@ -44,6 +44,18 @@ export interface LibrarianCopy {
|
|
|
44
44
|
/** The composer's placeholder, idle and while an answer streams. */
|
|
45
45
|
askPlaceholder: string;
|
|
46
46
|
answeringPlaceholder: string;
|
|
47
|
+
/** The depth switch: its group's accessible name, each side's label, and
|
|
48
|
+
* the one-line hint beside it saying what the picked side is for. Never
|
|
49
|
+
* which model sits behind it — that is never the colleague's concern. */
|
|
50
|
+
depthGroupLabel: string;
|
|
51
|
+
depthQuick: string;
|
|
52
|
+
depthThorough: string;
|
|
53
|
+
depthQuickHint: string;
|
|
54
|
+
depthThoroughHint: string;
|
|
55
|
+
/** Past a few turns, the quiet line above the composer that offers to
|
|
56
|
+
* start over — a follow-up re-reads the whole conversation, so each one
|
|
57
|
+
* costs more than the last. */
|
|
58
|
+
longConversation: string;
|
|
47
59
|
/** The pre-first-token words, cycled one every 2.6s. */
|
|
48
60
|
working: string[];
|
|
49
61
|
/** The scroll region's accessible name. */
|
|
@@ -68,6 +80,14 @@ export interface LibrarianCopy {
|
|
|
68
80
|
markSend: string;
|
|
69
81
|
markNoted: string;
|
|
70
82
|
markFailed: string;
|
|
83
|
+
/** Sharing an answer: the action, once shared, who can open it, and a
|
|
84
|
+
* share or a stop that did not go through. */
|
|
85
|
+
share: string;
|
|
86
|
+
copyLink: string;
|
|
87
|
+
linkCopied: string;
|
|
88
|
+
stopSharing: string;
|
|
89
|
+
sharedWith: string;
|
|
90
|
+
shareFailed: string;
|
|
71
91
|
/** The fair-use notice, while there are questions left and once there are
|
|
72
92
|
* none, filled by `fill()`: `{resets}` is a time already, "midnight" or
|
|
73
93
|
* "2:00 pm". */
|
package/dist/copy.js
CHANGED
|
@@ -66,6 +66,12 @@ export function copyFor(name = DEFAULT_PERSONA) {
|
|
|
66
66
|
welcome: `Ask ${who} a question.`,
|
|
67
67
|
askPlaceholder: `Ask ${who}…`,
|
|
68
68
|
answeringPlaceholder: `${who} is answering…`,
|
|
69
|
+
depthGroupLabel: `How hard ${who} works`,
|
|
70
|
+
depthQuick: 'Quick',
|
|
71
|
+
depthThorough: 'Thorough',
|
|
72
|
+
depthQuickHint: 'Fast, for looking something up',
|
|
73
|
+
depthThoroughHint: 'Slower, reads more, for questions of interpretation',
|
|
74
|
+
longConversation: `This conversation is getting long. A new question keeps ${who} quick.`,
|
|
69
75
|
working: [`${who} is looking`, `${who} is reading`],
|
|
70
76
|
conversationLabel: `Conversation with ${who}`,
|
|
71
77
|
copyAnswer: 'Copy',
|
|
@@ -84,6 +90,12 @@ export function copyFor(name = DEFAULT_PERSONA) {
|
|
|
84
90
|
markSend: 'Send',
|
|
85
91
|
markNoted: 'Thanks, noted.',
|
|
86
92
|
markFailed: "That couldn't be sent. Nothing was recorded.",
|
|
93
|
+
share: 'Share',
|
|
94
|
+
copyLink: 'Copy link',
|
|
95
|
+
linkCopied: 'Link copied',
|
|
96
|
+
stopSharing: 'Stop sharing',
|
|
97
|
+
sharedWith: `Shared: anyone who can ask ${who} here can open the link.`,
|
|
98
|
+
shareFailed: "That didn't go through. Try again.",
|
|
87
99
|
// The industry wording, whole: the number, that it is a fair-use limit
|
|
88
100
|
// rather than a fault, and when it comes back. No apology.
|
|
89
101
|
questionsLeft: '{remaining} of {limit} questions left today. Resets at {resets}.',
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* deltas that belong to each. An event type this does not know about is kept
|
|
7
7
|
* verbatim under `other`, so nothing is silently dropped.
|
|
8
8
|
*/
|
|
9
|
-
import type { AgentEvent } from './client';
|
|
9
|
+
import type { AgentEvent, Depth } from './client';
|
|
10
10
|
import type { Citation } from './citations';
|
|
11
|
+
import type { LibrarianCopy } from './copy';
|
|
12
|
+
export type { Depth };
|
|
11
13
|
export interface TextBlock {
|
|
12
14
|
kind: 'text';
|
|
13
15
|
index: number;
|
|
@@ -208,6 +210,12 @@ export interface Turn {
|
|
|
208
210
|
/** What the host asked for when this was not a plain question
|
|
209
211
|
* (`'briefing'`), in its own word, as `client.ask()` sent it. */
|
|
210
212
|
kind?: string;
|
|
213
|
+
/** How hard the persona worked on this one, as `client.ask()` sent it.
|
|
214
|
+
* Absent, a host that never offers the choice. */
|
|
215
|
+
depth?: Depth;
|
|
216
|
+
/** Where this answer's read-only view lives while it is shared, as the
|
|
217
|
+
* host's own address. Absent while it is not. */
|
|
218
|
+
shared?: string;
|
|
211
219
|
}
|
|
212
220
|
/** A turn's artefact, in the host's own words. */
|
|
213
221
|
export interface Artefact {
|
|
@@ -258,6 +266,23 @@ export declare function segment(blocks: Block[], describeTool?: DescribeTool): S
|
|
|
258
266
|
* it organises what it knows is not — no collection count, no shelf, no corpus.
|
|
259
267
|
*/
|
|
260
268
|
export declare function summariseActivity(group: ActivityGroup): string;
|
|
269
|
+
/**
|
|
270
|
+
* What a settled answer took, as one quiet line: `Quick · 56 s · about
|
|
271
|
+
* A$0.34`. Depth first (absent on a turn the host never asked one for, or
|
|
272
|
+
* one read back before the feature shipped), then the duration, then what it
|
|
273
|
+
* cost — only when the host can price it (`formatCost`) and the run actually
|
|
274
|
+
* spent something, since a zero-cost run (a local model) has nothing worth
|
|
275
|
+
* saying about money.
|
|
276
|
+
*
|
|
277
|
+
* The host prices `outcome.costUsd`, never this package: the currency and
|
|
278
|
+
* the rate are the app's own setting, not something a shared component
|
|
279
|
+
* should be converting.
|
|
280
|
+
*
|
|
281
|
+
* A turn asked with no depth at all renders exactly what this line always
|
|
282
|
+
* has (`56.3s`, one decimal, no gap) — a host that never wires depth sees no
|
|
283
|
+
* change here either.
|
|
284
|
+
*/
|
|
285
|
+
export declare function outcomeLine(outcome: Outcome, depth: Depth | undefined, words: Pick<LibrarianCopy, 'depthQuick' | 'depthThorough'>, formatCost?: (usd: number) => string): string;
|
|
261
286
|
/**
|
|
262
287
|
* What a turn's calls read that a reader can open, numbered in the order it
|
|
263
288
|
* was first read: the host's `ToolWords.source`, as the sources the answer
|
|
@@ -438,6 +438,31 @@ export function summariseActivity(group) {
|
|
|
438
438
|
const parts = group.tallies.map((t) => `${t.count} ${t.count === 1 ? t.one : t.many}`);
|
|
439
439
|
return parts.length ? parts.join(' · ') : 'Looked into it';
|
|
440
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* What a settled answer took, as one quiet line: `Quick · 56 s · about
|
|
443
|
+
* A$0.34`. Depth first (absent on a turn the host never asked one for, or
|
|
444
|
+
* one read back before the feature shipped), then the duration, then what it
|
|
445
|
+
* cost — only when the host can price it (`formatCost`) and the run actually
|
|
446
|
+
* spent something, since a zero-cost run (a local model) has nothing worth
|
|
447
|
+
* saying about money.
|
|
448
|
+
*
|
|
449
|
+
* The host prices `outcome.costUsd`, never this package: the currency and
|
|
450
|
+
* the rate are the app's own setting, not something a shared component
|
|
451
|
+
* should be converting.
|
|
452
|
+
*
|
|
453
|
+
* A turn asked with no depth at all renders exactly what this line always
|
|
454
|
+
* has (`56.3s`, one decimal, no gap) — a host that never wires depth sees no
|
|
455
|
+
* change here either.
|
|
456
|
+
*/
|
|
457
|
+
export function outcomeLine(outcome, depth, words, formatCost) {
|
|
458
|
+
const seconds = (outcome.durationMs ?? 0) / 1000;
|
|
459
|
+
if (!depth)
|
|
460
|
+
return `${seconds.toFixed(1)}s`;
|
|
461
|
+
const parts = [depth === 'quick' ? words.depthQuick : words.depthThorough, `${Math.round(seconds)} s`];
|
|
462
|
+
if (formatCost && outcome.costUsd)
|
|
463
|
+
parts.push(formatCost(outcome.costUsd));
|
|
464
|
+
return parts.join(' · ');
|
|
465
|
+
}
|
|
441
466
|
/**
|
|
442
467
|
* What a turn's calls read that a reader can open, numbered in the order it
|
|
443
468
|
* was first read: the host's `ToolWords.source`, as the sources the answer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/librarian",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.27",
|
|
4
4
|
"description": "An agent's conversation surface as a consumable Svelte 5 package: the stream client, transcript state and self-styling chat components (transcript, composer, markdown) every household app renders instead of rebuilding, speaking as whichever persona the app names.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|