Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/dashboard/app/(dashboard)/studio/deploy/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-3xl space-y-8">
<PageHeader
title="Deploy"
description="Grab the embed snippet and choose where this agent shows up."
/>
<DeployPanel product={product} />
</div>
);
}
24 changes: 24 additions & 0 deletions apps/dashboard/app/(dashboard)/studio/inbox/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-3xl space-y-8">
<PageHeader
title="Inbox"
description="Conversations from your embedded agent will show up here."
/>
<div className="rounded-xl border border-dashed px-6 py-14 text-center">
<h3 className="font-medium">Conversations coming soon</h3>
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
Chats, actions taken, and live handoff will land here in a later task.
</p>
</div>
</div>
);
}
8 changes: 8 additions & 0 deletions apps/dashboard/app/(dashboard)/studio/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";

export const dynamic = "force-dynamic";

/** /studio → Train (single-agent Studio). */
export default function StudioIndexPage() {
redirect("/studio/train");
}
32 changes: 32 additions & 0 deletions apps/dashboard/app/(dashboard)/studio/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-3xl space-y-8">
<PageHeader
title="Settings"
description="Name, brand, greeting, and whether this agent is live on your site."
action={
product.status === "live" ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-700">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> Live
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-slate-400" /> Draft
</span>
)
}
/>
<ProductSettingsForm product={product} />
</div>
);
}
21 changes: 21 additions & 0 deletions apps/dashboard/app/(dashboard)/studio/test/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-lg space-y-8">
<PageHeader
title="Test"
description="Chat with your agent before it goes live. Uses preview mode so drafts work too."
/>
<TestPreviewPanel product={product} />
</div>
);
}
100 changes: 100 additions & 0 deletions apps/dashboard/app/(dashboard)/studio/train/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { PageHeader } from "../../../../components/page-header";
import { AddContentForms } from "../../../../features/products/add-content-forms";
import { ContentList } from "../../../../features/products/content-list";
import { TrainActionsPanel } from "../../../../features/products/train-actions-panel";
import { TestPreviewPanel } from "../../../../features/products/test-preview-panel";
import {
getOrCreateProduct,
listActions,
listContent,
} from "../../../../features/products/queries";

export const dynamic = "force-dynamic";

/** Placeholder rows shown while a list streams in. */
function ListSkeleton() {
return (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-14 animate-pulse rounded-lg bg-muted" />
))}
</div>
);
}

/** The "Your content" list, fetched on its own so it streams in after the shell. */
async function ContentListSection({ productId }: { productId: string }) {
const items = await listContent(productId);
return (
<div className="space-y-3 pt-2">
<div className="flex items-baseline justify-between gap-3">
<h2 className="text-base font-semibold">Your content</h2>
<span className="text-xs text-muted-foreground">
{items.length} {items.length === 1 ? "source" : "sources"}
</span>
</div>
<ContentList items={items} />
</div>
);
}

/** The actions panel, fetched on its own so it streams in after the shell. */
async function ActionsSection({ productId }: { productId: string }) {
const items = await listActions(productId);
return <TrainActionsPanel productId={productId} items={items} />;
}

export default async function StudioTrainPage() {
// Resolve the product once (needed for the preview + status). The content and
// actions lists stream in via Suspense, so the shell + preview paint right
// away instead of blocking on every DB round-trip up front.
const product = await getOrCreateProduct();
if (!product) notFound();

return (
<div className="space-y-6">
<PageHeader
title="Train"
description="Add content and actions on the left. Preview the live agent on the right."
action={
product.status === "live" ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-700">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> Live
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-slate-400" /> Draft
</span>
)
}
/>

<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_400px] lg:items-start">
<div className="min-w-0 space-y-8">
<section className="space-y-6">
<div>
<h2 className="text-base font-semibold">Add content</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sources your agent can learn from. Enabled items are used in chat.
</p>
</div>
<AddContentForms productId={product.id} />
<Suspense fallback={<ListSkeleton />}>
<ContentListSection productId={product.id} />
</Suspense>
</section>

<Suspense fallback={<ListSkeleton />}>
<ActionsSection productId={product.id} />
</Suspense>
</div>

<aside className="min-w-0 lg:sticky lg:top-6">
<TestPreviewPanel product={product} className="w-full" />
</aside>
</div>
</div>
);
}
106 changes: 106 additions & 0 deletions apps/dashboard/app/api/widget/[publicKey]/actions/run/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { getProductByPublicKey } from "../../../../../../features/products/queries";
import {
getEnabledActionForPublicKey,
runProductAction,
} from "../../../../../../features/products/run-product-action";
import { createRateLimiter } from "../../../../../../features/products/widget-chat";

// Public confirm-gated action runner for the widget. Chat only proposes; this
// endpoint runs after the visitor (or Test preview) confirms.
export const runtime = "nodejs";
export const maxDuration = 60;

const allowRequest = createRateLimiter(20, 60_000);

const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
};

interface RunBody {
actionId?: string;
/** Card that pays for capability runs (authenticated owner / Test). */
agentId?: string;
params?: string | Record<string, unknown>;
}

function withCors(response: Response): Response {
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(CORS_HEADERS)) {
headers.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}

function json(data: unknown, status = 200): Response {
return withCors(
new Response(JSON.stringify(data), {
status,
headers: {
"content-type": "application/json",
"cache-control": "no-store",
},
}),
);
}

export async function OPTIONS() {
return withCors(new Response(null, { status: 204 }));
}

export async function POST(request: Request, context: { params: Promise<{ publicKey: string }> }) {
const { publicKey: rawKey } = await context.params;
const publicKey = decodeURIComponent(rawKey ?? "").trim();
if (!publicKey) return json({ error: "Missing public key." }, 400);

if (!allowRequest(publicKey)) {
return json({ error: "Too many requests. Try again in a minute." }, 429);
}

const product = await getProductByPublicKey(publicKey);
if (!product) return json({ error: "Agent not found." }, 404);

const preview = new URL(request.url).searchParams.get("preview") === "1";
if (product.status !== "live" && !preview) {
return json({ error: "Agent not found." }, 404);
}

const body = (await request.json().catch(() => null)) as RunBody | null;
const actionId = body?.actionId?.trim();
if (!actionId) return json({ error: "actionId is required." }, 400);

const owned = await getEnabledActionForPublicKey(publicKey, actionId);
if (!owned.ok) return json({ error: owned.error }, 404);

const result = await runProductAction({
actionId: owned.actionId,
agentId: body?.agentId,
params: body?.params,
});

if (!result.ok) {
return json(
{
ok: false,
error: result.error ?? "Could not run the action.",
status: result.status,
body: result.body,
},
result.error === "Not signed in." || result.error === "Not allowed." ? 401 : 400,
);
}

return json({
ok: true,
status: result.status,
body: result.body,
paid: result.paid,
txHash: result.txHash,
});
}
Loading
Loading