akanjs 3.0.0-beta.7 → 3.0.0-beta.8
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/common/index.ts +1 -0
- package/common/pathGet.ts +12 -2
- package/common/pathSet.ts +2 -3
- package/common/toPathSegments.ts +10 -0
- package/dictionary/base.dictionary.ts +5 -0
- package/index.ts +5 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/service/agent.service.ts +92 -1
- package/service/predefinedAdaptor/llm.adaptor.ts +17 -0
- package/store/agentic/index.ts +1 -0
- package/store/agentic/useAgentReference.ts +49 -0
- package/store/hooks.ts +1 -1
- package/types/common/index.d.ts +1 -0
- package/types/common/toPathSegments.d.ts +9 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/index.d.ts +5 -0
- package/types/service/agent.service.d.ts +54 -0
- package/types/service/predefinedAdaptor/llm.adaptor.d.ts +16 -0
- package/types/store/agentic/index.d.ts +1 -0
- package/types/store/agentic/useAgentReference.d.ts +32 -0
- package/types/store/hooks.d.ts +1 -1
- package/types/ui/Agent/Chat.d.ts +11 -1
- package/types/ui/Agent/Composer.d.ts +9 -2
- package/types/ui/Agent/Menu.d.ts +4 -2
- package/types/ui/Agent/Refer.d.ts +13 -0
- package/types/ui/Agent/useChatQueue.d.ts +3 -1
- package/types/ui/Agent/useChatReferences.d.ts +26 -0
- package/types/ui/Agent/useReferenceMenu.d.ts +43 -0
- package/types/ui/Field/lightRefCache.d.ts +19 -0
- package/types/ui/Field/useRelationOptions.d.ts +39 -0
- package/types/ui/Select.d.ts +2 -0
- package/types/ui/index.d.ts +4 -1
- package/types/vendor/use-agentic/AgentSession.d.ts +54 -1
- package/types/vendor/use-agentic/Reference.d.ts +56 -0
- package/types/vendor/use-agentic/index.d.ts +1 -0
- package/types/vendor/use-agentic/types.d.ts +36 -6
- package/ui/Agent/Bubble.tsx +2 -0
- package/ui/Agent/Chat.tsx +38 -8
- package/ui/Agent/Composer.tsx +18 -1
- package/ui/Agent/Menu.tsx +8 -3
- package/ui/Agent/Queued.tsx +2 -0
- package/ui/Agent/Refer.tsx +44 -0
- package/ui/Agent/sessionHistory.ts +35 -12
- package/ui/Agent/useChatQueue.ts +11 -1
- package/ui/Agent/useChatReferences.ts +67 -0
- package/ui/Agent/useReferenceMenu.ts +108 -0
- package/ui/Field/Relation.tsx +70 -150
- package/ui/Field/lightRefCache.ts +73 -0
- package/ui/Field/useRelationOptions.ts +106 -0
- package/ui/Select.tsx +24 -14
- package/ui/index.ts +8 -0
- package/vendor/use-agentic/AgentSession.ts +127 -1
- package/vendor/use-agentic/Compaction.ts +6 -0
- package/vendor/use-agentic/Reference.ts +99 -0
- package/vendor/use-agentic/Transcript.ts +7 -1
- package/vendor/use-agentic/index.ts +1 -0
- package/vendor/use-agentic/types.ts +38 -7
package/ui/Agent/Chat.tsx
CHANGED
|
@@ -37,9 +37,11 @@ import type { BuiltinOption } from "./sessionView";
|
|
|
37
37
|
import { tokenCount } from "./tokenCount";
|
|
38
38
|
import { useChatAttachments } from "./useChatAttachments";
|
|
39
39
|
import { type QueuedMessage, useChatQueue } from "./useChatQueue";
|
|
40
|
+
import { useChatReferences } from "./useChatReferences";
|
|
40
41
|
import { useChatVoice } from "./useChatVoice";
|
|
41
42
|
import { useDraftRecall } from "./useDraftRecall";
|
|
42
43
|
import { useKeyboardInset } from "./useKeyboardInset";
|
|
44
|
+
import { type ReferenceSource, useReferenceMenu } from "./useReferenceMenu";
|
|
43
45
|
import { useSlashMenu } from "./useSlashMenu";
|
|
44
46
|
import type { VoiceEngine } from "./voice";
|
|
45
47
|
|
|
@@ -117,6 +119,15 @@ export interface ChatProps {
|
|
|
117
119
|
* uploading reader gets a thumbnail without betting the answer on who can reach its storage.
|
|
118
120
|
*/
|
|
119
121
|
attach?: AttachReader;
|
|
122
|
+
/**
|
|
123
|
+
* What the composer's `@` menu can point at — one entry per kind of document a user may name while asking.
|
|
124
|
+
*
|
|
125
|
+
* Which documents those are is the app's answer, so the source carries its own `search`; the framework carries
|
|
126
|
+
* the token, the masking and the snapshot. Whole documents only: a field inside one is pointed at from the
|
|
127
|
+
* component that draws it, with `useAgentReference`, because that component is the thing that knows a rich-text
|
|
128
|
+
* field reads as a paragraph rather than as the editor document it is stored as.
|
|
129
|
+
*/
|
|
130
|
+
reference?: readonly ReferenceSource[];
|
|
120
131
|
/**
|
|
121
132
|
* Raises or lowers what the composer accepts — per file, per message, and how many. The defaults are what one
|
|
122
133
|
* turn's JSON safely carries to a conservative provider; an app pointed at a larger request limit, or one whose
|
|
@@ -176,6 +187,7 @@ export const DefaultChat = ({
|
|
|
176
187
|
defaultDraft,
|
|
177
188
|
attach,
|
|
178
189
|
attachLimits,
|
|
190
|
+
reference,
|
|
179
191
|
voice,
|
|
180
192
|
}: ChatProps) => {
|
|
181
193
|
const { l } = usePage();
|
|
@@ -225,6 +237,7 @@ export const DefaultChat = ({
|
|
|
225
237
|
const listRef = useRef<HTMLDivElement>(null);
|
|
226
238
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
227
239
|
const launcherRef = useRef<HTMLButtonElement>(null);
|
|
240
|
+
const refs = useChatReferences({ session, draft, version, inputRef, onDraft: setDraft });
|
|
228
241
|
|
|
229
242
|
const sticky = useRef(true);
|
|
230
243
|
const returning = useRef(false);
|
|
@@ -274,6 +287,7 @@ export const DefaultChat = ({
|
|
|
274
287
|
const write = (text: string) => {
|
|
275
288
|
setDraft(text);
|
|
276
289
|
menu.reopen();
|
|
290
|
+
mentions.reopen();
|
|
277
291
|
};
|
|
278
292
|
|
|
279
293
|
const closable = openProp === undefined || !!onOpenChange;
|
|
@@ -287,6 +301,7 @@ export const DefaultChat = ({
|
|
|
287
301
|
|
|
288
302
|
if (command.name === "new") {
|
|
289
303
|
files.clear();
|
|
304
|
+
session.clearStaged();
|
|
290
305
|
queue.take();
|
|
291
306
|
}
|
|
292
307
|
void ChatCommands.run(command, { session, l });
|
|
@@ -299,10 +314,15 @@ export const DefaultChat = ({
|
|
|
299
314
|
if (session.isRunning) return queue.push(message);
|
|
300
315
|
sticky.current = true;
|
|
301
316
|
speech.take(message.byVoice);
|
|
302
|
-
if (!message.attachments.length) void session.send(message.text);
|
|
317
|
+
if (!message.attachments.length && !message.references.length) void session.send(message.text);
|
|
303
318
|
else
|
|
304
319
|
void session.send([
|
|
305
|
-
{
|
|
320
|
+
{
|
|
321
|
+
role: "user",
|
|
322
|
+
...(message.text ? { text: message.text } : {}),
|
|
323
|
+
...(message.attachments.length ? { attachments: message.attachments } : {}),
|
|
324
|
+
...(message.references.length ? { references: message.references } : {}),
|
|
325
|
+
},
|
|
306
326
|
]);
|
|
307
327
|
return true;
|
|
308
328
|
};
|
|
@@ -311,10 +331,15 @@ export const DefaultChat = ({
|
|
|
311
331
|
const message = queue.queued;
|
|
312
332
|
if (!message || !files.restore(message.attachments)) return;
|
|
313
333
|
queue.take();
|
|
334
|
+
|
|
335
|
+
session.restoreStaged(message.references);
|
|
314
336
|
write([message.text, draft].filter(Boolean).join("\n"));
|
|
315
337
|
speech.hold(message.byVoice);
|
|
316
338
|
};
|
|
317
339
|
const menu = useSlashMenu({ draft, l, onCommand: runCommand });
|
|
340
|
+
const mentions = useReferenceMenu({ draft, sources: reference ?? [], session, l, onWrite: write });
|
|
341
|
+
|
|
342
|
+
const list = menu.at() ? menu : mentions;
|
|
318
343
|
const send = () => {
|
|
319
344
|
const text = draft.trim();
|
|
320
345
|
if (!text && !files.attached.length) return;
|
|
@@ -338,12 +363,13 @@ export const DefaultChat = ({
|
|
|
338
363
|
return;
|
|
339
364
|
}
|
|
340
365
|
const byVoice = speech.lift();
|
|
341
|
-
if (!dispatch({ text, attachments: files.attached, byVoice })) {
|
|
366
|
+
if (!dispatch({ text, attachments: files.attached, references: refs.references, byVoice })) {
|
|
342
367
|
speech.hold(byVoice);
|
|
343
368
|
return;
|
|
344
369
|
}
|
|
345
370
|
write("");
|
|
346
371
|
files.clear();
|
|
372
|
+
session.clearStaged();
|
|
347
373
|
if (text) recall.remember(text);
|
|
348
374
|
};
|
|
349
375
|
const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
@@ -352,22 +378,24 @@ export const DefaultChat = ({
|
|
|
352
378
|
const onEdgeLine = (up: boolean) =>
|
|
353
379
|
area.selectionStart === area.selectionEnd &&
|
|
354
380
|
!(up ? area.value.slice(0, area.selectionStart) : area.value.slice(area.selectionEnd)).includes("\n");
|
|
355
|
-
const row =
|
|
381
|
+
const row = list.at();
|
|
356
382
|
if (row) {
|
|
357
383
|
|
|
358
384
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
359
385
|
event.preventDefault();
|
|
360
|
-
|
|
386
|
+
list.move(event.key === "ArrowDown" ? 1 : -1);
|
|
361
387
|
return;
|
|
362
388
|
}
|
|
363
389
|
if (event.key === "Tab") {
|
|
364
390
|
event.preventDefault();
|
|
365
|
-
|
|
391
|
+
|
|
392
|
+
if (list === menu) write(`/${row.name} `);
|
|
393
|
+
else row.pick();
|
|
366
394
|
return;
|
|
367
395
|
}
|
|
368
396
|
if (event.key === "Escape") {
|
|
369
397
|
event.preventDefault();
|
|
370
|
-
|
|
398
|
+
list.hide();
|
|
371
399
|
return;
|
|
372
400
|
}
|
|
373
401
|
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
@@ -501,7 +529,7 @@ export const DefaultChat = ({
|
|
|
501
529
|
<Question key={session.pendingQuestion.callId} question={session.pendingQuestion} />
|
|
502
530
|
) : null}
|
|
503
531
|
{queue.queued ? <Queued message={queue.queued} onCancel={() => queue.take()} onEdit={unpark} /> : null}
|
|
504
|
-
<Menu onPick={(row) => row.pick()} rows={
|
|
532
|
+
<Menu onPick={(row) => row.pick()} prefix={list === menu ? "/" : "@"} rows={list.rows} selected={list.selected} />
|
|
505
533
|
<Composer
|
|
506
534
|
attached={files.attached}
|
|
507
535
|
pending={files.pending}
|
|
@@ -512,7 +540,9 @@ export const DefaultChat = ({
|
|
|
512
540
|
onFiles={(picked) => void files.add(picked)}
|
|
513
541
|
onKeyDown={onKeyDown}
|
|
514
542
|
onRemoveFile={files.remove}
|
|
543
|
+
onRemoveReference={refs.remove}
|
|
515
544
|
onSend={send}
|
|
545
|
+
references={refs.references}
|
|
516
546
|
onStop={() => {
|
|
517
547
|
speech.silence();
|
|
518
548
|
|
package/ui/Agent/Composer.tsx
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { cn, usePage } from "akanjs/client";
|
|
3
3
|
import { type KeyboardEvent, type RefObject, useEffect } from "react";
|
|
4
|
-
import type { AgentSession, MessageAttachment } from "../../vendor/use-agentic";
|
|
4
|
+
import type { AgentSession, MessageAttachment, MessageReference } from "../../vendor/use-agentic";
|
|
5
5
|
import { Button } from "../Button";
|
|
6
6
|
import { inputRecipe } from "../recipe";
|
|
7
7
|
import { createOverridable, useUiRecipe } from "../UiOverride";
|
|
8
8
|
import { Attach, Chips } from "./Attach";
|
|
9
9
|
import { Mic } from "./Mic";
|
|
10
|
+
import { ReferenceChips } from "./Refer";
|
|
10
11
|
|
|
11
12
|
export interface ComposerProps {
|
|
12
13
|
className?: string;
|
|
13
14
|
session: AgentSession;
|
|
14
15
|
draft: string;
|
|
15
16
|
attached: readonly MessageAttachment[];
|
|
17
|
+
/**
|
|
18
|
+
* What the draft's `@[…](mention:…)` tokens point at. The text is what carries them, so a composer that draws no
|
|
19
|
+
* chip still sends them — the chip is how somebody sees and removes one, not how it travels.
|
|
20
|
+
*/
|
|
21
|
+
references?: readonly MessageReference[];
|
|
16
22
|
/** Files still being read. An `attach` that uploads takes seconds, and a panel that shows nothing looks broken. */
|
|
17
23
|
pending?: number;
|
|
18
24
|
/** Absent when the screen cannot listen — the same rule as publishing no tool for a control that is not drawn. */
|
|
@@ -21,6 +27,8 @@ export interface ComposerProps {
|
|
|
21
27
|
onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
22
28
|
onFiles: (files: File[]) => void;
|
|
23
29
|
onRemoveFile: (idx: number) => void;
|
|
30
|
+
/** Keyed on `refName/refId#path`, not on a row index: the order is the draft's, and the draft is the truth. */
|
|
31
|
+
onRemoveReference?: (key: string) => void;
|
|
24
32
|
onSend: () => void;
|
|
25
33
|
onStop: () => void;
|
|
26
34
|
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
|
@@ -35,12 +43,14 @@ export const DefaultComposer = ({
|
|
|
35
43
|
session,
|
|
36
44
|
draft,
|
|
37
45
|
attached,
|
|
46
|
+
references = [],
|
|
38
47
|
pending = 0,
|
|
39
48
|
mic,
|
|
40
49
|
onDraft,
|
|
41
50
|
onKeyDown,
|
|
42
51
|
onFiles,
|
|
43
52
|
onRemoveFile,
|
|
53
|
+
onRemoveReference,
|
|
44
54
|
onSend,
|
|
45
55
|
onStop,
|
|
46
56
|
inputRef,
|
|
@@ -56,6 +66,13 @@ export const DefaultComposer = ({
|
|
|
56
66
|
}, [draft, inputRef]);
|
|
57
67
|
return (
|
|
58
68
|
<div className={cn("flex flex-col gap-2 border-foreground/5 border-t p-3", className)}>
|
|
69
|
+
{references.length ? (
|
|
70
|
+
<ReferenceChips
|
|
71
|
+
onRemove={onRemoveReference}
|
|
72
|
+
references={references}
|
|
73
|
+
removeLabel={l("base.agentReferenceRemove")}
|
|
74
|
+
/>
|
|
75
|
+
) : null}
|
|
59
76
|
{attached.length || pending ? (
|
|
60
77
|
<Chips
|
|
61
78
|
attachments={attached}
|
package/ui/Agent/Menu.tsx
CHANGED
|
@@ -15,11 +15,13 @@ export interface MenuProps {
|
|
|
15
15
|
rows: MenuRow[];
|
|
16
16
|
/** Index the arrows are on. Enter picks it and Tab completes its name, so it has to be visible. */
|
|
17
17
|
selected: number;
|
|
18
|
+
/** What the rows are being typed after, so a name reads as the thing the user is completing. */
|
|
19
|
+
prefix?: string;
|
|
18
20
|
onPick: (row: MenuRow) => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
/** The
|
|
22
|
-
export const DefaultAgentMenu = ({ className, rows, selected, onPick }: MenuProps) => {
|
|
23
|
+
/** The composer's completion list: `/` commands and `prompt()` endpoints, or the `@` menu's reference rows. */
|
|
24
|
+
export const DefaultAgentMenu = ({ className, rows, selected, prefix = "/", onPick }: MenuProps) => {
|
|
23
25
|
if (!rows.length) return null;
|
|
24
26
|
return (
|
|
25
27
|
<div
|
|
@@ -41,7 +43,10 @@ export const DefaultAgentMenu = ({ className, rows, selected, onPick }: MenuProp
|
|
|
41
43
|
role="option"
|
|
42
44
|
type="button"
|
|
43
45
|
>
|
|
44
|
-
<span className="shrink-0 font-mono text-xs"
|
|
46
|
+
<span className="shrink-0 font-mono text-xs">
|
|
47
|
+
{prefix}
|
|
48
|
+
{row.name}
|
|
49
|
+
</span>
|
|
45
50
|
{row.hint ? <span className="shrink-0 font-mono text-[10px] text-foreground/40">{row.hint}</span> : null}
|
|
46
51
|
{row.description ? (
|
|
47
52
|
<span className="ml-auto truncate text-[10px] text-foreground/50">{row.description}</span>
|
package/ui/Agent/Queued.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { cn, usePage } from "akanjs/client";
|
|
|
3
3
|
import { AiOutlineClose, AiOutlineEdit } from "react-icons/ai";
|
|
4
4
|
import { createOverridable } from "../UiOverride";
|
|
5
5
|
import { Chips } from "./Attach";
|
|
6
|
+
import { ReferenceChips } from "./Refer";
|
|
6
7
|
import type { QueuedMessage } from "./useChatQueue";
|
|
7
8
|
|
|
8
9
|
export interface QueuedProps {
|
|
@@ -29,6 +30,7 @@ export const DefaultQueued = ({ className, message, onEdit, onCancel }: QueuedPr
|
|
|
29
30
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
30
31
|
<span className="text-[10px] text-foreground/40">{l("base.agentQueued")}</span>
|
|
31
32
|
{message.attachments.length ? <Chips attachments={message.attachments} /> : null}
|
|
33
|
+
{message.references.length ? <ReferenceChips references={message.references} /> : null}
|
|
32
34
|
{message.text ? (
|
|
33
35
|
<p className="line-clamp-2 whitespace-pre-wrap text-foreground/70 text-sm">{message.text}</p>
|
|
34
36
|
) : null}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "akanjs/client";
|
|
3
|
+
import { AiOutlineClose } from "react-icons/ai";
|
|
4
|
+
import { type MessageReference, Reference } from "../../vendor/use-agentic";
|
|
5
|
+
|
|
6
|
+
export interface ReferenceChipsProps {
|
|
7
|
+
className?: string;
|
|
8
|
+
|
|
9
|
+
references: readonly MessageReference[];
|
|
10
|
+
/** Omitted for a sent message: what is already on the wire cannot be taken back. */
|
|
11
|
+
onRemove?: (key: string) => void;
|
|
12
|
+
removeLabel?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What the user pointed at, beside what they are typing. Keyed on the pointer rather than on the row's position:
|
|
17
|
+
* the order a message's references appear in is its text's, and removing one is removing its token.
|
|
18
|
+
*/
|
|
19
|
+
export const ReferenceChips = ({ className, references, onRemove, removeLabel }: ReferenceChipsProps) => (
|
|
20
|
+
<div className={cn("flex flex-wrap gap-1", className)}>
|
|
21
|
+
{references.map((reference) => {
|
|
22
|
+
const key = Reference.keyOf(reference);
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
className="flex items-center gap-1 rounded-field bg-primary/10 px-2 py-0.5 text-primary text-xs"
|
|
26
|
+
key={key}
|
|
27
|
+
title={key}
|
|
28
|
+
>
|
|
29
|
+
<span className="max-w-32 truncate">{reference.label || key}</span>
|
|
30
|
+
{onRemove ? (
|
|
31
|
+
<button
|
|
32
|
+
aria-label={removeLabel}
|
|
33
|
+
className="text-primary/50 hover:text-primary"
|
|
34
|
+
onClick={() => onRemove(key)}
|
|
35
|
+
type="button"
|
|
36
|
+
>
|
|
37
|
+
<AiOutlineClose />
|
|
38
|
+
</button>
|
|
39
|
+
) : null}
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
@@ -11,19 +11,42 @@ export type PersistOption = boolean | { storage?: "session" | "local"; key?: str
|
|
|
11
11
|
* letting it answer from the filename. A `ref` stays for the same reason and matters more: it is the host's own
|
|
12
12
|
* handle on the file, so dropping it leaves a restored conversation with nothing but a name and size to guess
|
|
13
13
|
* from — the guess `ref` exists to retire.
|
|
14
|
+
*
|
|
15
|
+
* A reference's `value` goes the same way and for the same reason — it is capped at 20,000 characters each, and a
|
|
16
|
+
* transcript of 50 messages would not fit beside it. What is left behind is better than what an attachment leaves,
|
|
17
|
+
* though, and the note says so rather than leaving the model to notice: `refName`/`refId`/`path` is a live pointer,
|
|
18
|
+
* so the answer to a restored reference is to read it again with a tool, not to guess from the label.
|
|
14
19
|
*/
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
const restoredValue =
|
|
21
|
+
"the conversation was restored from storage, which keeps what the user pointed at but not the value it held";
|
|
22
|
+
|
|
23
|
+
const withoutContent = (message: ChatMessage): ChatMessage => {
|
|
24
|
+
if (!message.attachments?.length && !message.references?.length) return message;
|
|
25
|
+
return {
|
|
26
|
+
...message,
|
|
27
|
+
...(message.attachments?.length
|
|
28
|
+
? {
|
|
29
|
+
attachments: message.attachments.map(({ name, mimeType, url, ref }) => ({
|
|
30
|
+
name,
|
|
31
|
+
mimeType,
|
|
32
|
+
...(url ? { url } : {}),
|
|
33
|
+
...(ref ? { ref } : {}),
|
|
34
|
+
})),
|
|
35
|
+
}
|
|
36
|
+
: {}),
|
|
37
|
+
...(message.references?.length
|
|
38
|
+
? {
|
|
39
|
+
references: message.references.map(({ refName, refId, label, path }) => ({
|
|
40
|
+
refName,
|
|
41
|
+
refId,
|
|
42
|
+
label,
|
|
43
|
+
...(path ? { path } : {}),
|
|
44
|
+
note: restoredValue,
|
|
45
|
+
})),
|
|
46
|
+
}
|
|
47
|
+
: {}),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
27
50
|
|
|
28
51
|
/**
|
|
29
52
|
* Maps the `persist` prop onto a `SessionHistory` over web storage. Session storage is the default on purpose:
|
package/ui/Agent/useChatQueue.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
|
-
import type { AgentSession, MessageAttachment } from "../../vendor/use-agentic";
|
|
3
|
+
import type { AgentSession, MessageAttachment, MessageReference } from "../../vendor/use-agentic";
|
|
4
|
+
import { Reference } from "../../vendor/use-agentic";
|
|
4
5
|
import { type AttachLimits, Attachment, maxMessageAttachments } from "./attachment";
|
|
5
6
|
|
|
6
7
|
/** What the composer held when it was sent: the shape a turn opens with, whether now or after the running one. */
|
|
7
8
|
export interface QueuedMessage {
|
|
8
9
|
text: string;
|
|
9
10
|
attachments: MessageAttachment[];
|
|
11
|
+
/** What the parked text's tokens pointed at. Carried so a turn that opens later still knows what they held. */
|
|
12
|
+
references: MessageReference[];
|
|
10
13
|
/** The ask came from the microphone, so the reply it gets is read aloud — carried until the send that opens it. */
|
|
11
14
|
byVoice: boolean;
|
|
12
15
|
}
|
|
@@ -56,6 +59,13 @@ export const useChatQueue = ({ session, limits = {}, version, l, onFlush }: Chat
|
|
|
56
59
|
put({
|
|
57
60
|
text: [before?.text, message.text].filter(Boolean).join("\n"),
|
|
58
61
|
attachments,
|
|
62
|
+
|
|
63
|
+
references: [
|
|
64
|
+
...message.references,
|
|
65
|
+
...(before?.references ?? []).filter(
|
|
66
|
+
(one) => !message.references.some((fresh) => Reference.keyOf(fresh) === Reference.keyOf(one)),
|
|
67
|
+
),
|
|
68
|
+
],
|
|
59
69
|
byVoice: !!before?.byVoice || message.byVoice,
|
|
60
70
|
});
|
|
61
71
|
return true;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { type RefObject, useEffect, useMemo, useRef } from "react";
|
|
3
|
+
import { type AgentSession, type MessageReference, Reference } from "../../vendor/use-agentic";
|
|
4
|
+
|
|
5
|
+
interface ChatReferencesSetup {
|
|
6
|
+
session: AgentSession;
|
|
7
|
+
draft: string;
|
|
8
|
+
/** The chat's own version snapshot — a `refer` from anywhere on the page is one of the changes it counts. */
|
|
9
|
+
version: number;
|
|
10
|
+
inputRef: RefObject<HTMLTextAreaElement | null>;
|
|
11
|
+
onDraft: (text: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The composer's half of a reference: writing the token for one somebody pointed at elsewhere, and reading back
|
|
16
|
+
* which references the draft now carries.
|
|
17
|
+
*
|
|
18
|
+
* **The text is the source of truth, and the staged values are looked up beside it.** A reference is on the
|
|
19
|
+
* message because its token is in the message — so deleting the token by hand drops it, exactly as deleting the
|
|
20
|
+
* chip does, and neither can leave the other behind. The draft is never scanned to re-derive a value; a token
|
|
21
|
+
* whose value was never staged (pasted out of an earlier message) travels as a pointer with a note saying to read
|
|
22
|
+
* it again, which is the same shape a restored conversation produces.
|
|
23
|
+
*/
|
|
24
|
+
export const useChatReferences = ({ session, draft, version, inputRef, onDraft }: ChatReferencesSetup) => {
|
|
25
|
+
const caret = useRef<number | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => session.attachChat(), [session]);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const pending = session.pendingInserts;
|
|
30
|
+
if (!pending.length) return;
|
|
31
|
+
const area = inputRef.current;
|
|
32
|
+
|
|
33
|
+
const at = area ? area.selectionStart : draft.length;
|
|
34
|
+
const head = draft.slice(0, at);
|
|
35
|
+
const tail = draft.slice(at);
|
|
36
|
+
const tokens = pending.map((one) => Reference.token(one)).join(" ");
|
|
37
|
+
|
|
38
|
+
const lead = head && !/\s$/.test(head) ? " " : "";
|
|
39
|
+
onDraft(`${head}${lead}${tokens} ${tail}`);
|
|
40
|
+
caret.current = head.length + lead.length + tokens.length + 1;
|
|
41
|
+
for (const one of pending) session.insertApplied(Reference.keyOf(one));
|
|
42
|
+
}, [version]);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const at = caret.current;
|
|
45
|
+
if (at === null) return;
|
|
46
|
+
caret.current = null;
|
|
47
|
+
const area = inputRef.current;
|
|
48
|
+
if (!area) return;
|
|
49
|
+
|
|
50
|
+
area.focus();
|
|
51
|
+
area.setSelectionRange(at, at);
|
|
52
|
+
}, [draft]);
|
|
53
|
+
const references = useMemo<MessageReference[]>(() => {
|
|
54
|
+
const staged = new Map(session.staged.map((one) => [Reference.keyOf(one), one]));
|
|
55
|
+
return Reference.parse(draft).map(
|
|
56
|
+
(pointer) => staged.get(Reference.keyOf(pointer)) ?? { ...pointer, note: Reference.unstagedNote },
|
|
57
|
+
);
|
|
58
|
+
}, [draft, version]);
|
|
59
|
+
return {
|
|
60
|
+
references,
|
|
61
|
+
/** Removing the chip removes the token, because the token is what puts the reference on the message. */
|
|
62
|
+
remove: (key: string) => {
|
|
63
|
+
onDraft(Reference.without(draft, key));
|
|
64
|
+
session.unstage(key);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { type AgentFieldType, AgentValue } from "akanjs/store";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { type AgentSession, Reference } from "../../vendor/use-agentic";
|
|
5
|
+
import type { MenuRow } from "./Menu";
|
|
6
|
+
|
|
7
|
+
/** One row the `@` menu offers: a document of its source's model, named the way the screen names it. */
|
|
8
|
+
export interface ReferenceCandidate {
|
|
9
|
+
refId: string;
|
|
10
|
+
label: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A kind of document the `@` menu can point at.
|
|
16
|
+
*
|
|
17
|
+
* `type` is the whole decision about what leaves the browser, in `st.expose`'s vocabulary — a model class masks by
|
|
18
|
+
* that model. **Name the class that actually carries the fields somebody is pointing at**: a `Light<Model>` is
|
|
19
|
+
* usually not it, and a reference masked by one arrives without the field that was the reason for pointing.
|
|
20
|
+
*
|
|
21
|
+
* `search` is the app's own query, because which documents a person may point at is the app's answer and not the
|
|
22
|
+
* framework's; the signal is aborted when the query moves on. `resolve` is called once, when a row is picked.
|
|
23
|
+
*/
|
|
24
|
+
export interface ReferenceSource<T extends AgentFieldType = AgentFieldType> {
|
|
25
|
+
refName: string;
|
|
26
|
+
/** What this group of rows is called in the menu. */
|
|
27
|
+
label: string;
|
|
28
|
+
type: T;
|
|
29
|
+
search: (query: string, signal: AbortSignal) => Promise<ReferenceCandidate[]>;
|
|
30
|
+
resolve: (refId: string) => Promise<unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ReferenceMenuSetup {
|
|
34
|
+
draft: string;
|
|
35
|
+
sources: readonly ReferenceSource[];
|
|
36
|
+
session: AgentSession;
|
|
37
|
+
l: (key: string, param?: Record<string, string | number>) => string;
|
|
38
|
+
onWrite: (text: string) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Only a word being typed at the end of the draft opens the menu. A `@` mid-sentence is left alone because the
|
|
43
|
+
* draft is the only thing this can read — a textarea's caret is not in React state — and a completed token always
|
|
44
|
+
* ends in a space, so the menu never reopens onto one it just wrote.
|
|
45
|
+
*/
|
|
46
|
+
const atQuery = /(^|\s)@([^\s@[\]()]*)$/;
|
|
47
|
+
|
|
48
|
+
/** Long enough that typing a name is one query rather than one per letter, short enough to feel like a menu. */
|
|
49
|
+
const searchDelay = 150;
|
|
50
|
+
|
|
51
|
+
export const useReferenceMenu = ({ draft, sources, session, l, onWrite }: ReferenceMenuSetup) => {
|
|
52
|
+
const [rows, setRows] = useState<MenuRow[]>([]);
|
|
53
|
+
const [cursor, setCursor] = useState(0);
|
|
54
|
+
const [hidden, setHidden] = useState(false);
|
|
55
|
+
|
|
56
|
+
const held = useRef(sources);
|
|
57
|
+
held.current = sources;
|
|
58
|
+
const match = hidden ? null : atQuery.exec(draft);
|
|
59
|
+
|
|
60
|
+
const query = match ? match[2] : null;
|
|
61
|
+
const write = (candidate: ReferenceCandidate, source: ReferenceSource) => {
|
|
62
|
+
const reference = { refName: source.refName, refId: candidate.refId, label: candidate.label };
|
|
63
|
+
|
|
64
|
+
onWrite(`${draft.slice(0, match?.index ?? 0)}${match?.[1] ?? ""}${Reference.token(reference)} `);
|
|
65
|
+
void source
|
|
66
|
+
.resolve(candidate.refId)
|
|
67
|
+
.then((value) => session.stage({ ...reference, value: AgentValue.serialize(source.type, value) }))
|
|
68
|
+
.catch(() => session.note(l("base.agentReferenceFailed", { label: candidate.label })));
|
|
69
|
+
};
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (query === null || !held.current.length) {
|
|
72
|
+
setRows([]);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
const current = held.current;
|
|
78
|
+
void Promise.all(
|
|
79
|
+
current.map((source) => source.search(query, controller.signal).catch(() => [] as ReferenceCandidate[])),
|
|
80
|
+
).then((found) => {
|
|
81
|
+
if (controller.signal.aborted) return;
|
|
82
|
+
setCursor(0);
|
|
83
|
+
setRows(
|
|
84
|
+
found.flatMap((candidates, at) =>
|
|
85
|
+
candidates.map((candidate) => ({
|
|
86
|
+
name: candidate.label,
|
|
87
|
+
description: candidate.description ?? current[at].label,
|
|
88
|
+
pick: () => write(candidate, current[at]),
|
|
89
|
+
})),
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
}, searchDelay);
|
|
94
|
+
return () => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
controller.abort();
|
|
97
|
+
};
|
|
98
|
+
}, [query, draft]);
|
|
99
|
+
const selected = Math.min(cursor, Math.max(rows.length - 1, 0));
|
|
100
|
+
return {
|
|
101
|
+
rows: query === null ? [] : rows,
|
|
102
|
+
selected,
|
|
103
|
+
reopen: () => setHidden(false),
|
|
104
|
+
hide: () => setHidden(true),
|
|
105
|
+
move: (delta: number) => setCursor(Math.max(0, Math.min(selected + delta, rows.length - 1))),
|
|
106
|
+
at: () => (query === null ? undefined : rows[selected]),
|
|
107
|
+
};
|
|
108
|
+
};
|