Build Verse: a poem-quote app on the agent-native chat template
CI / build (push) Successful in 4m26s
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:
@@ -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",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user