@agent-native/core 0.135.1 → 0.135.2
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/docs/content/getting-started-actions.mdx +235 -0
- package/corpus/core/docs/content/getting-started-database.mdx +253 -0
- package/corpus/core/docs/content/getting-started-pages.mdx +190 -0
- package/corpus/core/docs/content/getting-started.mdx +57 -613
- package/corpus/core/package.json +1 -1
- package/corpus/templates/clips/actions/add-comment.ts +6 -2
- package/corpus/templates/clips/app/components/player/comments-panel.tsx +9 -2
- package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +44 -27
- package/corpus/templates/clips/app/components/player/scrubber.tsx +10 -2
- package/corpus/templates/clips/app/components/player/video-player.tsx +4 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +22 -15
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +1 -0
- package/dist/observability/routes.d.ts +3 -3
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/docs/content/getting-started-actions.mdx +235 -0
- package/docs/content/getting-started-database.mdx +253 -0
- package/docs/content/getting-started-pages.mdx +190 -0
- package/docs/content/getting-started.mdx +57 -613
- package/package.json +1 -1
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.135.
|
|
3
|
+
"version": "0.135.2",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -67,6 +67,10 @@ export default defineAction({
|
|
|
67
67
|
|
|
68
68
|
if (!rec) throw new Error(`Recording not found: ${args.recordingId}`);
|
|
69
69
|
|
|
70
|
+
// Floor to the nearest second so nearby comments land on the same
|
|
71
|
+
// timestamp bucket for scrubber grouping and the playback overlay.
|
|
72
|
+
const videoTimestampMs = Math.floor(args.videoTimestampMs / 1000) * 1000;
|
|
73
|
+
|
|
70
74
|
await db.insert(schema.recordingComments).values({
|
|
71
75
|
id,
|
|
72
76
|
recordingId: args.recordingId,
|
|
@@ -76,7 +80,7 @@ export default defineAction({
|
|
|
76
80
|
authorEmail,
|
|
77
81
|
authorName: args.authorName ?? null,
|
|
78
82
|
content: args.content,
|
|
79
|
-
videoTimestampMs
|
|
83
|
+
videoTimestampMs,
|
|
80
84
|
createdAt: now,
|
|
81
85
|
updatedAt: now,
|
|
82
86
|
});
|
|
@@ -94,7 +98,7 @@ export default defineAction({
|
|
|
94
98
|
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
95
99
|
|
|
96
100
|
console.log(
|
|
97
|
-
`Added comment to recording ${args.recordingId} @ ${
|
|
101
|
+
`Added comment to recording ${args.recordingId} @ ${videoTimestampMs}ms (thread: ${threadId})`,
|
|
98
102
|
);
|
|
99
103
|
|
|
100
104
|
return { id, threadId, notified };
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useActionMutation,
|
|
3
|
+
useAvatarUrl,
|
|
4
|
+
} from "@agent-native/core/client/hooks";
|
|
2
5
|
import { useT } from "@agent-native/core/client/i18n";
|
|
3
6
|
import {
|
|
4
7
|
IconSend,
|
|
@@ -12,7 +15,7 @@ import {
|
|
|
12
15
|
import { useQueryClient } from "@tanstack/react-query";
|
|
13
16
|
import { type Ref, useEffect, useMemo, useRef, useState } from "react";
|
|
14
17
|
|
|
15
|
-
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
18
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
16
19
|
import { Button } from "@/components/ui/button";
|
|
17
20
|
import {
|
|
18
21
|
DropdownMenu,
|
|
@@ -860,10 +863,14 @@ function CommentCard({
|
|
|
860
863
|
}
|
|
861
864
|
|
|
862
865
|
const commentContent = linkifyCommentContent(comment.content);
|
|
866
|
+
const avatarUrl = useAvatarUrl(comment.authorEmail);
|
|
863
867
|
|
|
864
868
|
return (
|
|
865
869
|
<div className={cn("flex gap-2", comment.resolved && "opacity-60")}>
|
|
866
870
|
<Avatar className="h-7 w-7 shrink-0">
|
|
871
|
+
{avatarUrl ? (
|
|
872
|
+
<AvatarImage src={avatarUrl} alt={displayName(comment)} />
|
|
873
|
+
) : null}
|
|
867
874
|
<AvatarFallback className="text-[10px] bg-primary text-primary-foreground">
|
|
868
875
|
{initials(displayName(comment))}
|
|
869
876
|
</AvatarFallback>
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import { useAvatarUrl } from "@agent-native/core/client/hooks";
|
|
2
|
+
|
|
3
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
export const PLAYBACK_COMMENT_VISIBLE_MS = 1_000;
|
|
2
7
|
|
|
3
8
|
export function getPlaybackCommentVisibleMs(playbackRate = 1): number {
|
|
4
9
|
const safePlaybackRate =
|
|
@@ -50,50 +55,62 @@ export function PlaybackCommentOverlay({
|
|
|
50
55
|
comments,
|
|
51
56
|
currentMs,
|
|
52
57
|
playbackRate = 1,
|
|
58
|
+
onClick,
|
|
53
59
|
}: {
|
|
54
60
|
comments: PlaybackComment[] | undefined;
|
|
55
61
|
currentMs: number;
|
|
56
62
|
playbackRate?: number;
|
|
63
|
+
onClick?: () => void;
|
|
57
64
|
}) {
|
|
58
65
|
const activeComments = getActivePlaybackComments(
|
|
59
66
|
comments,
|
|
60
67
|
currentMs,
|
|
61
68
|
playbackRate,
|
|
62
69
|
);
|
|
70
|
+
const avatarUrl = useAvatarUrl(activeComments[0]?.authorEmail);
|
|
63
71
|
if (activeComments.length === 0) return null;
|
|
64
72
|
|
|
73
|
+
const [comment, ...rest] = activeComments;
|
|
74
|
+
const author = displayAuthor(comment);
|
|
75
|
+
const Card = (onClick ? "button" : "div") as "button" | "div";
|
|
76
|
+
|
|
65
77
|
return (
|
|
66
78
|
<div
|
|
67
79
|
data-player-ui
|
|
68
80
|
className="pointer-events-none absolute inset-x-3 bottom-[5.25rem] z-20 flex justify-center sm:inset-x-6"
|
|
69
81
|
aria-live="polite"
|
|
70
82
|
>
|
|
71
|
-
<
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
</
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
83
|
+
<Card
|
|
84
|
+
key={comment.id}
|
|
85
|
+
type={onClick ? "button" : undefined}
|
|
86
|
+
onClick={onClick}
|
|
87
|
+
className={cn(
|
|
88
|
+
"animate-in fade-in slide-in-from-bottom-2 flex w-full max-w-xl flex-col gap-1.5 rounded-xl bg-black/85 px-3 py-2.5 text-left text-white shadow-2xl ring-1 ring-white/15 backdrop-blur-md duration-200",
|
|
89
|
+
onClick && "pointer-events-auto cursor-pointer hover:bg-black/95",
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<div className="flex max-w-full items-start gap-2.5">
|
|
93
|
+
<Avatar aria-hidden="true" className="size-7 shrink-0">
|
|
94
|
+
{avatarUrl ? <AvatarImage src={avatarUrl} alt={author} /> : null}
|
|
95
|
+
<AvatarFallback className="bg-white/15 text-[10px] font-semibold text-white">
|
|
96
|
+
{initials(author)}
|
|
97
|
+
</AvatarFallback>
|
|
98
|
+
</Avatar>
|
|
99
|
+
<div className="min-w-0">
|
|
100
|
+
<p className="truncate text-xs font-semibold text-white/80">
|
|
101
|
+
{author}
|
|
102
|
+
</p>
|
|
103
|
+
<p className="line-clamp-3 break-words text-sm leading-5 text-white">
|
|
104
|
+
{comment.content}
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
{rest.length > 0 && (
|
|
109
|
+
<p className="pl-[2.375rem] text-xs text-white/60">
|
|
110
|
+
+{rest.length} other comment{rest.length > 1 ? "s" : ""}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
</Card>
|
|
97
114
|
</div>
|
|
98
115
|
);
|
|
99
116
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { IconMessage2Filled } from "@tabler/icons-react";
|
|
1
2
|
import { useMemo, useRef, useState } from "react";
|
|
2
3
|
|
|
3
4
|
import { cn } from "@/lib/utils";
|
|
@@ -225,10 +226,17 @@ export function Scrubber(props: ScrubberProps) {
|
|
|
225
226
|
e.stopPropagation();
|
|
226
227
|
onSeek(ms);
|
|
227
228
|
}}
|
|
228
|
-
className="absolute
|
|
229
|
+
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md ring-2 ring-background transition-transform hover:scale-125"
|
|
229
230
|
style={{ left: (ms / Math.max(1, durationMs)) * 100 + "%" }}
|
|
230
231
|
aria-label={`${list.length} comment${list.length > 1 ? "s" : ""}`}
|
|
231
|
-
|
|
232
|
+
>
|
|
233
|
+
<IconMessage2Filled className="h-2.5 w-2.5" />
|
|
234
|
+
{list.length > 1 && (
|
|
235
|
+
<span className="absolute -top-1.5 -right-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[8px] font-bold leading-none text-primary-foreground ring-2 ring-background">
|
|
236
|
+
{list.length}
|
|
237
|
+
</span>
|
|
238
|
+
)}
|
|
239
|
+
</button>
|
|
232
240
|
))}
|
|
233
241
|
|
|
234
242
|
{/* Reaction dots */}
|
|
@@ -218,6 +218,8 @@ export interface VideoPlayerProps {
|
|
|
218
218
|
* lifecycle instead of polling an imperative-handle getter.
|
|
219
219
|
*/
|
|
220
220
|
onVideoElementChange?: (video: HTMLVideoElement | null) => void;
|
|
221
|
+
/** Called when the viewer clicks the timestamped-comment overlay. */
|
|
222
|
+
onCommentClick?: () => void;
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
@@ -255,6 +257,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
255
257
|
recordingId,
|
|
256
258
|
role,
|
|
257
259
|
onVideoElementChange,
|
|
260
|
+
onCommentClick,
|
|
258
261
|
} = props;
|
|
259
262
|
|
|
260
263
|
const resolvedVideoSrc = useMemo(
|
|
@@ -1651,6 +1654,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
1651
1654
|
comments={comments}
|
|
1652
1655
|
currentMs={currentMs}
|
|
1653
1656
|
playbackRate={speed}
|
|
1657
|
+
onClick={onCommentClick}
|
|
1654
1658
|
/>
|
|
1655
1659
|
) : null}
|
|
1656
1660
|
|
|
@@ -261,15 +261,26 @@ export default function RecordingPage() {
|
|
|
261
261
|
// The compact layout stacks the panel below the video, so switching tabs
|
|
262
262
|
// alone leaves the user looking at the player. Desktop always renders the
|
|
263
263
|
// side aside, so nothing to scroll there.
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
264
|
+
const openSidePanel = useCallback(
|
|
265
|
+
(next: SidePanel) => {
|
|
266
|
+
setPanel(next);
|
|
267
|
+
if (!isCompactLayout) return;
|
|
268
|
+
requestAnimationFrame(() => {
|
|
269
|
+
document
|
|
270
|
+
.getElementById("clip-activity-panel")
|
|
271
|
+
?.scrollIntoView({ block: "start" });
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
[isCompactLayout],
|
|
275
|
+
);
|
|
276
|
+
const openInsightsPanel = useCallback(
|
|
277
|
+
() => openSidePanel("insights"),
|
|
278
|
+
[openSidePanel],
|
|
279
|
+
);
|
|
280
|
+
const openCommentsPanel = useCallback(
|
|
281
|
+
() => openSidePanel("comments"),
|
|
282
|
+
[openSidePanel],
|
|
283
|
+
);
|
|
273
284
|
const transcriptKickedRef = useRef<string | null>(null);
|
|
274
285
|
// When the recording lands in the processing state but never flips to
|
|
275
286
|
// 'ready', stop spinning forever and surface an error banner so the user
|
|
@@ -1608,6 +1619,7 @@ export default function RecordingPage() {
|
|
|
1608
1619
|
cta={firstCta}
|
|
1609
1620
|
onCtaClick={() => tracking.reportCtaClick()}
|
|
1610
1621
|
onTimeUpdate={(ms) => setCurrentMs(ms)}
|
|
1622
|
+
onCommentClick={openCommentsPanel}
|
|
1611
1623
|
className="h-full w-full rounded-none sm:rounded-xl"
|
|
1612
1624
|
/>
|
|
1613
1625
|
{commentOpen ? (
|
|
@@ -1667,12 +1679,7 @@ export default function RecordingPage() {
|
|
|
1667
1679
|
className="shrink-0"
|
|
1668
1680
|
onOpen={() => {
|
|
1669
1681
|
if (isCompactLayout) {
|
|
1670
|
-
|
|
1671
|
-
requestAnimationFrame(() => {
|
|
1672
|
-
document
|
|
1673
|
-
.getElementById("clip-activity-panel")
|
|
1674
|
-
?.scrollIntoView({ block: "start" });
|
|
1675
|
-
});
|
|
1682
|
+
openCommentsPanel();
|
|
1676
1683
|
return;
|
|
1677
1684
|
}
|
|
1678
1685
|
setCommentAtMs(resolvePlaybackMs());
|
|
@@ -997,6 +997,7 @@ export default function ShareRoute() {
|
|
|
997
997
|
cta={firstCta}
|
|
998
998
|
onCtaClick={() => tracking.reportCtaClick()}
|
|
999
999
|
onTimeUpdate={(ms) => setCurrentMs(ms)}
|
|
1000
|
+
onCommentClick={() => setPanel("comments")}
|
|
1000
1001
|
className="h-full w-full rounded-none sm:rounded-xl"
|
|
1001
1002
|
/>
|
|
1002
1003
|
</div>
|
|
@@ -41,16 +41,16 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
|
|
|
41
41
|
thumbsUpRate: number;
|
|
42
42
|
avgEvalScore: number;
|
|
43
43
|
} | {
|
|
44
|
-
error?: undefined;
|
|
45
44
|
summary: import("./types.js").TraceSummary;
|
|
46
45
|
spans: import("./types.js").TraceSpan[];
|
|
47
46
|
id?: undefined;
|
|
47
|
+
error?: undefined;
|
|
48
48
|
ok?: undefined;
|
|
49
49
|
} | {
|
|
50
|
-
error?: undefined;
|
|
51
50
|
summary?: undefined;
|
|
52
51
|
spans?: undefined;
|
|
53
52
|
id: string;
|
|
53
|
+
error?: undefined;
|
|
54
54
|
ok?: undefined;
|
|
55
55
|
} | {
|
|
56
56
|
summary?: undefined;
|
|
@@ -59,10 +59,10 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
|
|
|
59
59
|
error: any;
|
|
60
60
|
ok?: undefined;
|
|
61
61
|
} | {
|
|
62
|
-
error?: undefined;
|
|
63
62
|
summary?: undefined;
|
|
64
63
|
spans?: undefined;
|
|
65
64
|
id?: undefined;
|
|
65
|
+
error?: undefined;
|
|
66
66
|
ok: boolean;
|
|
67
67
|
}>>;
|
|
68
68
|
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -48,8 +48,8 @@ export declare function handleUpdateResource(event: any): Promise<import("./stor
|
|
|
48
48
|
}>;
|
|
49
49
|
/** DELETE /_agent-native/resources/:id — delete a resource */
|
|
50
50
|
export declare function handleDeleteResource(event: any): Promise<{
|
|
51
|
-
ok?: undefined;
|
|
52
51
|
error: string;
|
|
52
|
+
ok?: undefined;
|
|
53
53
|
} | {
|
|
54
54
|
error?: undefined;
|
|
55
55
|
ok: boolean;
|
package/dist/secrets/routes.d.ts
CHANGED
|
@@ -34,37 +34,37 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
|
|
|
34
34
|
/** POST /_agent-native/secrets/:key — write a secret. */
|
|
35
35
|
export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
36
36
|
error: string;
|
|
37
|
-
ok?: undefined;
|
|
38
37
|
status?: undefined;
|
|
38
|
+
ok?: undefined;
|
|
39
39
|
} | {
|
|
40
|
-
error?: undefined;
|
|
41
40
|
ok: boolean;
|
|
42
41
|
status: string;
|
|
42
|
+
error?: undefined;
|
|
43
43
|
} | {
|
|
44
|
-
ok?: undefined;
|
|
45
44
|
error: string;
|
|
46
45
|
removed?: undefined;
|
|
46
|
+
ok?: undefined;
|
|
47
47
|
} | {
|
|
48
|
-
error?: undefined;
|
|
49
48
|
ok: boolean;
|
|
50
49
|
removed: boolean;
|
|
50
|
+
error?: undefined;
|
|
51
51
|
}>>;
|
|
52
52
|
/**
|
|
53
53
|
* POST /_agent-native/secrets/:key/test — re-run the validator against the
|
|
54
54
|
* current stored value without changing anything. Useful for the "Test" button.
|
|
55
55
|
*/
|
|
56
56
|
export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
57
|
-
ok?: undefined;
|
|
58
57
|
error: string;
|
|
59
58
|
note?: undefined;
|
|
59
|
+
ok?: undefined;
|
|
60
60
|
} | {
|
|
61
|
-
error?: undefined;
|
|
62
61
|
ok: boolean;
|
|
63
62
|
note?: undefined;
|
|
64
|
-
} | {
|
|
65
63
|
error?: undefined;
|
|
64
|
+
} | {
|
|
66
65
|
ok: boolean;
|
|
67
66
|
note: string;
|
|
67
|
+
error?: undefined;
|
|
68
68
|
} | {
|
|
69
69
|
note?: undefined;
|
|
70
70
|
ok: boolean;
|
|
@@ -95,12 +95,12 @@ export interface AdHocSecretPayload {
|
|
|
95
95
|
export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
|
|
96
96
|
error: string;
|
|
97
97
|
} | {
|
|
98
|
-
error?: undefined;
|
|
99
98
|
ok: boolean;
|
|
100
99
|
key: string;
|
|
101
|
-
} | {
|
|
102
100
|
error?: undefined;
|
|
101
|
+
} | {
|
|
103
102
|
ok: boolean;
|
|
104
103
|
removed: boolean;
|
|
104
|
+
error?: undefined;
|
|
105
105
|
}>>;
|
|
106
106
|
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Add an Action"
|
|
3
|
+
description: "Define your first action and render its result as a UI component directly inside the chat transcript."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add an Action
|
|
7
|
+
|
|
8
|
+
This is part two of the Getting Started series. In [Getting Started](/docs/getting-started) you created a Chat app and connected an AI engine. Here you'll define your first action and render its result inline in chat.
|
|
9
|
+
|
|
10
|
+
## Add an action {#add-an-action}
|
|
11
|
+
|
|
12
|
+
An action is a typed operation that both your agent and your UI can call. It's
|
|
13
|
+
how the agent does things in your app. Actions live in the `actions/` directory
|
|
14
|
+
and can be triggered from chat, from React components, from the CLI, or on a
|
|
15
|
+
schedule. You define them once and call them from anywhere.
|
|
16
|
+
|
|
17
|
+
### Try the starter action
|
|
18
|
+
|
|
19
|
+
The Chat template includes a `hello` action at `actions/hello.ts`:
|
|
20
|
+
|
|
21
|
+
```ts filename="actions/hello.ts"
|
|
22
|
+
import { defineAction } from "@agent-native/core/action";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
|
|
25
|
+
export default defineAction({
|
|
26
|
+
description: "Return a friendly greeting.",
|
|
27
|
+
schema: z.object({
|
|
28
|
+
name: z.string().default("world").describe("Name to greet"),
|
|
29
|
+
}),
|
|
30
|
+
http: { method: "GET" },
|
|
31
|
+
run: async ({ name }) => {
|
|
32
|
+
return { message: `Hello, ${name}!` };
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Run it from the terminal (inside your `my-app/` directory):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pnpm action hello --name Alice
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or open your app at `http://localhost:8080` and ask the agent in the chat there:
|
|
44
|
+
|
|
45
|
+
> Use the hello action with the name Alice.
|
|
46
|
+
|
|
47
|
+
### Add your own action
|
|
48
|
+
|
|
49
|
+
Replace the starter action with the first real operation in your domain. This example
|
|
50
|
+
counts words, sentences, and paragraphs in any text you pass it. It computes
|
|
51
|
+
everything locally, so there's nothing to configure and no external service to connect.
|
|
52
|
+
|
|
53
|
+
Create a new file called `analyze-text.ts` in your `actions/` directory:
|
|
54
|
+
|
|
55
|
+
```ts filename="actions/analyze-text.ts"
|
|
56
|
+
import { defineAction } from "@agent-native/core/action";
|
|
57
|
+
import { z } from "zod";
|
|
58
|
+
|
|
59
|
+
const textStatsSchema = z.object({
|
|
60
|
+
title: z.string(),
|
|
61
|
+
points: z.array(z.object({ label: z.string(), value: z.number() })),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export default defineAction({
|
|
65
|
+
description: "Count words, sentences, and paragraphs in a block of text.",
|
|
66
|
+
schema: z.object({
|
|
67
|
+
text: z
|
|
68
|
+
.string()
|
|
69
|
+
.default(
|
|
70
|
+
"The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.",
|
|
71
|
+
),
|
|
72
|
+
}),
|
|
73
|
+
outputSchema: textStatsSchema,
|
|
74
|
+
chatUI: {
|
|
75
|
+
renderer: "text.stats-chart",
|
|
76
|
+
title: "Text stats",
|
|
77
|
+
},
|
|
78
|
+
readOnly: true,
|
|
79
|
+
run: async ({ text }) => ({
|
|
80
|
+
title: "Text statistics",
|
|
81
|
+
points: [
|
|
82
|
+
{ label: "Characters", value: text.length },
|
|
83
|
+
{ label: "Words", value: text.split(/\s+/).filter(Boolean).length },
|
|
84
|
+
{
|
|
85
|
+
label: "Sentences",
|
|
86
|
+
value: text.split(/[.!?]+/).filter(Boolean).length,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
label: "Paragraphs",
|
|
90
|
+
value: text.split(/\n\n+/).filter(Boolean).length,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Try it from the terminal:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pnpm action analyze-text --text "Hello world. How are you today?"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Or open your app at `http://localhost:8080` and ask the agent in the chat there:
|
|
104
|
+
|
|
105
|
+
> Run the analyze-text action on "Hello world. How are you today?"
|
|
106
|
+
|
|
107
|
+
#### Define once, call from anywhere
|
|
108
|
+
|
|
109
|
+
This action is now reachable from chat, React hooks, CLI, HTTP, MCP, A2A,
|
|
110
|
+
scheduled jobs, and webhooks.
|
|
111
|
+
|
|
112
|
+
TIP: Any time you want the agent to call a specific action without ambiguity, phrasing it as "Run the `<action-name>` action" is most reliable. Natural-language prompts work well once the agent has enough context about your app's domain. For a brand-new app with no data or context yet, explicit is safer.
|
|
113
|
+
|
|
114
|
+
## Render the result inline {#render-inline}
|
|
115
|
+
|
|
116
|
+
When the agent runs `analyze-text`, it returns structured data: a title and an
|
|
117
|
+
array of counts. By default the agent will describe that data in prose: "The
|
|
118
|
+
text has 9 words, 2 sentences..." and so on. That works, but you can
|
|
119
|
+
also render the result as a real UI component (a bar chart, a table, a card)
|
|
120
|
+
directly inside the chat transcript, right where the agent responded.
|
|
121
|
+
|
|
122
|
+
This is what `chatUI.renderer` in the action does. It's a label that says "when
|
|
123
|
+
this action's result appears in chat, hand it to this React component instead of
|
|
124
|
+
summarizing it in text." The component receives the validated action output as
|
|
125
|
+
props and renders whatever you want.
|
|
126
|
+
|
|
127
|
+
In the next step, you'll create `app/chat-renderers.tsx`, but first, add one import line
|
|
128
|
+
to `app/root.tsx` so it runs on startup:
|
|
129
|
+
|
|
130
|
+
```ts filename="app/root.tsx"
|
|
131
|
+
import "./chat-renderers";
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Add it alongside your other imports at the top of the file. That's the only
|
|
135
|
+
change to `root.tsx`. The import just ensures the file runs and registers the
|
|
136
|
+
renderer. Now create the renderer file:
|
|
137
|
+
|
|
138
|
+
```tsx filename="app/chat-renderers.tsx"
|
|
139
|
+
import {
|
|
140
|
+
registerActionChatRenderer,
|
|
141
|
+
type ToolRendererProps,
|
|
142
|
+
} from "@agent-native/core/client/chat";
|
|
143
|
+
|
|
144
|
+
type TextStatsResult = {
|
|
145
|
+
title: string;
|
|
146
|
+
points: Array<{ label: string; value: number }>;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const MAX_BAR_PX = 80;
|
|
150
|
+
|
|
151
|
+
function TextStatsChart({ context }: ToolRendererProps) {
|
|
152
|
+
const result = context.resultJson as TextStatsResult;
|
|
153
|
+
const max = Math.max(...result.points.map((point) => point.value), 1);
|
|
154
|
+
return (
|
|
155
|
+
<section className="rounded-lg border bg-card p-4">
|
|
156
|
+
<h3 className="text-sm font-medium">{result.title}</h3>
|
|
157
|
+
<div className="mt-4 flex items-end gap-2">
|
|
158
|
+
{result.points.map((point) => (
|
|
159
|
+
<div
|
|
160
|
+
key={point.label}
|
|
161
|
+
className="flex flex-1 flex-col items-center gap-2"
|
|
162
|
+
>
|
|
163
|
+
<div
|
|
164
|
+
className="w-full rounded-t bg-blue-500"
|
|
165
|
+
style={{
|
|
166
|
+
height: `${Math.max(Math.round((point.value / max) * MAX_BAR_PX), 2)}px`,
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
<span className="text-xs text-muted-foreground">{point.label}</span>
|
|
170
|
+
</div>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
</section>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
registerActionChatRenderer({
|
|
178
|
+
id: "text.stats-chart",
|
|
179
|
+
renderer: "text.stats-chart",
|
|
180
|
+
Component: TextStatsChart,
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Once the renderer is registered, the agent's response looks like this. Instead
|
|
185
|
+
of a paragraph of text, your React component renders directly inside the chat
|
|
186
|
+
transcript:
|
|
187
|
+
|
|
188
|
+
<WireframeBlock id="doc-block-inline-result-wireframe">
|
|
189
|
+
<Screen
|
|
190
|
+
surface="desktop"
|
|
191
|
+
html={
|
|
192
|
+
"<div style='min-height:340px;box-sizing:border-box;padding:24px;display:flex;justify-content:center;align-items:center;background:var(--wf-bg)'><div style='width:min(640px,100%);display:flex;flex-direction:column;gap:14px'><div class='wf-card' data-rough style='align-self:flex-end;max-width:70%;padding:12px 14px'><strong>User</strong><p style='margin:6px 0 0'>Run the analyze-text action on \"Hello world. How are you today?\"</p></div><div class='wf-card' data-rough style='align-self:flex-start;width:min(480px,100%);padding:14px'><strong>Agent</strong><p class='wf-muted' style='margin:6px 0 12px'>Rendered with text.stats-chart.</p><section class='wf-card' data-rough style='padding:14px'><h3 style='margin:0 0 12px;font-size:14px'>Text statistics</h3><div data-rough='line:bottom' style='height:104px;display:flex;align-items:end;gap:8px;border-bottom:1.4px solid var(--wf-line);padding-bottom:4px'><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:80px;width:100%;background:color-mix(in srgb, var(--wf-accent) 36%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Characters</span></div><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:18px;width:100%;background:color-mix(in srgb, var(--wf-accent) 30%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Words</span></div><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:2px;width:100%;background:color-mix(in srgb, var(--wf-accent) 24%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Sentences</span></div><div style='flex:1;display:flex;flex-direction:column;align-items:center;gap:6px'><div data-rough style='height:2px;width:100%;background:color-mix(in srgb, var(--wf-accent) 24%, transparent);border:1.4px solid var(--wf-accent);border-radius:8px 8px 3px 3px'></div><span class='wf-muted'>Paragraphs</span></div></div></section></div></div></div>"
|
|
193
|
+
}
|
|
194
|
+
/>
|
|
195
|
+
</WireframeBlock>
|
|
196
|
+
|
|
197
|
+
Use this step when the result belongs where the agent is speaking:
|
|
198
|
+
|
|
199
|
+
- setup summaries
|
|
200
|
+
- short reports
|
|
201
|
+
- approvals
|
|
202
|
+
- tables or charts small enough to inspect inline
|
|
203
|
+
- links into durable app views
|
|
204
|
+
|
|
205
|
+
For reusable generic outputs, the framework also ships built-in
|
|
206
|
+
`data-chart` and `data-table` renderers, plus `data-insights` for combined
|
|
207
|
+
summary/chart/table cards. See [Native Chat UI](/docs/native-chat-ui). For
|
|
208
|
+
temporary controls the agent creates at runtime, see
|
|
209
|
+
[Generative UI](/docs/generative-ui).
|
|
210
|
+
|
|
211
|
+
## What's next {#next}
|
|
212
|
+
|
|
213
|
+
<Cards>
|
|
214
|
+
|
|
215
|
+
### [Persist Data in SQL](/docs/getting-started-database)
|
|
216
|
+
|
|
217
|
+
Save action results to a database so the agent can reference them across
|
|
218
|
+
conversations. Next in the series.
|
|
219
|
+
|
|
220
|
+
### [Actions](/docs/actions)
|
|
221
|
+
|
|
222
|
+
Schemas, auth, approvals, hooks, and transport — the full reference for what
|
|
223
|
+
actions can do.
|
|
224
|
+
|
|
225
|
+
### [Native Chat UI](/docs/native-chat-ui)
|
|
226
|
+
|
|
227
|
+
Built-in renderers for tables, charts, and typed cards — beyond the custom
|
|
228
|
+
renderer you just built.
|
|
229
|
+
|
|
230
|
+
### [Key Concepts](/docs/key-concepts)
|
|
231
|
+
|
|
232
|
+
The architecture underneath this tutorial: SQL, actions, live sync, and context
|
|
233
|
+
awareness.
|
|
234
|
+
|
|
235
|
+
</Cards>
|