akanjs 3.0.0-beta.13 → 3.0.0-beta.15
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/CodeAgentClient.ts +102 -0
- package/common/CodeTranscript.ts +337 -0
- package/common/codeAgentProfile.ts +175 -0
- package/common/codeAgentWire.ts +409 -0
- package/common/index.ts +51 -0
- package/common/markdownSpans.ts +57 -0
- package/dictionary/agent.dictionary.ts +4 -11
- package/dictionary/base.dictionary.ts +1 -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 +3 -1
- package/server/akanOption.ts +9 -2
- package/server/di/predefinedAdaptor.ts +2 -2
- package/service/predefinedAdaptor/anthropicLlm.ts +6 -4
- package/service/predefinedAdaptor/index.ts +0 -1
- package/service/predefinedAdaptor/llm.adaptor.ts +21 -1
- package/service/predefinedAdaptor/openaiLlm.ts +37 -18
- package/store/agentic/StToolBuilder.ts +54 -0
- package/store/agentic/attachAgentic.ts +2 -1
- package/store/agentic/useFormTools.ts +1 -1
- package/types/common/CodeAgentClient.d.ts +26 -0
- package/types/common/CodeTranscript.d.ts +99 -0
- package/types/common/codeAgentProfile.d.ts +111 -0
- package/types/common/codeAgentWire.d.ts +388 -0
- package/types/common/index.d.ts +7 -0
- package/types/common/markdownSpans.d.ts +40 -0
- package/types/dictionary/agent.dictionary.d.ts +1 -1
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +9 -9
- package/types/server/akanOption.d.ts +9 -2
- package/types/server/di/predefinedAdaptor.d.ts +2 -2
- package/types/service/predefinedAdaptor/anthropicLlm.d.ts +1 -1
- package/types/service/predefinedAdaptor/index.d.ts +0 -1
- package/types/service/predefinedAdaptor/llm.adaptor.d.ts +14 -1
- package/types/service/predefinedAdaptor/openaiLlm.d.ts +20 -11
- package/types/store/agentic/StToolBuilder.d.ts +22 -0
- package/types/store/agentic/attachAgentic.d.ts +2 -1
- package/types/store/baseSt.d.ts +2 -2
- package/types/ui/Agent/Chat.d.ts +7 -1
- package/types/ui/Agent/Composer.d.ts +17 -1
- package/types/ui/Agent/MentionNode.d.ts +26 -0
- package/types/ui/Agent/RichInput.d.ts +22 -0
- package/types/ui/Agent/ToolCard.d.ts +16 -0
- package/types/ui/Agent/markdownSpans.d.ts +1 -0
- package/types/ui/Agent/mentionDraft.d.ts +19 -0
- package/types/ui/Agent/useChatReferences.d.ts +3 -2
- package/types/ui/UiOverride/context.d.ts +2 -0
- package/types/ui/index.d.ts +2 -1
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/vendor/use-agentic/AgentSession.d.ts +15 -1
- package/types/vendor/use-agentic/ToolRunner.d.ts +21 -1
- package/types/vendor/use-agentic/types.d.ts +31 -1
- package/ui/Agent/Chat.tsx +24 -7
- package/ui/Agent/Composer.tsx +73 -21
- package/ui/Agent/Markdown.tsx +2 -2
- package/ui/Agent/MentionNode.ts +62 -0
- package/ui/Agent/RichInput.tsx +154 -0
- package/ui/Agent/ToolCard.tsx +39 -0
- package/ui/Agent/markdownSpans.tsx +26 -36
- package/ui/Agent/mentionDraft.ts +101 -0
- package/ui/Agent/useChatReferences.ts +5 -8
- package/ui/UiOverride/context.ts +2 -0
- package/ui/index.ts +2 -1
- package/vendor/use-agentic/AgentSession.ts +45 -1
- package/vendor/use-agentic/AgenticSurface.ts +2 -0
- package/vendor/use-agentic/ToolRunner.ts +55 -1
- package/vendor/use-agentic/types.ts +36 -1
- package/service/predefinedAdaptor/deepseekLlm.ts +0 -82
- package/types/service/predefinedAdaptor/deepseekLlm.d.ts +0 -19
- /package/{ui/Agent → common}/markdownBlocks.ts +0 -0
- /package/{ui/Agent → common}/markdownTable.ts +0 -0
- /package/types/{ui/Agent → common}/markdownBlocks.d.ts +0 -0
- /package/types/{ui/Agent → common}/markdownTable.d.ts +0 -0
package/ui/Agent/Chat.tsx
CHANGED
|
@@ -28,7 +28,7 @@ import { agentSessionOf } from "./agentSessionOf";
|
|
|
28
28
|
import type { AttachLimits, AttachReader } from "./attachment";
|
|
29
29
|
import Bubble from "./Bubble";
|
|
30
30
|
import { type ChatCommand, ChatCommands } from "./ChatCommands";
|
|
31
|
-
import { Composer } from "./Composer";
|
|
31
|
+
import { Composer, type ComposerHandle } from "./Composer";
|
|
32
32
|
import { Launcher } from "./Launcher";
|
|
33
33
|
import Menu from "./Menu";
|
|
34
34
|
import Question from "./Question";
|
|
@@ -36,6 +36,7 @@ import Queued from "./Queued";
|
|
|
36
36
|
import Steps from "./Steps";
|
|
37
37
|
import type { PersistOption } from "./sessionHistory";
|
|
38
38
|
import type { BuiltinOption } from "./sessionView";
|
|
39
|
+
import ToolCard from "./ToolCard";
|
|
39
40
|
import { tokenCount } from "./tokenCount";
|
|
40
41
|
import { useChatAttachments } from "./useChatAttachments";
|
|
41
42
|
import { type QueuedMessage, useChatQueue } from "./useChatQueue";
|
|
@@ -138,6 +139,12 @@ export interface ChatProps {
|
|
|
138
139
|
* field reads as a paragraph rather than as the editor document it is stored as.
|
|
139
140
|
*/
|
|
140
141
|
reference?: readonly ReferenceSource[];
|
|
142
|
+
/**
|
|
143
|
+
* Draws each pointer in the composer as the name it points at rather than as the `@[…](mention:…)` token that
|
|
144
|
+
* carries it. On wherever `reference` sources are declared, and `false` keeps the plain textarea — for an app
|
|
145
|
+
* that overrides the composer, or one that would rather see the tokens it is sending.
|
|
146
|
+
*/
|
|
147
|
+
mentions?: boolean;
|
|
141
148
|
/**
|
|
142
149
|
* Raises or lowers what the composer accepts — per file, per message, and how many. The defaults are what one
|
|
143
150
|
* turn's JSON safely carries to a conservative provider; an app pointed at a larger request limit, or one whose
|
|
@@ -199,6 +206,7 @@ export const DefaultChat = ({
|
|
|
199
206
|
attach,
|
|
200
207
|
attachLimits,
|
|
201
208
|
reference,
|
|
209
|
+
mentions,
|
|
202
210
|
voice,
|
|
203
211
|
}: ChatProps) => {
|
|
204
212
|
const { l } = usePage();
|
|
@@ -249,7 +257,13 @@ export const DefaultChat = ({
|
|
|
249
257
|
const listRef = useRef<HTMLDivElement>(null);
|
|
250
258
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
251
259
|
const launcherRef = useRef<HTMLButtonElement>(null);
|
|
252
|
-
const
|
|
260
|
+
const handleRef = useRef<ComposerHandle | null>(null);
|
|
261
|
+
|
|
262
|
+
const focusComposer = () => {
|
|
263
|
+
if (handleRef.current) handleRef.current.focus();
|
|
264
|
+
else inputRef.current?.focus();
|
|
265
|
+
};
|
|
266
|
+
const refs = useChatReferences({ session, draft, version, handleRef, onDraft: setDraft });
|
|
253
267
|
|
|
254
268
|
const sticky = useRef(true);
|
|
255
269
|
const returning = useRef(false);
|
|
@@ -274,7 +288,7 @@ export const DefaultChat = ({
|
|
|
274
288
|
event.preventDefault();
|
|
275
289
|
sticky.current = true;
|
|
276
290
|
setOpen(true);
|
|
277
|
-
|
|
291
|
+
focusComposer();
|
|
278
292
|
};
|
|
279
293
|
|
|
280
294
|
window.addEventListener("keydown", onKeyDown, true);
|
|
@@ -287,7 +301,7 @@ export const DefaultChat = ({
|
|
|
287
301
|
returning.current = false;
|
|
288
302
|
return;
|
|
289
303
|
}
|
|
290
|
-
|
|
304
|
+
focusComposer();
|
|
291
305
|
}, [open, session.pendingQuestion?.callId]);
|
|
292
306
|
useEffect(
|
|
293
307
|
() => () => {
|
|
@@ -299,7 +313,7 @@ export const DefaultChat = ({
|
|
|
299
313
|
const write = (text: string) => {
|
|
300
314
|
setDraft(text);
|
|
301
315
|
menu.reopen();
|
|
302
|
-
|
|
316
|
+
mentionMenu.reopen();
|
|
303
317
|
};
|
|
304
318
|
|
|
305
319
|
const closable = openProp === undefined || !!onOpenChange;
|
|
@@ -349,9 +363,9 @@ export const DefaultChat = ({
|
|
|
349
363
|
speech.hold(message.byVoice);
|
|
350
364
|
};
|
|
351
365
|
const menu = useSlashMenu({ draft, l, onCommand: runCommand });
|
|
352
|
-
const
|
|
366
|
+
const mentionMenu = useReferenceMenu({ draft, sources: reference ?? [], session, l, onWrite: write });
|
|
353
367
|
|
|
354
|
-
const list = menu.at() ? menu :
|
|
368
|
+
const list = menu.at() ? menu : mentionMenu;
|
|
355
369
|
const send = () => {
|
|
356
370
|
const text = draft.trim();
|
|
357
371
|
if (!text && !files.attached.length) return;
|
|
@@ -558,6 +572,7 @@ export const DefaultChat = ({
|
|
|
558
572
|
) : null}
|
|
559
573
|
</div>
|
|
560
574
|
{session.pendingApproval ? <Approval approval={session.pendingApproval} /> : null}
|
|
575
|
+
{session.pendingCard ? <ToolCard card={session.pendingCard} key={session.pendingCard.callId} /> : null}
|
|
561
576
|
{session.pendingQuestion ? (
|
|
562
577
|
<Question key={session.pendingQuestion.callId} question={session.pendingQuestion} />
|
|
563
578
|
) : null}
|
|
@@ -567,7 +582,9 @@ export const DefaultChat = ({
|
|
|
567
582
|
attached={files.attached}
|
|
568
583
|
pending={files.pending}
|
|
569
584
|
draft={draft}
|
|
585
|
+
handleRef={handleRef}
|
|
570
586
|
inputRef={inputRef}
|
|
587
|
+
mentions={mentions ?? !!reference?.length}
|
|
571
588
|
{...(speech.canListen ? { mic: { listening: speech.listening, onToggle: speech.toggle } } : {})}
|
|
572
589
|
onDraft={write}
|
|
573
590
|
onFiles={(picked) => void files.add(picked)}
|
package/ui/Agent/Composer.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { cn, usePage } from "akanjs/client";
|
|
3
|
+
import { lazy } from "akanjs/webkit";
|
|
3
4
|
import { type KeyboardEvent, type RefObject, useEffect } from "react";
|
|
4
5
|
import type { AgentSession, MessageAttachment, MessageReference } from "../../vendor/use-agentic";
|
|
5
6
|
import { Button } from "../Button";
|
|
@@ -9,6 +10,16 @@ import { Attach, Chips } from "./Attach";
|
|
|
9
10
|
import { Mic } from "./Mic";
|
|
10
11
|
import { ReferenceChips } from "./Refer";
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* What the chat needs of whatever the composer draws into — a textarea, or the mention editor. Every offset is an
|
|
15
|
+
* offset into the draft string, tokens included, because that is the text the chat reasons about.
|
|
16
|
+
*/
|
|
17
|
+
export interface ComposerHandle {
|
|
18
|
+
focus: () => void;
|
|
19
|
+
caret: () => number | null;
|
|
20
|
+
setCaret: (at: number) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
export interface ComposerProps {
|
|
13
24
|
className?: string;
|
|
14
25
|
session: AgentSession;
|
|
@@ -32,8 +43,21 @@ export interface ComposerProps {
|
|
|
32
43
|
onSend: () => void;
|
|
33
44
|
onStop: () => void;
|
|
34
45
|
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Draws each `@` pointer as the name it points at instead of as its token. On only where the chat was given
|
|
48
|
+
* `reference` sources, so an app that declared none never fetches the editor's chunk.
|
|
49
|
+
*/
|
|
50
|
+
mentions?: boolean;
|
|
51
|
+
/** Filled by whichever input is drawn. An override that draws its own textarea leaves it null and `inputRef` answers. */
|
|
52
|
+
handleRef?: RefObject<ComposerHandle | null>;
|
|
35
53
|
}
|
|
36
54
|
|
|
55
|
+
const RichInput = lazy(() => import("./RichInput"), {
|
|
56
|
+
ssr: false,
|
|
57
|
+
suspense: true,
|
|
58
|
+
loading: () => <div className="flex-1" />,
|
|
59
|
+
});
|
|
60
|
+
|
|
37
61
|
/** Where the box stops growing and starts scrolling: a chat composer is a paragraph at most. */
|
|
38
62
|
const maxComposerHeight = 128;
|
|
39
63
|
|
|
@@ -54,9 +78,31 @@ export const DefaultComposer = ({
|
|
|
54
78
|
onSend,
|
|
55
79
|
onStop,
|
|
56
80
|
inputRef,
|
|
81
|
+
mentions = false,
|
|
82
|
+
handleRef,
|
|
57
83
|
}: ComposerProps) => {
|
|
58
84
|
const { l } = usePage();
|
|
59
85
|
const surface = useUiRecipe("input") ?? inputRecipe;
|
|
86
|
+
const field = surface({ kind: "area", size: "sm" }, "max-h-32 flex-1 resize-none py-1.5");
|
|
87
|
+
const placeholder = session.pendingQuestion
|
|
88
|
+
? l("base.agentAnswer")
|
|
89
|
+
: session.isRunning
|
|
90
|
+
? l("base.agentQueuePlaceholder")
|
|
91
|
+
: l("base.agentPlaceholder");
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!handleRef || mentions) return;
|
|
94
|
+
handleRef.current = {
|
|
95
|
+
focus: () => inputRef?.current?.focus(),
|
|
96
|
+
caret: () => inputRef?.current?.selectionStart ?? null,
|
|
97
|
+
setCaret: (at) => {
|
|
98
|
+
inputRef?.current?.focus();
|
|
99
|
+
inputRef?.current?.setSelectionRange(at, at);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
return () => {
|
|
103
|
+
handleRef.current = null;
|
|
104
|
+
};
|
|
105
|
+
}, [handleRef, inputRef, mentions]);
|
|
60
106
|
useEffect(() => {
|
|
61
107
|
const area = inputRef?.current;
|
|
62
108
|
if (!area) return;
|
|
@@ -87,27 +133,33 @@ export const DefaultComposer = ({
|
|
|
87
133
|
<Mic className="pb-2" label={l("base.agentListen")} listening={mic.listening} onToggle={mic.onToggle} />
|
|
88
134
|
) : null}
|
|
89
135
|
<Attach className="pb-2" label={l("base.agentAttach")} onPick={onFiles} />
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
136
|
+
{mentions ? (
|
|
137
|
+
<RichInput
|
|
138
|
+
className={field}
|
|
139
|
+
draft={draft}
|
|
140
|
+
handleRef={handleRef}
|
|
141
|
+
onDraft={onDraft}
|
|
142
|
+
onFiles={onFiles}
|
|
143
|
+
onKeyDown={onKeyDown}
|
|
144
|
+
placeholder={placeholder}
|
|
145
|
+
/>
|
|
146
|
+
) : (
|
|
147
|
+
<textarea
|
|
148
|
+
className={field}
|
|
149
|
+
onChange={(event) => onDraft(event.target.value)}
|
|
150
|
+
onKeyDown={onKeyDown}
|
|
151
|
+
onPaste={(event) => {
|
|
152
|
+
const pasted = [...event.clipboardData.files];
|
|
153
|
+
if (!pasted.length) return;
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
onFiles(pasted);
|
|
156
|
+
}}
|
|
157
|
+
placeholder={placeholder}
|
|
158
|
+
ref={inputRef}
|
|
159
|
+
rows={1}
|
|
160
|
+
value={draft}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
111
163
|
{/* A parked question is not a turn to stop: the loop is waiting on the card, and the card has its own out. */}
|
|
112
164
|
{session.isRunning && !session.pendingQuestion ? (
|
|
113
165
|
<>
|
package/ui/Agent/Markdown.tsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { cn } from "akanjs/client";
|
|
3
|
+
import type { Align, MarkdownBlock, MarkdownItem, TableBlock } from "akanjs/common";
|
|
4
|
+
import { MarkdownBlocks } from "akanjs/common";
|
|
3
5
|
import type { ReactNode } from "react";
|
|
4
6
|
import { createOverridable } from "../UiOverride";
|
|
5
|
-
import { type MarkdownBlock, MarkdownBlocks, type MarkdownItem } from "./markdownBlocks";
|
|
6
7
|
import { spans } from "./markdownSpans";
|
|
7
|
-
import type { Align, TableBlock } from "./markdownTable";
|
|
8
8
|
|
|
9
9
|
const alignClass: { [key in Align]: string } = { left: "text-left", center: "text-center", right: "text-right" };
|
|
10
10
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { $applyNodeReplacement, type LexicalNode, type NodeKey, type SerializedTextNode, TextNode } from "lexical";
|
|
3
|
+
import { type MessageReference, Reference } from "../../vendor/use-agentic";
|
|
4
|
+
|
|
5
|
+
export interface SerializedMentionNode extends SerializedTextNode {
|
|
6
|
+
reference: MessageReference;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One `@` pointer inside the composer: the label is what the node draws, and `getTextContent()` is the
|
|
11
|
+
* `@[label](mention:…)` token — so the editor's own text is the draft string the rest of the chat already reads,
|
|
12
|
+
* pointers and all, while the person sees a name.
|
|
13
|
+
*
|
|
14
|
+
* `token` mode is what makes it one thing: the caret never lands inside it, a backspace takes the whole pointer
|
|
15
|
+
* rather than a character of a label that would then name nothing, and a paste over it replaces it entirely.
|
|
16
|
+
*/
|
|
17
|
+
export class MentionNode extends TextNode {
|
|
18
|
+
#reference: MessageReference;
|
|
19
|
+
|
|
20
|
+
static override getType() {
|
|
21
|
+
return "mention";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static override clone(node: MentionNode): MentionNode {
|
|
25
|
+
return new MentionNode(node.#reference, node.__key);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static override importJSON(json: Parameters<typeof TextNode.importJSON>[0]): MentionNode {
|
|
29
|
+
return new MentionNode((json as Partial<SerializedMentionNode>).reference);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
constructor(reference: MessageReference = { refName: "", refId: "", label: "" }, key?: NodeKey) {
|
|
33
|
+
super(reference.label || Reference.keyOf(reference), key);
|
|
34
|
+
this.#reference = reference;
|
|
35
|
+
this.setMode("token");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get reference(): MessageReference {
|
|
39
|
+
return this.#reference;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
override getTextContent(): string {
|
|
43
|
+
return Reference.token(this.#reference);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
override createDOM(...args: Parameters<TextNode["createDOM"]>): HTMLElement {
|
|
47
|
+
const dom = super.createDOM(...args);
|
|
48
|
+
dom.className = "rounded-field bg-primary/10 px-1 text-primary";
|
|
49
|
+
dom.title = Reference.keyOf(this.#reference);
|
|
50
|
+
return dom;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override exportJSON(): SerializedMentionNode {
|
|
54
|
+
return { ...super.exportJSON(), reference: this.#reference };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const $createMentionNode = (reference: MessageReference): MentionNode =>
|
|
59
|
+
$applyNodeReplacement(new MentionNode(reference));
|
|
60
|
+
|
|
61
|
+
export const $isMentionNode = (node: LexicalNode | null | undefined): node is MentionNode =>
|
|
62
|
+
node instanceof MentionNode;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { registerPlainText } from "@lexical/plain-text";
|
|
3
|
+
import { cn } from "akanjs/client";
|
|
4
|
+
import {
|
|
5
|
+
COMMAND_PRIORITY_HIGH,
|
|
6
|
+
createEditor,
|
|
7
|
+
KEY_ARROW_DOWN_COMMAND,
|
|
8
|
+
KEY_ARROW_UP_COMMAND,
|
|
9
|
+
KEY_ENTER_COMMAND,
|
|
10
|
+
KEY_ESCAPE_COMMAND,
|
|
11
|
+
KEY_TAB_COMMAND,
|
|
12
|
+
type LexicalCommand,
|
|
13
|
+
PASTE_COMMAND,
|
|
14
|
+
} from "lexical";
|
|
15
|
+
import { type KeyboardEvent as ReactKeyboardEvent, type RefObject, useEffect, useRef, useState } from "react";
|
|
16
|
+
import type { ComposerHandle } from "./Composer";
|
|
17
|
+
import { MentionNode } from "./MentionNode";
|
|
18
|
+
import { MentionDraft } from "./mentionDraft";
|
|
19
|
+
|
|
20
|
+
interface RichInputProps {
|
|
21
|
+
className?: string;
|
|
22
|
+
draft: string;
|
|
23
|
+
placeholder: string;
|
|
24
|
+
onDraft: (text: string) => void;
|
|
25
|
+
/** The textarea's own handler, unchanged: the chat reads a key off the draft and the caret, not off the DOM. */
|
|
26
|
+
onKeyDown: (event: ReactKeyboardEvent<HTMLTextAreaElement>) => void;
|
|
27
|
+
onFiles: (files: File[]) => void;
|
|
28
|
+
handleRef?: RefObject<ComposerHandle | null>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The composer's input when the chat has `@` sources: the same draft string, drawn with each pointer as the name
|
|
33
|
+
* it points at instead of as the token that carries it.
|
|
34
|
+
*
|
|
35
|
+
* Lexical rather than a contenteditable of our own, for one reason — a Korean or Japanese IME composing into a
|
|
36
|
+
* contenteditable React also re-renders is the bug class the library exists to own, and it is not one an app can
|
|
37
|
+
* work around from outside.
|
|
38
|
+
*/
|
|
39
|
+
export const RichInput = ({
|
|
40
|
+
className,
|
|
41
|
+
draft,
|
|
42
|
+
placeholder,
|
|
43
|
+
onDraft,
|
|
44
|
+
onKeyDown,
|
|
45
|
+
onFiles,
|
|
46
|
+
handleRef,
|
|
47
|
+
}: RichInputProps) => {
|
|
48
|
+
const [editor] = useState(() =>
|
|
49
|
+
createEditor({
|
|
50
|
+
namespace: "akanAgentComposer",
|
|
51
|
+
nodes: [MentionNode],
|
|
52
|
+
onError: (error: Error) => console.error(error),
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
56
|
+
const live = useRef({ draft, onDraft, onKeyDown, onFiles });
|
|
57
|
+
live.current = { draft, onDraft, onKeyDown, onFiles };
|
|
58
|
+
|
|
59
|
+
const settled = useRef(draft);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const root = rootRef.current;
|
|
62
|
+
if (!root) return;
|
|
63
|
+
editor.setRootElement(root);
|
|
64
|
+
const forward = (key: string, event: KeyboardEvent | null) => {
|
|
65
|
+
const value = MentionDraft.read();
|
|
66
|
+
const caret = MentionDraft.caret() ?? value.length;
|
|
67
|
+
let prevented = false;
|
|
68
|
+
live.current.onKeyDown({
|
|
69
|
+
key,
|
|
70
|
+
shiftKey: !!event?.shiftKey,
|
|
71
|
+
nativeEvent: { isComposing: !!event?.isComposing },
|
|
72
|
+
currentTarget: { value, selectionStart: caret, selectionEnd: caret },
|
|
73
|
+
preventDefault: () => {
|
|
74
|
+
prevented = true;
|
|
75
|
+
event?.preventDefault();
|
|
76
|
+
},
|
|
77
|
+
} as unknown as ReactKeyboardEvent<HTMLTextAreaElement>);
|
|
78
|
+
return prevented;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const onKey = <T extends KeyboardEvent | null>(command: LexicalCommand<T>, key: string) =>
|
|
82
|
+
editor.registerCommand<T>(command, (event) => forward(key, event), COMMAND_PRIORITY_HIGH);
|
|
83
|
+
const teardowns = [
|
|
84
|
+
registerPlainText(editor),
|
|
85
|
+
editor.registerUpdateListener(({ editorState }) => {
|
|
86
|
+
const text = editorState.read(() => MentionDraft.read());
|
|
87
|
+
if (text === settled.current) return;
|
|
88
|
+
|
|
89
|
+
settled.current = text;
|
|
90
|
+
live.current.onDraft(text);
|
|
91
|
+
}),
|
|
92
|
+
onKey(KEY_ENTER_COMMAND, "Enter"),
|
|
93
|
+
onKey(KEY_TAB_COMMAND, "Tab"),
|
|
94
|
+
onKey(KEY_ARROW_UP_COMMAND, "ArrowUp"),
|
|
95
|
+
onKey(KEY_ARROW_DOWN_COMMAND, "ArrowDown"),
|
|
96
|
+
onKey(KEY_ESCAPE_COMMAND, "Escape"),
|
|
97
|
+
editor.registerCommand(
|
|
98
|
+
PASTE_COMMAND,
|
|
99
|
+
(event) => {
|
|
100
|
+
const files = [...(("clipboardData" in event ? event.clipboardData?.files : null) ?? [])];
|
|
101
|
+
if (!files.length) return false;
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
live.current.onFiles(files);
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
106
|
+
COMMAND_PRIORITY_HIGH,
|
|
107
|
+
),
|
|
108
|
+
];
|
|
109
|
+
if (live.current.draft) editor.update(() => MentionDraft.write(live.current.draft), { discrete: true });
|
|
110
|
+
return () => {
|
|
111
|
+
for (const teardown of teardowns) teardown();
|
|
112
|
+
editor.setRootElement(null);
|
|
113
|
+
};
|
|
114
|
+
}, [editor]);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (draft === settled.current) return;
|
|
117
|
+
settled.current = draft;
|
|
118
|
+
editor.update(() => MentionDraft.write(draft), { discrete: true });
|
|
119
|
+
}, [draft, editor]);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!handleRef) return;
|
|
122
|
+
handleRef.current = {
|
|
123
|
+
focus: () => editor.focus(),
|
|
124
|
+
caret: () => editor.getEditorState().read(() => MentionDraft.caret()),
|
|
125
|
+
setCaret: (at) => {
|
|
126
|
+
editor.update(() => MentionDraft.place(at), { discrete: true });
|
|
127
|
+
editor.focus();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
return () => {
|
|
131
|
+
handleRef.current = null;
|
|
132
|
+
};
|
|
133
|
+
}, [editor, handleRef]);
|
|
134
|
+
return (
|
|
135
|
+
<div className="relative flex-1">
|
|
136
|
+
<div
|
|
137
|
+
aria-label={placeholder}
|
|
138
|
+
aria-multiline
|
|
139
|
+
className={cn("max-h-32 overflow-y-auto whitespace-pre-wrap break-words outline-none", className)}
|
|
140
|
+
contentEditable
|
|
141
|
+
ref={rootRef}
|
|
142
|
+
role="textbox"
|
|
143
|
+
suppressContentEditableWarning
|
|
144
|
+
/>
|
|
145
|
+
{draft ? null : (
|
|
146
|
+
<span className="pointer-events-none absolute inset-0 select-none px-3 py-1.5 text-foreground/40 text-sm">
|
|
147
|
+
{placeholder}
|
|
148
|
+
</span>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export default RichInput;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn, usePage } from "akanjs/client";
|
|
3
|
+
import type { PendingCard } from "../../vendor/use-agentic";
|
|
4
|
+
import { createOverridable } from "../UiOverride";
|
|
5
|
+
|
|
6
|
+
export interface ToolCardProps {
|
|
7
|
+
className?: string;
|
|
8
|
+
card: PendingCard;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A call the user answers, parked above the composer where the approval and the question cards sit. What it draws
|
|
13
|
+
* is the app's own — the declaration that published the tool also said what filling it in looks like — so this
|
|
14
|
+
* frame owns only the placement and the way out of it.
|
|
15
|
+
*
|
|
16
|
+
* The way out is not the app's to forget: a card that renders no cancel of its own would otherwise park the turn
|
|
17
|
+
* on a component the user cannot dismiss, so the frame always draws one.
|
|
18
|
+
*/
|
|
19
|
+
export const DefaultToolCard = ({ className, card }: ToolCardProps) => {
|
|
20
|
+
const { l } = usePage();
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
aria-label={l("base.agentToolCard")}
|
|
24
|
+
className={cn("flex flex-col gap-2 border-primary/30 border-t bg-primary/5 px-4 py-3", className)}
|
|
25
|
+
role="group"
|
|
26
|
+
>
|
|
27
|
+
{card.render({ args: card.args, submit: card.submit, cancel: card.dismiss })}
|
|
28
|
+
<button
|
|
29
|
+
className="self-end text-foreground/50 text-xs hover:text-foreground"
|
|
30
|
+
onClick={() => card.dismiss()}
|
|
31
|
+
type="button"
|
|
32
|
+
>
|
|
33
|
+
{l("base.skip")}
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default createOverridable("AgentToolCard", DefaultToolCard);
|
|
@@ -1,49 +1,39 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { type MarkdownSpan, MarkdownSpans } from "akanjs/common";
|
|
2
3
|
import type { ReactNode } from "react";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
/** The browser rendering of {@link MarkdownSpans}; the scanner, and which hrefs are refused, live there. */
|
|
6
|
+
export const spans = (text: string): ReactNode[] => MarkdownSpans.of(text).map((span, at) => node(span, at));
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const [, image, label, href, code, strong, em, del] = match;
|
|
15
|
-
if (at > cut) nodes.push(text.slice(cut, at));
|
|
16
|
-
cut = at + match[0].length;
|
|
17
|
-
if (href)
|
|
18
|
-
nodes.push(
|
|
19
|
-
image || !isSafeHref(href) ? (
|
|
20
|
-
label
|
|
21
|
-
) : (
|
|
22
|
-
<a className="underline" href={href} key={at} rel="noreferrer" target="_blank">
|
|
23
|
-
{spans(label)}
|
|
24
|
-
</a>
|
|
25
|
-
),
|
|
8
|
+
const node = (span: MarkdownSpan, at: number): ReactNode => {
|
|
9
|
+
switch (span.kind) {
|
|
10
|
+
case "link":
|
|
11
|
+
return (
|
|
12
|
+
<a className="underline" href={span.href} key={at} rel="noreferrer" target="_blank">
|
|
13
|
+
{spans(span.text)}
|
|
14
|
+
</a>
|
|
26
15
|
);
|
|
27
|
-
|
|
28
|
-
|
|
16
|
+
case "code":
|
|
17
|
+
return (
|
|
29
18
|
<code className="rounded-field bg-muted px-1 font-mono text-[11px]" key={at}>
|
|
30
|
-
{
|
|
31
|
-
</code
|
|
19
|
+
{span.text}
|
|
20
|
+
</code>
|
|
32
21
|
);
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
case "strong":
|
|
23
|
+
return (
|
|
35
24
|
<strong className="font-semibold" key={at}>
|
|
36
|
-
{spans(
|
|
37
|
-
</strong
|
|
25
|
+
{spans(span.text)}
|
|
26
|
+
</strong>
|
|
38
27
|
);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
case "em":
|
|
29
|
+
return <em key={at}>{spans(span.text)}</em>;
|
|
30
|
+
case "del":
|
|
31
|
+
return (
|
|
42
32
|
<del className="opacity-60" key={at}>
|
|
43
|
-
{
|
|
44
|
-
</del
|
|
33
|
+
{span.text}
|
|
34
|
+
</del>
|
|
45
35
|
);
|
|
36
|
+
default:
|
|
37
|
+
return span.text;
|
|
46
38
|
}
|
|
47
|
-
if (cut < text.length) nodes.push(text.slice(cut));
|
|
48
|
-
return nodes;
|
|
49
39
|
};
|