@poodle64/librarian 2026.9.6 → 2026.9.7
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 +128 -43
- package/dist/attachments.d.ts +30 -0
- package/dist/attachments.js +80 -0
- package/dist/citations.d.ts +63 -0
- package/dist/citations.js +89 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +38 -12
- package/dist/components/activity-group/activity-group.svelte +36 -44
- package/dist/components/activity-group/activity-group.svelte.d.ts +1 -1
- package/dist/components/agent-transcript/agent-transcript.svelte +166 -26
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
- package/dist/components/composer/composer.svelte +206 -53
- package/dist/components/composer/composer.svelte.d.ts +5 -1
- package/dist/components/conversation/conversation.svelte +179 -0
- package/dist/components/conversation/conversation.svelte.d.ts +26 -0
- package/dist/components/conversation/index.d.ts +2 -0
- package/dist/components/conversation/index.js +2 -0
- package/dist/components/document-pane/document-pane.svelte +177 -0
- package/dist/components/document-pane/document-pane.svelte.d.ts +9 -0
- package/dist/components/document-pane/index.d.ts +2 -0
- package/dist/components/document-pane/index.js +2 -0
- package/dist/components/markdown/index.d.ts +1 -1
- package/dist/components/markdown/index.js +1 -1
- package/dist/components/markdown/markdown.d.ts +13 -0
- package/dist/components/markdown/markdown.js +49 -1
- package/dist/components/markdown/markdown.svelte +85 -3
- package/dist/components/markdown/markdown.svelte.d.ts +6 -0
- package/dist/follow-scroll.svelte.d.ts +34 -0
- package/dist/follow-scroll.svelte.js +46 -0
- package/dist/transcript.svelte.d.ts +27 -0
- package/dist/transcript.svelte.js +27 -0
- package/package.json +16 -2
package/README.md
CHANGED
|
@@ -1,34 +1,35 @@
|
|
|
1
1
|
# @poodle64/librarian
|
|
2
2
|
|
|
3
3
|
Milton's conversation surface, as a Svelte 5 package: the stream client, the
|
|
4
|
-
transcript state, and the chat components
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
transcript state, and the chat components an app renders instead of
|
|
5
|
+
rebuilding: the transcript with its own follow-scroll, the composer with
|
|
6
|
+
attachments, citation chips and the source pane they open.
|
|
7
7
|
|
|
8
|
-
Owned by the library
|
|
8
|
+
Owned by the library. This is Milton's surface; design-system is its press.
|
|
9
9
|
Change it here, consume it there.
|
|
10
10
|
|
|
11
11
|
## What is here
|
|
12
12
|
|
|
13
13
|
```text
|
|
14
14
|
src/lib/
|
|
15
|
-
client.ts
|
|
15
|
+
client.ts ask(): streams Claude Code's OWN events, unaltered
|
|
16
16
|
transcript.svelte.ts Transcript state, the fold/segment/describe helpers
|
|
17
|
-
|
|
17
|
+
citations.ts the Citation shape, `[n]` markers, "## Sources"
|
|
18
|
+
attachments.ts what a reader may attach, and the limits
|
|
19
|
+
follow-scroll.svelte.ts follow the stream until the reader disagrees
|
|
20
|
+
history.svelte.ts the browser-held conversation list, per caller
|
|
18
21
|
components/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
conversation/ the whole reading surface: scroll, pill, pane, composer slot
|
|
23
|
+
agent-transcript/ one question and everything Milton did answering it
|
|
24
|
+
document-pane/ the cited document, open at the cited passage
|
|
25
|
+
composer/ the input box: attachments, scope chips, send/stop
|
|
26
|
+
markdown/ sanitised, streaming-safe markdown + highlighting
|
|
27
|
+
activity-group/ a whole investigation, as one quiet line
|
|
28
|
+
tool-row/ one tool call
|
|
29
|
+
thinking-row/ one thinking block
|
|
30
|
+
working/ the pre-first-token "something is happening" indicator
|
|
26
31
|
```
|
|
27
32
|
|
|
28
|
-
Deliberately excluded: the library console's own `CorpusTree`, `DocumentPane`
|
|
29
|
-
and collection picker. Those are furniture for browsing a corpus, not part of
|
|
30
|
-
talking to Milton, and stay in the library's own frontend.
|
|
31
|
-
|
|
32
33
|
## Installation
|
|
33
34
|
|
|
34
35
|
```bash
|
|
@@ -52,37 +53,53 @@ lint hit, just an unstyled transcript.
|
|
|
52
53
|
|
|
53
54
|
## Consuming the package
|
|
54
55
|
|
|
55
|
-
Every export is its own subpath, matching `@poodle64/ui`'s convention
|
|
56
|
+
Every export is its own subpath, matching `@poodle64/ui`'s convention.
|
|
57
|
+
`Conversation` owns the scroll container and the source pane, so the host
|
|
58
|
+
gives it a height and a composer and nothing else:
|
|
56
59
|
|
|
57
60
|
```svelte
|
|
58
61
|
<script lang="ts">
|
|
59
62
|
import { ask } from '@poodle64/librarian/client';
|
|
60
|
-
import { Transcript } from '@poodle64/librarian/transcript';
|
|
61
|
-
import
|
|
63
|
+
import { Transcript, type Turn } from '@poodle64/librarian/transcript';
|
|
64
|
+
import Conversation from '@poodle64/librarian/conversation';
|
|
62
65
|
import Composer from '@poodle64/librarian/composer';
|
|
63
66
|
|
|
64
67
|
let question = $state('');
|
|
68
|
+
let files = $state<File[]>([]);
|
|
65
69
|
let running = $state(false);
|
|
70
|
+
let turns = $state<Turn[]>([]);
|
|
71
|
+
let asked = $state('');
|
|
66
72
|
const transcript = new Transcript();
|
|
67
73
|
let controller: AbortController | null = null;
|
|
68
74
|
|
|
69
75
|
async function submit() {
|
|
70
|
-
|
|
76
|
+
asked = question.trim();
|
|
71
77
|
if (!asked || running) return;
|
|
78
|
+
await run(asked, files);
|
|
79
|
+
}
|
|
72
80
|
|
|
81
|
+
async function run(text: string, attached: File[]) {
|
|
73
82
|
question = '';
|
|
83
|
+
files = [];
|
|
74
84
|
running = true;
|
|
75
85
|
transcript.reset();
|
|
86
|
+
turns = [...turns, { id: crypto.randomUUID(), question: text, blocks: [], outcome: null }];
|
|
76
87
|
controller = new AbortController();
|
|
77
88
|
|
|
78
89
|
try {
|
|
79
90
|
for await (const event of ask({
|
|
80
|
-
question:
|
|
91
|
+
question: text,
|
|
92
|
+
files: attached,
|
|
81
93
|
endpoint: '/api/caller/ask',
|
|
82
94
|
signal: controller.signal
|
|
83
95
|
})) {
|
|
84
96
|
transcript.apply(event);
|
|
85
|
-
|
|
97
|
+
const live = turns.at(-1);
|
|
98
|
+
if (live) {
|
|
99
|
+
live.blocks = transcript.blocks;
|
|
100
|
+
live.outcome = transcript.outcome;
|
|
101
|
+
live.citations = transcript.citations;
|
|
102
|
+
}
|
|
86
103
|
}
|
|
87
104
|
} finally {
|
|
88
105
|
running = false;
|
|
@@ -90,31 +107,86 @@ Every export is its own subpath, matching `@poodle64/ui`'s convention:
|
|
|
90
107
|
}
|
|
91
108
|
}
|
|
92
109
|
|
|
93
|
-
function
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
async function loadDocument(id: string) {
|
|
111
|
+
const response = await fetch(`/api/sources/documents/${id}/content?page=all`);
|
|
112
|
+
const doc = await response.json();
|
|
113
|
+
return { title: doc.document_title, sections: sectionsFrom(doc) };
|
|
96
114
|
}
|
|
97
115
|
</script>
|
|
98
116
|
|
|
99
|
-
<
|
|
117
|
+
<div class="flex h-dvh flex-col">
|
|
118
|
+
<Conversation
|
|
119
|
+
{turns}
|
|
120
|
+
{running}
|
|
121
|
+
version={transcript.version}
|
|
122
|
+
welcome="Ask Milton about pay, allowances, leave and conditions of service."
|
|
123
|
+
examples={['How much recreation leave do I get?']}
|
|
124
|
+
onexample={(q) => (question = q)}
|
|
125
|
+
onregenerate={() => run(asked, [])}
|
|
126
|
+
{loadDocument}
|
|
127
|
+
>
|
|
128
|
+
{#snippet composer()}
|
|
129
|
+
<Composer
|
|
130
|
+
bind:value={question}
|
|
131
|
+
bind:files
|
|
132
|
+
{running}
|
|
133
|
+
scope="library"
|
|
134
|
+
onscope={() => {}}
|
|
135
|
+
onsubmit={submit}
|
|
136
|
+
onstop={() => controller?.abort()}
|
|
137
|
+
/>
|
|
138
|
+
{/snippet}
|
|
139
|
+
</Conversation>
|
|
140
|
+
</div>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`version` is what keeps the scroll following: streaming grows an EXISTING
|
|
144
|
+
block's text in place, so a count of turns never changes and an effect keyed
|
|
145
|
+
on it fires once and never again.
|
|
146
|
+
|
|
147
|
+
### What the host must wire
|
|
148
|
+
|
|
149
|
+
| Concern | How |
|
|
150
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
151
|
+
| The ask route | `ask({ endpoint })`: a room's `/api/rooms/{id}/ask`, a caller's `/api/caller/ask` |
|
|
152
|
+
| 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
|
+
| 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
|
+
| Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself |
|
|
155
|
+
| The empty state | `welcome` and up to three `examples` |
|
|
156
|
+
|
|
157
|
+
Nothing here fetches on its own behalf. The library's document read is
|
|
158
|
+
authenticated, and a package that called it directly would be reaching past
|
|
159
|
+
the app's proxy with a session it has no business holding.
|
|
160
|
+
|
|
161
|
+
### Citations
|
|
100
162
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
163
|
+
The library emits one SSE frame after the final assistant text:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"type": "citations",
|
|
168
|
+
"items": [
|
|
169
|
+
{ "n": 1, "document_id": "…", "title": "…", "section": "…", "anchor": "…", "snippet": "…" }
|
|
170
|
+
]
|
|
171
|
+
}
|
|
109
172
|
```
|
|
110
173
|
|
|
111
|
-
`
|
|
112
|
-
|
|
113
|
-
|
|
174
|
+
`Transcript.apply()` puts it on `transcript.citations`; inline `[n]` markers
|
|
175
|
+
in the prose become chips, and the chip opens `DocumentPane` at the cited
|
|
176
|
+
section. Until that frame ships everywhere, the same chips are DERIVED from
|
|
177
|
+
a trailing "## Sources" block in the answer: those render and read, and are
|
|
178
|
+
inert, because a title and a section are not an id.
|
|
179
|
+
|
|
180
|
+
### The system preamble
|
|
181
|
+
|
|
182
|
+
A host prepends its own instruction to every question (cadmus sends
|
|
183
|
+
`{room.preamble}\n\n{question}`). `readerQuestion()` strips leading
|
|
184
|
+
paragraphs addressed to the model before the question renders, so a
|
|
185
|
+
colleague never sees it. Pass the question as it went on the wire; the
|
|
186
|
+
transcript shows what they asked.
|
|
114
187
|
|
|
115
188
|
`createHistory(namespace)` from `@poodle64/librarian/history` gives each app,
|
|
116
|
-
or each room inside an app, its own `localStorage` key
|
|
117
|
-
never collide on one conversation list:
|
|
189
|
+
or each room inside an app, its own `localStorage` key:
|
|
118
190
|
|
|
119
191
|
```ts
|
|
120
192
|
import { createHistory, titleFrom } from '@poodle64/librarian/history';
|
|
@@ -126,9 +198,22 @@ history.load();
|
|
|
126
198
|
## Verifying a change
|
|
127
199
|
|
|
128
200
|
```bash
|
|
129
|
-
pnpm run build
|
|
130
|
-
pnpm run check
|
|
131
|
-
pnpm run test
|
|
201
|
+
pnpm run build # svelte-package + publint
|
|
202
|
+
pnpm run check # svelte-check
|
|
203
|
+
pnpm run test # build + vitest
|
|
204
|
+
pnpm run screenshots # the state grid, real engine (see below)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`docs/screenshots/` is eight states x three widths x both themes, taken by
|
|
208
|
+
`scripts/screenshots.mjs` against the console's `/librarian` lab route
|
|
209
|
+
running from its own static build. The same script asserts what a screenshot
|
|
210
|
+
cannot: that nothing scrolls sideways at any width, and that the source pane
|
|
211
|
+
opens and closes from the keyboard with focus returning to the chip. It
|
|
212
|
+
exits non-zero on either.
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
pnpm --filter @poodle64/console run build
|
|
216
|
+
pnpm --filter @poodle64/librarian run screenshots
|
|
132
217
|
```
|
|
133
218
|
|
|
134
219
|
## Releasing
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a reader may hand Milton with a question.
|
|
3
|
+
*
|
|
4
|
+
* Session-scoped by design: these files are read for THIS conversation and
|
|
5
|
+
* never filed into a shelf, so the limits here are about what a browser can
|
|
6
|
+
* post and a model can read in one turn, not about a corpus.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAX_FILES = 10;
|
|
9
|
+
export declare const MAX_BYTES: number;
|
|
10
|
+
export declare const ACCEPTED_TYPES: readonly ["image/png", "image/jpeg", "image/webp", "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "text/plain", "text/markdown"];
|
|
11
|
+
export declare const ACCEPT_ATTRIBUTE: string;
|
|
12
|
+
export interface RejectedFile {
|
|
13
|
+
name: string;
|
|
14
|
+
reason: 'type' | 'size' | 'count';
|
|
15
|
+
}
|
|
16
|
+
export interface FileCheck {
|
|
17
|
+
accepted: File[];
|
|
18
|
+
rejected: RejectedFile[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Merge a drop or a picker's selection into the files already attached.
|
|
22
|
+
*
|
|
23
|
+
* Returns the WHOLE new list rather than only the additions, because the count
|
|
24
|
+
* limit is a property of the list and a caller that appended the return value
|
|
25
|
+
* would silently exceed it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function acceptFiles(existing: File[], incoming: File[] | FileList): FileCheck;
|
|
28
|
+
/** One line a reader can act on, or empty when everything was taken. */
|
|
29
|
+
export declare function rejectionMessage(rejected: RejectedFile[]): string;
|
|
30
|
+
export declare function formatSize(bytes: number): string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a reader may hand Milton with a question.
|
|
3
|
+
*
|
|
4
|
+
* Session-scoped by design: these files are read for THIS conversation and
|
|
5
|
+
* never filed into a shelf, so the limits here are about what a browser can
|
|
6
|
+
* post and a model can read in one turn, not about a corpus.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_FILES = 10;
|
|
9
|
+
export const MAX_BYTES = 20 * 1024 * 1024;
|
|
10
|
+
const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
11
|
+
export const ACCEPTED_TYPES = [
|
|
12
|
+
'image/png',
|
|
13
|
+
'image/jpeg',
|
|
14
|
+
'image/webp',
|
|
15
|
+
'application/pdf',
|
|
16
|
+
DOCX,
|
|
17
|
+
'text/plain',
|
|
18
|
+
'text/markdown'
|
|
19
|
+
];
|
|
20
|
+
/** Browsers disagree about a `.md` file's type — Safari says `text/markdown`,
|
|
21
|
+
* Chrome on some platforms says `''` — so the extension is checked too, and
|
|
22
|
+
* the `accept` attribute lists both forms for the same reason. */
|
|
23
|
+
const ACCEPTED_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.pdf', '.docx', '.txt', '.md'];
|
|
24
|
+
export const ACCEPT_ATTRIBUTE = [...ACCEPTED_TYPES, ...ACCEPTED_EXTENSIONS].join(',');
|
|
25
|
+
function typeAllowed(file) {
|
|
26
|
+
if (ACCEPTED_TYPES.includes(file.type))
|
|
27
|
+
return true;
|
|
28
|
+
const name = file.name.toLowerCase();
|
|
29
|
+
return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Merge a drop or a picker's selection into the files already attached.
|
|
33
|
+
*
|
|
34
|
+
* Returns the WHOLE new list rather than only the additions, because the count
|
|
35
|
+
* limit is a property of the list and a caller that appended the return value
|
|
36
|
+
* would silently exceed it.
|
|
37
|
+
*/
|
|
38
|
+
export function acceptFiles(existing, incoming) {
|
|
39
|
+
const accepted = [...existing];
|
|
40
|
+
const rejected = [];
|
|
41
|
+
for (const file of Array.from(incoming)) {
|
|
42
|
+
if (!typeAllowed(file)) {
|
|
43
|
+
rejected.push({ name: file.name, reason: 'type' });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (file.size > MAX_BYTES) {
|
|
47
|
+
rejected.push({ name: file.name, reason: 'size' });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (accepted.length >= MAX_FILES) {
|
|
51
|
+
rejected.push({ name: file.name, reason: 'count' });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (accepted.some((f) => f.name === file.name && f.size === file.size))
|
|
55
|
+
continue;
|
|
56
|
+
accepted.push(file);
|
|
57
|
+
}
|
|
58
|
+
return { accepted, rejected };
|
|
59
|
+
}
|
|
60
|
+
/** One line a reader can act on, or empty when everything was taken. */
|
|
61
|
+
export function rejectionMessage(rejected) {
|
|
62
|
+
if (rejected.length === 0)
|
|
63
|
+
return '';
|
|
64
|
+
const reasons = {
|
|
65
|
+
type: "isn't a file type Milton can read",
|
|
66
|
+
size: 'is over 20 MB',
|
|
67
|
+
count: `won't fit — ${MAX_FILES} files is the limit`
|
|
68
|
+
};
|
|
69
|
+
const [first] = rejected;
|
|
70
|
+
const rest = rejected.length - 1;
|
|
71
|
+
return `${first.name} ${reasons[first.reason]}${rest ? `, and ${rest} more` : ''}.`;
|
|
72
|
+
}
|
|
73
|
+
export function formatSize(bytes) {
|
|
74
|
+
if (bytes < 1024)
|
|
75
|
+
return `${bytes} B`;
|
|
76
|
+
const kb = bytes / 1024;
|
|
77
|
+
if (kb < 1024)
|
|
78
|
+
return `${Math.round(kb)} KB`;
|
|
79
|
+
return `${(kb / 1024).toFixed(kb / 1024 < 10 ? 1 : 0)} MB`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an answer came from.
|
|
3
|
+
*
|
|
4
|
+
* Two sources, one shape. The library emits a `citations` event after the
|
|
5
|
+
* final assistant text, and that is the real one — it carries a document id,
|
|
6
|
+
* so a chip can open the document. Until it ships everywhere, the same chips
|
|
7
|
+
* are DERIVED from the "## Sources" block Milton already writes, which names
|
|
8
|
+
* a title and a section but no id: those chips render and read, and cannot
|
|
9
|
+
* open a pane. A derived citation is marked `derived` so the UI can tell.
|
|
10
|
+
*/
|
|
11
|
+
export interface Citation {
|
|
12
|
+
n: number;
|
|
13
|
+
document_id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
section?: string;
|
|
16
|
+
anchor?: string;
|
|
17
|
+
snippet?: string;
|
|
18
|
+
/** True when this came from the prose block rather than the wire event. */
|
|
19
|
+
derived?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Inline `[n]` markers, in order of first appearance.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately not a global "[digits]" match: `[1](…)` is a markdown link and
|
|
24
|
+
* `[1]: …` a link definition, and both appear in real answers. */
|
|
25
|
+
export declare function citationMarkers(markdown: string): number[];
|
|
26
|
+
export interface SplitAnswer {
|
|
27
|
+
/** The prose, with any trailing Sources block removed. */
|
|
28
|
+
body: string;
|
|
29
|
+
/** Citations read out of that block; empty when there was none. */
|
|
30
|
+
citations: Citation[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Split a trailing "## Sources" block off an answer.
|
|
34
|
+
*
|
|
35
|
+
* The block is rendered by the Sources list instead, so leaving it in the
|
|
36
|
+
* prose would print every source twice. A block that parses to nothing is left
|
|
37
|
+
* where it was rather than silently deleted.
|
|
38
|
+
*/
|
|
39
|
+
export declare function splitSources(markdown: string): SplitAnswer;
|
|
40
|
+
/**
|
|
41
|
+
* The citations a turn should render.
|
|
42
|
+
*
|
|
43
|
+
* The wire event wins outright whenever it arrived — it is the same list with
|
|
44
|
+
* ids attached — so a turn never shows both.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveCitations(fromEvent: Citation[], fromProse: Citation[]): Citation[];
|
|
47
|
+
/** One addressable slice of a document — the granularity a citation names. */
|
|
48
|
+
export interface DocumentSection {
|
|
49
|
+
anchor: string;
|
|
50
|
+
heading: string;
|
|
51
|
+
text: string;
|
|
52
|
+
}
|
|
53
|
+
export interface LoadedDocument {
|
|
54
|
+
title: string;
|
|
55
|
+
sections: DocumentSection[];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* How the pane reads a document. The host supplies it, because the host is the
|
|
59
|
+
* one holding the caller's session: the library's document read is
|
|
60
|
+
* authenticated, and a package that fetched it directly would be reaching past
|
|
61
|
+
* the app's own proxy with credentials it has no business holding.
|
|
62
|
+
*/
|
|
63
|
+
export type LoadDocument = (documentId: string) => Promise<LoadedDocument>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an answer came from.
|
|
3
|
+
*
|
|
4
|
+
* Two sources, one shape. The library emits a `citations` event after the
|
|
5
|
+
* final assistant text, and that is the real one — it carries a document id,
|
|
6
|
+
* so a chip can open the document. Until it ships everywhere, the same chips
|
|
7
|
+
* are DERIVED from the "## Sources" block Milton already writes, which names
|
|
8
|
+
* a title and a section but no id: those chips render and read, and cannot
|
|
9
|
+
* open a pane. A derived citation is marked `derived` so the UI can tell.
|
|
10
|
+
*/
|
|
11
|
+
/** Inline `[n]` markers, in order of first appearance.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately not a global "[digits]" match: `[1](…)` is a markdown link and
|
|
14
|
+
* `[1]: …` a link definition, and both appear in real answers. */
|
|
15
|
+
export function citationMarkers(markdown) {
|
|
16
|
+
const seen = [];
|
|
17
|
+
for (const match of markdown.matchAll(/\[(\d{1,3})\](?![(:[])/g)) {
|
|
18
|
+
const n = Number(match[1]);
|
|
19
|
+
if (!seen.includes(n))
|
|
20
|
+
seen.push(n);
|
|
21
|
+
}
|
|
22
|
+
return seen;
|
|
23
|
+
}
|
|
24
|
+
const SOURCES_HEADING = /^[ \t]{0,3}#{1,6}[ \t]*sources[ \t]*:?[ \t]*$/im;
|
|
25
|
+
/**
|
|
26
|
+
* Split a trailing "## Sources" block off an answer.
|
|
27
|
+
*
|
|
28
|
+
* The block is rendered by the Sources list instead, so leaving it in the
|
|
29
|
+
* prose would print every source twice. A block that parses to nothing is left
|
|
30
|
+
* where it was rather than silently deleted.
|
|
31
|
+
*/
|
|
32
|
+
export function splitSources(markdown) {
|
|
33
|
+
const heading = markdown.match(SOURCES_HEADING);
|
|
34
|
+
if (!heading || heading.index === undefined)
|
|
35
|
+
return { body: markdown, citations: [] };
|
|
36
|
+
const block = markdown.slice(heading.index + heading[0].length);
|
|
37
|
+
const citations = parseSourceLines(block);
|
|
38
|
+
if (citations.length === 0)
|
|
39
|
+
return { body: markdown, citations: [] };
|
|
40
|
+
return { body: markdown.slice(0, heading.index).trimEnd(), citations };
|
|
41
|
+
}
|
|
42
|
+
/** One list item per source; anything else in the block is ignored. */
|
|
43
|
+
function parseSourceLines(block) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const raw of block.split('\n')) {
|
|
46
|
+
const line = raw.trim();
|
|
47
|
+
if (!line)
|
|
48
|
+
continue;
|
|
49
|
+
// A second heading ends the block — the Sources list is the tail of the
|
|
50
|
+
// answer, not a section anything follows.
|
|
51
|
+
if (/^#{1,6}\s/.test(line))
|
|
52
|
+
break;
|
|
53
|
+
const item = line.match(/^(?:[-*+]|\[?(\d{1,3})\]?[.)])\s+(.*)$/);
|
|
54
|
+
if (!item)
|
|
55
|
+
continue;
|
|
56
|
+
const numbered = item[1] ? Number(item[1]) : undefined;
|
|
57
|
+
const parsed = parseSource(item[2]);
|
|
58
|
+
if (!parsed.title)
|
|
59
|
+
continue;
|
|
60
|
+
out.push({
|
|
61
|
+
n: numbered ?? out.length + 1,
|
|
62
|
+
document_id: '',
|
|
63
|
+
derived: true,
|
|
64
|
+
...parsed
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** `**Title** — Section`, `Title – Section`, `Title: Section`, or just a title. */
|
|
70
|
+
function parseSource(text) {
|
|
71
|
+
const plain = text
|
|
72
|
+
.replace(/\[(\d{1,3})\]\s*/, '')
|
|
73
|
+
.replace(/\*\*/g, '')
|
|
74
|
+
.replace(/[`*_]/g, '')
|
|
75
|
+
.trim();
|
|
76
|
+
const split = plain.match(/^(.+?)\s*(?:—|–|\s-\s|:)\s*(.+)$/);
|
|
77
|
+
if (!split)
|
|
78
|
+
return { title: plain };
|
|
79
|
+
return { title: split[1].trim(), section: split[2].trim() };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The citations a turn should render.
|
|
83
|
+
*
|
|
84
|
+
* The wire event wins outright whenever it arrived — it is the same list with
|
|
85
|
+
* ids attached — so a turn never shows both.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveCitations(fromEvent, fromProse) {
|
|
88
|
+
return fromEvent.length > 0 ? fromEvent : fromProse;
|
|
89
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* backend sent, so the Console renders the real thing and a new Claude Code
|
|
8
8
|
* event type needs no change on either side.
|
|
9
9
|
*/
|
|
10
|
+
import type { Citation } from './citations';
|
|
10
11
|
/** One Claude Code stream event. Typed only where we branch on it. */
|
|
11
12
|
export interface AgentEvent {
|
|
12
13
|
type: string;
|
|
@@ -39,6 +40,8 @@ export interface AgentEvent {
|
|
|
39
40
|
detail?: string;
|
|
40
41
|
tools?: string[];
|
|
41
42
|
model?: string;
|
|
43
|
+
/** `citations` frames only: the library's sources for the answer just sent. */
|
|
44
|
+
items?: Citation[];
|
|
42
45
|
[key: string]: unknown;
|
|
43
46
|
}
|
|
44
47
|
export interface AskOptions {
|
|
@@ -47,6 +50,8 @@ export interface AskOptions {
|
|
|
47
50
|
subtree?: string;
|
|
48
51
|
/** Collections this question may see. Empty means all of them. */
|
|
49
52
|
collections?: string[];
|
|
53
|
+
/** Files Milton reads for this question. Switches the request to multipart. */
|
|
54
|
+
files?: File[];
|
|
50
55
|
signal?: AbortSignal;
|
|
51
56
|
/** Where the ask lands. Each app mounts its own ask route. */
|
|
52
57
|
endpoint?: string;
|
package/dist/client.js
CHANGED
|
@@ -7,21 +7,47 @@
|
|
|
7
7
|
* backend sent, so the Console renders the real thing and a new Claude Code
|
|
8
8
|
* event type needs no change on either side.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* The request body for one ask.
|
|
12
|
+
*
|
|
13
|
+
* JSON when there are no files, multipart when there are — one endpoint, two
|
|
14
|
+
* encodings, because a `File` cannot cross a JSON body and base64 would double
|
|
15
|
+
* a 20 MB attachment on the wire for nothing. The multipart field names are
|
|
16
|
+
* the form convention (`collections[]`, `files[]`) rather than the JSON keys.
|
|
17
|
+
*
|
|
18
|
+
* `Content-Type` is deliberately absent for multipart: setting it by hand
|
|
19
|
+
* omits the boundary the browser generates, and the server then reads zero
|
|
20
|
+
* fields from a body that is on the wire perfectly.
|
|
21
|
+
*/
|
|
22
|
+
function requestInit(options, signal) {
|
|
23
|
+
if (!options.files?.length) {
|
|
24
|
+
return {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
credentials: 'include',
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
question: options.question,
|
|
30
|
+
resume: options.resume ?? null,
|
|
31
|
+
subtree: options.subtree ?? '',
|
|
32
|
+
collections: options.collections ?? []
|
|
33
|
+
}),
|
|
34
|
+
signal
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const form = new FormData();
|
|
38
|
+
form.append('question', options.question);
|
|
39
|
+
if (options.resume)
|
|
40
|
+
form.append('resume', options.resume);
|
|
41
|
+
for (const collection of options.collections ?? [])
|
|
42
|
+
form.append('collections[]', collection);
|
|
43
|
+
for (const file of options.files)
|
|
44
|
+
form.append('files[]', file, file.name);
|
|
45
|
+
return { method: 'POST', credentials: 'include', body: form, signal };
|
|
46
|
+
}
|
|
10
47
|
/** Async-iterate the events of one question. */
|
|
11
48
|
export async function* ask(options) {
|
|
12
49
|
const doFetch = options.fetch ?? fetch;
|
|
13
|
-
const response = await doFetch(options.endpoint ?? '/api/agent/ask',
|
|
14
|
-
method: 'POST',
|
|
15
|
-
headers: { 'Content-Type': 'application/json' },
|
|
16
|
-
credentials: 'include',
|
|
17
|
-
body: JSON.stringify({
|
|
18
|
-
question: options.question,
|
|
19
|
-
resume: options.resume ?? null,
|
|
20
|
-
subtree: options.subtree ?? '',
|
|
21
|
-
collections: options.collections ?? []
|
|
22
|
-
}),
|
|
23
|
-
signal: options.signal
|
|
24
|
-
});
|
|
50
|
+
const response = await doFetch(options.endpoint ?? '/api/agent/ask', requestInit(options, options.signal));
|
|
25
51
|
if (!response.ok || !response.body) {
|
|
26
52
|
yield { type: 'library_error', error: "Milton can't be reached right now." };
|
|
27
53
|
return;
|