From 23ddcc6cd952ebf14ba748a95f8ccc2a1adb347c Mon Sep 17 00:00:00 2001 From: rahulsainlll Date: Fri, 31 Jul 2026 15:02:47 +0530 Subject: [PATCH 01/10] Added: Products schema and Studio agent config CRUD Co-authored-by: Cursor --- .../app/(dashboard)/studio/[id]/page.tsx | 51 + .../dashboard/app/(dashboard)/studio/page.tsx | 32 + .../features/navigation/nav.config.ts | 2 + apps/dashboard/features/products/actions.ts | 158 ++ .../products/create-product-dialog.tsx | 86 + .../products/product-settings-form.tsx | 163 ++ .../features/products/products-list.tsx | 48 + apps/dashboard/features/products/queries.ts | 27 + .../features/products/studio-tabs.tsx | 94 + .../drizzle/0016_common_captain_cross.sql | 52 + .../database/drizzle/meta/0016_snapshot.json | 1563 +++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/schema/index.ts | 1 + packages/database/src/schema/products.ts | 108 ++ packages/database/src/schema/relations.ts | 16 + 15 files changed, 2408 insertions(+) create mode 100644 apps/dashboard/app/(dashboard)/studio/[id]/page.tsx create mode 100644 apps/dashboard/app/(dashboard)/studio/page.tsx create mode 100644 apps/dashboard/features/products/actions.ts create mode 100644 apps/dashboard/features/products/create-product-dialog.tsx create mode 100644 apps/dashboard/features/products/product-settings-form.tsx create mode 100644 apps/dashboard/features/products/products-list.tsx create mode 100644 apps/dashboard/features/products/queries.ts create mode 100644 apps/dashboard/features/products/studio-tabs.tsx create mode 100644 packages/database/drizzle/0016_common_captain_cross.sql create mode 100644 packages/database/drizzle/meta/0016_snapshot.json create mode 100644 packages/database/src/schema/products.ts diff --git a/apps/dashboard/app/(dashboard)/studio/[id]/page.tsx b/apps/dashboard/app/(dashboard)/studio/[id]/page.tsx new file mode 100644 index 0000000..c28a71a --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/[id]/page.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { getProduct } from "../../../../features/products/queries"; +import { StudioTabs } from "../../../../features/products/studio-tabs"; + +export const dynamic = "force-dynamic"; + +export default async function StudioDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const product = await getProduct(id); + if (!product) notFound(); + + return ( +
+ + Your agents + + +
+
+
+ +

{product.name}

+
+
+ {product.slug} + {product.status === "live" ? ( + + Live + + ) : ( + + Draft + + )} +
+
+
+ + +
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/studio/page.tsx b/apps/dashboard/app/(dashboard)/studio/page.tsx new file mode 100644 index 0000000..ef4d23a --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/page.tsx @@ -0,0 +1,32 @@ +import { Sparkles } from "lucide-react"; +import { EmptyState } from "../../../components/empty-state"; +import { PageHeader } from "../../../components/page-header"; +import { CreateProductDialog } from "../../../features/products/create-product-dialog"; +import { ProductsList } from "../../../features/products/products-list"; +import { listProducts } from "../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +export default async function StudioPage() { + const products = await listProducts(); + + return ( + <> + } + /> + {products.length === 0 ? ( + } + /> + ) : ( + + )} + + ); +} diff --git a/apps/dashboard/features/navigation/nav.config.ts b/apps/dashboard/features/navigation/nav.config.ts index 60820a9..865c142 100644 --- a/apps/dashboard/features/navigation/nav.config.ts +++ b/apps/dashboard/features/navigation/nav.config.ts @@ -7,6 +7,7 @@ import { LayoutDashboard, ArrowLeftRight, Settings, + Sparkles, Star, Store, Wallet, @@ -42,6 +43,7 @@ export const navGroups: NavGroup[] = [ items: [ { label: "My Capabilities", href: "/capabilities", icon: Boxes }, { label: "Cards", href: "/agents", icon: CreditCard }, + { label: "Studio", href: "/studio", icon: Sparkles }, ], }, { diff --git a/apps/dashboard/features/products/actions.ts b/apps/dashboard/features/products/actions.ts new file mode 100644 index 0000000..505bae8 --- /dev/null +++ b/apps/dashboard/features/products/actions.ts @@ -0,0 +1,158 @@ +"use server"; + +import { randomBytes } from "node:crypto"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { and, eq, ilike, products } from "@tael/database"; +import { db } from "../../lib/db"; +import { getCurrentUser } from "../capabilities/current-user"; + +const nameSchema = z.string().trim().min(1, "Name is required").max(80); +const brandColorSchema = z + .string() + .trim() + .regex(/^#[0-9A-Fa-f]{6}$/, "Use a hex color like #156DFC"); +const greetingSchema = z.string().max(500); +const descriptionSchema = z.string().max(1000); +const statusSchema = z.enum(["draft", "live"]); + +export interface ActionResult { + ok: boolean; + error?: string; + id?: string; +} + +/** Build a URL-safe slug from a name (the action makes it unique on collision). */ +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} + +/** + * Turn a base slug into a unique one. Returns the clean name when free; + * falls back to `name-2`, `name-3`, … on collision. + */ +async function uniqueSlug(base: string): Promise { + const fallback = base || "agent"; + const rows = await db + .select({ slug: products.slug }) + .from(products) + .where(ilike(products.slug, `${fallback}%`)); + const taken = new Set(rows.map((r) => r.slug)); + if (!taken.has(fallback)) return fallback; + for (let i = 2; ; i += 1) { + const candidate = `${fallback}-${i}`; + if (!taken.has(candidate)) return candidate; + } +} + +/** Stripe-style publishable key: `tael_pub_` + 24 hex chars. */ +function generatePublicKey(): string { + return `tael_pub_${randomBytes(12).toString("hex")}`; +} + +const createProductSchema = z.object({ + name: nameSchema, +}); + +/** + * Create a product agent for the signed-in user. Generates a unique slug from + * the name and a random publicKey safe to expose in the embed snippet. + */ +export async function createProduct(input: { name: string }): Promise { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Not signed in." }; + + const parsed = createProductSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." }; + } + + const slug = await uniqueSlug(slugify(parsed.data.name)); + const publicKey = generatePublicKey(); + + try { + const [row] = await db + .insert(products) + .values({ + ownerId: user.id, + name: parsed.data.name, + slug, + publicKey, + }) + .returning({ id: products.id }); + + revalidatePath("/studio"); + return { ok: true, id: row!.id }; + } catch { + return { ok: false, error: "Could not create the agent. Try again." }; + } +} + +const updateProductSchema = z.object({ + name: nameSchema.optional(), + description: descriptionSchema.optional(), + brandColor: brandColorSchema.optional(), + greeting: greetingSchema.optional(), + status: statusSchema.optional(), + logoUrl: z.string().max(200_000).nullable().optional(), +}); + +/** Update product settings. Ownership-checked. */ +export async function updateProduct( + id: string, + input: z.infer, +): Promise { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Not signed in." }; + + const parsed = updateProductSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." }; + } + + const patch = parsed.data; + if (Object.keys(patch).length === 0) { + return { ok: false, error: "Nothing to update." }; + } + + try { + const result = await db + .update(products) + .set(patch) + .where(and(eq(products.id, id), eq(products.ownerId, user.id))) + .returning({ id: products.id }); + + if (!result[0]) return { ok: false, error: "Agent not found." }; + + revalidatePath("/studio"); + revalidatePath(`/studio/${id}`); + return { ok: true, id }; + } catch { + return { ok: false, error: "Could not save. Try again." }; + } +} + +/** Delete a product the signed-in user owns. Cascades content + actions. */ +export async function deleteProduct(id: string): Promise { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Not signed in." }; + + try { + const result = await db + .delete(products) + .where(and(eq(products.id, id), eq(products.ownerId, user.id))) + .returning({ id: products.id }); + + if (!result[0]) return { ok: false, error: "Agent not found." }; + + revalidatePath("/studio"); + return { ok: true }; + } catch { + return { ok: false, error: "Could not delete. Try again." }; + } +} diff --git a/apps/dashboard/features/products/create-product-dialog.tsx b/apps/dashboard/features/products/create-product-dialog.tsx new file mode 100644 index 0000000..9bc6f22 --- /dev/null +++ b/apps/dashboard/features/products/create-product-dialog.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Input, +} from "@tael/ui"; +import { createProduct } from "./actions"; + +/** Create a product agent from a name. Redirects to the detail page on success. */ +export function CreateProductDialog() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [name, setName] = useState(""); + + function reset() { + setError(null); + setName(""); + } + + function submit() { + setError(null); + startTransition(async () => { + const res = await createProduct({ name }); + if (res.ok && res.id) { + setOpen(false); + reset(); + router.push(`/studio/${res.id}`); + router.refresh(); + } else { + setError(res.error ?? "Could not create the agent."); + } + }); + } + + return ( + <> + + { + setOpen(o); + if (!o) reset(); + }} + > + + + New agent + + Train it on your content, connect actions, and embed it on your site. + + + +
+ + + {error ?

{error}

: null} + + +
+
+
+ + ); +} diff --git a/apps/dashboard/features/products/product-settings-form.tsx b/apps/dashboard/features/products/product-settings-form.tsx new file mode 100644 index 0000000..15431bf --- /dev/null +++ b/apps/dashboard/features/products/product-settings-form.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Check, Trash2 } from "lucide-react"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Input, + Textarea, +} from "@tael/ui"; +import type { Product } from "@tael/database"; +import { deleteProduct, updateProduct } from "./actions"; + +/** Basic product settings: name, brand color, greeting. Delete lives here too. */ +export function ProductSettingsForm({ product }: { product: Product }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmText, setConfirmText] = useState(""); + + const [name, setName] = useState(product.name); + const [brandColor, setBrandColor] = useState(product.brandColor); + const [greeting, setGreeting] = useState(product.greeting); + + const dirty = + name !== product.name || brandColor !== product.brandColor || greeting !== product.greeting; + + function save() { + setError(null); + setSaved(false); + startTransition(async () => { + const res = await updateProduct(product.id, { name, brandColor, greeting }); + if (res.ok) { + setSaved(true); + router.refresh(); + setTimeout(() => setSaved(false), 2000); + } else { + setError(res.error ?? "Could not save."); + } + }); + } + + function remove() { + startTransition(async () => { + const res = await deleteProduct(product.id); + if (res.ok) router.push("/studio"); + else setError(res.error ?? "Could not delete."); + }); + } + + return ( +
+
+
+

Settings

+

+ Name, brand color, and the greeting shown when the widget opens. +

+
+ + + + + +
", + "", + '", + ].join(""); + root.appendChild(wrap); + + var panel = wrap.querySelector(".panel"); + var launcher = wrap.querySelector(".launcher"); + var closeBtn = wrap.querySelector(".close"); + var msgs = wrap.querySelector(".msgs"); + var form = wrap.querySelector(".composer"); + var input = wrap.querySelector("textarea"); + var sendBtn = wrap.querySelector(".composer button"); + var titleEl = wrap.querySelector(".title h1"); + var logoEl = wrap.querySelector(".logo"); + + var open = false; + var busy = false; + var history = []; + var greeted = false; + + function setBrand(color) { + brand = color || "#156DFC"; + launcher.style.background = brand; + sendBtn.style.background = brand; + wrap.style.setProperty("--tael-brand", brand); + if (logoEl.classList.contains("fallback")) { + logoEl.style.background = brand; + } + } + + function setOpen(next) { + open = next; + panel.classList.toggle("open", open); + launcher.setAttribute("aria-expanded", open ? "true" : "false"); + launcher.setAttribute("aria-label", open ? "Close chat" : "Open chat"); + if (open) { + if (!greeted && greeting) { + appendAssistant(greeting, null); + greeted = true; + } + input.focus(); + scrollBottom(); + } + } + + function scrollBottom() { + msgs.scrollTop = msgs.scrollHeight; + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function formatText(s) { + var escaped = escapeHtml(s); + return escaped.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\n/g, "
"); + } + + function appendBubble(role, html) { + var el = document.createElement("div"); + el.className = "bubble " + role; + el.innerHTML = html; + msgs.appendChild(el); + scrollBottom(); + return el; + } + + function appendAssistant(text, action) { + appendBubble("assistant", formatText(text || "")); + if (action) appendConfirm(action); + } + + function appendConfirm(action) { + var card = document.createElement("div"); + card.className = "confirm"; + var label = document.createElement("p"); + label.className = "label"; + label.textContent = action.kind === "http" ? "Run action" : "Run capability"; + var name = document.createElement("p"); + name.className = "name"; + name.textContent = action.name || "Action"; + var btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "Confirm"; + btn.addEventListener("click", function () { + runAction(action, card, btn); + }); + card.appendChild(label); + card.appendChild(name); + card.appendChild(btn); + msgs.appendChild(card); + scrollBottom(); + } + + function setBusy(next) { + busy = next; + sendBtn.disabled = busy || !input.value.trim(); + input.disabled = busy; + } + + function showTyping() { + var el = document.createElement("div"); + el.className = "bubble assistant"; + el.innerHTML = ''; + msgs.appendChild(el); + scrollBottom(); + return el; + } + + function applyConfig(cfg) { + if (!cfg) return; + agentName = cfg.name || agentName; + greeting = (cfg.greeting || "").trim(); + logoUrl = cfg.logoUrl || null; + titleEl.textContent = agentName; + setBrand(cfg.brandColor); + if (logoUrl) { + var img = document.createElement("img"); + img.className = "logo"; + img.alt = ""; + img.src = logoUrl; + logoEl.replaceWith(img); + logoEl = img; + } else { + logoEl.textContent = (agentName.charAt(0) || "T").toUpperCase(); + logoEl.style.background = brand; + } + } + + function loadConfig() { + return fetch(apiRoot + "/config", { method: "GET", credentials: "omit" }) + .then(function (res) { + return res.json().then(function (data) { + if (!res.ok) throw new Error((data && data.error) || "Config failed"); + return data; + }); + }) + .then(applyConfig) + .catch(function () { + titleEl.textContent = agentName; + setBrand(brand); + logoEl.textContent = "T"; + logoEl.style.background = brand; + }); + } + + function sendMessage(text) { + var content = (text || "").trim(); + if (!content || busy) return; + appendBubble("user", escapeHtml(content)); + history.push({ role: "user", content: content }); + input.value = ""; + input.style.height = "auto"; + setBusy(true); + var typing = showTyping(); + + fetch(apiRoot + "/chat", { + method: "POST", + credentials: "omit", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: history.slice(-20) }), + }) + .then(function (res) { + return res.json().then(function (data) { + return { res: res, data: data }; + }); + }) + .then(function (out) { + typing.remove(); + if (!out.res.ok) { + appendBubble( + "assistant", + escapeHtml((out.data && out.data.error) || "Something went wrong. Please try again."), + ); + return; + } + var reply = (out.data && out.data.reply) || ""; + var action = out.data && out.data.action ? out.data.action : null; + if (reply) history.push({ role: "assistant", content: reply }); + appendAssistant(reply || "…", action); + }) + .catch(function () { + typing.remove(); + appendBubble("assistant", "Could not reach the agent. Please try again."); + }) + .then(function () { + setBusy(false); + input.focus(); + }); + } + + function runAction(action, card, btn) { + if (busy) return; + btn.disabled = true; + btn.textContent = "Running…"; + setBusy(true); + + var body = { actionId: action.actionId }; + if (action.kind === "http" && action.params) body.params = action.params; + if (action.kind === "capability" && action.params) body.params = action.params; + + fetch(apiRoot + "/actions/run", { + method: "POST", + credentials: "omit", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + .then(function (res) { + return res.json().then(function (data) { + return { res: res, data: data }; + }); + }) + .then(function (out) { + card.remove(); + if (out.res.status === 401 || (out.data && out.data.error === "Not signed in.")) { + appendBubble( + "assistant", + "This action needs the site owner to run it. It is not available to visitors.", + ); + return; + } + if (!out.res.ok || !(out.data && out.data.ok)) { + appendBubble( + "assistant", + escapeHtml( + (out.data && out.data.error) || "Could not run that action. Please try again.", + ), + ); + return; + } + var msg = "Ran **" + (action.name || "action") + "**."; + if (out.data.body) { + var clipped = + String(out.data.body).length > 400 + ? String(out.data.body).slice(0, 400) + "…" + : String(out.data.body); + msg += "\n\n" + clipped; + } + appendAssistant(msg, null); + }) + .catch(function () { + btn.disabled = false; + btn.textContent = "Confirm"; + appendBubble("assistant", "Could not reach the server. Please try again."); + }) + .then(function () { + setBusy(false); + }); + } + + launcher.addEventListener("click", function () { + setOpen(!open); + }); + closeBtn.addEventListener("click", function () { + setOpen(false); + }); + + form.addEventListener("submit", function (e) { + e.preventDefault(); + sendMessage(input.value); + }); + + input.addEventListener("input", function () { + sendBtn.disabled = busy || !input.value.trim(); + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 96) + "px"; + }); + + input.addEventListener("keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(input.value); + } + }); + + setBrand(brand); + titleEl.textContent = agentName; + loadConfig(); +})(); From 30da617176a8899430cae4b47eedc2fc18064587 Mon Sep 17 00:00:00 2001 From: rahulsainlll Date: Fri, 31 Jul 2026 16:51:26 +0530 Subject: [PATCH 07/10] Changed: Single Studio agent with Train/Test/Deploy/Inbox nav Co-authored-by: Cursor --- .../app/(dashboard)/studio/[id]/page.tsx | 52 ---- .../app/(dashboard)/studio/deploy/page.tsx | 21 ++ .../app/(dashboard)/studio/inbox/page.tsx | 24 ++ .../dashboard/app/(dashboard)/studio/page.tsx | 32 +-- .../app/(dashboard)/studio/test/page.tsx | 21 ++ .../app/(dashboard)/studio/train/page.tsx | 41 ++++ .../features/navigation/nav.config.ts | 15 +- .../features/products/action-actions.ts | 16 +- apps/dashboard/features/products/actions.ts | 84 +------ .../features/products/content-actions.ts | 18 +- .../products/create-product-dialog.tsx | 86 ------- .../products/product-settings-form.tsx | 26 +- .../features/products/products-list.tsx | 48 ---- apps/dashboard/features/products/queries.ts | 76 +++++- .../features/products/studio-tabs.tsx | 98 -------- .../features/products/test-preview-panel.tsx | 231 ++++++++++++++++++ 16 files changed, 470 insertions(+), 419 deletions(-) delete mode 100644 apps/dashboard/app/(dashboard)/studio/[id]/page.tsx create mode 100644 apps/dashboard/app/(dashboard)/studio/deploy/page.tsx create mode 100644 apps/dashboard/app/(dashboard)/studio/inbox/page.tsx create mode 100644 apps/dashboard/app/(dashboard)/studio/test/page.tsx create mode 100644 apps/dashboard/app/(dashboard)/studio/train/page.tsx delete mode 100644 apps/dashboard/features/products/create-product-dialog.tsx delete mode 100644 apps/dashboard/features/products/products-list.tsx delete mode 100644 apps/dashboard/features/products/studio-tabs.tsx create mode 100644 apps/dashboard/features/products/test-preview-panel.tsx diff --git a/apps/dashboard/app/(dashboard)/studio/[id]/page.tsx b/apps/dashboard/app/(dashboard)/studio/[id]/page.tsx deleted file mode 100644 index ee693e0..0000000 --- a/apps/dashboard/app/(dashboard)/studio/[id]/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import Link from "next/link"; -import { notFound } from "next/navigation"; -import { ArrowLeft } from "lucide-react"; -import { getProduct, listActions, listContent } from "../../../../features/products/queries"; -import { StudioTabs } from "../../../../features/products/studio-tabs"; - -export const dynamic = "force-dynamic"; - -export default async function StudioDetailPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const product = await getProduct(id); - if (!product) notFound(); - const [content, actions] = await Promise.all([listContent(id), listActions(id)]); - - return ( -
- - Your agents - - -
-
-
- -

{product.name}

-
-
- {product.slug} - {product.status === "live" ? ( - - Live - - ) : ( - - Draft - - )} -
-
-
- - -
- ); -} diff --git a/apps/dashboard/app/(dashboard)/studio/deploy/page.tsx b/apps/dashboard/app/(dashboard)/studio/deploy/page.tsx new file mode 100644 index 0000000..266514b --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/deploy/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import { PageHeader } from "../../../../components/page-header"; +import { DeployPanel } from "../../../../features/products/deploy-panel"; +import { getOrCreateProduct } from "../../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +export default async function StudioDeployPage() { + const product = await getOrCreateProduct(); + if (!product) notFound(); + + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/studio/inbox/page.tsx b/apps/dashboard/app/(dashboard)/studio/inbox/page.tsx new file mode 100644 index 0000000..f5cd296 --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/inbox/page.tsx @@ -0,0 +1,24 @@ +import { PageHeader } from "../../../../components/page-header"; +import { getOrCreateProduct } from "../../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +export default async function StudioInboxPage() { + // Ensure the single agent exists so nav into Inbox is consistent with other Studio pages. + await getOrCreateProduct(); + + return ( +
+ +
+

Conversations coming soon

+

+ Chats, actions taken, and live handoff will land here in a later task. +

+
+
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/studio/page.tsx b/apps/dashboard/app/(dashboard)/studio/page.tsx index ef4d23a..ec6e9e6 100644 --- a/apps/dashboard/app/(dashboard)/studio/page.tsx +++ b/apps/dashboard/app/(dashboard)/studio/page.tsx @@ -1,32 +1,8 @@ -import { Sparkles } from "lucide-react"; -import { EmptyState } from "../../../components/empty-state"; -import { PageHeader } from "../../../components/page-header"; -import { CreateProductDialog } from "../../../features/products/create-product-dialog"; -import { ProductsList } from "../../../features/products/products-list"; -import { listProducts } from "../../../features/products/queries"; +import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; -export default async function StudioPage() { - const products = await listProducts(); - - return ( - <> - } - /> - {products.length === 0 ? ( - } - /> - ) : ( - - )} - - ); +/** /studio → Train (single-agent Studio). */ +export default function StudioIndexPage() { + redirect("/studio/train"); } diff --git a/apps/dashboard/app/(dashboard)/studio/test/page.tsx b/apps/dashboard/app/(dashboard)/studio/test/page.tsx new file mode 100644 index 0000000..c411161 --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/test/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import { PageHeader } from "../../../../components/page-header"; +import { TestPreviewPanel } from "../../../../features/products/test-preview-panel"; +import { getOrCreateProduct } from "../../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +export default async function StudioTestPage() { + const product = await getOrCreateProduct(); + if (!product) notFound(); + + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/studio/train/page.tsx b/apps/dashboard/app/(dashboard)/studio/train/page.tsx new file mode 100644 index 0000000..d52b5b5 --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/train/page.tsx @@ -0,0 +1,41 @@ +import { notFound } from "next/navigation"; +import { PageHeader } from "../../../../components/page-header"; +import { ProductSettingsForm } from "../../../../features/products/product-settings-form"; +import { TrainActionsPanel } from "../../../../features/products/train-actions-panel"; +import { TrainContentPanel } from "../../../../features/products/train-content-panel"; +import { + getOrCreateProduct, + listActions, + listContent, +} from "../../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +export default async function StudioTrainPage() { + const product = await getOrCreateProduct(); + if (!product) notFound(); + const [content, actions] = await Promise.all([listContent(product.id), listActions(product.id)]); + + return ( +
+ + Live + + ) : ( + + Draft + + ) + } + /> + + + +
+ ); +} diff --git a/apps/dashboard/features/navigation/nav.config.ts b/apps/dashboard/features/navigation/nav.config.ts index 865c142..fc1ddce 100644 --- a/apps/dashboard/features/navigation/nav.config.ts +++ b/apps/dashboard/features/navigation/nav.config.ts @@ -1,13 +1,16 @@ import { BarChart3, + BookOpen, Boxes, Building2, CreditCard, + FlaskConical, + Inbox, KeyRound, LayoutDashboard, ArrowLeftRight, + Rocket, Settings, - Sparkles, Star, Store, Wallet, @@ -43,7 +46,15 @@ export const navGroups: NavGroup[] = [ items: [ { label: "My Capabilities", href: "/capabilities", icon: Boxes }, { label: "Cards", href: "/agents", icon: CreditCard }, - { label: "Studio", href: "/studio", icon: Sparkles }, + ], + }, + { + label: "Studio", + items: [ + { label: "Train", href: "/studio/train", icon: BookOpen }, + { label: "Test", href: "/studio/test", icon: FlaskConical }, + { label: "Deploy", href: "/studio/deploy", icon: Rocket }, + { label: "Inbox", href: "/studio/inbox", icon: Inbox }, ], }, { diff --git a/apps/dashboard/features/products/action-actions.ts b/apps/dashboard/features/products/action-actions.ts index 38ce2be..d208b8d 100644 --- a/apps/dashboard/features/products/action-actions.ts +++ b/apps/dashboard/features/products/action-actions.ts @@ -1,11 +1,10 @@ "use server"; -import { revalidatePath } from "next/cache"; import { z } from "zod"; import { and, eq, productActions, products } from "@tael/database"; import { db } from "../../lib/db"; import { getCurrentUser } from "../capabilities/current-user"; -import type { ActionResult } from "./actions"; +import { revalidateStudioPaths, type ActionResult } from "./actions"; const nameSchema = z.string().trim().min(1, "Name is required").max(80); const descriptionSchema = z.string().trim().min(1, "Description is required").max(1000); @@ -72,11 +71,6 @@ async function assertActionOwner( return { ok: true, productId: row.productId }; } -function revalidateStudio(productId: string) { - revalidatePath("/studio"); - revalidatePath(`/studio/${productId}`); -} - export type AddActionInput = z.infer; /** Insert an action for a product the signed-in user owns. */ @@ -105,7 +99,7 @@ export async function addAction(productId: string, input: AddActionInput): Promi }) .returning({ id: productActions.id }); - revalidateStudio(productId); + revalidateStudioPaths(); return { ok: true, id: row!.id }; } catch { return { ok: false, error: "Could not add action. Try again." }; @@ -158,7 +152,7 @@ export async function updateAction( try { await db.update(productActions).set(patch).where(eq(productActions.id, actionId)); - revalidateStudio(owned.productId); + revalidateStudioPaths(); return { ok: true, id: actionId }; } catch { return { ok: false, error: "Could not save. Try again." }; @@ -175,7 +169,7 @@ export async function deleteAction(actionId: string): Promise { try { await db.delete(productActions).where(eq(productActions.id, actionId)); - revalidateStudio(owned.productId); + revalidateStudioPaths(); return { ok: true }; } catch { return { ok: false, error: "Could not delete. Try again." }; @@ -192,7 +186,7 @@ export async function toggleAction(actionId: string, enabled: boolean): Promise< try { await db.update(productActions).set({ enabled }).where(eq(productActions.id, actionId)); - revalidateStudio(owned.productId); + revalidateStudioPaths(); return { ok: true, id: actionId }; } catch { return { ok: false, error: "Could not update. Try again." }; diff --git a/apps/dashboard/features/products/actions.ts b/apps/dashboard/features/products/actions.ts index 505bae8..199f633 100644 --- a/apps/dashboard/features/products/actions.ts +++ b/apps/dashboard/features/products/actions.ts @@ -1,9 +1,8 @@ "use server"; -import { randomBytes } from "node:crypto"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { and, eq, ilike, products } from "@tael/database"; +import { and, eq, products } from "@tael/database"; import { db } from "../../lib/db"; import { getCurrentUser } from "../capabilities/current-user"; @@ -22,75 +21,13 @@ export interface ActionResult { id?: string; } -/** Build a URL-safe slug from a name (the action makes it unique on collision). */ -function slugify(name: string): string { - return name - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 40); -} - -/** - * Turn a base slug into a unique one. Returns the clean name when free; - * falls back to `name-2`, `name-3`, … on collision. - */ -async function uniqueSlug(base: string): Promise { - const fallback = base || "agent"; - const rows = await db - .select({ slug: products.slug }) - .from(products) - .where(ilike(products.slug, `${fallback}%`)); - const taken = new Set(rows.map((r) => r.slug)); - if (!taken.has(fallback)) return fallback; - for (let i = 2; ; i += 1) { - const candidate = `${fallback}-${i}`; - if (!taken.has(candidate)) return candidate; - } -} - -/** Stripe-style publishable key: `tael_pub_` + 24 hex chars. */ -function generatePublicKey(): string { - return `tael_pub_${randomBytes(12).toString("hex")}`; -} - -const createProductSchema = z.object({ - name: nameSchema, -}); - -/** - * Create a product agent for the signed-in user. Generates a unique slug from - * the name and a random publicKey safe to expose in the embed snippet. - */ -export async function createProduct(input: { name: string }): Promise { - const user = await getCurrentUser(); - if (!user) return { ok: false, error: "Not signed in." }; - - const parsed = createProductSchema.safeParse(input); - if (!parsed.success) { - return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." }; - } - - const slug = await uniqueSlug(slugify(parsed.data.name)); - const publicKey = generatePublicKey(); - - try { - const [row] = await db - .insert(products) - .values({ - ownerId: user.id, - name: parsed.data.name, - slug, - publicKey, - }) - .returning({ id: products.id }); - - revalidatePath("/studio"); - return { ok: true, id: row!.id }; - } catch { - return { ok: false, error: "Could not create the agent. Try again." }; - } +/** Revalidate every Studio page after a product mutation. */ +export function revalidateStudioPaths() { + revalidatePath("/studio"); + revalidatePath("/studio/train"); + revalidatePath("/studio/test"); + revalidatePath("/studio/deploy"); + revalidatePath("/studio/inbox"); } const updateProductSchema = z.object({ @@ -129,8 +66,7 @@ export async function updateProduct( if (!result[0]) return { ok: false, error: "Agent not found." }; - revalidatePath("/studio"); - revalidatePath(`/studio/${id}`); + revalidateStudioPaths(); return { ok: true, id }; } catch { return { ok: false, error: "Could not save. Try again." }; @@ -150,7 +86,7 @@ export async function deleteProduct(id: string): Promise { if (!result[0]) return { ok: false, error: "Agent not found." }; - revalidatePath("/studio"); + revalidateStudioPaths(); return { ok: true }; } catch { return { ok: false, error: "Could not delete. Try again." }; diff --git a/apps/dashboard/features/products/content-actions.ts b/apps/dashboard/features/products/content-actions.ts index aa191ae..1341336 100644 --- a/apps/dashboard/features/products/content-actions.ts +++ b/apps/dashboard/features/products/content-actions.ts @@ -1,11 +1,10 @@ "use server"; -import { revalidatePath } from "next/cache"; import { z } from "zod"; import { and, eq, productContent, products } from "@tael/database"; import { db } from "../../lib/db"; import { getCurrentUser } from "../capabilities/current-user"; -import type { ActionResult } from "./actions"; +import { revalidateStudioPaths, type ActionResult } from "./actions"; import { extractTitle, htmlToText, MAX_BODY_BYTES } from "./html-to-text"; const FETCH_TIMEOUT_MS = 15_000; @@ -95,11 +94,6 @@ async function assertContentOwner( return { ok: true, productId: row.productId }; } -function revalidateStudio(productId: string) { - revalidatePath("/studio"); - revalidatePath(`/studio/${productId}`); -} - const addContentSchema = z.object({ type: contentTypeSchema, title: titleSchema, @@ -140,7 +134,7 @@ export async function addContent( }) .returning({ id: productContent.id }); - revalidateStudio(productId); + revalidateStudioPaths(); return { ok: true, id: row!.id }; } catch { return { ok: false, error: "Could not add content. Try again." }; @@ -176,7 +170,7 @@ export async function updateContent( try { await db.update(productContent).set(patch).where(eq(productContent.id, contentId)); - revalidateStudio(owned.productId); + revalidateStudioPaths(); return { ok: true, id: contentId }; } catch { return { ok: false, error: "Could not save. Try again." }; @@ -193,7 +187,7 @@ export async function deleteContent(contentId: string): Promise { try { await db.delete(productContent).where(eq(productContent.id, contentId)); - revalidateStudio(owned.productId); + revalidateStudioPaths(); return { ok: true }; } catch { return { ok: false, error: "Could not delete. Try again." }; @@ -210,7 +204,7 @@ export async function toggleContent(contentId: string, enabled: boolean): Promis try { await db.update(productContent).set({ enabled }).where(eq(productContent.id, contentId)); - revalidateStudio(owned.productId); + revalidateStudioPaths(); return { ok: true, id: contentId }; } catch { return { ok: false, error: "Could not update. Try again." }; @@ -276,7 +270,7 @@ export async function syncWebsite(productId: string, url: string): Promise(null); - const [name, setName] = useState(""); - - function reset() { - setError(null); - setName(""); - } - - function submit() { - setError(null); - startTransition(async () => { - const res = await createProduct({ name }); - if (res.ok && res.id) { - setOpen(false); - reset(); - router.push(`/studio/${res.id}`); - router.refresh(); - } else { - setError(res.error ?? "Could not create the agent."); - } - }); - } - - return ( - <> - - { - setOpen(o); - if (!o) reset(); - }} - > - - - New agent - - Train it on your content, connect actions, and embed it on your site. - - - -
- - - {error ?

{error}

: null} - - -
-
-
- - ); -} diff --git a/apps/dashboard/features/products/product-settings-form.tsx b/apps/dashboard/features/products/product-settings-form.tsx index 15431bf..1c6b75d 100644 --- a/apps/dashboard/features/products/product-settings-form.tsx +++ b/apps/dashboard/features/products/product-settings-form.tsx @@ -28,15 +28,19 @@ export function ProductSettingsForm({ product }: { product: Product }) { const [name, setName] = useState(product.name); const [brandColor, setBrandColor] = useState(product.brandColor); const [greeting, setGreeting] = useState(product.greeting); + const [status, setStatus] = useState(product.status); const dirty = - name !== product.name || brandColor !== product.brandColor || greeting !== product.greeting; + name !== product.name || + brandColor !== product.brandColor || + greeting !== product.greeting || + status !== product.status; function save() { setError(null); setSaved(false); startTransition(async () => { - const res = await updateProduct(product.id, { name, brandColor, greeting }); + const res = await updateProduct(product.id, { name, brandColor, greeting, status }); if (res.ok) { setSaved(true); router.refresh(); @@ -50,7 +54,7 @@ export function ProductSettingsForm({ product }: { product: Product }) { function remove() { startTransition(async () => { const res = await deleteProduct(product.id); - if (res.ok) router.push("/studio"); + if (res.ok) router.push("/studio/train"); else setError(res.error ?? "Could not delete."); }); } @@ -99,6 +103,22 @@ export function ProductSettingsForm({ product }: { product: Product }) { /> + +
Slug
diff --git a/apps/dashboard/features/products/products-list.tsx b/apps/dashboard/features/products/products-list.tsx deleted file mode 100644 index 841bc53..0000000 --- a/apps/dashboard/features/products/products-list.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import Link from "next/link"; -import type { Product } from "@tael/database"; - -export function ProductsList({ products }: { products: Product[] }) { - return ( -
- - - - - - - - - - {products.map((p) => ( - - - - - - ))} - -
AgentSlugStatus
- - - {p.name} - - - {p.slug} - - {p.status === "live" ? ( - - Live - - ) : ( - - Draft - - )} -
-
- ); -} diff --git a/apps/dashboard/features/products/queries.ts b/apps/dashboard/features/products/queries.ts index 3243f89..5446fe3 100644 --- a/apps/dashboard/features/products/queries.ts +++ b/apps/dashboard/features/products/queries.ts @@ -1,8 +1,10 @@ import "server-only"; +import { randomBytes } from "node:crypto"; import { and, desc, eq, + ilike, productActions, productContent, products, @@ -13,15 +15,79 @@ import { import { db } from "../../lib/db"; import { getCurrentUser } from "../capabilities/current-user"; -/** List the signed-in user's products (agents), newest first. */ -export async function listProducts(): Promise { +/** Build a URL-safe slug from a name. */ +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} + +async function uniqueSlug(base: string): Promise { + const fallback = base || "agent"; + const rows = await db + .select({ slug: products.slug }) + .from(products) + .where(ilike(products.slug, `${fallback}%`)); + const taken = new Set(rows.map((r) => r.slug)); + if (!taken.has(fallback)) return fallback; + for (let i = 2; ; i += 1) { + const candidate = `${fallback}-${i}`; + if (!taken.has(candidate)) return candidate; + } +} + +function generatePublicKey(): string { + return `tael_pub_${randomBytes(12).toString("hex")}`; +} + +/** Default display name for a newly auto-created agent. */ +function defaultAgentName(displayName: string | null, walletAddress: string): string { + const named = displayName?.trim(); + if (named) return named.slice(0, 80); + if (walletAddress.length > 10) { + return `${walletAddress.slice(0, 4)}…${walletAddress.slice(-4)}`; + } + return "My Agent"; +} + +/** + * The signed-in user's single product agent. Creates one on first visit + * (name from displayName / wallet handle, or "My Agent"). + */ +export async function getOrCreateProduct(): Promise { const user = await getCurrentUser(); - if (!user) return []; - return db + if (!user) return null; + + const existing = await db .select() .from(products) .where(eq(products.ownerId, user.id)) - .orderBy(desc(products.createdAt)); + .orderBy(desc(products.createdAt)) + .limit(1); + if (existing[0]) return existing[0]; + + const name = defaultAgentName(user.displayName, user.walletAddress); + try { + const slug = await uniqueSlug(slugify(name)); + const publicKey = generatePublicKey(); + const [row] = await db + .insert(products) + .values({ ownerId: user.id, name, slug, publicKey }) + .returning(); + return row ?? null; + } catch { + // Race: another request created one first. Re-read. + const again = await db + .select() + .from(products) + .where(eq(products.ownerId, user.id)) + .orderBy(desc(products.createdAt)) + .limit(1); + return again[0] ?? null; + } } /** Load a single product owned by the current user. Returns null if missing. */ diff --git a/apps/dashboard/features/products/studio-tabs.tsx b/apps/dashboard/features/products/studio-tabs.tsx deleted file mode 100644 index ebf9084..0000000 --- a/apps/dashboard/features/products/studio-tabs.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import { useState, type ReactNode } from "react"; -import { cn } from "@tael/ui"; -import type { Product, ProductAction, ProductContent } from "@tael/database"; -import { ProductSettingsForm } from "./product-settings-form"; -import { TrainContentPanel } from "./train-content-panel"; -import { TrainActionsPanel } from "./train-actions-panel"; -import { DeployPanel } from "./deploy-panel"; - -const TABS = [ - { id: "train", label: "Train" }, - { id: "test", label: "Test" }, - { id: "deploy", label: "Deploy" }, - { id: "analyze", label: "Analyze" }, -] as const; - -type TabId = (typeof TABS)[number]["id"]; - -function ComingSoon({ title, description }: { title: string; description: string }) { - return ( -
-

{title}

-

{description}

-
- ); -} - -/** Tab shells for Train / Test / Deploy / Analyze. */ -export function StudioTabs({ - product, - content, - actions, -}: { - product: Product; - content: ProductContent[]; - actions: ProductAction[]; -}) { - const [tab, setTab] = useState("train"); - - let body: ReactNode; - switch (tab) { - case "train": - body = ( -
- - - -
- ); - break; - case "test": - body = ( - - ); - break; - case "deploy": - body = ; - break; - case "analyze": - body = ( - - ); - break; - } - - return ( -
-
- {TABS.map((t) => ( - - ))} -
-
{body}
-
- ); -} diff --git a/apps/dashboard/features/products/test-preview-panel.tsx b/apps/dashboard/features/products/test-preview-panel.tsx new file mode 100644 index 0000000..4baaa20 --- /dev/null +++ b/apps/dashboard/features/products/test-preview-panel.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Button, Input, cn } from "@tael/ui"; +import type { Product } from "@tael/database"; +import type { ProposedWidgetAction } from "./widget-chat"; + +interface ChatLine { + id: string; + role: "user" | "assistant"; + content: string; + action?: ProposedWidgetAction; + actionDone?: boolean; +} + +let counter = 0; +function nextId(): string { + counter += 1; + return `t${counter}-${Date.now()}`; +} + +/** In-dashboard Test chat: hits the widget endpoints with ?preview=1. */ +export function TestPreviewPanel({ product }: { product: Product }) { + const [messages, setMessages] = useState(() => + product.greeting.trim() + ? [{ id: nextId(), role: "assistant", content: product.greeting.trim() }] + : [], + ); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const scrollRef = useRef(null); + const chatBase = `/api/widget/${encodeURIComponent(product.publicKey)}`; + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); + }, [messages, busy]); + + async function send(text: string) { + const content = text.trim(); + if (!content || busy) return; + setBusy(true); + setDraft(""); + + const userMsg: ChatLine = { id: nextId(), role: "user", content }; + const history = [...messages, userMsg].filter((m) => m.role === "user" || m.content); + setMessages((prev) => [...prev, userMsg]); + + try { + const res = await fetch(`${chatBase}/chat?preview=1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: history + .filter((m) => m.role === "user" || (m.role === "assistant" && m.content)) + .map((m) => ({ role: m.role, content: m.content })), + }), + }); + const data = (await res.json().catch(() => null)) as { + reply?: string; + action?: ProposedWidgetAction | null; + error?: string; + } | null; + + if (!res.ok || !data) { + setMessages((prev) => [ + ...prev, + { + id: nextId(), + role: "assistant", + content: data?.error ?? "Something went wrong. Please try again.", + }, + ]); + return; + } + + setMessages((prev) => [ + ...prev, + { + id: nextId(), + role: "assistant", + content: data.reply ?? "", + action: data.action ?? undefined, + }, + ]); + } catch { + setMessages((prev) => [ + ...prev, + { + id: nextId(), + role: "assistant", + content: "Could not reach the agent. Please try again.", + }, + ]); + } finally { + setBusy(false); + } + } + + async function confirmAction(messageId: string, action: ProposedWidgetAction) { + if (busy) return; + setBusy(true); + setMessages((prev) => prev.map((m) => (m.id === messageId ? { ...m, actionDone: true } : m))); + + try { + const res = await fetch(`${chatBase}/actions/run?preview=1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + actionId: action.actionId, + ...(action.params != null ? { params: action.params } : {}), + }), + }); + const data = (await res.json().catch(() => null)) as { + ok?: boolean; + error?: string; + body?: string; + } | null; + + let content: string; + if ( + res.status === 401 || + data?.error === "Not signed in." || + data?.error === "Pick a Card to pay for this run." + ) { + content = + "This action needs the site owner (and a Card for capability runs). HTTP actions work for everyone."; + } else if (!res.ok || !data?.ok) { + content = data?.error ?? "Could not run that action."; + } else { + content = data.body + ? `Ran ${action.name}.\n\n${data.body.slice(0, 600)}` + : `Ran ${action.name}.`; + } + + setMessages((prev) => [...prev, { id: nextId(), role: "assistant", content }]); + } catch { + setMessages((prev) => [ + ...prev, + { + id: nextId(), + role: "assistant", + content: "Could not reach the server. Please try again.", + }, + ]); + } finally { + setBusy(false); + } + } + + return ( +
+
+ +
+

{product.name}

+

Preview mode (draft agents work here)

+
+
+ +
+ {messages.length === 0 ? ( +

+ Ask something your agent should know from Train content. +

+ ) : null} + {messages.map((m) => ( +
+
+ {m.content} +
+ {m.action && !m.actionDone ? ( +
+

+ {m.action.kind === "http" ? "Run action" : "Run capability"} +

+

{m.action.name}

+ +
+ ) : null} +
+ ))} + {busy ? ( +

+ Thinking… +

+ ) : null} +
+ +
{ + e.preventDefault(); + void send(draft); + }} + > + setDraft(e.target.value)} + placeholder="Message your agent…" + disabled={busy} + className="flex-1" + /> + +
+
+ ); +} From 4723cbebe1add8ec75ac7340fb0df1925d703f11 Mon Sep 17 00:00:00 2001 From: rahulsainlll Date: Fri, 31 Jul 2026 17:27:22 +0530 Subject: [PATCH 08/10] fix(studio): move revalidateStudioPaths out of the use-server module A "use server" file may only export async server actions, so the sync revalidateStudioPaths helper broke the build. Moved it to a plain server-only module (revalidate.ts) and updated imports. --- .../dashboard/features/products/action-actions.ts | 3 ++- apps/dashboard/features/products/actions.ts | 11 +---------- .../features/products/content-actions.ts | 3 ++- apps/dashboard/features/products/revalidate.ts | 15 +++++++++++++++ 4 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 apps/dashboard/features/products/revalidate.ts diff --git a/apps/dashboard/features/products/action-actions.ts b/apps/dashboard/features/products/action-actions.ts index d208b8d..5b11bd1 100644 --- a/apps/dashboard/features/products/action-actions.ts +++ b/apps/dashboard/features/products/action-actions.ts @@ -4,7 +4,8 @@ import { z } from "zod"; import { and, eq, productActions, products } from "@tael/database"; import { db } from "../../lib/db"; import { getCurrentUser } from "../capabilities/current-user"; -import { revalidateStudioPaths, type ActionResult } from "./actions"; +import { type ActionResult } from "./actions"; +import { revalidateStudioPaths } from "./revalidate"; const nameSchema = z.string().trim().min(1, "Name is required").max(80); const descriptionSchema = z.string().trim().min(1, "Description is required").max(1000); diff --git a/apps/dashboard/features/products/actions.ts b/apps/dashboard/features/products/actions.ts index 199f633..a4a69b6 100644 --- a/apps/dashboard/features/products/actions.ts +++ b/apps/dashboard/features/products/actions.ts @@ -1,10 +1,10 @@ "use server"; -import { revalidatePath } from "next/cache"; import { z } from "zod"; import { and, eq, products } from "@tael/database"; import { db } from "../../lib/db"; import { getCurrentUser } from "../capabilities/current-user"; +import { revalidateStudioPaths } from "./revalidate"; const nameSchema = z.string().trim().min(1, "Name is required").max(80); const brandColorSchema = z @@ -21,15 +21,6 @@ export interface ActionResult { id?: string; } -/** Revalidate every Studio page after a product mutation. */ -export function revalidateStudioPaths() { - revalidatePath("/studio"); - revalidatePath("/studio/train"); - revalidatePath("/studio/test"); - revalidatePath("/studio/deploy"); - revalidatePath("/studio/inbox"); -} - const updateProductSchema = z.object({ name: nameSchema.optional(), description: descriptionSchema.optional(), diff --git a/apps/dashboard/features/products/content-actions.ts b/apps/dashboard/features/products/content-actions.ts index 1341336..54ba0f2 100644 --- a/apps/dashboard/features/products/content-actions.ts +++ b/apps/dashboard/features/products/content-actions.ts @@ -4,7 +4,8 @@ import { z } from "zod"; import { and, eq, productContent, products } from "@tael/database"; import { db } from "../../lib/db"; import { getCurrentUser } from "../capabilities/current-user"; -import { revalidateStudioPaths, type ActionResult } from "./actions"; +import { type ActionResult } from "./actions"; +import { revalidateStudioPaths } from "./revalidate"; import { extractTitle, htmlToText, MAX_BODY_BYTES } from "./html-to-text"; const FETCH_TIMEOUT_MS = 15_000; diff --git a/apps/dashboard/features/products/revalidate.ts b/apps/dashboard/features/products/revalidate.ts new file mode 100644 index 0000000..0db717d --- /dev/null +++ b/apps/dashboard/features/products/revalidate.ts @@ -0,0 +1,15 @@ +import "server-only"; +import { revalidatePath } from "next/cache"; + +/** + * Revalidate every Studio page after a product mutation. Kept out of the + * "use server" action modules because a server-actions file may only export + * async server actions, and this is a plain server-side helper. + */ +export function revalidateStudioPaths() { + revalidatePath("/studio"); + revalidatePath("/studio/train"); + revalidatePath("/studio/test"); + revalidatePath("/studio/deploy"); + revalidatePath("/studio/inbox"); +} From 39946539f7c255fbf65cf7bb486f8c4233c76597 Mon Sep 17 00:00:00 2001 From: rahulsainlll Date: Fri, 31 Jul 2026 18:18:23 +0530 Subject: [PATCH 09/10] Changed: Two-column Train with Fin-style content and live preview Co-authored-by: Cursor --- .../app/(dashboard)/studio/settings/page.tsx | 32 +++ .../app/(dashboard)/studio/test/page.tsx | 2 +- .../app/(dashboard)/studio/train/page.tsx | 20 +- .../features/navigation/nav.config.ts | 2 + .../features/products/action-list.tsx | 137 ------------- .../features/products/add-content-forms.tsx | 86 ++++++-- .../features/products/content-dialogs.tsx | 92 ++++++++- .../features/products/content-list.tsx | 157 ++++++++++----- .../dashboard/features/products/revalidate.ts | 1 + .../features/products/test-preview-panel.tsx | 133 +++++++++---- .../features/products/train-actions-panel.tsx | 186 +++++++++++++++++- .../features/products/train-content-panel.tsx | 21 +- 12 files changed, 607 insertions(+), 262 deletions(-) create mode 100644 apps/dashboard/app/(dashboard)/studio/settings/page.tsx delete mode 100644 apps/dashboard/features/products/action-list.tsx diff --git a/apps/dashboard/app/(dashboard)/studio/settings/page.tsx b/apps/dashboard/app/(dashboard)/studio/settings/page.tsx new file mode 100644 index 0000000..68d9c53 --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/settings/page.tsx @@ -0,0 +1,32 @@ +import { notFound } from "next/navigation"; +import { PageHeader } from "../../../../components/page-header"; +import { ProductSettingsForm } from "../../../../features/products/product-settings-form"; +import { getOrCreateProduct } from "../../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +export default async function StudioSettingsPage() { + const product = await getOrCreateProduct(); + if (!product) notFound(); + + return ( +
+ + Live + + ) : ( + + Draft + + ) + } + /> + +
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/studio/test/page.tsx b/apps/dashboard/app/(dashboard)/studio/test/page.tsx index c411161..ab9050b 100644 --- a/apps/dashboard/app/(dashboard)/studio/test/page.tsx +++ b/apps/dashboard/app/(dashboard)/studio/test/page.tsx @@ -10,7 +10,7 @@ export default async function StudioTestPage() { if (!product) notFound(); return ( -
+
+
@@ -33,9 +33,17 @@ export default async function StudioTrainPage() { ) } /> - - - + +
+
+ + +
+ + +
); } diff --git a/apps/dashboard/features/navigation/nav.config.ts b/apps/dashboard/features/navigation/nav.config.ts index fc1ddce..cf8281e 100644 --- a/apps/dashboard/features/navigation/nav.config.ts +++ b/apps/dashboard/features/navigation/nav.config.ts @@ -11,6 +11,7 @@ import { ArrowLeftRight, Rocket, Settings, + SlidersHorizontal, Star, Store, Wallet, @@ -55,6 +56,7 @@ export const navGroups: NavGroup[] = [ { label: "Test", href: "/studio/test", icon: FlaskConical }, { label: "Deploy", href: "/studio/deploy", icon: Rocket }, { label: "Inbox", href: "/studio/inbox", icon: Inbox }, + { label: "Settings", href: "/studio/settings", icon: SlidersHorizontal }, ], }, { diff --git a/apps/dashboard/features/products/action-list.tsx b/apps/dashboard/features/products/action-list.tsx deleted file mode 100644 index b05b96e..0000000 --- a/apps/dashboard/features/products/action-list.tsx +++ /dev/null @@ -1,137 +0,0 @@ -"use client"; - -import { useTransition } from "react"; -import { useRouter } from "next/navigation"; -import { Link2, Trash2, Zap } from "lucide-react"; -import { Button, cn } from "@tael/ui"; -import type { ProductAction } from "@tael/database"; -import { deleteAction, toggleAction, updateAction } from "./action-actions"; - -const KIND_META: Record = - { - capability: { - label: "Capability", - icon: Zap, - badge: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300", - }, - http: { - label: "HTTP", - icon: Link2, - badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", - }, - }; - -function configSummary(item: ProductAction): string { - if (item.kind === "capability" && "slug" in item.config) { - return item.config.slug; - } - if (item.kind === "http" && "url" in item.config) { - return `${item.config.method} ${item.config.url}`; - } - return ""; -} - -export function ActionList({ items }: { items: ProductAction[] }) { - if (items.length === 0) { - return ( -

- No actions yet. Connect a capability or HTTP endpoint the agent can propose. -

- ); - } - - return ( -
-
    - {items.map((item) => ( - - ))} -
-
- ); -} - -function ActionRow({ item }: { item: ProductAction }) { - const router = useRouter(); - const [pending, startTransition] = useTransition(); - const meta = KIND_META[item.kind]; - const Icon = meta.icon; - const summary = configSummary(item); - - function onToggle(enabled: boolean) { - startTransition(async () => { - await toggleAction(item.id, enabled); - router.refresh(); - }); - } - - function onShare(shareAsCapability: boolean) { - startTransition(async () => { - await updateAction(item.id, { shareAsCapability }); - router.refresh(); - }); - } - - function onDelete() { - startTransition(async () => { - await deleteAction(item.id); - router.refresh(); - }); - } - - return ( -
  • - - {meta.label} - - -
    -

    {item.name}

    - {summary ? ( -

    {summary}

    - ) : null} -
    - - - - - - -
  • - ); -} diff --git a/apps/dashboard/features/products/add-content-forms.tsx b/apps/dashboard/features/products/add-content-forms.tsx index fe0d48d..6ad2283 100644 --- a/apps/dashboard/features/products/add-content-forms.tsx +++ b/apps/dashboard/features/products/add-content-forms.tsx @@ -1,31 +1,85 @@ "use client"; import { useState } from "react"; -import { FileText, Globe, HelpCircle, Quote } from "lucide-react"; -import { Button } from "@tael/ui"; +import { FileText, Globe, HelpCircle, Quote, type LucideIcon } from "lucide-react"; +import { cn } from "@tael/ui"; import { FaqDialog, TextContentDialog, WebsiteDialog } from "./content-dialogs"; type Mode = "website" | "doc" | "snippet" | "faq" | null; -/** Four entry points for adding Train content, each opening a focused dialog. */ +const ADD_CARDS: { + id: NonNullable; + label: string; + description: string; + icon: LucideIcon; + tone: string; +}[] = [ + { + id: "website", + label: "Website", + description: "Sync a page", + icon: Globe, + tone: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + }, + { + id: "doc", + label: "Doc", + description: "Longer text", + icon: FileText, + tone: "bg-sky-500/10 text-sky-700 dark:text-sky-300", + }, + { + id: "snippet", + label: "Snippet", + description: "Short note", + icon: Quote, + tone: "bg-violet-500/10 text-violet-700 dark:text-violet-300", + }, + { + id: "faq", + label: "FAQ", + description: "Q & A", + icon: HelpCircle, + tone: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + }, +]; + +/** Fin-style add-content cards; each opens an existing dialog. */ export function AddContentForms({ productId }: { productId: string }) { const [mode, setMode] = useState(null); return (
    -
    - - - - +
    + {ADD_CARDS.map((card) => { + const Icon = card.icon; + return ( + + ); + })}
    ); } + +/** Edit an existing content row (title + body). */ +export function EditContentDialog({ + open, + onClose, + item, +}: { + open: boolean; + onClose: () => void; + item: ProductContent | null; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + if (open && item) { + setTitle(item.title); + setBody(item.body); + setError(null); + } + }, [open, item]); + + function reset() { + setTitle(""); + setBody(""); + setError(null); + } + + function submit() { + if (!item) return; + setError(null); + startTransition(async () => { + const res = await updateContent(item.id, { title, body }); + if (res.ok) { + reset(); + onClose(); + router.refresh(); + } else { + setError(res.error ?? "Could not save."); + } + }); + } + + const isFaq = item?.type === "faq"; + + return ( + { + if (!o) { + reset(); + onClose(); + } + }} + > + + + Edit {isFaq ? "FAQ" : "content"} + Update what the agent can use from this source. + +
    + + setTitle(e.target.value)} autoFocus /> + + +