fix(frontend): add public case study routes (#4635)

This commit is contained in:
Daoyuan Li 2026-08-04 17:49:14 -07:00 committed by GitHub
parent d732b90dc3
commit 480a3757ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 100 additions and 10 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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 (
<I18nProvider initialLocale={locale}>
<AuthProvider initialUser={null}>
<QueryClientProvider>
<SidebarProvider className="h-screen" defaultOpen={false}>
<SidebarInset className="min-w-0">
<ChatProviders>{children}</ChatProviders>
</SidebarInset>
</SidebarProvider>
<Toaster position="top-center" />
</QueryClientProvider>
</AuthProvider>
</I18nProvider>
);
}

View File

@ -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 <ChatPage />;
}

View File

@ -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 [];

View File

@ -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",
)}
>
<SidebarTrigger className="md:hidden" />
{!isMock && <SidebarTrigger className="md:hidden" />}
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex shrink-0 items-center gap-2">
{!isNewThread && (
{!isNewThread && !isMock && (
<ThreadScheduledTasksLink threadId={threadId} />
)}
{tokenUsageEnabled ? (

View File

@ -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) => (
<Link
key={caseStudy.title}
href={pathOfThread(caseStudy.threadId) + "?mock=true"}
href={pathOfPublicDemoThread(caseStudy.threadId)}
target="_blank"
rel="noopener noreferrer"
>

View File

@ -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,

View File

@ -125,6 +125,16 @@ export function resolveStaticDemoArtifact(
return `/demo/threads/${threadId}/${artifactPath}`;
}
const DEMO_THREAD_ID_SET = new Set<string>(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<ThreadsClient["search"]>[0]
>;

View File

@ -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 });

View File

@ -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",
);
});
});