Build Verse: a poem-quote app on the agent-native chat template
CI / build (push) Successful in 4m26s

Adds a Postgres-backed quotes domain (poems/quotes/favorites) exposed as
five agent-native actions (list-quotes, get-random-quote, create-quote,
toggle-favorite, list-favorites) callable from both chat and the React UI
via useActionQuery/useActionMutation. Moves the primary UI from chat to a
mobile-first Quotes/Favorites experience (chat moves to /chat), adds
manual OpenTelemetry instrumentation exporting traces/metrics/logs over
OTLP, and wires a local Docker Postgres for shared state.
This commit is contained in:
exe.dev user
2026-07-25 17:42:04 +00:00
parent ffea2aeff7
commit 0a18c774a5
44 changed files with 14702 additions and 156 deletions
+11
View File
@@ -3,3 +3,14 @@ PING_MESSAGE=pong
# Skip login/signup (local dev / preview only)
# AUTH_DISABLED=true
# Persistent secret for auth sessions (generate with: openssl rand -hex 32)
# BETTER_AUTH_SECRET=
# Postgres connection (see docker-compose.yml for local Docker Postgres)
# POSTGRES_PASSWORD=
# DATABASE_URL=postgres://versequote:password@127.0.0.1:5434/versequote
# Observability — OTLP http endpoint (Grafana LGTM / otel-collector)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_SERVICE_NAME=verse-quote-app
+35
View File
@@ -0,0 +1,35 @@
name: CI
on:
push:
branches: ['**']
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: '10'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Build
run: pnpm build
+3
View File
@@ -11,6 +11,9 @@ pnpm-debug.log*
node_modules
dist
dist-ssr
.output
.deploy-tmp
build
*.local
# Editor directories and files
+36 -5
View File
@@ -1,8 +1,9 @@
# Chat — Agent Guide
# Verse — Agent Guide
Chat is the minimal chat-first agent-native app template. Keep chat as the
primary surface, add actions for real capabilities, and add screens only when a
workflow needs durable UI around the conversation.
Verse is a quote app built on poems: browse, search, and favorite quotes drawn
from poems, or submit new ones. It started from the agent-native chat template,
but the primary surface is the quotes UI at `/` — chat lives at `/chat` and is
a secondary way to reach the same actions (see "Domain & Actions" below).
## Core Rules
@@ -35,10 +36,40 @@ workflow needs durable UI around the conversation.
- For new features, update UI, actions, skills/instructions, and application
state when applicable.
## Domain & Actions
Data model: `poems` (author, title, source, year) → `quotes` (text, tags,
is_user_submitted) → `favorites` (quote_id, visitor_id). `visitor_id` is an
anonymous per-browser id (localStorage), not a login — there is no user
account system in this app; `AUTH_DISABLED=true` by default since no Zitadel
client is configured for this instance.
- **list-quotes** (GET) — browse/search quotes. Optional `tag`, `author`,
`search` (free text over quote + poem title), `visitorId` (marks
`isFavorited` on each result), `favoritesOnly`, `limit`. Use this for any
"show me quotes about X" / "quotes by Y" request.
- **get-random-quote** (GET) — one random quote, optionally filtered by `tag`.
Use for "quote of the day" / "surprise me" requests.
- **create-quote** (POST) — submit a new quote. Requires `text`, `author`,
`poemTitle`; optional `source`, `year`, `tags`. Creates the poem record if
author+title doesn't already exist (case-sensitive exact match). Marks the
quote `is_user_submitted: true`.
- **toggle-favorite** (POST) — favorite/unfavorite a quote for a `visitorId`
(toggles based on current state, no separate favorite/unfavorite actions).
Returns `{ favorited, favoriteCount }`.
- **list-favorites** (GET) — all quotes a `visitorId` has favorited, most
recent first. Requires `visitorId`.
All five are callable from chat and from the React UI via
`useActionQuery`/`useActionMutation` — there is no separate `/api/*` REST
layer. Every action call emits an OpenTelemetry span, a structured log line,
and increments a call counter, exported over OTLP to the shared Grafana LGTM
stack (see `server/otel.ts`).
## Application State
- `navigation` should describe the current view and selected entity ids. The
default chat view is `chat` at `/`.
quotes home view is `quotes` at `/`; chat is a secondary view at `/chat`.
- `navigate` may be used to move the UI when the app supports it.
- `view-screen` is the first tool to call when the user's visible context
matters.
+31 -20
View File
@@ -1,32 +1,43 @@
# Chat
# Verse
The minimal agent-native starter app — a clean, ChatGPT-style shell with chat at
the center, durable threads, standard app navigation, auth, live sync, and
actions. Start here when you want a real browser app to build on without
committing to a domain template.
**Live app: [chat.agent-native.com](https://chat.agent-native.com)**
Chat is the basic agent-native app starting point. It gives you the app-agent
loop wired end to end and one example action, so you can add your own UI, data,
and actions on top.
A quote app built on poems: browse, search, and favorite quotes drawn from
classic poems, or submit your own. Built with
[Agent Native](https://agent-native.com) — every app operation is a single
`defineAction` callable from the React UI *and* from agent chat.
## Features
- ChatGPT-style shell with a threads list and durable chat history.
- Auth, live sync, and application state wired out of the box.
- The action surface the agent and UI share, plus one example action to copy.
- A minimal, brandable base for any domain app.
- Browse and search quotes by poet, poem, or theme tag.
- Favorite quotes anonymously (per-browser, no login required).
- Submit new quotes, which create the poem record if it doesn't exist yet.
- The same actions are callable from the in-app agent chat at `/chat`.
- Postgres-backed shared state, seeded with ~20 public-domain poems on first boot.
- OpenTelemetry traces/metrics/logs for every action call, exported over OTLP.
## Action surface
See `AGENTS.md` for the full list. In short: `list-quotes`, `get-random-quote`,
`create-quote`, `toggle-favorite`, `list-favorites`.
## Develop locally
Scaffold your own copy and run it:
```bash
npx @agent-native/core@latest create my-app --standalone --template chat
cd my-app
pnpm install
docker compose up -d # starts local Postgres
pnpm dev
```
Full docs: [agent-native.com/docs/template-chat](https://agent-native.com/docs/template-chat).
Set `DATABASE_URL` in `.env` (see `.env.example`) to point at the Postgres
container, or any Postgres/Neon/Supabase/libSQL connection string — schema and
seed data are created automatically on first boot via the migration plugin in
`server/plugins/db.ts`.
## Production
```bash
pnpm build
node .output/server/index.mjs
```
Built on the Agent Native `chat` template:
[agent-native.com/docs/template-chat](https://agent-native.com/docs/template-chat).
+68
View File
@@ -0,0 +1,68 @@
import { defineAction } from "@agent-native/core/action";
import { eq, and } from "drizzle-orm";
import { z } from "zod";
import { db, schema } from "../server/db/index.js";
import { hydrateQuotes } from "../server/lib/quotes.js";
import { withActionSpan } from "../server/otel.js";
export default defineAction({
description:
"Submit a new poem quote. Finds the poem by author + title (case-insensitive) or creates it if it doesn't exist yet, then adds the quote under it, marked as user-submitted.",
schema: z.object({
text: z.string().min(1).describe("The quote text (an excerpt from the poem)"),
author: z.string().min(1).describe("The poet's name"),
poemTitle: z.string().min(1).describe("Title of the poem this quote is from"),
source: z.string().optional().describe("Collection/book the poem was published in"),
year: z.coerce.number().int().optional().describe("Year the poem was written or published"),
tags: z
.array(z.string())
.optional()
.describe("Theme tags for the quote, e.g. [\"hope\", \"nature\"]"),
}),
run: async (args) => {
return withActionSpan(
"create-quote",
{ author: args.author, poemTitle: args.poemTitle },
async () => {
const existing = await db()
.select()
.from(schema.poems)
.where(
and(
eq(schema.poems.author, args.author),
eq(schema.poems.title, args.poemTitle),
),
)
.limit(1);
let poem = existing[0];
if (!poem) {
const inserted = await db()
.insert(schema.poems)
.values({
author: args.author,
title: args.poemTitle,
source: args.source ?? null,
year: args.year ?? null,
})
.returning();
poem = inserted[0];
}
const insertedQuote = await db()
.insert(schema.quotes)
.values({
poem_id: poem.id,
text: args.text,
tags_json: JSON.stringify(args.tags ?? []),
is_user_submitted: true,
})
.returning();
const [hydrated] = await hydrateQuotes([{ quote: insertedQuote[0], poem }]);
return hydrated;
},
);
},
});
+32
View File
@@ -0,0 +1,32 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
import { withActionSpan } from "../server/otel.js";
import { fetchAllQuoteRows, hydrateQuotes, parseTags } from "../server/lib/quotes.js";
export default defineAction({
description:
"Get one random poem quote, optionally restricted to a tag. Use for a \"quote of the day\" / discover surface.",
schema: z.object({
tag: z.string().optional().describe("Only pick from quotes with this tag"),
visitorId: z
.string()
.optional()
.describe("Anonymous visitor id, used to mark whether this visitor already favorited the pick"),
}),
http: { method: "GET" },
run: async (args) => {
return withActionSpan("get-random-quote", { tag: args.tag }, async () => {
const rows = await fetchAllQuoteRows();
const candidates = args.tag
? rows.filter((row) => parseTags(row.quote.tags_json).includes(args.tag!))
: rows;
if (candidates.length === 0) return null;
const pick = candidates[Math.floor(Math.random() * candidates.length)];
const [hydrated] = await hydrateQuotes([pick], args.visitorId);
return hydrated;
});
},
});
-13
View File
@@ -1,13 +0,0 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
export default defineAction({
description: "Return a friendly greeting.",
schema: z.object({
name: z.string().default("world").describe("Name to greet"),
}),
http: { method: "GET" },
run: async ({ name }) => {
return { message: `Hello, ${name}!` };
},
});
+32
View File
@@ -0,0 +1,32 @@
import { defineAction } from "@agent-native/core/action";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db, schema } from "../server/db/index.js";
import { fetchAllQuoteRows, hydrateQuotes } from "../server/lib/quotes.js";
import { withActionSpan } from "../server/otel.js";
export default defineAction({
description: "List all quotes a given visitor has favorited, most recently favorited first.",
schema: z.object({
visitorId: z.string().min(1).describe("Anonymous visitor id (from browser storage)"),
}),
http: { method: "GET" },
run: async (args) => {
return withActionSpan("list-favorites", { visitorId: args.visitorId }, async () => {
const favorites = await db()
.select()
.from(schema.favorites)
.where(eq(schema.favorites.visitor_id, args.visitorId));
if (favorites.length === 0) return [];
const favoritedAt = new Map(favorites.map((f) => [f.quote_id, f.created_at ?? ""]));
const allRows = await fetchAllQuoteRows();
const rows = allRows.filter((row) => favoritedAt.has(row.quote.id));
rows.sort((a, b) => (favoritedAt.get(b.quote.id) ?? "").localeCompare(favoritedAt.get(a.quote.id) ?? ""));
return hydrateQuotes(rows, args.visitorId);
});
},
});
+65
View File
@@ -0,0 +1,65 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
import { withActionSpan } from "../server/otel.js";
import { fetchAllQuoteRows, hydrateQuotes, parseTags } from "../server/lib/quotes.js";
export default defineAction({
description:
"List/browse poem quotes, optionally filtered by tag, author, or a free-text search over the quote and poem title. Set favoritesOnly=\"true\" with visitorId to list only that visitor's favorites (prefer list-favorites for that case though).",
schema: z.object({
tag: z.string().optional().describe("Only quotes with this tag (e.g. \"hope\", \"nature\")"),
author: z.string().optional().describe("Only quotes by this poet (exact match)"),
search: z
.string()
.optional()
.describe("Free-text search across quote text and poem title"),
visitorId: z
.string()
.optional()
.describe("Anonymous visitor id, used to mark which quotes this visitor has favorited"),
favoritesOnly: z
.string()
.optional()
.describe("Pass \"true\" (with visitorId) to only return favorited quotes"),
limit: z.coerce
.number()
.int()
.min(1)
.max(200)
.optional()
.describe("Max quotes to return (default 100)"),
}),
http: { method: "GET" },
run: async (args) => {
return withActionSpan(
"list-quotes",
{ tag: args.tag, author: args.author, search: args.search },
async () => {
const rows = await fetchAllQuoteRows();
const search = args.search?.trim().toLowerCase();
const filtered = rows.filter((row) => {
if (args.author && row.poem.author !== args.author) return false;
if (args.tag && !parseTags(row.quote.tags_json).includes(args.tag)) {
return false;
}
if (search) {
const haystack = `${row.quote.text} ${row.poem.title} ${row.poem.author}`.toLowerCase();
if (!haystack.includes(search)) return false;
}
return true;
});
filtered.sort((a, b) => (b.quote.created_at ?? "").localeCompare(a.quote.created_at ?? ""));
const limited = filtered.slice(0, args.limit ?? 100);
let hydrated = await hydrateQuotes(limited, args.visitorId);
if (args.favoritesOnly === "true") {
hydrated = hydrated.filter((q) => q.isFavorited);
}
return hydrated;
},
);
},
});
+54
View File
@@ -0,0 +1,54 @@
import { defineAction } from "@agent-native/core/action";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db, schema } from "../server/db/index.js";
import { quoteFavoriteCounter, withActionSpan } from "../server/otel.js";
export default defineAction({
description:
"Favorite or unfavorite a quote for a given visitor (toggles based on current state). Returns the new favorited state and total favorite count for the quote.",
schema: z.object({
quoteId: z.coerce.number().int().describe("The quote's id"),
visitorId: z.string().min(1).describe("Anonymous visitor id (from browser storage)"),
}),
run: async (args) => {
return withActionSpan(
"toggle-favorite",
{ quoteId: args.quoteId },
async () => {
const existing = await db()
.select()
.from(schema.favorites)
.where(
and(
eq(schema.favorites.quote_id, args.quoteId),
eq(schema.favorites.visitor_id, args.visitorId),
),
)
.limit(1);
let favorited: boolean;
if (existing.length > 0) {
await db().delete(schema.favorites).where(eq(schema.favorites.id, existing[0].id));
favorited = false;
quoteFavoriteCounter.add(1, { action: "remove" });
} else {
await db().insert(schema.favorites).values({
quote_id: args.quoteId,
visitor_id: args.visitorId,
});
favorited = true;
quoteFavoriteCounter.add(1, { action: "add" });
}
const allFavorites = await db()
.select()
.from(schema.favorites)
.where(eq(schema.favorites.quote_id, args.quoteId));
return { quoteId: args.quoteId, favorited, favoriteCount: allFavorites.length };
},
);
},
});
+3 -1
View File
@@ -10,7 +10,9 @@ import { useLocation } from "react-router";
import { APP_TITLE } from "@/lib/app-config";
const pageTitleKeys: Record<string, string> = {
"/": "navigation.chat",
"/": "navigation.quotes",
"/favorites": "navigation.favorites",
"/chat": "navigation.chat",
"/observability": "navigation.observability",
"/agent": "settings.agentTitle",
"/settings": "navigation.settings",
+4 -4
View File
@@ -38,7 +38,7 @@ const SIDEBAR_COLLAPSE_KEY = "chat.sidebar.collapsed";
*/
function routeOwnsToolbar(pathname: string): boolean {
return (
pathname === "/" ||
pathname === "/chat" ||
pathname.startsWith("/chat/") ||
pathname === "/database" ||
pathname.startsWith("/extensions")
@@ -52,7 +52,7 @@ export function Layout({ children }: LayoutProps) {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const isChatRoute =
location.pathname === "/" || location.pathname.startsWith("/chat/");
location.pathname === "/chat" || location.pathname.startsWith("/chat/");
const chatHomeHandoffActive = useAgentChatHomeHandoff({
storageKey: "chat",
activePath: location.pathname,
@@ -61,7 +61,7 @@ export function Layout({ children }: LayoutProps) {
const chatHomeHandoffPending = isAgentChatHomeHandoffActive("chat");
useAgentChatHomeHandoffLinks({
storageKey: "chat",
isChatPath: (pathname) => pathname === "/" || pathname.startsWith("/chat/"),
isChatPath: (pathname) => pathname === "/chat" || pathname.startsWith("/chat/"),
requireActiveHandoff: true,
});
@@ -100,7 +100,7 @@ export function Layout({ children }: LayoutProps) {
const ownsToolbar = routeOwnsToolbar(location.pathname);
function openAskAgentFullscreen() {
focusAgentChat();
navigateWithAgentChatViewTransition(navigate, "/");
navigateWithAgentChatViewTransition(navigate, "/chat");
}
const contentFrame = (
+22 -6
View File
@@ -14,6 +14,8 @@ import {
type ChatHistoryItem,
} from "@agent-native/toolkit/chat-history";
import {
IconBooks,
IconHeart,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconMessageCircle,
@@ -33,10 +35,22 @@ import { APP_TITLE } from "@/lib/app-config";
import { cn } from "@/lib/utils";
const navItems = [
{
icon: IconBooks,
labelKey: "navigation.quotes",
href: "/",
view: "quotes",
},
{
icon: IconHeart,
labelKey: "navigation.favorites",
href: "/favorites",
view: "favorites",
},
{
icon: IconMessageCircle,
labelKey: "navigation.chat",
href: "/",
href: "/chat",
view: "chat",
},
];
@@ -275,7 +289,7 @@ export function Sidebar({
const navigate = useNavigate();
const t = useT();
const isChatRoute =
location.pathname === "/" || location.pathname.startsWith("/chat/");
location.pathname === "/chat" || location.pathname.startsWith("/chat/");
const ToggleIcon = collapsed
? IconLayoutSidebarLeftExpand
: IconLayoutSidebarLeftCollapse;
@@ -397,15 +411,17 @@ export function Sidebar({
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
item.href === "/"
item.href === "/chat"
? isChatRoute
: location.pathname.startsWith(item.href);
: item.href === "/"
? location.pathname === "/"
: location.pathname.startsWith(item.href);
const link = (
<Link
to={item.href}
onClick={(event) => {
if (
item.href === "/" &&
item.href === "/chat" &&
!isChatRoute &&
!event.metaKey &&
!event.ctrlKey &&
@@ -413,7 +429,7 @@ export function Sidebar({
!event.altKey
) {
event.preventDefault();
navigateWithAgentChatViewTransition(navigate, "/");
navigateWithAgentChatViewTransition(navigate, "/chat");
}
}}
className={navClass({ isActive })}
+95
View File
@@ -0,0 +1,95 @@
import { useActionMutation } from "@agent-native/core/client/hooks";
import { IconHeart, IconHeartFilled } from "@tabler/icons-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useVisitorId } from "@/lib/visitor-id";
export type QuoteCardData = {
id: number;
text: string;
tags: string[];
poem: {
title: string;
author: string;
source: string | null;
year: number | null;
};
favoriteCount: number;
isFavorited: boolean;
};
interface QuoteCardProps {
quote: QuoteCardData;
onTagClick?: (tag: string) => void;
className?: string;
}
export function QuoteCard({ quote, onTagClick, className }: QuoteCardProps) {
const visitorId = useVisitorId();
const [optimistic, setOptimistic] = useState<{ favorited: boolean; count: number } | null>(
null,
);
const { mutate, isPending } = useActionMutation("toggle-favorite", {
onSuccess: (result) => {
setOptimistic({ favorited: result.favorited, count: result.favoriteCount });
},
onError: () => setOptimistic(null),
});
const favorited = optimistic?.favorited ?? quote.isFavorited;
const count = optimistic?.count ?? quote.favoriteCount;
return (
<Card className={cn("flex h-full flex-col justify-between", className)}>
<CardContent className="pt-6">
<blockquote className="text-balance text-lg leading-relaxed font-medium text-foreground">
{quote.text}
</blockquote>
<p className="mt-4 text-sm text-muted-foreground">
{quote.poem.author}, <span className="italic">{quote.poem.title}</span>
{quote.poem.year ? ` (${quote.poem.year})` : ""}
</p>
{quote.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1.5">
{quote.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className={cn(onTagClick && "cursor-pointer hover:bg-secondary/70")}
onClick={() => onTagClick?.(tag)}
>
{tag}
</Badge>
))}
</div>
)}
</CardContent>
<CardFooter className="flex items-center justify-between border-t pt-4">
<span className="text-xs text-muted-foreground">
{count} {count === 1 ? "favorite" : "favorites"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending || !visitorId}
onClick={() => mutate({ quoteId: quote.id, visitorId })}
aria-pressed={favorited}
aria-label={favorited ? "Remove from favorites" : "Add to favorites"}
className={cn("gap-1.5", favorited && "text-rose-500 hover:text-rose-500")}
>
{favorited ? (
<IconHeartFilled className="size-4" />
) : (
<IconHeart className="size-4" />
)}
{favorited ? "Favorited" : "Favorite"}
</Button>
</CardFooter>
</Card>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useActionMutation } from "@agent-native/core/client/hooks";
import { IconPlus } from "@tabler/icons-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const emptyForm = { text: "", author: "", poemTitle: "", source: "", year: "", tags: "" };
export function SubmitQuoteDialog() {
const [open, setOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const formId = useId();
const { mutate, isPending } = useActionMutation("create-quote", {
onSuccess: () => {
toast.success("Quote added");
setForm(emptyForm);
setOpen(false);
},
onError: () => toast.error("Couldn't save that quote — try again"),
});
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!form.text.trim() || !form.author.trim() || !form.poemTitle.trim()) return;
mutate({
text: form.text.trim(),
author: form.author.trim(),
poemTitle: form.poemTitle.trim(),
source: form.source.trim() || undefined,
year: form.year ? Number(form.year) : undefined,
tags: form.tags
.split(",")
.map((t) => t.trim().toLowerCase())
.filter(Boolean),
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" className="gap-1.5">
<IconPlus className="size-4" />
Submit a quote
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Submit a quote</DialogTitle>
<DialogDescription>
Share a line or passage from a poem you love.
</DialogDescription>
</DialogHeader>
<form id={formId} onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-text`}>Quote</Label>
<Textarea
id={`${formId}-text`}
required
rows={4}
value={form.text}
onChange={(e) => setForm((f) => ({ ...f, text: e.target.value }))}
placeholder="Two roads diverged in a wood..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-author`}>Poet</Label>
<Input
id={`${formId}-author`}
required
value={form.author}
onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}
placeholder="Robert Frost"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-title`}>Poem title</Label>
<Input
id={`${formId}-title`}
required
value={form.poemTitle}
onChange={(e) => setForm((f) => ({ ...f, poemTitle: e.target.value }))}
placeholder="The Road Not Taken"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-source`}>Source (optional)</Label>
<Input
id={`${formId}-source`}
value={form.source}
onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))}
placeholder="Collection name"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-year`}>Year (optional)</Label>
<Input
id={`${formId}-year`}
inputMode="numeric"
value={form.year}
onChange={(e) => setForm((f) => ({ ...f, year: e.target.value.replace(/\D/g, "") }))}
placeholder="1916"
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-tags`}>Tags (optional, comma separated)</Label>
<Input
id={`${formId}-tags`}
value={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
placeholder="hope, nature, journey"
/>
</div>
</form>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={isPending}>
{isPending ? "Saving..." : "Save quote"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface TagFilterBarProps {
tags: string[];
activeTag: string | null;
onSelect: (tag: string | null) => void;
}
export function TagFilterBar({ tags, activeTag, onSelect }: TagFilterBarProps) {
if (tags.length === 0) return null;
return (
<div className="flex gap-1.5 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Badge
variant={activeTag === null ? "default" : "outline"}
className="shrink-0 cursor-pointer"
onClick={() => onSelect(null)}
>
All
</Badge>
{tags.map((tag) => (
<Badge
key={tag}
variant={activeTag === tag ? "default" : "outline"}
className={cn("shrink-0 cursor-pointer")}
onClick={() => onSelect(activeTag === tag ? null : tag)}
>
{tag}
</Badge>
))}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { IconX as X } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+164
View File
@@ -0,0 +1,164 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import {
IconCheck as Check,
IconChevronDown as ChevronDown,
IconChevronUp as ChevronUp,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+2
View File
@@ -45,10 +45,12 @@ const messages = {
database: "Database",
expandSidebar: "Expand Sidebar",
extensions: "Extensions",
favorites: "Favorites",
navigation: "Navigation",
navigationDescription: "Main navigation",
observability: "Observability",
openNavigation: "Open navigation",
quotes: "Quotes",
settings: "Settings",
team: "Team",
},
+2 -2
View File
@@ -1,5 +1,5 @@
const rawAppName = "app";
const rawAppTitle = "App";
const rawAppName = "verse-quote-app";
const rawAppTitle = "Verse";
const APP_NAME_PLACEHOLDER = "{" + "{APP_NAME}}";
const APP_TITLE_PLACEHOLDER = "{" + "{APP_TITLE}}";
+38
View File
@@ -0,0 +1,38 @@
import { useState } from "react";
const STORAGE_KEY = "verse-quote-app.visitor-id";
function generateId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `v-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* A stable anonymous id for this browser, used to scope favorites without
* requiring a login. Not a real user identity — just enough to remember
* "your" favorites on this device.
*/
export function getVisitorId(): string {
if (typeof window === "undefined") return "";
try {
const existing = window.localStorage.getItem(STORAGE_KEY);
if (existing) return existing;
const created = generateId();
window.localStorage.setItem(STORAGE_KEY, created);
return created;
} catch {
return generateId();
}
}
/**
* React hook wrapper around getVisitorId(). Routes that use this render only
* an SSR loading shell (see DEVELOPING.md), so reading localStorage during
* the initial client render here does not cause a hydration mismatch.
*/
export function useVisitorId(): string {
const [visitorId] = useState(() => getVisitorId());
return visitorId;
}
+131 -80
View File
@@ -1,94 +1,145 @@
import {
AgentChatSurface,
markAgentChatHomeHandoff,
} from "@agent-native/core/client/agent-chat";
import { useT } from "@agent-native/core/client/i18n";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import { useActionQuery } from "@agent-native/core/client/hooks";
import { IconArrowsShuffle, IconFeather, IconSearch } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { APP_TITLE } from "@/lib/app-config";
import { TAB_ID } from "@/lib/tab-id";
const SEO_TITLE = `${APP_TITLE} - Open Source AI app starter with actions`;
const SEO_DESCRIPTION =
"Open Source starter for agent-native apps with durable chat, shared actions, UI state, tools, and a backend your agent can extend.";
import { QuoteCard } from "@/components/quotes/QuoteCard";
import { SubmitQuoteDialog } from "@/components/quotes/SubmitQuoteDialog";
import { TagFilterBar } from "@/components/quotes/TagFilterBar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useVisitorId } from "@/lib/visitor-id";
export function meta() {
return [
{ title: SEO_TITLE },
{ title: "Verse — quotes drawn from poems" },
{
name: "description",
content: SEO_DESCRIPTION,
content: "Discover, search, and save quotes drawn from classic and community-submitted poems.",
},
{ property: "og:title", content: SEO_TITLE },
{ property: "og:description", content: SEO_DESCRIPTION },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: SEO_TITLE },
{ name: "twitter:description", content: SEO_DESCRIPTION },
];
}
function chatThreadPath(threadId: string | null) {
return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/";
}
export default function ChatRoute() {
const { threadId } = useParams();
const navigate = useNavigate();
const t = useT();
const threadUrlSync = threadId
? {
routeThreadId: threadId,
getPath: chatThreadPath,
navigate,
}
: undefined;
useEffect(() => {
function handleChatRunning(event: Event) {
const detail = (event as CustomEvent).detail;
if (detail?.isRunning === true) markAgentChatHomeHandoff("chat");
}
window.addEventListener("agentNative.chatRunning", handleChatRunning);
return () =>
window.removeEventListener("agentNative.chatRunning", handleChatRunning);
}, []);
export function HydrateFallback() {
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<AgentChatSurface
mode="page"
chatViewTransition
className="h-full"
defaultMode="chat"
storageKey="chat"
threadUrlSync={threadUrlSync}
browserTabId={TAB_ID}
showHeader={false}
showTabBar={false}
dynamicSuggestions={false}
suggestions={[
t("chat.suggestionCapabilities"),
t("chat.suggestionCustomize"),
t("chat.suggestionActions"),
]}
emptyStateText={t("chat.emptyState")}
emptyStateDisplay="hidden"
centerComposerWhenEmpty
composerLayoutVariant="hero"
composerPlaceholder={t("chat.composerPlaceholder")}
composerSlot={
<div className="mx-auto mb-5 max-w-xl px-4 text-center">
<h1 className="text-2xl font-semibold tracking-normal text-foreground sm:text-3xl">
{t("chat.heroTitle")}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t("chat.heroDescription")}
</p>
</div>
}
/>
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground" />
</div>
);
}
function DailyQuoteHero() {
const visitorId = useVisitorId();
const { data: quote, isLoading, refetch, isFetching } = useActionQuery("get-random-quote", {
visitorId,
});
return (
<div className="rounded-xl border bg-card p-6 sm:p-8">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<IconFeather className="size-4" />
Quote of the moment
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
disabled={isFetching}
onClick={() => refetch()}
>
<IconArrowsShuffle className="size-4" />
Shuffle
</Button>
</div>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-1/3" />
</div>
) : quote ? (
<QuoteCard quote={quote} className="border-0 p-0 shadow-none" />
) : (
<p className="text-sm text-muted-foreground">
No quotes yet be the first to submit one below.
</p>
)}
</div>
);
}
export default function QuotesHomeRoute() {
const visitorId = useVisitorId();
const [search, setSearch] = useState("");
const [activeTag, setActiveTag] = useState<string | null>(null);
const { data: quotes, isLoading } = useActionQuery("list-quotes", { visitorId });
const allTags = useMemo(() => {
const set = new Set<string>();
for (const q of quotes ?? []) for (const tag of q.tags) set.add(tag);
return Array.from(set).sort();
}, [quotes]);
const filtered = useMemo(() => {
const term = search.trim().toLowerCase();
return (quotes ?? []).filter((q) => {
if (activeTag && !q.tags.includes(activeTag)) return false;
if (!term) return true;
const haystack = `${q.text} ${q.poem.title} ${q.poem.author}`.toLowerCase();
return haystack.includes(term);
});
}, [quotes, search, activeTag]);
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-4 sm:p-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Verse</h1>
<p className="text-sm text-muted-foreground">
Quotes drawn from poems browse, search, and save your favorites.
</p>
</div>
<DailyQuoteHero />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-xs">
<IconSearch className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search quotes, poets, poems..."
className="pl-8"
/>
</div>
<SubmitQuoteDialog />
</div>
<TagFilterBar tags={allTags} activeTag={activeTag} onSelect={setActiveTag} />
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No quotes match. Try a different search or tag.
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{filtered.map((quote) => (
<QuoteCard
key={quote.id}
quote={quote}
onTagClick={(tag) => setActiveTag(tag)}
/>
))}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -1 +1 @@
export { default, meta } from "./_index";
export { default, meta } from "./chat._index";
+94
View File
@@ -0,0 +1,94 @@
import {
AgentChatSurface,
markAgentChatHomeHandoff,
} from "@agent-native/core/client/agent-chat";
import { useT } from "@agent-native/core/client/i18n";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import { APP_TITLE } from "@/lib/app-config";
import { TAB_ID } from "@/lib/tab-id";
const SEO_TITLE = `Chat - ${APP_TITLE}`;
const SEO_DESCRIPTION =
"Ask the agent to look up, add, or organize poem quotes in Verse.";
export function meta() {
return [
{ title: SEO_TITLE },
{
name: "description",
content: SEO_DESCRIPTION,
},
{ property: "og:title", content: SEO_TITLE },
{ property: "og:description", content: SEO_DESCRIPTION },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: SEO_TITLE },
{ name: "twitter:description", content: SEO_DESCRIPTION },
];
}
function chatThreadPath(threadId: string | null) {
return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/chat";
}
export default function ChatRoute() {
const { threadId } = useParams();
const navigate = useNavigate();
const t = useT();
const threadUrlSync = threadId
? {
routeThreadId: threadId,
getPath: chatThreadPath,
navigate,
}
: undefined;
useEffect(() => {
function handleChatRunning(event: Event) {
const detail = (event as CustomEvent).detail;
if (detail?.isRunning === true) markAgentChatHomeHandoff("chat");
}
window.addEventListener("agentNative.chatRunning", handleChatRunning);
return () =>
window.removeEventListener("agentNative.chatRunning", handleChatRunning);
}, []);
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<AgentChatSurface
mode="page"
chatViewTransition
className="h-full"
defaultMode="chat"
storageKey="chat"
threadUrlSync={threadUrlSync}
browserTabId={TAB_ID}
showHeader={false}
showTabBar={false}
dynamicSuggestions={false}
suggestions={[
t("chat.suggestionCapabilities"),
t("chat.suggestionCustomize"),
t("chat.suggestionActions"),
]}
emptyStateText={t("chat.emptyState")}
emptyStateDisplay="hidden"
centerComposerWhenEmpty
composerLayoutVariant="hero"
composerPlaceholder={t("chat.composerPlaceholder")}
composerSlot={
<div className="mx-auto mb-5 max-w-xl px-4 text-center">
<h1 className="text-2xl font-semibold tracking-normal text-foreground sm:text-3xl">
{t("chat.heroTitle")}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t("chat.heroDescription")}
</p>
</div>
}
/>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useActionQuery } from "@agent-native/core/client/hooks";
import { IconHeart } from "@tabler/icons-react";
import { QuoteCard } from "@/components/quotes/QuoteCard";
import { Skeleton } from "@/components/ui/skeleton";
import { useVisitorId } from "@/lib/visitor-id";
export function meta() {
return [{ title: "Favorites — Verse" }];
}
export function HydrateFallback() {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground" />
</div>
);
}
export default function FavoritesRoute() {
const visitorId = useVisitorId();
const { data: quotes, isLoading } = useActionQuery(
"list-favorites",
{ visitorId },
{ enabled: Boolean(visitorId) },
);
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-4 sm:p-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Favorites</h1>
<p className="text-sm text-muted-foreground">
Quotes you've saved on this device.
</p>
</div>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full" />
))}
</div>
) : !quotes || quotes.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground">
<IconHeart className="size-8" />
<p className="text-sm">
No favorites yet tap the heart on any quote to save it here.
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{quotes.map((quote) => (
<QuoteCard key={quote.id} quote={quote} />
))}
</div>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
services:
postgres:
image: postgres:16-alpine
container_name: verse-quote-app-postgres
restart: unless-stopped
environment:
POSTGRES_DB: versequote
POSTGRES_USER: versequote
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "127.0.0.1:5434:5432"
volumes:
- verse-quote-app-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U versequote -d versequote"]
interval: 5s
timeout: 5s
retries: 10
volumes:
verse-quote-app-pgdata:
+3
View File
@@ -0,0 +1,3 @@
import { createDrizzleConfig } from "@agent-native/core/db/drizzle-config";
export default createDrizzleConfig({ out: "./drizzle" });
+18 -14
View File
@@ -1,8 +1,8 @@
{
"name": "app",
"displayName": "App",
"name": "verse-quote-app",
"displayName": "Verse",
"private": true,
"description": "Workspace app for App.",
"description": "Quotes drawn from poems — browse, search, and save your favorites.",
"type": "module",
"scripts": {
"dev": "agent-native dev --open",
@@ -18,16 +18,27 @@
"@agent-native/toolkit": "^0.10.6",
"@fontsource-variable/inter": "^5.2.8",
"@libsql/client": "^0.15.8",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.221.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.221.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.221.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
"@opentelemetry/resources": "^2.10.0",
"@opentelemetry/sdk-logs": "^0.221.0",
"@opentelemetry/sdk-metrics": "^2.10.0",
"@opentelemetry/sdk-trace-base": "^2.10.0",
"@opentelemetry/semantic-conventions": "^1.43.0",
"@react-router/dev": "8.1.0",
"@react-router/fs-routes": "8.1.0",
"@tabler/icons-react": "^3.41.1",
"drizzle-orm": "^0.45.2",
"h3": "^2.0.1-rc.20",
"isbot": "^5",
"node-pty": "^1.1.0",
"zod": "^4.4.3",
"postgres": "^3.4.9",
"@react-router/dev": "8.1.0",
"@react-router/fs-routes": "8.1.0",
"react-router": "8.1.0",
"vite": "8.1.0"
"vite": "8.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@radix-ui/react-accordion": "^1.2.12",
@@ -99,12 +110,5 @@
"coreVersion": "0.122.1",
"shape": "standalone"
}
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"node-pty"
]
}
}
+12556
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,8 +1,8 @@
allowBuilds:
node-pty: true
esbuild: true
better-sqlite3: true
tesseract.js: true
overrides:
nf3: "0.3.17"
@@ -15,3 +15,4 @@ minimumReleaseAgeExclude:
- fast-xml-parser
- "@sentry/*"
- "@typescript/*"
- '@agent-native/core@0.122.1'
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "App",
"short_name": "App",
"description": "Workspace app for App.",
"name": "Verse",
"short_name": "Verse",
"description": "Quotes drawn from poems — browse, search, and save your favorites.",
"start_url": ".",
"display": "standalone",
"background_color": "#111111",
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Nightly Postgres backup for verse-quote-app. Invoked by app-754b9a8a-backup.timer.
set -euo pipefail
BACKUP_DIR="/home/exedev/backups"
RETENTION_DAYS=7
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
FILE="$BACKUP_DIR/verse-quote-app-$TIMESTAMP.sql.gz"
mkdir -p "$BACKUP_DIR"
docker exec verse-quote-app-postgres pg_dump -U versequote -d versequote | gzip > "$FILE"
find "$BACKUP_DIR" -name 'verse-quote-app-*.sql.gz' -mtime +"$RETENTION_DAYS" -delete
echo "Backed up verse-quote-app database to $FILE"
+12
View File
@@ -0,0 +1,12 @@
import { createGetDb } from "@agent-native/core/db";
import * as schema from "./schema.js";
export const getDb = createGetDb(schema);
// Convenience: callable as db().select()...
export function db() {
return getDb();
}
export { schema };
+35
View File
@@ -0,0 +1,35 @@
import { table, text, integer } from "@agent-native/core/db/schema";
// A poem a quote is drawn from. Kept minimal — this app curates excerpts,
// not full poem texts.
export const poems = table("poems", {
id: integer("id").primaryKey(),
author: text("author").notNull(),
title: text("title").notNull(),
source: text("source"),
year: integer("year"),
created_at: text("created_at").$defaultFn(() => new Date().toISOString()),
});
// tags_json is a JSON-encoded string array (e.g. '["hope","nature"]").
// Dialect-agnostic schema helpers have no array type, so tags are
// JSON-encoded text — the same convention other first-party templates use.
export const quotes = table("quotes", {
id: integer("id").primaryKey(),
poem_id: integer("poem_id").notNull(),
text: text("text").notNull(),
tags_json: text("tags_json").notNull().default("[]"),
is_user_submitted: integer("is_user_submitted", { mode: "boolean" })
.notNull()
.default(false),
created_at: text("created_at").$defaultFn(() => new Date().toISOString()),
});
// visitor_id is a client-generated anonymous id (localStorage), not a real
// user account — this app has no login requirement.
export const favorites = table("favorites", {
id: integer("id").primaryKey(),
quote_id: integer("quote_id").notNull(),
visitor_id: text("visitor_id").notNull(),
created_at: text("created_at").$defaultFn(() => new Date().toISOString()),
});
+273
View File
@@ -0,0 +1,273 @@
// Public-domain poem excerpts used to seed a fresh database. Shared by the
// startup migration plugin so seeding works the same way regardless of which
// SQL provider (Docker Postgres, Neon, Supabase, local SQLite, ...) is
// behind DATABASE_URL.
export type SeedQuote = {
text: string;
tags: string[];
};
export type SeedPoem = {
author: string;
title: string;
source: string | null;
year: number | null;
quotes: SeedQuote[];
};
export const SEED_POEMS: SeedPoem[] = [
{
author: "Emily Dickinson",
title: "“Hope” is the thing with feathers",
source: "Poems (1891)",
year: 1861,
quotes: [
{
text: "“Hope” is the thing with feathers — that perches in the soul — and sings the tune without the words — and never stops — at all —",
tags: ["hope", "resilience"],
},
],
},
{
author: "Robert Frost",
title: "The Road Not Taken",
source: "Mountain Interval",
year: 1916,
quotes: [
{
text: "Two roads diverged in a wood, and I— I took the one less traveled by, and that has made all the difference.",
tags: ["choice", "life", "journey"],
},
],
},
{
author: "Walt Whitman",
title: "Song of Myself",
source: "Leaves of Grass",
year: 1855,
quotes: [
{ text: "I contain multitudes.", tags: ["identity", "self"] },
{
text: "Do I contradict myself? Very well then I contradict myself, (I am large, I contain multitudes.)",
tags: ["identity", "self"],
},
],
},
{
author: "William Wordsworth",
title: "I Wandered Lonely as a Cloud",
source: "Poems in Two Volumes",
year: 1807,
quotes: [
{
text: "For oft, when on my couch I lie in vacant or in pensive mood, they flash upon that inward eye which is the bliss of solitude.",
tags: ["memory", "solitude", "nature"],
},
{
text: "I wandered lonely as a cloud that floats on high o'er vales and hills, when all at once I saw a crowd, a host, of golden daffodils.",
tags: ["nature", "solitude"],
},
],
},
{
author: "William Shakespeare",
title: "Sonnet 18",
source: "Shakespeare's Sonnets",
year: 1609,
quotes: [
{
text: "Shall I compare thee to a summer's day? Thou art more lovely and more temperate.",
tags: ["love", "beauty"],
},
{
text: "So long as men can breathe or eyes can see, so long lives this, and this gives life to thee.",
tags: ["love", "immortality"],
},
],
},
{
author: "John Keats",
title: "Endymion",
source: null,
year: 1818,
quotes: [
{
text: "A thing of beauty is a joy for ever: its loveliness increases; it will never pass into nothingness.",
tags: ["beauty", "joy"],
},
],
},
{
author: "William Butler Yeats",
title: "The Second Coming",
source: "Michael Robartes and the Dancer",
year: 1920,
quotes: [
{
text: "Turning and turning in the widening gyre the falcon cannot hear the falconer; things fall apart; the centre cannot hold.",
tags: ["chaos", "change"],
},
{
text: "The best lack all conviction, while the worst are full of passionate intensity.",
tags: ["conviction", "society"],
},
],
},
{
author: "Percy Bysshe Shelley",
title: "Ozymandias",
source: null,
year: 1818,
quotes: [
{
text: "My name is Ozymandias, king of kings: look on my works, ye Mighty, and despair! Nothing beside remains.",
tags: ["time", "pride", "impermanence"],
},
],
},
{
author: "Alfred, Lord Tennyson",
title: "Ulysses",
source: null,
year: 1842,
quotes: [
{
text: "Though much is taken, much abides; and though we are not now that strength which in old days moved earth and heaven... one equal temper of heroic hearts... strong in will to strive, to seek, to find, and not to yield.",
tags: ["perseverance", "age", "courage"],
},
],
},
{
author: "Edgar Allan Poe",
title: "A Dream Within a Dream",
source: null,
year: 1849,
quotes: [
{
text: "Is all that we see or seem but a dream within a dream?",
tags: ["dreams", "existence"],
},
],
},
{
author: "William Blake",
title: "Auguries of Innocence",
source: null,
year: 1863,
quotes: [
{
text: "To see a World in a Grain of Sand, and a Heaven in a Wild Flower, hold Infinity in the palm of your hand, and Eternity in an hour.",
tags: ["wonder", "nature", "infinity"],
},
],
},
{
author: "Christina Rossetti",
title: "Remember",
source: "Goblin Market and Other Poems",
year: 1862,
quotes: [
{
text: "Better by far you should forget and smile than that you should remember and be sad.",
tags: ["memory", "love", "loss"],
},
],
},
{
author: "Robert Frost",
title: "Stopping by Woods on a Snowy Evening",
source: "New Hampshire",
year: 1923,
quotes: [
{
text: "The woods are lovely, dark and deep, but I have promises to keep, and miles to go before I sleep.",
tags: ["duty", "rest", "nature"],
},
],
},
{
author: "Emily Dickinson",
title: "Because I could not stop for Death",
source: "Poems (1890)",
year: 1863,
quotes: [
{
text: "Because I could not stop for Death, he kindly stopped for me; the carriage held but just ourselves and Immortality.",
tags: ["death", "time"],
},
],
},
{
author: "Walt Whitman",
title: "O Captain! My Captain!",
source: "Sequel to Drums-Taps",
year: 1865,
quotes: [
{
text: "O Captain! my Captain! our fearful trip is done, the ship has weather'd every rack, the prize we sought is won.",
tags: ["triumph", "loss", "leadership"],
},
],
},
{
author: "Rumi",
title: "The Guest House",
source: "The Essential Rumi (translation)",
year: 1207,
quotes: [
{
text: "This being human is a guest house. Every morning a new arrival... Welcome and entertain them all!",
tags: ["acceptance", "emotion", "mindfulness"],
},
],
},
{
author: "Sara Teasdale",
title: "Barter",
source: "Rivers to the Sea",
year: 1915,
quotes: [
{
text: "Life has loveliness to sell, all beautiful and splendid things, blue waves whitened on a cliff, soaring fire that sways and sings.",
tags: ["beauty", "life", "joy"],
},
],
},
{
author: "Elizabeth Barrett Browning",
title: "Sonnet 43 (How Do I Love Thee?)",
source: "Sonnets from the Portuguese",
year: 1850,
quotes: [
{
text: "How do I love thee? Let me count the ways. I love thee to the depth and breadth and height my soul can reach.",
tags: ["love"],
},
],
},
{
author: "Langston Hughes",
title: "Dreams",
source: "The Weary Blues",
year: 1926,
quotes: [
{
text: "Hold fast to dreams, for if dreams die, life is a broken-winged bird that cannot fly.",
tags: ["dreams", "hope"],
},
],
},
{
author: "Henry Wadsworth Longfellow",
title: "A Psalm of Life",
source: "Voices of the Night",
year: 1838,
quotes: [
{
text: "Life is real! Life is earnest! And the grave is not its goal... Let us, then, be up and doing, with a heart for any fate.",
tags: ["life", "purpose", "courage"],
},
],
},
];
+85
View File
@@ -0,0 +1,85 @@
import { eq } from "drizzle-orm";
import { db, schema } from "../db/index.js";
export type PoemInfo = {
id: number;
title: string;
author: string;
source: string | null;
year: number | null;
};
export type QuoteWithPoem = {
id: number;
text: string;
tags: string[];
isUserSubmitted: boolean;
createdAt: string | null;
poem: PoemInfo;
favoriteCount: number;
isFavorited: boolean;
};
export function parseTags(tagsJson: string): string[] {
try {
const value = JSON.parse(tagsJson);
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
} catch {
return [];
}
}
type RawRow = {
quote: typeof schema.quotes.$inferSelect;
poem: typeof schema.poems.$inferSelect;
};
/**
* Joins quote rows with their poem, favorite counts, and (when visitorId is
* given) whether that visitor has favorited each quote. Shared by every
* action that returns quotes so the shape stays identical across list,
* random, favorites, and create.
*/
export async function hydrateQuotes(
rows: RawRow[],
visitorId?: string,
): Promise<QuoteWithPoem[]> {
if (rows.length === 0) return [];
const quoteIds = rows.map((r) => r.quote.id);
const allFavorites = await db().select().from(schema.favorites);
const countByQuoteId = new Map<number, number>();
const favoritedByVisitor = new Set<number>();
for (const fav of allFavorites) {
if (!quoteIds.includes(fav.quote_id)) continue;
countByQuoteId.set(fav.quote_id, (countByQuoteId.get(fav.quote_id) ?? 0) + 1);
if (visitorId && fav.visitor_id === visitorId) {
favoritedByVisitor.add(fav.quote_id);
}
}
return rows.map((row) => ({
id: row.quote.id,
text: row.quote.text,
tags: parseTags(row.quote.tags_json),
isUserSubmitted: row.quote.is_user_submitted,
createdAt: row.quote.created_at ?? null,
poem: {
id: row.poem.id,
title: row.poem.title,
author: row.poem.author,
source: row.poem.source ?? null,
year: row.poem.year ?? null,
},
favoriteCount: countByQuoteId.get(row.quote.id) ?? 0,
isFavorited: favoritedByVisitor.has(row.quote.id),
}));
}
export async function fetchAllQuoteRows(): Promise<RawRow[]> {
return db()
.select({ quote: schema.quotes, poem: schema.poems })
.from(schema.quotes)
.innerJoin(schema.poems, eq(schema.quotes.poem_id, schema.poems.id));
}
+149
View File
@@ -0,0 +1,149 @@
/**
* OpenTelemetry setup for verse-quote-app: exports traces, metrics, and logs
* over OTLP/HTTP to the shared Grafana LGTM collector on this VM
* (http://localhost:4318). Manual instrumentation rather than
* auto-instrumentation-node — this app's ESM/Nitro entrypoint doesn't give
* us a require-before-everything-else hook to patch http/pg at, so we
* instrument the one seam that matters for this app: action calls.
*/
import { trace, metrics, diag, DiagConsoleLogger, DiagLogLevel, type Span } from "@opentelemetry/api";
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import {
BasicTracerProvider,
BatchSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import {
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import {
LoggerProvider,
BatchLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
const SERVICE_NAME = process.env.OTEL_SERVICE_NAME || "verse-quote-app";
const OTLP_ENDPOINT =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318";
let started = false;
export function startOtel(): void {
if (started) return;
started = true;
if (process.env.OTEL_DEBUG) {
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
}
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: SERVICE_NAME,
});
const tracerProvider = new BasicTracerProvider({
resource,
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({ url: `${OTLP_ENDPOINT}/v1/traces` }),
),
],
});
trace.setGlobalTracerProvider(tracerProvider);
const meterProvider = new MeterProvider({
resource,
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${OTLP_ENDPOINT}/v1/metrics` }),
exportIntervalMillis: 15000,
}),
],
});
metrics.setGlobalMeterProvider(meterProvider);
const loggerProvider = new LoggerProvider({
resource,
processors: [
new BatchLogRecordProcessor({
exporter: new OTLPLogExporter({ url: `${OTLP_ENDPOINT}/v1/logs` }),
}),
],
});
logs.setGlobalLoggerProvider(loggerProvider);
const shutdown = () => {
Promise.allSettled([
tracerProvider.shutdown(),
meterProvider.shutdown(),
loggerProvider.shutdown(),
]).finally(() => process.exit(0));
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
console.log(`[otel] started, exporting to ${OTLP_ENDPOINT} as service "${SERVICE_NAME}"`);
}
// Must run before getMeter()/createCounter() below: unlike the tracer and
// logger proxies (which re-resolve their delegate on every call), the metrics
// API's Counter is bound to whatever provider is active at createCounter()
// time — calling startOtel() after creating counters would permanently bind
// them to the no-op backend and silently drop every metric.
startOtel();
const tracer = trace.getTracer(SERVICE_NAME);
const meter = metrics.getMeter(SERVICE_NAME);
const otelLogger = logs.getLogger(SERVICE_NAME);
export const actionCallCounter = meter.createCounter("verse_quote_action_calls_total", {
description: "Number of verse-quote-app action invocations, by action name and outcome",
});
export const quoteFavoriteCounter = meter.createCounter("verse_quote_favorites_total", {
description: "Number of favorite/unfavorite toggles on quotes",
});
/**
* Wrap an action's run() body in a span + call counter + structured log.
* Records success/error outcome and re-throws so defineAction's own error
* handling is unaffected.
*/
export async function withActionSpan<T>(
actionName: string,
attributes: Record<string, string | number | boolean | undefined>,
fn: (span: Span) => Promise<T>,
): Promise<T> {
return tracer.startActiveSpan(`action.${actionName}`, async (span) => {
for (const [key, value] of Object.entries(attributes)) {
if (value !== undefined) span.setAttribute(key, value);
}
const start = Date.now();
try {
const result = await fn(span);
actionCallCounter.add(1, { action: actionName, outcome: "success" });
otelLogger.emit({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
body: `action ${actionName} succeeded in ${Date.now() - start}ms`,
attributes: { action: actionName, ...attributes },
});
return result;
} catch (err) {
actionCallCounter.add(1, { action: actionName, outcome: "error" });
span.recordException(err instanceof Error ? err : new Error(String(err)));
otelLogger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: "ERROR",
body: `action ${actionName} failed: ${err instanceof Error ? err.message : String(err)}`,
attributes: { action: actionName, ...attributes },
});
throw err;
} finally {
span.end();
}
});
}
+5 -6
View File
@@ -1,17 +1,16 @@
import { createAuthPlugin } from "@agent-native/core/server";
const rawAppTitle = "App";
const rawAppTitle = "Verse";
const appTitle = rawAppTitle === "{" + "{APP_TITLE}}" ? "Chat" : rawAppTitle;
export default createAuthPlugin({
marketing: {
appName: appTitle,
tagline:
"Start from a chat-first agent-native app and add actions, screens, and workflows as you grow.",
tagline: "Quotes drawn from poems — browse, search, and save your favorites.",
features: [
"Full-page chat with durable threads and tool call history",
"Add actions once and use them from chat, UI, HTTP, MCP, A2A, and CLI",
"Plug in your own agent runtime or build on the included app-agent loop",
"Curated and community-submitted quotes from classic poems",
"Search and filter by poet or theme, favorite the ones you love",
"An agent chat that can look up, add, and organize quotes for you",
],
},
});
+143
View File
@@ -0,0 +1,143 @@
import {
ensureAdditiveColumns,
getDbExec,
runMigrations,
isPostgres,
} from "@agent-native/core/db";
import { db } from "../db/index.js";
import * as schema from "../db/schema.js";
import { SEED_POEMS } from "../db/seed-data.js";
function pk(): string {
return isPostgres() ? "SERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY";
}
function boolType(): string {
return isPostgres() ? "BOOLEAN" : "INTEGER";
}
function boolDefault(): string {
return isPostgres() ? "false" : "0";
}
/**
* Every Drizzle table exported from schema.ts. Filters out type-only and
* helper exports the same way the framework's own templates do: a real
* table carries a Symbol-keyed drizzle metadata bag, plain exports don't.
*/
function isDrizzleTable(value: unknown): value is object {
return (
!!value &&
typeof value === "object" &&
Object.getOwnPropertySymbols(value).some((s) =>
s.toString().includes("drizzle"),
)
);
}
const schemaTables = Object.values(schema).filter(isDrizzleTable);
const runVerseQuoteMigrations = runMigrations(
[
{
version: 1,
name: "verse-quote-app-poems-quotes-favorites",
get sql() {
return `
CREATE TABLE IF NOT EXISTS poems (
id ${pk()},
author TEXT NOT NULL,
title TEXT NOT NULL,
source TEXT,
year INTEGER,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS quotes (
id ${pk()},
poem_id INTEGER NOT NULL REFERENCES poems(id) ON DELETE CASCADE,
text TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
is_user_submitted ${boolType()} NOT NULL DEFAULT ${boolDefault()},
created_at TEXT
);
CREATE TABLE IF NOT EXISTS favorites (
id ${pk()},
quote_id INTEGER NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
visitor_id TEXT NOT NULL,
created_at TEXT,
UNIQUE (quote_id, visitor_id)
);
CREATE INDEX IF NOT EXISTS verse_quote_quotes_poem_id_idx ON quotes(poem_id);
CREATE INDEX IF NOT EXISTS verse_quote_favorites_quote_id_idx ON favorites(quote_id);
CREATE INDEX IF NOT EXISTS verse_quote_favorites_visitor_id_idx ON favorites(visitor_id);
`;
},
},
],
{ table: "verse_quote_migrations" },
);
/**
* Seeds the curated poem/quote set on first boot only. Runs after the table
* migration so it works the same against any DATABASE_URL (Docker Postgres
* here, but also Neon/Supabase/local SQLite) — the seed isn't tied to the
* Docker container's initdb hook.
*/
async function seedIfEmpty(): Promise<void> {
const existing = await db().select({ id: schema.poems.id }).from(schema.poems).limit(1);
if (existing.length > 0) return;
for (const poem of SEED_POEMS) {
const inserted = await db()
.insert(schema.poems)
.values({
author: poem.author,
title: poem.title,
source: poem.source,
year: poem.year,
})
.returning({ id: schema.poems.id });
const poemId = inserted[0].id;
for (const quote of poem.quotes) {
await db().insert(schema.quotes).values({
poem_id: poemId,
text: quote.text,
tags_json: JSON.stringify(quote.tags),
is_user_submitted: false,
});
}
}
console.log(`[db] Seeded ${SEED_POEMS.length} poems.`);
}
export default async (nitroApp: any): Promise<void> => {
await runVerseQuoteMigrations(nitroApp);
try {
const summary = await ensureAdditiveColumns({
db: getDbExec(),
tables: schemaTables,
});
if (summary.errors.length > 0) {
console.warn(
"[db] ensureAdditiveColumns completed with errors:",
summary.errors,
);
}
} catch (err) {
console.warn(
"[db] ensureAdditiveColumns failed (non-fatal):",
err instanceof Error ? err.message : err,
);
}
try {
await seedIfEmpty();
} catch (err) {
console.warn(
"[db] seedIfEmpty failed (non-fatal):",
err instanceof Error ? err.message : err,
);
}
};