@poodle64/librarian 2026.9.1
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 +115 -0
- package/dist/client.d.ts +57 -0
- package/dist/client.js +60 -0
- package/dist/components/activity-group/activity-group.svelte +63 -0
- package/dist/components/activity-group/activity-group.svelte.d.ts +9 -0
- package/dist/components/activity-group/index.d.ts +2 -0
- package/dist/components/activity-group/index.js +2 -0
- package/dist/components/agent-transcript/agent-transcript.svelte +71 -0
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +12 -0
- package/dist/components/agent-transcript/index.d.ts +2 -0
- package/dist/components/agent-transcript/index.js +2 -0
- package/dist/components/composer/composer.svelte +115 -0
- package/dist/components/composer/composer.svelte.d.ts +15 -0
- package/dist/components/composer/index.d.ts +3 -0
- package/dist/components/composer/index.js +2 -0
- package/dist/components/markdown/index.d.ts +3 -0
- package/dist/components/markdown/index.js +3 -0
- package/dist/components/markdown/markdown.d.ts +37 -0
- package/dist/components/markdown/markdown.js +132 -0
- package/dist/components/markdown/markdown.svelte +221 -0
- package/dist/components/markdown/markdown.svelte.d.ts +9 -0
- package/dist/components/thinking-row/index.d.ts +2 -0
- package/dist/components/thinking-row/index.js +2 -0
- package/dist/components/thinking-row/thinking-row.svelte +35 -0
- package/dist/components/thinking-row/thinking-row.svelte.d.ts +8 -0
- package/dist/components/tool-row/index.d.ts +2 -0
- package/dist/components/tool-row/index.js +2 -0
- package/dist/components/tool-row/tool-row.svelte +77 -0
- package/dist/components/tool-row/tool-row.svelte.d.ts +11 -0
- package/dist/components/working/index.d.ts +2 -0
- package/dist/components/working/index.js +2 -0
- package/dist/components/working/working.svelte +50 -0
- package/dist/components/working/working.svelte.d.ts +7 -0
- package/dist/history.svelte.d.ts +37 -0
- package/dist/history.svelte.js +59 -0
- package/dist/transcript.svelte.d.ts +105 -0
- package/dist/transcript.svelte.js +316 -0
- package/package.json +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# @poodle64/librarian
|
|
2
|
+
|
|
3
|
+
Milton's conversation surface, as a Svelte 5 package: the stream client, the
|
|
4
|
+
transcript state, and the chat components (transcript, composer, markdown,
|
|
5
|
+
tool/thinking rows, the working indicator). An app renders the librarian
|
|
6
|
+
instead of rebuilding it.
|
|
7
|
+
|
|
8
|
+
Owned by the library — this is Milton's surface; design-system is its press.
|
|
9
|
+
Change it here, consume it there.
|
|
10
|
+
|
|
11
|
+
## What is here
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
src/lib/
|
|
15
|
+
client.ts ask(): streams Claude Code's OWN events, unaltered
|
|
16
|
+
transcript.svelte.ts Transcript state, the fold/segment/describe helpers
|
|
17
|
+
history.svelte.ts the browser-held conversation list, namespaced per caller
|
|
18
|
+
components/
|
|
19
|
+
agent-transcript/ one question and everything the agent did answering it
|
|
20
|
+
composer/ the input box: value, scope chips, send/stop
|
|
21
|
+
markdown/ sanitised, streaming-safe markdown + syntax highlighting
|
|
22
|
+
activity-group/ a run of tool calls, collapsed to one line
|
|
23
|
+
tool-row/ one tool call
|
|
24
|
+
thinking-row/ one thinking block
|
|
25
|
+
working/ the pre-first-token "something is happening" indicator
|
|
26
|
+
```
|
|
27
|
+
|
|
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
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pnpm add @poodle64/librarian @poodle64/ui @lucide/svelte
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`svelte`, `@poodle64/ui`, `@lucide/svelte`, `marked`, `isomorphic-dompurify`
|
|
39
|
+
and `shiki` are peer dependencies: declare them yourself so Renovate tracks
|
|
40
|
+
their versions and `pnpm ls` shows them.
|
|
41
|
+
|
|
42
|
+
## Consuming the package
|
|
43
|
+
|
|
44
|
+
Every export is its own subpath, matching `@poodle64/ui`'s convention:
|
|
45
|
+
|
|
46
|
+
```svelte
|
|
47
|
+
<script lang="ts">
|
|
48
|
+
import { ask } from '@poodle64/librarian/client';
|
|
49
|
+
import { Transcript } from '@poodle64/librarian/transcript';
|
|
50
|
+
import AgentTranscript from '@poodle64/librarian/agent-transcript';
|
|
51
|
+
import Composer from '@poodle64/librarian/composer';
|
|
52
|
+
|
|
53
|
+
let question = $state('');
|
|
54
|
+
let running = $state(false);
|
|
55
|
+
const transcript = new Transcript();
|
|
56
|
+
|
|
57
|
+
async function submit() {
|
|
58
|
+
const asked = question.trim();
|
|
59
|
+
if (!asked || running) return;
|
|
60
|
+
|
|
61
|
+
question = '';
|
|
62
|
+
running = true;
|
|
63
|
+
transcript.reset();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
for await (const event of ask({ question: asked, endpoint: '/api/caller/ask' })) {
|
|
67
|
+
transcript.apply(event);
|
|
68
|
+
if (event.type === 'result' || event.type === 'library_error') running = false;
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
running = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<AgentTranscript {question} blocks={transcript.blocks} outcome={transcript.outcome} {running} />
|
|
77
|
+
|
|
78
|
+
<Composer
|
|
79
|
+
bind:value={question}
|
|
80
|
+
{running}
|
|
81
|
+
scope="library"
|
|
82
|
+
onscope={() => {}}
|
|
83
|
+
onsubmit={submit}
|
|
84
|
+
onstop={() => {}}
|
|
85
|
+
/>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`ask()`'s `endpoint` defaults to `/api/agent/ask`; pass whatever route the
|
|
89
|
+
consuming app mounts (a room's `/api/rooms/{id}/ask`, a caller's
|
|
90
|
+
`/api/caller/ask`) and an optional `fetch` for a caller-authenticated wrapper.
|
|
91
|
+
|
|
92
|
+
`createHistory(namespace)` from `@poodle64/librarian/history` gives each app,
|
|
93
|
+
or each room inside an app, its own `localStorage` key, so two consumers
|
|
94
|
+
never collide on one conversation list:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { createHistory, titleFrom } from '@poodle64/librarian/history';
|
|
98
|
+
|
|
99
|
+
const history = createHistory('cadmus.rooms.defence-personnel');
|
|
100
|
+
history.load();
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Verifying a change
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pnpm run build # svelte-package + publint
|
|
107
|
+
pnpm run check # svelte-check
|
|
108
|
+
pnpm run test # build + vitest
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Releasing
|
|
112
|
+
|
|
113
|
+
1. Change a component; bump `version` in `package.json` (CalVer).
|
|
114
|
+
2. `pnpm build`, which runs `svelte-package` then `publint`.
|
|
115
|
+
3. Commit, tag `librarian-v<version>`, push the tag; CI publishes to public npm.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent stream.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately thin: it frames SSE and hands back Claude Code's OWN events,
|
|
5
|
+
* unaltered. There is no mapping onto a house vocabulary here or anywhere
|
|
6
|
+
* else — the backend forwards what the CLI emits and this forwards what the
|
|
7
|
+
* backend sent, so the Console renders the real thing and a new Claude Code
|
|
8
|
+
* event type needs no change on either side.
|
|
9
|
+
*/
|
|
10
|
+
/** One Claude Code stream event. Typed only where we branch on it. */
|
|
11
|
+
export interface AgentEvent {
|
|
12
|
+
type: string;
|
|
13
|
+
subtype?: string;
|
|
14
|
+
event?: {
|
|
15
|
+
type: string;
|
|
16
|
+
index?: number;
|
|
17
|
+
delta?: {
|
|
18
|
+
type: string;
|
|
19
|
+
text?: string;
|
|
20
|
+
thinking?: string;
|
|
21
|
+
partial_json?: string;
|
|
22
|
+
};
|
|
23
|
+
content_block?: {
|
|
24
|
+
type: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
id?: string;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
message?: {
|
|
30
|
+
content?: Array<Record<string, unknown>>;
|
|
31
|
+
};
|
|
32
|
+
session_id?: string;
|
|
33
|
+
result?: string;
|
|
34
|
+
num_turns?: number;
|
|
35
|
+
total_cost_usd?: number;
|
|
36
|
+
duration_ms?: number;
|
|
37
|
+
is_error?: boolean;
|
|
38
|
+
error?: string;
|
|
39
|
+
detail?: string;
|
|
40
|
+
tools?: string[];
|
|
41
|
+
model?: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
export interface AskOptions {
|
|
45
|
+
question: string;
|
|
46
|
+
resume?: string | null;
|
|
47
|
+
subtree?: string;
|
|
48
|
+
/** Collections this question may see. Empty means all of them. */
|
|
49
|
+
collections?: string[];
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
/** Where the ask lands. Each app mounts its own ask route. */
|
|
52
|
+
endpoint?: string;
|
|
53
|
+
/** Override for the ambient `fetch`, e.g. a caller-authenticated wrapper. */
|
|
54
|
+
fetch?: typeof fetch;
|
|
55
|
+
}
|
|
56
|
+
/** Async-iterate the events of one question. */
|
|
57
|
+
export declare function ask(options: AskOptions): AsyncGenerator<AgentEvent>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent stream.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately thin: it frames SSE and hands back Claude Code's OWN events,
|
|
5
|
+
* unaltered. There is no mapping onto a house vocabulary here or anywhere
|
|
6
|
+
* else — the backend forwards what the CLI emits and this forwards what the
|
|
7
|
+
* backend sent, so the Console renders the real thing and a new Claude Code
|
|
8
|
+
* event type needs no change on either side.
|
|
9
|
+
*/
|
|
10
|
+
/** Async-iterate the events of one question. */
|
|
11
|
+
export async function* ask(options) {
|
|
12
|
+
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
|
+
});
|
|
25
|
+
if (!response.ok || !response.body) {
|
|
26
|
+
yield { type: 'library_error', error: `The agent answered ${response.status}.` };
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
30
|
+
// Frames are separated by a blank line and split across network reads at
|
|
31
|
+
// arbitrary points, so the tail of each read is carried rather than parsed.
|
|
32
|
+
let buffered = '';
|
|
33
|
+
for (;;) {
|
|
34
|
+
const { done, value } = await reader.read();
|
|
35
|
+
if (done)
|
|
36
|
+
break;
|
|
37
|
+
buffered += value;
|
|
38
|
+
let boundary = buffered.indexOf('\n\n');
|
|
39
|
+
while (boundary !== -1) {
|
|
40
|
+
const frame = buffered.slice(0, boundary);
|
|
41
|
+
buffered = buffered.slice(boundary + 2);
|
|
42
|
+
boundary = buffered.indexOf('\n\n');
|
|
43
|
+
// `data:` only — the type lives inside the payload where Claude Code
|
|
44
|
+
// puts it, so there is no second place to look.
|
|
45
|
+
const data = frame
|
|
46
|
+
.split('\n')
|
|
47
|
+
.filter((l) => l.startsWith('data:'))
|
|
48
|
+
.map((l) => l.slice(5).trim())
|
|
49
|
+
.join('');
|
|
50
|
+
if (!data)
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
yield JSON.parse(data);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* a partial frame at end of stream is not an error */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A whole investigation, as one line the reader can open.
|
|
3
|
+
|
|
4
|
+
Fifteen tool rows stood between the question and the first word of the
|
|
5
|
+
answer, so the answer had to be scrolled to. While the agent is working the
|
|
6
|
+
steps are worth watching, so they show live. The moment prose starts
|
|
7
|
+
arriving they fold to a single summary — the same move Claude makes with
|
|
8
|
+
"Researched for 49s".
|
|
9
|
+
-->
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
|
12
|
+
import { summariseActivity, type ActivityGroup } from '../../transcript.svelte';
|
|
13
|
+
import ToolRow from '../tool-row/tool-row.svelte';
|
|
14
|
+
import ThinkingRow from '../thinking-row/thinking-row.svelte';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
group: ActivityGroup;
|
|
18
|
+
/** True while this group is the live one — it stays open. */
|
|
19
|
+
live: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let { group, live }: Props = $props();
|
|
23
|
+
let expanded = $state(false);
|
|
24
|
+
const open = $derived(live || expanded);
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
{#if live}
|
|
28
|
+
<div class="flex flex-col gap-1.5">
|
|
29
|
+
{#each group.steps as step (step.block.index)}
|
|
30
|
+
{#if step.block.kind === 'tool'}
|
|
31
|
+
<ToolRow block={step.block} repeats={step.repeats} running={true} />
|
|
32
|
+
{:else}
|
|
33
|
+
<ThinkingRow
|
|
34
|
+
block={step.block}
|
|
35
|
+
active={step.block.index === group.steps.at(-1)?.block.index}
|
|
36
|
+
/>
|
|
37
|
+
{/if}
|
|
38
|
+
{/each}
|
|
39
|
+
</div>
|
|
40
|
+
{:else}
|
|
41
|
+
<div>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
class="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-sm transition-colors"
|
|
45
|
+
onclick={() => (expanded = !expanded)}
|
|
46
|
+
aria-expanded={expanded}
|
|
47
|
+
>
|
|
48
|
+
<ChevronRightIcon class="size-3.5 transition-transform {expanded ? 'rotate-90' : ''}" />
|
|
49
|
+
<span>{summariseActivity(group)}</span>
|
|
50
|
+
</button>
|
|
51
|
+
{#if open}
|
|
52
|
+
<div class="border-border mt-2 ml-1.75 flex flex-col gap-1.5 border-l pl-3">
|
|
53
|
+
{#each group.steps as step (step.block.index)}
|
|
54
|
+
{#if step.block.kind === 'tool'}
|
|
55
|
+
<ToolRow block={step.block} repeats={step.repeats} running={false} />
|
|
56
|
+
{:else}
|
|
57
|
+
<ThinkingRow block={step.block} active={false} />
|
|
58
|
+
{/if}
|
|
59
|
+
{/each}
|
|
60
|
+
</div>
|
|
61
|
+
{/if}
|
|
62
|
+
</div>
|
|
63
|
+
{/if}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ActivityGroup } from '../../transcript.svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
group: ActivityGroup;
|
|
4
|
+
/** True while this group is the live one — it stays open. */
|
|
5
|
+
live: boolean;
|
|
6
|
+
}
|
|
7
|
+
declare const ActivityGroup: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type ActivityGroup = ReturnType<typeof ActivityGroup>;
|
|
9
|
+
export default ActivityGroup;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
One question and everything the agent did answering it.
|
|
3
|
+
|
|
4
|
+
User turn as a rounded bubble, assistant prose led by a status dot, tool
|
|
5
|
+
calls as rows in the flow, thinking as stacked phase labels that dim once
|
|
6
|
+
superseded.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { segment, type Block, type Outcome } from '../../transcript.svelte';
|
|
10
|
+
import Working from '../working/working.svelte';
|
|
11
|
+
import ActivityGroup from '../activity-group/activity-group.svelte';
|
|
12
|
+
import Markdown from '../markdown/markdown.svelte';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
question: string;
|
|
16
|
+
blocks: Block[];
|
|
17
|
+
outcome: Outcome | null;
|
|
18
|
+
running: boolean;
|
|
19
|
+
/** Forwarded to Markdown; omit if the caller has no collections to chip. */
|
|
20
|
+
collectionNames?: Set<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let { question, blocks, outcome, running, collectionNames = new Set() }: Props = $props();
|
|
24
|
+
|
|
25
|
+
// An activity group stays live — and therefore open — only while it is the
|
|
26
|
+
// last thing in the turn. As soon as prose arrives after it, it folds.
|
|
27
|
+
const segments = $derived(segment(blocks));
|
|
28
|
+
const lastIndex = $derived(segments.at(-1)?.index ?? -1);
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<article class="flex flex-col gap-3">
|
|
32
|
+
<!-- Right-aligned, following Claude and ChatGPT rather than a VS Code-style
|
|
33
|
+
panel: this surface is read by people who arrive with those two as
|
|
34
|
+
their model of what a chat looks like. -->
|
|
35
|
+
<div
|
|
36
|
+
class="bg-surface-3 text-foreground border-border max-w-[85%] self-end rounded-2xl border px-4 py-2.5 text-base"
|
|
37
|
+
>
|
|
38
|
+
{question}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
{#if running && segments.length === 0}
|
|
42
|
+
<Working />
|
|
43
|
+
{/if}
|
|
44
|
+
|
|
45
|
+
{#each segments as seg (seg.index)}
|
|
46
|
+
{#if seg.kind === 'activity'}
|
|
47
|
+
<ActivityGroup group={seg} live={running && seg.index === lastIndex} />
|
|
48
|
+
{:else}
|
|
49
|
+
<div class="flex gap-2.5">
|
|
50
|
+
<span class="bg-muted-foreground/40 mt-2.5 size-1.5 shrink-0 rounded-full"></span>
|
|
51
|
+
<div class="min-w-0 flex-1">
|
|
52
|
+
<Markdown
|
|
53
|
+
content={seg.text}
|
|
54
|
+
streaming={running && seg.index === lastIndex}
|
|
55
|
+
{collectionNames}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
{/if}
|
|
60
|
+
{/each}
|
|
61
|
+
|
|
62
|
+
{#if outcome}
|
|
63
|
+
<p class="text-muted-foreground pl-4 font-mono text-xs tabular-nums">
|
|
64
|
+
{#if outcome.error}
|
|
65
|
+
{outcome.error}
|
|
66
|
+
{:else}
|
|
67
|
+
{outcome.turns} turns · {((outcome.durationMs ?? 0) / 1000).toFixed(1)}s
|
|
68
|
+
{/if}
|
|
69
|
+
</p>
|
|
70
|
+
{/if}
|
|
71
|
+
</article>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Block, type Outcome } from '../../transcript.svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
question: string;
|
|
4
|
+
blocks: Block[];
|
|
5
|
+
outcome: Outcome | null;
|
|
6
|
+
running: boolean;
|
|
7
|
+
/** Forwarded to Markdown; omit if the caller has no collections to chip. */
|
|
8
|
+
collectionNames?: Set<string>;
|
|
9
|
+
}
|
|
10
|
+
declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type AgentTranscript = ReturnType<typeof AgentTranscript>;
|
|
12
|
+
export default AgentTranscript;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The composer: one rounded box holding the input AND a footer row.
|
|
3
|
+
|
|
4
|
+
Two things carry it: the chips sit BELOW the input inside the same box
|
|
5
|
+
rather than beside it, and the trailing control is a circular filled accent
|
|
6
|
+
button. The empty→typing transition lives almost entirely in that control —
|
|
7
|
+
the box chrome does not resize.
|
|
8
|
+
|
|
9
|
+
The composer stays enabled while the agent works. Queuing the next message
|
|
10
|
+
is not built yet; disabling the box would be a different and worse
|
|
11
|
+
behaviour than the one being copied, so it is not disabled.
|
|
12
|
+
-->
|
|
13
|
+
<script lang="ts">
|
|
14
|
+
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
|
15
|
+
import SquareIcon from '@lucide/svelte/icons/square';
|
|
16
|
+
|
|
17
|
+
export type Scope = 'document' | 'collection' | 'library';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
value: string;
|
|
21
|
+
running: boolean;
|
|
22
|
+
scope: Scope;
|
|
23
|
+
/** Names for the two narrower scopes; absent means that scope is unavailable. */
|
|
24
|
+
documentName?: string;
|
|
25
|
+
collectionName?: string;
|
|
26
|
+
onscope: (scope: Scope) => void;
|
|
27
|
+
onsubmit: () => void;
|
|
28
|
+
onstop: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
value = $bindable(),
|
|
33
|
+
running,
|
|
34
|
+
scope,
|
|
35
|
+
documentName,
|
|
36
|
+
collectionName,
|
|
37
|
+
onscope,
|
|
38
|
+
onsubmit,
|
|
39
|
+
onstop
|
|
40
|
+
}: Props = $props();
|
|
41
|
+
|
|
42
|
+
// What the agent is being asked about — chat with this document, this
|
|
43
|
+
// collection, or the whole library. Callers that have no narrower scope
|
|
44
|
+
// pass neither name and only "All collections" ever renders.
|
|
45
|
+
const choices = $derived(
|
|
46
|
+
[
|
|
47
|
+
documentName ? { id: 'document' as const, label: documentName } : null,
|
|
48
|
+
collectionName ? { id: 'collection' as const, label: collectionName } : null,
|
|
49
|
+
{ id: 'library' as const, label: 'All collections' }
|
|
50
|
+
].filter((c) => c !== null)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Three chips in a narrow column clipped all three to fragments
|
|
54
|
+
// ("defence-s…", "All colle…"). Wrapping beats truncating: a chip a reader
|
|
55
|
+
// cannot finish reading is not a control, it is decoration.
|
|
56
|
+
|
|
57
|
+
function keydown(event: KeyboardEvent) {
|
|
58
|
+
// Enter sends, Shift+Enter breaks the line — the convention in every
|
|
59
|
+
// reference and the one a user will try first.
|
|
60
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
if (value.trim()) onsubmit();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<div
|
|
68
|
+
class="border-border bg-surface-1 focus-within:border-primary/60 rounded-xl border transition-colors"
|
|
69
|
+
>
|
|
70
|
+
<textarea
|
|
71
|
+
bind:value
|
|
72
|
+
onkeydown={keydown}
|
|
73
|
+
rows="2"
|
|
74
|
+
placeholder={running ? 'Queue another message…' : 'Ask anything'}
|
|
75
|
+
class="text-foreground placeholder:text-muted-foreground max-h-52 w-full resize-none bg-transparent px-4 pt-3 pb-2 text-base outline-none"
|
|
76
|
+
></textarea>
|
|
77
|
+
|
|
78
|
+
<div class="flex items-center gap-2 px-3 pb-2.5">
|
|
79
|
+
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
|
80
|
+
{#each choices as choice (choice.id)}
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onclick={() => onscope(choice.id)}
|
|
84
|
+
class="max-w-full truncate rounded-full px-2 py-0.5 text-xs transition-colors {scope ===
|
|
85
|
+
choice.id
|
|
86
|
+
? 'bg-primary/15 text-foreground border-primary/40 border'
|
|
87
|
+
: 'text-muted-foreground hover:text-foreground border border-transparent'}"
|
|
88
|
+
>
|
|
89
|
+
{choice.label}
|
|
90
|
+
</button>
|
|
91
|
+
{/each}
|
|
92
|
+
</div>
|
|
93
|
+
<span class="flex-1"></span>
|
|
94
|
+
{#if running}
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onclick={onstop}
|
|
98
|
+
aria-label="Stop"
|
|
99
|
+
class="bg-foreground text-background hover:bg-foreground/90 flex size-8 items-center justify-center rounded-full transition-colors"
|
|
100
|
+
>
|
|
101
|
+
<SquareIcon class="size-3.5 fill-current" />
|
|
102
|
+
</button>
|
|
103
|
+
{:else}
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onclick={onsubmit}
|
|
107
|
+
disabled={!value.trim()}
|
|
108
|
+
aria-label="Send"
|
|
109
|
+
class="bg-primary text-primary-foreground hover:bg-primary/90 disabled:hover:bg-primary flex size-8 items-center justify-center rounded-full transition-all disabled:opacity-30"
|
|
110
|
+
>
|
|
111
|
+
<ArrowUpIcon class="size-4" />
|
|
112
|
+
</button>
|
|
113
|
+
{/if}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type Scope = 'document' | 'collection' | 'library';
|
|
2
|
+
interface Props {
|
|
3
|
+
value: string;
|
|
4
|
+
running: boolean;
|
|
5
|
+
scope: Scope;
|
|
6
|
+
/** Names for the two narrower scopes; absent means that scope is unavailable. */
|
|
7
|
+
documentName?: string;
|
|
8
|
+
collectionName?: string;
|
|
9
|
+
onscope: (scope: Scope) => void;
|
|
10
|
+
onsubmit: () => void;
|
|
11
|
+
onstop: () => void;
|
|
12
|
+
}
|
|
13
|
+
declare const Composer: import("svelte").Component<Props, {}, "value">;
|
|
14
|
+
type Composer = ReturnType<typeof Composer>;
|
|
15
|
+
export default Composer;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown for a STREAMING agent transcript.
|
|
3
|
+
*
|
|
4
|
+
* Two things separate this from calling `marked()` on a string, and both are
|
|
5
|
+
* what make a streaming transcript look finished rather than glitchy:
|
|
6
|
+
*
|
|
7
|
+
* 1. Half-arrived markdown is not valid markdown. A code fence that has opened
|
|
8
|
+
* and not yet closed makes `marked` treat the rest of the answer as code, so
|
|
9
|
+
* the message visibly flips between prose and a grey slab on every token.
|
|
10
|
+
* Unterminated constructs are closed before parsing.
|
|
11
|
+
* 2. Highlighting every code block on every token is wasted work the user never
|
|
12
|
+
* sees — the block changes again a few milliseconds later. Highlighting is a
|
|
13
|
+
* separate pass the caller runs once the text has settled.
|
|
14
|
+
*
|
|
15
|
+
* Sanitised on the way out, always. The content is model output rendered as
|
|
16
|
+
* HTML, which is exactly the case DOMPurify exists for.
|
|
17
|
+
*/
|
|
18
|
+
/** Close anything the stream has opened but not yet finished. */
|
|
19
|
+
export declare function balance(markdown: string): string;
|
|
20
|
+
export declare function render(markdown: string, { streaming }?: {
|
|
21
|
+
streaming?: boolean | undefined;
|
|
22
|
+
}): string;
|
|
23
|
+
/**
|
|
24
|
+
* Mark every inline `code` span that names a known collection.
|
|
25
|
+
*
|
|
26
|
+
* The agent puts identifiers in backticks — document titles, requirement
|
|
27
|
+
* numbers, collection names — and they all render alike. Matching against
|
|
28
|
+
* the caller's real list rather than a prompt convention means the prompt
|
|
29
|
+
* cannot drift out of sync with the rendering.
|
|
30
|
+
*/
|
|
31
|
+
export declare function markCollections(root: HTMLElement, collections: Set<string>): void;
|
|
32
|
+
/**
|
|
33
|
+
* Replace every `<pre><code>` in a rendered fragment with a highlighted one.
|
|
34
|
+
* Runs against the DOM node rather than the HTML string so it can be applied
|
|
35
|
+
* after paint without re-parsing the markdown.
|
|
36
|
+
*/
|
|
37
|
+
export declare function highlight(root: HTMLElement): Promise<void>;
|