diff --git a/README.md b/README.md index d13e2ee50..958cd4e53 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18 ## Official Website Learn more and see **real demos** on our [**official website**](https://deerflow.tech). +The landing-page case studies open as allowlisted, read-only showcases without requiring a sign-in. ## Sister Projects diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 5a168292f..c71ad7430 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -48,7 +48,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat ### Source Layout (`src/`) -- **`app/`** — Next.js App Router. Routes include `/` (landing), `/workspace/chats/[thread_id]` (chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`). +- **`app/`** — Next.js App Router. Routes include `/` (landing), `/showcase/[thread_id]` (allowlisted public read-only demos), `/workspace/chats/[thread_id]` (authenticated chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`). - **`components/`** — React components: - `ui/` — Shadcn UI primitives (auto-generated, ESLint-ignored) - `ai-elements/` — Vercel AI SDK elements (auto-generated, ESLint-ignored) diff --git a/frontend/README.md b/frontend/README.md index 76c450232..7660d8e34 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -97,6 +97,7 @@ tests/ src/ ├── app/ # Next.js App Router pages │ ├── api/ # API routes +│ ├── showcase/ # Allowlisted public read-only demos │ ├── workspace/ # Main workspace pages │ └── mock/ # Mock/demo pages ├── components/ # React components diff --git a/frontend/src/app/showcase/[thread_id]/layout.tsx b/frontend/src/app/showcase/[thread_id]/layout.tsx new file mode 100644 index 000000000..7bf94bfb2 --- /dev/null +++ b/frontend/src/app/showcase/[thread_id]/layout.tsx @@ -0,0 +1,34 @@ +import "katex/dist/katex.min.css"; +import "streamdown/styles.css"; + +import { Toaster } from "sonner"; + +import { QueryClientProvider } from "@/components/query-client-provider"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { ChatProviders } from "@/components/workspace/chats/chat-providers"; +import { AuthProvider } from "@/core/auth/AuthProvider"; +import { I18nProvider } from "@/core/i18n/context"; +import { detectLocaleServer } from "@/core/i18n/server"; + +export default async function PublicShowcaseLayout({ + children, +}: { + children: React.ReactNode; +}) { + const locale = await detectLocaleServer(); + + return ( + + + + + + {children} + + + + + + + ); +} diff --git a/frontend/src/app/showcase/[thread_id]/page.tsx b/frontend/src/app/showcase/[thread_id]/page.tsx new file mode 100644 index 000000000..2a76fa06b --- /dev/null +++ b/frontend/src/app/showcase/[thread_id]/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from "next/navigation"; + +import ChatPage from "@/app/workspace/chats/[thread_id]/page"; +import { DEMO_THREAD_IDS, isDemoThreadId } from "@/core/threads/static-demo"; + +export const dynamicParams = false; + +export function generateStaticParams() { + return DEMO_THREAD_IDS.map((thread_id) => ({ thread_id })); +} + +export default async function PublicShowcasePage({ + params, +}: { + params: Promise<{ thread_id: string }>; +}) { + const { thread_id: threadId } = await params; + if (!isDemoThreadId(threadId)) { + notFound(); + } + return ; +} diff --git a/frontend/src/app/workspace/chats/[thread_id]/layout.tsx b/frontend/src/app/workspace/chats/[thread_id]/layout.tsx index eeee68347..f4dd28133 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/layout.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/layout.tsx @@ -1,8 +1,7 @@ +import { ChatProviders } from "@/components/workspace/chats/chat-providers"; import { isStaticWebsiteOnly } from "@/core/static-mode"; import { DEMO_THREAD_IDS } from "@/core/threads/static-demo"; -import { ChatProviders } from "./providers"; - export function generateStaticParams() { if (!isStaticWebsiteOnly()) { return []; diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 7175db866..4c79aa2e6 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -254,7 +254,7 @@ export default function ChatPage() { ? localSettings.tokenUsage.inlineMode : "off"; const hasTodos = (thread.values.todos?.length ?? 0) > 0; - const browserEnabled = !isNewThread && browserControlEnabled; + const browserEnabled = !isNewThread && !isMock && browserControlEnabled; const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal( threadId, thread.values.goal, @@ -285,12 +285,12 @@ export default function ChatPage() { : "bg-background/80 shadow-xs backdrop-blur", )} > - + {!isMock && }
- {!isNewThread && ( + {!isNewThread && !isMock && ( )} {tokenUsageEnabled ? ( diff --git a/frontend/src/components/landing/sections/case-study-section.tsx b/frontend/src/components/landing/sections/case-study-section.tsx index 87b0522d2..385d9d1b3 100644 --- a/frontend/src/components/landing/sections/case-study-section.tsx +++ b/frontend/src/components/landing/sections/case-study-section.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import Link from "next/link"; import { Card } from "@/components/ui/card"; -import { pathOfThread } from "@/core/threads/utils"; +import { pathOfPublicDemoThread } from "@/core/threads/static-demo"; import { cn } from "@/lib/utils"; import { Section } from "../section"; @@ -56,7 +56,7 @@ export function CaseStudySection({ className }: { className?: string }) { {caseStudies.map((caseStudy) => ( diff --git a/frontend/src/app/workspace/chats/[thread_id]/providers.tsx b/frontend/src/components/workspace/chats/chat-providers.tsx similarity index 100% rename from frontend/src/app/workspace/chats/[thread_id]/providers.tsx rename to frontend/src/components/workspace/chats/chat-providers.tsx diff --git a/frontend/src/components/workspace/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts index fded8dfbe..4c2cddc50 100644 --- a/frontend/src/components/workspace/chats/use-thread-chat.ts +++ b/frontend/src/components/workspace/chats/use-thread-chat.ts @@ -119,7 +119,9 @@ export function useThreadChat() { setIsNewThreadState(nextIsNewThread); }, []); - const isMock = searchParams.get("mock") === "true"; + const isMock = + actualPathname.startsWith("/showcase/") || + searchParams.get("mock") === "true"; return { threadId: isNewPath ? (newThreadIdRef.current ?? threadId) : threadId, setThreadId, diff --git a/frontend/src/core/threads/static-demo.ts b/frontend/src/core/threads/static-demo.ts index 8671a7f46..e0894af73 100644 --- a/frontend/src/core/threads/static-demo.ts +++ b/frontend/src/core/threads/static-demo.ts @@ -125,6 +125,16 @@ export function resolveStaticDemoArtifact( return `/demo/threads/${threadId}/${artifactPath}`; } +const DEMO_THREAD_ID_SET = new Set(DEMO_THREAD_IDS); + +export function isDemoThreadId(threadId: string): boolean { + return DEMO_THREAD_ID_SET.has(threadId); +} + +export function pathOfPublicDemoThread(threadId: string): string { + return `/showcase/${encodeURIComponent(threadId)}`; +} + export type ThreadSearchParams = NonNullable< Parameters[0] >; diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts index 1c622e10c..5076093fc 100644 --- a/frontend/tests/e2e/thread-history.spec.ts +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -728,7 +728,7 @@ test.describe("Thread history", () => { }, ); - await page.goto(`/workspace/chats/${DEMO_THREAD_ID}?mock=true`); + await page.goto(`/showcase/${DEMO_THREAD_ID}`); await expect( page.getByText("What might be the trends and opportunities in 2026?"), @@ -739,6 +739,12 @@ test.describe("Thread history", () => { expect(backendRunHistoryUrls).toEqual([]); }); + test("public showcase rejects unknown thread IDs", async ({ page }) => { + const response = await page.goto("/showcase/not-a-bundled-demo"); + + expect(response?.status()).toBe(404); + }); + test("chats list page shows all threads", async ({ page }) => { mockLangGraphAPI(page, { threads: THREADS }); diff --git a/frontend/tests/unit/core/threads/static-demo.test.ts b/frontend/tests/unit/core/threads/static-demo.test.ts index 7848cf7d7..0d0bf2225 100644 --- a/frontend/tests/unit/core/threads/static-demo.test.ts +++ b/frontend/tests/unit/core/threads/static-demo.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from "@rstest/core"; import { DEMO_THREAD_IDS, + isDemoThreadId, + pathOfPublicDemoThread, resolveStaticDemoArtifact, STATIC_DEMO_ARTIFACTS, } from "@/core/threads/static-demo"; @@ -65,3 +67,16 @@ describe("resolveStaticDemoArtifact", () => { } }); }); + +describe("public demo threads", () => { + it("recognizes only bundled demo thread IDs", () => { + expect(isDemoThreadId(DEMO_THREAD_IDS[0])).toBe(true); + expect(isDemoThreadId("not-a-demo-thread")).toBe(false); + }); + + it("builds an encoded public route in mock mode", () => { + expect(pathOfPublicDemoThread("thread/id?")).toBe( + "/showcase/thread%2Fid%3F", + ); + }); +});