mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-16 23:01:06 +02:00
Merge remote-tracking branch 'upstream/dev' into feature/1354-embedding-base-url
# Conflicts: # surfsense_web/content/docs/docker-installation/docker-compose.mdx # surfsense_web/content/docs/manual-installation.mdx
This commit is contained in:
commit
41edac7641
1421 changed files with 59476 additions and 27448 deletions
94
surfsense_web/app/(home)/[slug]/page.tsx
Normal file
94
surfsense_web/app/(home)/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ConnectorPage } from "@/components/connectors-marketing/connector-page";
|
||||
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
|
||||
import { getAllConnectorSlugs, getConnector } from "@/lib/connectors-marketing";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
// Only the known connector slugs are served; every other path falls through to 404.
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getAllConnectorSlugs().map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const content = getConnector(slug);
|
||||
if (!content) return { title: "Connector Not Found | SurfSense" };
|
||||
|
||||
const canonicalUrl = `https://www.surfsense.com/${content.slug}`;
|
||||
|
||||
return {
|
||||
title: content.metaTitle,
|
||||
description: content.metaDescription,
|
||||
keywords: content.keywords,
|
||||
alternates: { canonical: canonicalUrl },
|
||||
openGraph: {
|
||||
title: content.metaTitle,
|
||||
description: content.metaDescription,
|
||||
url: canonicalUrl,
|
||||
siteName: "SurfSense",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `${content.name} Scraper API on SurfSense`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: content.metaTitle,
|
||||
description: content.metaDescription,
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ConnectorMarketingPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const content = getConnector(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const canonicalUrl = `https://www.surfsense.com/${content.slug}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: `${content.name} Scraper API`,
|
||||
applicationCategory: "DeveloperApplication",
|
||||
operatingSystem: "Web, API",
|
||||
description: content.metaDescription,
|
||||
url: canonicalUrl,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
description: "Free tier included",
|
||||
},
|
||||
provider: {
|
||||
"@type": "Organization",
|
||||
name: "SurfSense",
|
||||
url: "https://www.surfsense.com",
|
||||
},
|
||||
isPartOf: {
|
||||
"@type": "WebSite",
|
||||
name: "SurfSense",
|
||||
url: "https://www.surfsense.com",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<FAQJsonLd questions={content.faq} />
|
||||
<ConnectorPage content={content} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
117
surfsense_web/app/(home)/connectors/page.tsx
Normal file
117
surfsense_web/app/(home)/connectors/page.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { ArrowRight, Plug, Server } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getAllConnectors } from "@/lib/connectors-marketing";
|
||||
|
||||
const canonicalUrl = "https://www.surfsense.com/connectors";
|
||||
|
||||
const metaDescription =
|
||||
"Platform-native scraper APIs for AI agents. Pull live data from the platforms your market uses through one typed API or the SurfSense MCP server. Explore every connector.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Scraper APIs for AI Agents: All Connectors | SurfSense",
|
||||
description: metaDescription,
|
||||
keywords: [
|
||||
"scraper api",
|
||||
"web scraping api",
|
||||
"scraper api for ai agents",
|
||||
"data connectors",
|
||||
"mcp server",
|
||||
"competitive intelligence platform",
|
||||
],
|
||||
alternates: { canonical: canonicalUrl },
|
||||
openGraph: {
|
||||
title: "Scraper APIs for AI Agents: All Connectors | SurfSense",
|
||||
description: metaDescription,
|
||||
url: canonicalUrl,
|
||||
siteName: "SurfSense",
|
||||
type: "website",
|
||||
images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense connectors" }],
|
||||
},
|
||||
};
|
||||
|
||||
export default function ConnectorsIndexPage() {
|
||||
const connectors = getAllConnectors();
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-16 sm:pt-32">
|
||||
<div className="mx-auto w-full max-w-7xl px-2 md:px-8 xl:px-0">
|
||||
<header className="max-w-2xl">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
|
||||
Connectors for every platform your market uses
|
||||
</h1>
|
||||
<p className="mt-5 text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||
Each connector is a platform-native scraper API your AI agents can call directly, or
|
||||
through the SurfSense MCP server. They are the live data behind the SurfSense{" "}
|
||||
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
|
||||
competitive intelligence platform
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{connectors.map((connector) => {
|
||||
const Icon = connector.icon;
|
||||
return (
|
||||
<Link
|
||||
key={connector.slug}
|
||||
href={`/${connector.slug}`}
|
||||
className="group flex flex-col rounded-xl border bg-card p-6 transition-colors hover:border-brand/40"
|
||||
>
|
||||
<span className="flex size-11 items-center justify-center rounded-lg border bg-muted/40 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
|
||||
<Icon className="size-5 text-foreground" />
|
||||
</span>
|
||||
<h2 className="mt-4 text-lg font-semibold">
|
||||
{connector.cardTitle ?? `${connector.name} API`}
|
||||
</h2>
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground line-clamp-4">
|
||||
{connector.heroLede}
|
||||
</p>
|
||||
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-foreground">
|
||||
Explore
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{/* Bespoke pages (not in the scrape-API registry): the two MCP directions. */}
|
||||
<Link
|
||||
href="/mcp-server"
|
||||
className="group flex flex-col rounded-xl border bg-card p-6 transition-colors hover:border-brand/40"
|
||||
>
|
||||
<span className="flex size-11 items-center justify-center rounded-lg border bg-muted/40 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
|
||||
<Server className="size-5 text-foreground" />
|
||||
</span>
|
||||
<h2 className="mt-4 text-lg font-semibold">SurfSense MCP Server</h2>
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground line-clamp-4">
|
||||
Give Claude, Cursor, or any MCP client native tools for your workspace: every scraper
|
||||
API plus knowledge base search, reads, and writes. One API key.
|
||||
</p>
|
||||
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-foreground">
|
||||
Explore
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/external-mcp-connectors"
|
||||
className="group flex flex-col rounded-xl border bg-card p-6 transition-colors hover:border-brand/40"
|
||||
>
|
||||
<span className="flex size-11 items-center justify-center rounded-lg border bg-muted/40 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
|
||||
<Plug className="size-5 text-foreground" />
|
||||
</span>
|
||||
<h2 className="mt-4 text-lg font-semibold">External MCP Connectors</h2>
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground line-clamp-4">
|
||||
Bring any MCP server to your agents. Paste a config like you would in Cursor, tools
|
||||
are auto-discovered, and Notion, Slack, Jira, and more connect with one-click OAuth.
|
||||
</p>
|
||||
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-foreground">
|
||||
Explore
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
380
surfsense_web/app/(home)/external-mcp-connectors/page.tsx
Normal file
380
surfsense_web/app/(home)/external-mcp-connectors/page.tsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
import { ArrowRight, Check, Plug, ShieldCheck, Wrench } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ConnectorFaq } from "@/components/connectors-marketing/connector-faq";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
||||
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { FaqItem } from "@/lib/connectors-marketing/types";
|
||||
|
||||
const canonicalUrl = "https://www.surfsense.com/external-mcp-connectors";
|
||||
|
||||
const metaDescription =
|
||||
"External MCP connectors let your SurfSense agents use any MCP server. Paste a config, tools are auto-discovered, and every call runs with per-tool approval. Try it free.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "External MCP Connectors: Add Any MCP Server | SurfSense",
|
||||
description: metaDescription,
|
||||
keywords: [
|
||||
"mcp connector",
|
||||
"external mcp connectors",
|
||||
"what is an mcp connector",
|
||||
"mcp client",
|
||||
"add mcp server",
|
||||
"connect mcp server",
|
||||
"mcp integrations",
|
||||
],
|
||||
alternates: { canonical: canonicalUrl },
|
||||
openGraph: {
|
||||
title: "External MCP Connectors: Add Any MCP Server | SurfSense",
|
||||
description: metaDescription,
|
||||
url: canonicalUrl,
|
||||
siteName: "SurfSense",
|
||||
type: "website",
|
||||
images: [
|
||||
{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense external MCP connectors" },
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "External MCP Connectors: Add Any MCP Server | SurfSense",
|
||||
description: metaDescription,
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
};
|
||||
|
||||
/* Mirrors the real server_config contract (stdio + HTTP transports). */
|
||||
const STDIO_CONFIG = `{
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
|
||||
"env": { "LOG_LEVEL": "info" },
|
||||
"transport": "stdio"
|
||||
}`;
|
||||
|
||||
const HTTP_CONFIG = `{
|
||||
"url": "https://mcp.example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer <token>" },
|
||||
"transport": "streamable-http"
|
||||
}`;
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
icon: Plug,
|
||||
title: "Paste a server config",
|
||||
description:
|
||||
"Add any MCP server the same way you would in Cursor: a local command for stdio servers, or a URL and headers for remote HTTP and SSE servers.",
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "Tools are auto-discovered",
|
||||
description:
|
||||
"SurfSense tests the connection and pulls the full tool list from the server. No manual tool configuration, no schema files to maintain.",
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: "Your agent uses them, safely",
|
||||
description:
|
||||
"Read-only tools run automatically. Anything that writes asks for your approval first, and you can trust a tool once to always allow it.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Hosted MCP apps with one-click OAuth (mirrors the backend MCP service registry). */
|
||||
const ONE_CLICK_APPS = [
|
||||
"Notion",
|
||||
"Slack",
|
||||
"Jira",
|
||||
"Confluence",
|
||||
"Linear",
|
||||
"ClickUp",
|
||||
"Airtable",
|
||||
] as const;
|
||||
|
||||
const FAQ: FaqItem[] = [
|
||||
{
|
||||
question: "What is an external MCP connector?",
|
||||
answer:
|
||||
"An external MCP connector links SurfSense to an outside MCP (Model Context Protocol) server, so your agents can call that server's tools. You add a server config once, its tools are auto-discovered, and every agent in your workspace can use them with per-tool approval.",
|
||||
},
|
||||
{
|
||||
question: "How is this different from the SurfSense MCP server?",
|
||||
answer:
|
||||
"Direction. External MCP connectors make SurfSense the client: outside tools flow into your SurfSense agents. The SurfSense MCP server is the reverse: it exposes your workspace and the scraper APIs as tools inside Claude, Cursor, or any MCP client you already run.",
|
||||
},
|
||||
{
|
||||
question: "Which MCP transports are supported?",
|
||||
answer:
|
||||
"All the common ones. Local stdio servers run as a process with a command, args, and environment variables. Remote servers connect over streamable HTTP, plain HTTP, or SSE with a URL and optional headers, which covers hosted MCP servers that require an auth token.",
|
||||
},
|
||||
{
|
||||
question: "Is it safe to give an agent MCP tools?",
|
||||
answer:
|
||||
"Every MCP tool runs through SurfSense's permission layer. Read-only tools are allowed automatically, while any tool that can write or act asks for your approval before it executes. You can mark tools you rely on as trusted so they skip the prompt on later calls.",
|
||||
},
|
||||
{
|
||||
question: "Can I connect Notion or Slack without writing a config?",
|
||||
answer:
|
||||
"Yes. Notion, Slack, Jira, Confluence, Linear, ClickUp, and Airtable connect through their official hosted MCP servers with one-click OAuth. SurfSense handles the token exchange and curates each app's tool list, so you sign in once and your agents can use them immediately.",
|
||||
},
|
||||
];
|
||||
|
||||
function ConfigCard() {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-5 shadow-sm">
|
||||
<p className="font-mono text-xs text-muted-foreground">Local server (stdio)</p>
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted/50 p-4 font-mono text-xs leading-relaxed">
|
||||
{STDIO_CONFIG}
|
||||
</pre>
|
||||
<p className="mt-4 font-mono text-xs text-muted-foreground">Remote server (HTTP / SSE)</p>
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted/50 p-4 font-mono text-xs leading-relaxed">
|
||||
{HTTP_CONFIG}
|
||||
</pre>
|
||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Check className="size-3.5 text-brand" aria-hidden />
|
||||
Tools auto-discovered on connect
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExternalMcpConnectorsPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "SurfSense External MCP Connectors",
|
||||
applicationCategory: "DeveloperApplication",
|
||||
operatingSystem: "Web",
|
||||
description: metaDescription,
|
||||
url: canonicalUrl,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
description: "Free tier included",
|
||||
},
|
||||
provider: {
|
||||
"@type": "Organization",
|
||||
name: "SurfSense",
|
||||
url: "https://www.surfsense.com",
|
||||
},
|
||||
isPartOf: { "@type": "WebSite", name: "SurfSense", url: "https://www.surfsense.com" },
|
||||
}}
|
||||
/>
|
||||
<FAQJsonLd questions={FAQ} />
|
||||
|
||||
<div className="pb-4">
|
||||
{/* Hero */}
|
||||
<MarketingSection className="pt-28 pb-12 sm:pt-32 sm:pb-16">
|
||||
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
|
||||
<div>
|
||||
<BreadcrumbNav
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ name: "Connectors", href: "/connectors" },
|
||||
{ name: "External MCP Connectors", href: "/external-mcp-connectors" },
|
||||
]}
|
||||
/>
|
||||
<Badge variant="outline" className="mb-5 gap-1.5 py-1">
|
||||
<Plug className="size-3.5" />
|
||||
External MCP connectors
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
|
||||
Bring any external MCP server to your agents
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||
External MCP connectors turn your SurfSense workspace into an MCP client. Add any
|
||||
MCP server with the same config you'd use in Cursor, and its tools are
|
||||
auto-discovered and handed to your agents, guarded by per-tool approval. Notion,
|
||||
Slack, Jira, and more connect with one-click OAuth.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/register">
|
||||
Start for free
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/docs">Read the docs</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="lg">
|
||||
<Link
|
||||
href="https://github.com/MODSetter/SurfSense"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconBrandGithub className="size-4" />
|
||||
GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ConfigCard />
|
||||
</div>
|
||||
</MarketingSection>
|
||||
|
||||
{/* How it works */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
From config to agent tool in three steps
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-3">
|
||||
{STEPS.map((step) => (
|
||||
<Reveal key={step.title}>
|
||||
<div className="h-full rounded-xl border bg-card p-6 transition-colors hover:border-brand/40">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg border bg-muted/40">
|
||||
<step.icon className="size-5 text-foreground" aria-hidden />
|
||||
</span>
|
||||
<h3 className="mt-4 font-semibold">{step.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</MarketingSection>
|
||||
|
||||
{/* One-click apps */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Your work apps, no config required
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
These apps run on their official hosted MCP servers. Sign in once with OAuth and
|
||||
SurfSense manages the tokens and curates each tool list, so your agents can search
|
||||
Notion, read Slack threads, or file Jira issues alongside your market intelligence.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-8 flex flex-wrap gap-2">
|
||||
{ONE_CLICK_APPS.map((app) => (
|
||||
<span
|
||||
key={app}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border bg-card px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<Check className="size-3.5 text-brand" aria-hidden />
|
||||
{app}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Connector vs server */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
External MCP connectors vs the SurfSense MCP server
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
They are two sides of the same protocol. The external MCP connectors on this page make
|
||||
SurfSense a <em>client</em>: they consume tools from outside MCP servers. The{" "}
|
||||
<Link
|
||||
href="/mcp-server"
|
||||
className="font-medium text-foreground underline underline-offset-4"
|
||||
>
|
||||
SurfSense MCP server
|
||||
</Link>{" "}
|
||||
does the reverse, exposing your workspace and scraper APIs like{" "}
|
||||
<Link
|
||||
href="/reddit"
|
||||
className="font-medium text-foreground underline underline-offset-4"
|
||||
>
|
||||
Reddit
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="/google-maps"
|
||||
className="font-medium text-foreground underline underline-offset-4"
|
||||
>
|
||||
Google Maps
|
||||
</Link>{" "}
|
||||
as native tools inside Claude, Cursor, or any agent you already run. Use both and data
|
||||
flows in either direction.
|
||||
</p>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* FAQ */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
External MCP connectors: frequently asked questions
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-6 max-w-3xl">
|
||||
<ConnectorFaq items={FAQ} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Closing CTA + related */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<div className="rounded-2xl border bg-card p-8 text-center sm:p-12">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Give your agents every tool they need
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-muted-foreground leading-relaxed">
|
||||
External MCP connectors are part of the SurfSense{" "}
|
||||
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
|
||||
competitive intelligence platform
|
||||
</Link>
|
||||
. Start free, no credit card required.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap justify-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/register">
|
||||
Start for free
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/pricing">See pricing</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
<nav aria-label="Other connectors" className="flex flex-wrap justify-center gap-2">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/connectors">All connectors</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/mcp-server">SurfSense MCP Server</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/reddit">Reddit API</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/youtube">YouTube API</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/google-maps">Google Maps API</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/google-search">SERP API</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/web-crawl">Web Crawl API</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
420
surfsense_web/app/(home)/mcp-server/page.tsx
Normal file
420
surfsense_web/app/(home)/mcp-server/page.tsx
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
import { ArrowRight, Check, Database, KeyRound, Server, TerminalSquare } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ConnectorFaq } from "@/components/connectors-marketing/connector-faq";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
import { AgentSetupTabs } from "@/components/mcp/agent-setup-tabs";
|
||||
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
||||
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { FaqItem } from "@/lib/connectors-marketing/types";
|
||||
|
||||
const canonicalUrl = "https://www.surfsense.com/mcp-server";
|
||||
|
||||
const metaDescription =
|
||||
"The SurfSense MCP server gives Claude, Cursor, and any MCP client native tools for your workspace: scrape Reddit, YouTube, Google Maps, Google Search, and the web, plus full knowledge base access. One API key.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
|
||||
description: metaDescription,
|
||||
keywords: [
|
||||
"surfsense mcp server",
|
||||
"mcp server",
|
||||
"mcp server for web scraping",
|
||||
"reddit mcp server",
|
||||
"youtube mcp server",
|
||||
"google maps mcp server",
|
||||
"serp mcp server",
|
||||
"mcp server for claude",
|
||||
"mcp server for cursor",
|
||||
"knowledge base mcp server",
|
||||
],
|
||||
alternates: { canonical: canonicalUrl },
|
||||
openGraph: {
|
||||
title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
|
||||
description: metaDescription,
|
||||
url: canonicalUrl,
|
||||
siteName: "SurfSense",
|
||||
type: "website",
|
||||
images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense MCP server" }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
|
||||
description: metaDescription,
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
};
|
||||
|
||||
/* Mirrors surfsense_mcp/README.md: the real Cursor config. */
|
||||
const CURSOR_CONFIG = `{
|
||||
"mcpServers": {
|
||||
"surfsense": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--directory", ".../surfsense_mcp",
|
||||
"python", "-m", "surfsense_mcp"],
|
||||
"env": {
|
||||
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
|
||||
"SURFSENSE_API_KEY": "ss_pat_..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
icon: KeyRound,
|
||||
title: "Create an API key",
|
||||
description:
|
||||
"In SurfSense, go to Settings, then API, and create a key. Enable API access on the workspaces you want your agents to reach. That key is all the server needs.",
|
||||
},
|
||||
{
|
||||
icon: TerminalSquare,
|
||||
title: "Add the server to your client",
|
||||
description:
|
||||
"Drop the config into Cursor's mcp.json, run claude mcp add for Claude Code, or paste it into Claude Desktop. Point it at the cloud or your own self-hosted instance.",
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: "Your agent has the tools",
|
||||
description:
|
||||
"Every scraper and knowledge base operation shows up as a native, typed MCP tool. Your agent picks a workspace once and the server carries the context between calls.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Mirrors the tool registry in surfsense_mcp (see its README). */
|
||||
const TOOL_GROUPS = [
|
||||
{
|
||||
icon: Server,
|
||||
title: "Live scrapers",
|
||||
description: "Structured, current platform data. One returned item is one billable unit.",
|
||||
tools: [
|
||||
"surfsense_reddit_scrape",
|
||||
"surfsense_youtube_scrape",
|
||||
"surfsense_youtube_comments",
|
||||
"surfsense_google_maps_scrape",
|
||||
"surfsense_google_maps_reviews",
|
||||
"surfsense_google_search",
|
||||
"surfsense_web_crawl",
|
||||
"surfsense_list_scraper_runs",
|
||||
"surfsense_get_scraper_run",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
title: "Knowledge base",
|
||||
description: "Read and write the same knowledge base your SurfSense agents use.",
|
||||
tools: [
|
||||
"surfsense_search_knowledge_base",
|
||||
"surfsense_list_documents",
|
||||
"surfsense_get_document",
|
||||
"surfsense_add_document",
|
||||
"surfsense_upload_file",
|
||||
"surfsense_update_document",
|
||||
"surfsense_delete_document",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
title: "Workspace selector",
|
||||
description: "Pick a workspace once; every later call defaults to it.",
|
||||
tools: ["surfsense_list_workspaces", "surfsense_select_workspace"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const FAQ: FaqItem[] = [
|
||||
{
|
||||
question: "What is the SurfSense MCP server?",
|
||||
answer:
|
||||
"It is a Model Context Protocol server that exposes your SurfSense workspace to MCP clients like Claude Code, Cursor, and Claude Desktop. Your agents get native tools for every scraper API (Reddit, YouTube, Google Maps, Google Search, web crawl) and for searching, reading, and writing your knowledge base.",
|
||||
},
|
||||
{
|
||||
question: "Which MCP clients does it work with?",
|
||||
answer:
|
||||
"Any MCP client that supports stdio servers. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI are documented with copy-paste configs on this page, and the same command works in custom agent harnesses built on the MCP SDK.",
|
||||
},
|
||||
{
|
||||
question: "How is usage billed?",
|
||||
answer:
|
||||
"Exactly like the REST API, because the server is a thin layer over it. Scraper tools consume the same pay-as-you-go credits, priced per returned item, and knowledge base operations work within your plan. New accounts start with $5 of free credit.",
|
||||
},
|
||||
{
|
||||
question: "Does it work with a self-hosted SurfSense?",
|
||||
answer:
|
||||
"Yes. The server talks to SurfSense purely over its REST API and imports no backend code, so pointing SURFSENSE_BASE_URL at your own instance is all it takes. It works with the cloud at api.surfsense.com the same way.",
|
||||
},
|
||||
{
|
||||
question: "How does the agent know which workspace to use?",
|
||||
answer:
|
||||
"The server ships a workspace selector: the agent lists the workspaces your API key can access, selects one by name, and every later call defaults to it. Any tool also accepts a workspace override for a single call, and ids never need to be typed by hand.",
|
||||
},
|
||||
];
|
||||
|
||||
function ConfigCard() {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-5 shadow-sm">
|
||||
<p className="font-mono text-xs text-muted-foreground">.cursor/mcp.json</p>
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted/50 p-4 font-mono text-xs leading-relaxed">
|
||||
{CURSOR_CONFIG}
|
||||
</pre>
|
||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Check className="size-3.5 text-brand" aria-hidden />
|
||||
Works with Claude Code, Cursor, Claude Desktop, and any MCP client
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function McpServerPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "SurfSense MCP Server",
|
||||
applicationCategory: "DeveloperApplication",
|
||||
operatingSystem: "Web",
|
||||
description: metaDescription,
|
||||
url: canonicalUrl,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
description: "$5 free credit included, pay as you go",
|
||||
},
|
||||
provider: {
|
||||
"@type": "Organization",
|
||||
name: "SurfSense",
|
||||
url: "https://www.surfsense.com",
|
||||
},
|
||||
isPartOf: { "@type": "WebSite", name: "SurfSense", url: "https://www.surfsense.com" },
|
||||
}}
|
||||
/>
|
||||
<FAQJsonLd questions={FAQ} />
|
||||
|
||||
<div className="pb-4">
|
||||
{/* Hero */}
|
||||
<MarketingSection className="pt-28 pb-12 sm:pt-32 sm:pb-16">
|
||||
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
|
||||
<div>
|
||||
<BreadcrumbNav
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ name: "Connectors", href: "/connectors" },
|
||||
{ name: "SurfSense MCP Server", href: "/mcp-server" },
|
||||
]}
|
||||
/>
|
||||
<Badge variant="outline" className="mb-5 gap-1.5 py-1">
|
||||
<Server className="size-3.5" />
|
||||
SurfSense MCP server
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
|
||||
Give your agents SurfSense as native tools
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||
The SurfSense MCP server hands Claude, Cursor, or any MCP client the whole platform:
|
||||
scrape Reddit, YouTube, Google Maps, Google Search, and the open web, and search,
|
||||
read, and write your knowledge base. One API key, typed tools, pay as you go.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/register">
|
||||
Get your API key
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/docs">Read the docs</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="lg">
|
||||
<Link
|
||||
href="https://github.com/MODSetter/SurfSense"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconBrandGithub className="size-4" />
|
||||
GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ConfigCard />
|
||||
</div>
|
||||
</MarketingSection>
|
||||
|
||||
{/* How it works */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
From API key to agent tools in three steps
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-3">
|
||||
{STEPS.map((step) => (
|
||||
<Reveal key={step.title}>
|
||||
<div className="h-full rounded-xl border bg-card p-6 transition-colors hover:border-brand/40">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg border bg-muted/40">
|
||||
<step.icon className="size-5 text-foreground" aria-hidden />
|
||||
</span>
|
||||
<h3 className="mt-4 font-semibold">{step.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Per-agent setup */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Step-by-step setup for every agent
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
Pick your client, follow its two steps, and paste the config. Replace the placeholder
|
||||
path with your surfsense_mcp checkout and the key with one from API Playground → API
|
||||
Keys — or grab a pre-filled config from the playground itself.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-8 rounded-xl border bg-card p-5 shadow-sm sm:p-6">
|
||||
<AgentSetupTabs />
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Tools */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Every tool the server exposes
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
The server is a thin layer over the SurfSense REST API: the same endpoints, the same
|
||||
billing, no backend code imported. Whatever ships in the API shows up here.
|
||||
</p>
|
||||
</Reveal>
|
||||
<div className="mt-8 grid gap-4 lg:grid-cols-3">
|
||||
{TOOL_GROUPS.map((group) => (
|
||||
<Reveal key={group.title}>
|
||||
<div className="h-full rounded-xl border bg-card p-6">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg border bg-muted/40">
|
||||
<group.icon className="size-5 text-foreground" aria-hidden />
|
||||
</span>
|
||||
<h3 className="mt-4 font-semibold">{group.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{group.description}
|
||||
</p>
|
||||
<ul className="mt-4 space-y-1.5">
|
||||
{group.tools.map((tool) => (
|
||||
<li key={tool} className="truncate font-mono text-xs text-muted-foreground">
|
||||
{tool}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Server vs external connectors */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
The SurfSense MCP server vs external MCP connectors
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
They are two sides of the same protocol. The MCP <em>server</em> on this page pushes
|
||||
SurfSense tools out to agents you already run in Claude, Cursor, or your own harness.{" "}
|
||||
<Link
|
||||
href="/external-mcp-connectors"
|
||||
className="font-medium text-foreground underline underline-offset-4"
|
||||
>
|
||||
External MCP connectors
|
||||
</Link>{" "}
|
||||
do the reverse: they pull outside tools like Notion, Slack, and Jira into your
|
||||
SurfSense agents. Use both and data flows in either direction.
|
||||
</p>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* FAQ */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
SurfSense MCP server: frequently asked questions
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-6 max-w-3xl">
|
||||
<ConnectorFaq items={FAQ} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Closing CTA + related */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<div className="rounded-2xl border bg-card p-8 text-center sm:p-12">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Put live market data inside your agents
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-muted-foreground leading-relaxed">
|
||||
The MCP server is part of the SurfSense{" "}
|
||||
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
|
||||
competitive intelligence platform
|
||||
</Link>
|
||||
. Start with $5 of free credit, no credit card required.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap justify-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/register">
|
||||
Start for free
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/pricing">See pricing</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
<nav aria-label="Other connectors" className="flex flex-wrap justify-center gap-2">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/connectors">All connectors</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/external-mcp-connectors">External MCP Connectors</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/reddit">Reddit API</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/youtube">YouTube API</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/google-maps">Google Maps API</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/google-search">SERP API</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/web-crawl">Web Crawl API</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,29 +1,25 @@
|
|||
import dynamic from "next/dynamic";
|
||||
import { AuthRedirect } from "@/components/homepage/auth-redirect";
|
||||
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
|
||||
import { FeaturesCards } from "@/components/homepage/features-card";
|
||||
import { CommunityStrip } from "@/components/homepage/community-strip";
|
||||
import { CompareTable } from "@/components/homepage/compare-table";
|
||||
import { ConnectorGrid } from "@/components/homepage/connector-grid";
|
||||
import { HeroSection } from "@/components/homepage/hero-section";
|
||||
|
||||
const WhySurfSense = dynamic(() =>
|
||||
import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense }))
|
||||
);
|
||||
|
||||
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"));
|
||||
|
||||
const CTAHomepage = dynamic(() =>
|
||||
import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage }))
|
||||
);
|
||||
import { HomeFaq } from "@/components/homepage/home-faq";
|
||||
import { HowItWorks } from "@/components/homepage/how-it-works";
|
||||
import { PersonaPaths } from "@/components/homepage/persona-paths";
|
||||
import { UseCasesRow } from "@/components/homepage/use-cases";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
|
||||
<div className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
|
||||
<AuthRedirect />
|
||||
<HeroSection />
|
||||
<WhySurfSense />
|
||||
<FeaturesCards />
|
||||
<FeaturesBentoGrid />
|
||||
<ExternalIntegrations />
|
||||
<CTAHomepage />
|
||||
<PersonaPaths />
|
||||
<ConnectorGrid />
|
||||
<HowItWorks />
|
||||
<UseCasesRow />
|
||||
<CompareTable />
|
||||
<HomeFaq />
|
||||
<CommunityStrip />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,74 @@
|
|||
import type { Metadata } from "next";
|
||||
import PricingBasic from "@/components/pricing/pricing-section";
|
||||
import { JsonLd } from "@/components/seo/json-ld";
|
||||
|
||||
const canonicalUrl = "https://www.surfsense.com/pricing";
|
||||
|
||||
const metaTitle = "SurfSense Pricing: Self-Host Free or Pay As You Go";
|
||||
const metaDescription =
|
||||
"Self-host SurfSense for free from our open-source repo, or use the cloud with $5 of free credit and pay as you go at provider cost. No subscription.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing | SurfSense - Free AI Workspace, Automations & Agents",
|
||||
description:
|
||||
"Explore SurfSense plans and pricing. Start free with 500 pages & $5 in premium credits. Run AI automations and agents, use ChatGPT, Claude AI, and premium AI models, and pay as you go at provider cost.",
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
keywords: [
|
||||
"surfsense pricing",
|
||||
"pay as you go ai platform",
|
||||
"open source ai agent platform",
|
||||
"self-hosted ai workspace",
|
||||
"ai automation pricing",
|
||||
"competitive intelligence pricing",
|
||||
],
|
||||
alternates: {
|
||||
canonical: "https://www.surfsense.com/pricing",
|
||||
canonical: canonicalUrl,
|
||||
},
|
||||
openGraph: {
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
url: canonicalUrl,
|
||||
siteName: "SurfSense",
|
||||
type: "website",
|
||||
images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense pricing" }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
};
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<JsonLd
|
||||
data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "SurfSense",
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Windows, macOS, Linux, Web",
|
||||
url: canonicalUrl,
|
||||
offers: [
|
||||
{
|
||||
"@type": "Offer",
|
||||
name: "Free (Self-Hosted)",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
description:
|
||||
"Open source and self-hostable with unlimited usage. Bring your own model keys.",
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
name: "Pay As You Go",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
description:
|
||||
"Cloud accounts start with $5 of free credit. Top up any amount after that; $1 buys $1 of credit at provider cost with no subscription.",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<PricingBasic />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function SearchSpaceDashboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
}) {
|
||||
const { search_space_id } = await params;
|
||||
redirect(`/dashboard/${search_space_id}/new-chat`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import type React from "react";
|
||||
import { use } from "react";
|
||||
import { SearchSpaceSettingsLayoutShell } from "./layout-shell";
|
||||
|
||||
export default function SearchSpaceSettingsLayout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { search_space_id } = use(params);
|
||||
|
||||
return (
|
||||
<SearchSpaceSettingsLayoutShell searchSpaceId={search_space_id}>
|
||||
{children}
|
||||
</SearchSpaceSettingsLayoutShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function SearchSpaceSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
}) {
|
||||
const { search_space_id } = await params;
|
||||
redirect(`/dashboard/${search_space_id}/search-space-settings/general`);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||
const { search_space_id } = await params;
|
||||
return <PromptConfigManager searchSpaceId={Number(search_space_id)} />;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { RolesManager } from "@/components/settings/roles-manager";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||
const { search_space_id } = await params;
|
||||
return <RolesManager searchSpaceId={Number(search_space_id)} />;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { TeamContent } from "./team-content";
|
||||
|
||||
export default async function TeamPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
}) {
|
||||
const { search_space_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full select-none space-y-6">
|
||||
<TeamContent searchSpaceId={Number(search_space_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { AgentStatusContent } from "../components/AgentStatusContent";
|
||||
|
||||
export default function Page() {
|
||||
return <AgentStatusContent />;
|
||||
}
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, CircleCheck, CircleSlash, Info } from "lucide-react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { AgentFeatureFlags } from "@/lib/apis/agent-flags-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FlagKey = keyof AgentFeatureFlags;
|
||||
|
||||
interface FlagDef {
|
||||
key: FlagKey;
|
||||
label: string;
|
||||
description: string;
|
||||
envVar: string;
|
||||
}
|
||||
|
||||
interface FlagGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
flags: FlagDef[];
|
||||
}
|
||||
|
||||
const FLAG_GROUPS: FlagGroup[] = [
|
||||
{
|
||||
id: "tier1",
|
||||
title: "Tier 1 — Agent quality",
|
||||
subtitle: "Context editing, retries, fallbacks, doom-loop, tool-call repair.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_context_editing",
|
||||
label: "Context editing",
|
||||
description: "Trim tool outputs and spill old text into backend storage.",
|
||||
envVar: "SURFSENSE_ENABLE_CONTEXT_EDITING",
|
||||
},
|
||||
{
|
||||
key: "enable_compaction_v2",
|
||||
label: "Compaction v2",
|
||||
description: "SurfSense-aware compaction replacing safe summarization.",
|
||||
envVar: "SURFSENSE_ENABLE_COMPACTION_V2",
|
||||
},
|
||||
{
|
||||
key: "enable_retry_after",
|
||||
label: "Retry-After",
|
||||
description: "Honour rate-limit retry-after headers automatically.",
|
||||
envVar: "SURFSENSE_ENABLE_RETRY_AFTER",
|
||||
},
|
||||
{
|
||||
key: "enable_model_fallback",
|
||||
label: "Model fallback",
|
||||
description: "Fail over to a backup model on persistent errors.",
|
||||
envVar: "SURFSENSE_ENABLE_MODEL_FALLBACK",
|
||||
},
|
||||
{
|
||||
key: "enable_model_call_limit",
|
||||
label: "Model call limit",
|
||||
description: "Cap total model calls per turn to prevent budget run-aways.",
|
||||
envVar: "SURFSENSE_ENABLE_MODEL_CALL_LIMIT",
|
||||
},
|
||||
{
|
||||
key: "enable_tool_call_limit",
|
||||
label: "Tool call limit",
|
||||
description: "Cap total tool calls per turn.",
|
||||
envVar: "SURFSENSE_ENABLE_TOOL_CALL_LIMIT",
|
||||
},
|
||||
{
|
||||
key: "enable_tool_call_repair",
|
||||
label: "Tool-call name repair",
|
||||
description: "Recover from lower-cased / fuzzy tool names emitted by smaller models.",
|
||||
envVar: "SURFSENSE_ENABLE_TOOL_CALL_REPAIR",
|
||||
},
|
||||
{
|
||||
key: "enable_doom_loop",
|
||||
label: "Doom-loop detection",
|
||||
description: "Detect repeated identical tool calls and ask the user to confirm.",
|
||||
envVar: "SURFSENSE_ENABLE_DOOM_LOOP",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier2",
|
||||
title: "Tier 2 — Safety",
|
||||
subtitle: "Permission rules, busy-mutex, smarter tool selection.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_permission",
|
||||
label: "Permission middleware",
|
||||
description: "Apply allow/deny/ask rules from the Agent Permissions tab.",
|
||||
envVar: "SURFSENSE_ENABLE_PERMISSION",
|
||||
},
|
||||
{
|
||||
key: "enable_busy_mutex",
|
||||
label: "Busy mutex",
|
||||
description: "Prevent two concurrent runs from corrupting the same thread.",
|
||||
envVar: "SURFSENSE_ENABLE_BUSY_MUTEX",
|
||||
},
|
||||
{
|
||||
key: "enable_llm_tool_selector",
|
||||
label: "LLM tool selector",
|
||||
description: "Use a smaller model to pre-filter the tool list per turn.",
|
||||
envVar: "SURFSENSE_ENABLE_LLM_TOOL_SELECTOR",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier4",
|
||||
title: "Tier 4 — Skills + subagents",
|
||||
subtitle: "Built-in skills, specialized subagents, KB planner runnable.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_skills",
|
||||
label: "Skills",
|
||||
description: "Load on-demand skill packs (kb-research, report-writing, …).",
|
||||
envVar: "SURFSENSE_ENABLE_SKILLS",
|
||||
},
|
||||
{
|
||||
key: "enable_specialized_subagents",
|
||||
label: "Specialized subagents",
|
||||
description: "Spin up explore / report_writer / connector_negotiator subagents.",
|
||||
envVar: "SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier5",
|
||||
title: "Tier 5 — Audit + revert",
|
||||
subtitle: "Action log + revert route used by the Agent Actions dialog.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_action_log",
|
||||
label: "Action log",
|
||||
description: "Persist every tool call to agent_action_log.",
|
||||
envVar: "SURFSENSE_ENABLE_ACTION_LOG",
|
||||
},
|
||||
{
|
||||
key: "enable_revert_route",
|
||||
label: "Revert route",
|
||||
description: "Allow reverting reversible actions from the action log.",
|
||||
envVar: "SURFSENSE_ENABLE_REVERT_ROUTE",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier6",
|
||||
title: "Tier 6 — Plugins",
|
||||
subtitle: "Optional middleware loaded from entry points.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_plugin_loader",
|
||||
label: "Plugin loader",
|
||||
description: "Load surfsense.plugins entry-point middleware.",
|
||||
envVar: "SURFSENSE_ENABLE_PLUGIN_LOADER",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "obs",
|
||||
title: "Observability",
|
||||
subtitle: "Telemetry pipelines (orthogonal to feature gating).",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_otel",
|
||||
label: "OpenTelemetry",
|
||||
description: "Emit OTel spans (also requires OTEL_EXPORTER_OTLP_ENDPOINT).",
|
||||
envVar: "SURFSENSE_ENABLE_OTEL",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "desktop",
|
||||
title: "Desktop",
|
||||
subtitle: "Desktop-only capabilities exposed by the backend deployment.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_desktop_local_filesystem",
|
||||
label: "Local filesystem",
|
||||
description: "Allow Desktop chat sessions to operate directly on selected local folders.",
|
||||
envVar: "ENABLE_DESKTOP_LOCAL_FILESYSTEM",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{def.label}</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{def.envVar}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{def.description}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={value ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"shrink-0 gap-1",
|
||||
value
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{value ? <CircleCheck className="size-3" /> : <CircleSlash className="size-3" />}
|
||||
{value ? "On" : "Off"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentStatusContent() {
|
||||
const { data: flags, isLoading, isError, error } = useAtomValue(agentFlagsAtom);
|
||||
|
||||
const enabledCount = useMemo(() => {
|
||||
if (!flags) return 0;
|
||||
return Object.entries(flags).filter(([k, v]) => k !== "disable_new_agent_stack" && v === true)
|
||||
.length;
|
||||
}, [flags]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !flags) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Failed to load agent status</AlertTitle>
|
||||
<AlertDescription>
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const masterOff = flags.disable_new_agent_stack;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{masterOff ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Master kill-switch is on</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Showing that{" "}
|
||||
<code className="rounded bg-muted px-1 text-[10px]">
|
||||
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
|
||||
</code>
|
||||
, which forces every new middleware off, regardless of the individual flags below.
|
||||
Restart the backend after changing it.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
Agent stack
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded bg-popover px-1 py-0.5 text-[9px] text-popover-foreground"
|
||||
>
|
||||
{enabledCount} on
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Showing a read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an
|
||||
env var and restart the backend to change a value.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{FLAG_GROUPS.map((group, groupIdx) => {
|
||||
const allOff = group.flags.every((f) => !flags[f.key]);
|
||||
return (
|
||||
<div key={group.id}>
|
||||
{groupIdx > 0 && <Separator className="my-4 bg-border" />}
|
||||
<div className="rounded-lg border border-border/60 bg-card">
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{group.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
|
||||
</div>
|
||||
{allOff && (
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||
all off
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="bg-border" />
|
||||
<div className="px-4">
|
||||
{group.flags.map((def, flagIdx) => (
|
||||
<Fragment key={def.key}>
|
||||
{flagIdx > 0 && <Separator className="bg-border" />}
|
||||
<FlagRow def={def} value={flags[def.key]} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UserSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
}) {
|
||||
const { search_space_id } = await params;
|
||||
redirect(`/dashboard/${search_space_id}/user-settings/profile`);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { ArtifactsLibrary } from "@/features/artifacts-library";
|
|||
|
||||
export default function ArtifactsPage() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const workspaceId = Number(params.workspace_id);
|
||||
|
||||
return <ArtifactsLibrary searchSpaceId={searchSpaceId} />;
|
||||
return <ArtifactsLibrary workspaceId={workspaceId} />;
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import { AutomationRunsSection } from "./components/automation-runs-section";
|
|||
import { AutomationTriggersSection } from "./components/automation-triggers-section";
|
||||
|
||||
interface AutomationDetailContentProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
automationId: number;
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ interface AutomationDetailContentProps {
|
|||
* so the orchestrator stays thin.
|
||||
*/
|
||||
export function AutomationDetailContent({
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
automationId,
|
||||
}: AutomationDetailContentProps) {
|
||||
const perms = useAutomationPermissions();
|
||||
|
|
@ -45,14 +45,14 @@ export function AutomationDetailContent({
|
|||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You don't have permission to view automations in this search space.
|
||||
You don't have permission to view automations in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!validId) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} />;
|
||||
return <AutomationNotFound workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -60,14 +60,14 @@ export function AutomationDetailContent({
|
|||
}
|
||||
|
||||
if (error || !automation) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
|
||||
return <AutomationNotFound workspaceId={workspaceId} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutomationDetailHeader
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
canUpdate={perms.canUpdate}
|
||||
canDelete={perms.canDelete}
|
||||
/>
|
||||
|
|
@ -12,7 +12,7 @@ import { DeleteAutomationDialog } from "../../components/delete-automation-dialo
|
|||
|
||||
interface AutomationDetailHeaderProps {
|
||||
automation: Automation;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ interface AutomationDetailHeaderProps {
|
|||
*/
|
||||
export function AutomationDetailHeader({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationDetailHeaderProps) {
|
||||
|
|
@ -44,8 +44,8 @@ export function AutomationDetailHeader({
|
|||
const PauseIcon = automation.status === "active" ? Pause : Play;
|
||||
|
||||
const handleDeleted = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/automations`);
|
||||
}, [router, searchSpaceId]);
|
||||
router.push(`/dashboard/${workspaceId}/automations`);
|
||||
}, [router, workspaceId]);
|
||||
|
||||
async function handleTogglePause() {
|
||||
await updateAutomation({
|
||||
|
|
@ -59,7 +59,7 @@ export function AutomationDetailHeader({
|
|||
<div className="space-y-3">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/automations`}
|
||||
href={`/dashboard/${workspaceId}/automations`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
|
@ -86,7 +86,7 @@ export function AutomationDetailHeader({
|
|||
size="sm"
|
||||
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||
>
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/${automation.id}/edit`}>
|
||||
<Link href={`/dashboard/${workspaceId}/automations/${automation.id}/edit`}>
|
||||
<Pencil className="mr-1 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
|
|
@ -141,7 +141,7 @@ export function AutomationDetailHeader({
|
|||
onOpenChange={setDeleteOpen}
|
||||
automationId={automation.id}
|
||||
automationName={automation.name}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -4,7 +4,7 @@ import Link from "next/link";
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationNotFoundProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
error?: Error | null;
|
||||
}
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ interface AutomationNotFoundProps {
|
|||
* UI on purpose — leaking that an id exists you can't read is worse than
|
||||
* a vague message.
|
||||
*/
|
||||
export function AutomationNotFound({ searchSpaceId, error }: AutomationNotFoundProps) {
|
||||
export function AutomationNotFound({ workspaceId, error }: AutomationNotFoundProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<FileWarning className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
|
|
@ -24,7 +24,7 @@ export function AutomationNotFound({ searchSpaceId, error }: AutomationNotFoundP
|
|||
{error?.message ? ` (${error.message})` : null}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-6">
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations`}>
|
||||
<Link href={`/dashboard/${workspaceId}/automations`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to automations
|
||||
</Link>
|
||||
|
|
@ -8,7 +8,7 @@ import { AutomationNotFound } from "../components/automation-not-found";
|
|||
import { AutomationEditHeader } from "./components/automation-edit-header";
|
||||
|
||||
interface AutomationEditContentProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
automationId: number;
|
||||
}
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ interface AutomationEditContentProps {
|
|||
* structure but gates on ``canUpdate`` instead of ``canRead``: a user who
|
||||
* can read but not update is bounced to the access-denied panel.
|
||||
*/
|
||||
export function AutomationEditContent({ searchSpaceId, automationId }: AutomationEditContentProps) {
|
||||
export function AutomationEditContent({ workspaceId, automationId }: AutomationEditContentProps) {
|
||||
const perms = useAutomationPermissions();
|
||||
const validId = Number.isInteger(automationId) && automationId > 0;
|
||||
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
|
||||
|
|
@ -32,14 +32,14 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
|
|||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You don't have permission to edit automations in this search space.
|
||||
You don't have permission to edit automations in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!validId) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} />;
|
||||
return <AutomationNotFound workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -47,18 +47,18 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
|
|||
}
|
||||
|
||||
if (error || !automation) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
|
||||
return <AutomationNotFound workspaceId={workspaceId} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutomationBuilderForm
|
||||
mode="edit"
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
automation={automation}
|
||||
renderModeSwitcher={(modeSwitcher) => (
|
||||
<AutomationEditHeader
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
modeSwitcher={modeSwitcher}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -7,16 +7,16 @@ import type { Automation } from "@/contracts/types/automation.types";
|
|||
|
||||
interface AutomationEditHeaderProps {
|
||||
automation: Automation;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
modeSwitcher?: ReactNode;
|
||||
}
|
||||
|
||||
export function AutomationEditHeader({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
modeSwitcher,
|
||||
}: AutomationEditHeaderProps) {
|
||||
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
|
||||
const detailHref = `/dashboard/${workspaceId}/automations/${automation.id}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -3,14 +3,14 @@ import { AutomationEditContent } from "./automation-edit-content";
|
|||
export default async function AutomationEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string; automation_id: string }>;
|
||||
params: Promise<{ workspace_id: string; automation_id: string }>;
|
||||
}) {
|
||||
const { search_space_id, automation_id } = await params;
|
||||
const { workspace_id, automation_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<AutomationEditContent
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
workspaceId={Number(workspace_id)}
|
||||
automationId={Number(automation_id)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -3,14 +3,14 @@ import { AutomationDetailContent } from "./automation-detail-content";
|
|||
export default async function AutomationDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string; automation_id: string }>;
|
||||
params: Promise<{ workspace_id: string; automation_id: string }>;
|
||||
}) {
|
||||
const { search_space_id, automation_id } = await params;
|
||||
const { workspace_id, automation_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<AutomationDetailContent
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
workspaceId={Number(workspace_id)}
|
||||
automationId={Number(automation_id)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -8,19 +8,19 @@ import { AutomationsTable } from "./components/automations-table";
|
|||
import { useAutomationPermissions } from "./hooks/use-automation-permissions";
|
||||
|
||||
interface AutomationsContentProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client orchestrator for the automations list page. Pulls the active
|
||||
* search space's first page (via ``useAutomations`` → ``automationsListAtom``)
|
||||
* workspace's first page (via ``useAutomations`` → ``automationsListAtom``)
|
||||
* and the user's permissions, then decides between empty / loading / table.
|
||||
*
|
||||
* Read access is mandatory; anything else is hidden behind RBAC. The
|
||||
* permissions hook is co-located in this slice so adding/removing
|
||||
* surfaces is a one-file change.
|
||||
*/
|
||||
export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
|
||||
export function AutomationsContent({ workspaceId }: AutomationsContentProps) {
|
||||
const { automations, total, loading, error } = useAutomations();
|
||||
const perms = useAutomationPermissions();
|
||||
|
||||
|
|
@ -28,10 +28,10 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
|
|||
// Permissions gate the entire page; defer everything until we know.
|
||||
return (
|
||||
<>
|
||||
<AutomationsHeader searchSpaceId={searchSpaceId} total={0} loading canCreate={false} />
|
||||
<AutomationsHeader workspaceId={workspaceId} total={0} loading canCreate={false} />
|
||||
<AutomationsTable
|
||||
automations={[]}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
loading
|
||||
canUpdate={false}
|
||||
canDelete={false}
|
||||
|
|
@ -46,7 +46,7 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
|
|||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You don't have permission to view automations in this search space.
|
||||
You don't have permission to view automations in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -56,7 +56,7 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
|
|||
return (
|
||||
<>
|
||||
<AutomationsHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
total={0}
|
||||
loading={false}
|
||||
canCreate={perms.canCreate}
|
||||
|
|
@ -73,13 +73,13 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
|
|||
return (
|
||||
<>
|
||||
<AutomationsHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
total={0}
|
||||
loading={false}
|
||||
canCreate={perms.canCreate}
|
||||
showCreateCta={false}
|
||||
/>
|
||||
<AutomationsEmptyState searchSpaceId={searchSpaceId} canCreate={perms.canCreate} />
|
||||
<AutomationsEmptyState workspaceId={workspaceId} canCreate={perms.canCreate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -87,14 +87,14 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
|
|||
return (
|
||||
<>
|
||||
<AutomationsHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
total={total}
|
||||
loading={loading}
|
||||
canCreate={perms.canCreate}
|
||||
/>
|
||||
<AutomationsTable
|
||||
automations={automations}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
loading={loading}
|
||||
canUpdate={perms.canUpdate}
|
||||
canDelete={perms.canDelete}
|
||||
|
|
@ -15,7 +15,7 @@ import { DeleteAutomationDialog } from "./delete-automation-dialog";
|
|||
|
||||
interface AutomationRowActionsProps {
|
||||
automation: AutomationSummary;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ interface AutomationRowActionsProps {
|
|||
*/
|
||||
export function AutomationRowActions({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationRowActionsProps) {
|
||||
|
|
@ -85,7 +85,7 @@ export function AutomationRowActions({
|
|||
onOpenChange={setDeleteOpen}
|
||||
automationId={automation.id}
|
||||
automationName={automation.name}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -8,7 +8,7 @@ import { AutomationStatusBadge } from "./automation-status-badge";
|
|||
|
||||
interface AutomationRowProps {
|
||||
automation: AutomationSummary;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ interface AutomationRowProps {
|
|||
*/
|
||||
export function AutomationRow({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationRowProps) {
|
||||
|
|
@ -29,7 +29,7 @@ export function AutomationRow({
|
|||
<TableRow className="h-12 border-b border-border/60 hover:bg-muted/40">
|
||||
<TableCell className="px-4 md:px-6 py-2.5 border-r border-border/60 align-middle">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/automations/${automation.id}`}
|
||||
href={`/dashboard/${workspaceId}/automations/${automation.id}`}
|
||||
className="block truncate text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
{automation.name}
|
||||
|
|
@ -45,7 +45,7 @@ export function AutomationRow({
|
|||
<div className="flex justify-end">
|
||||
<AutomationRowActions
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
|
|
@ -4,7 +4,7 @@ import Link from "next/link";
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationsEmptyStateProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ interface AutomationsEmptyStateProps {
|
|||
* "new automation" form. We surface the chat path explicitly so users
|
||||
* don't go hunting for an "add" button that doesn't exist.
|
||||
*/
|
||||
export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) {
|
||||
export function AutomationsEmptyState({ workspaceId, canCreate }: AutomationsEmptyStateProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
|
|
@ -28,19 +28,19 @@ export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsE
|
|||
{canCreate ? (
|
||||
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
|
||||
<Button asChild>
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Create via chat</Link>
|
||||
<Link href={`/dashboard/${workspaceId}/new-chat`}>Create via chat</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-10 justify-start rounded-md bg-muted px-3 text-sm hover:bg-accent"
|
||||
>
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>Create manually</Link>
|
||||
<Link href={`/dashboard/${workspaceId}/automations/new`}>Create manually</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-6 text-xs text-muted-foreground">
|
||||
You don't have permission to create automations in this search space.
|
||||
You don't have permission to create automations in this workspace.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -3,7 +3,7 @@ import Link from "next/link";
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationsHeaderProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
total: number;
|
||||
loading: boolean;
|
||||
canCreate: boolean;
|
||||
|
|
@ -22,7 +22,7 @@ interface AutomationsHeaderProps {
|
|||
* handled per-automation in the builder + approval card, not gated here.
|
||||
*/
|
||||
export function AutomationsHeader({
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
total,
|
||||
loading,
|
||||
canCreate,
|
||||
|
|
@ -46,10 +46,10 @@ export function AutomationsHeader({
|
|||
variant="ghost"
|
||||
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||
>
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>Create manually</Link>
|
||||
<Link href={`/dashboard/${workspaceId}/automations/new`}>Create manually</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Create via chat</Link>
|
||||
<Link href={`/dashboard/${workspaceId}/new-chat`}>Create via chat</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -7,7 +7,7 @@ import { AutomationsLoadingRows } from "./automations-loading";
|
|||
|
||||
interface AutomationsTableProps {
|
||||
automations: AutomationSummary[];
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
loading: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
|
|
@ -19,7 +19,7 @@ interface AutomationsTableProps {
|
|||
*/
|
||||
export function AutomationsTable({
|
||||
automations,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
loading,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
|
|
@ -60,7 +60,7 @@ export function AutomationsTable({
|
|||
<AutomationRow
|
||||
key={automation.id}
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
|
|
@ -49,7 +49,7 @@ import { UnattendedToggle } from "./unattended-toggle";
|
|||
|
||||
interface AutomationBuilderFormProps {
|
||||
mode: "create" | "edit";
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
/** Required in edit mode; seeds the form and trigger reconciliation. */
|
||||
automation?: Automation;
|
||||
/**
|
||||
|
|
@ -78,7 +78,7 @@ function mapFormErrors(error: z.ZodError): Record<string, string> {
|
|||
|
||||
export function AutomationBuilderForm({
|
||||
mode,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
automation,
|
||||
submitDisabledReason,
|
||||
renderModeSwitcher,
|
||||
|
|
@ -120,7 +120,7 @@ export function AutomationBuilderForm({
|
|||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Eligible models + the search-space-seeded defaults. Models are chosen per
|
||||
// Eligible models + the workspace-seeded defaults. Models are chosen per
|
||||
// automation on create; in edit mode the backend preserves the captured
|
||||
// snapshot, so the picker is create-only.
|
||||
const eligibleModels = useAutomationEligibleModels();
|
||||
|
|
@ -156,10 +156,7 @@ export function AutomationBuilderForm({
|
|||
if (mode === "edit" && automation) {
|
||||
return { ...buildUpdatePayload(formForPayload), status: automation.status };
|
||||
}
|
||||
const { search_space_id: _ignored, ...rest } = buildCreatePayload(
|
||||
formForPayload,
|
||||
searchSpaceId
|
||||
);
|
||||
const { workspace_id: _ignored, ...rest } = buildCreatePayload(formForPayload, workspaceId);
|
||||
return rest;
|
||||
}
|
||||
|
||||
|
|
@ -265,16 +262,16 @@ export function AutomationBuilderForm({
|
|||
}
|
||||
await updateAutomation({ automationId: automation.id, patch: parsed.data });
|
||||
await reconcileTriggers(automation.id);
|
||||
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
|
||||
router.push(`/dashboard/${workspaceId}/automations/${automation.id}`);
|
||||
} else {
|
||||
const payload = buildCreatePayload(formForPayload, searchSpaceId);
|
||||
const payload = buildCreatePayload(formForPayload, workspaceId);
|
||||
const parsed = automationCreateRequest.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
setRootError(zodIssueList(parsed.error).join("; "));
|
||||
return;
|
||||
}
|
||||
const created = await createAutomation(parsed.data);
|
||||
router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
|
||||
router.push(`/dashboard/${workspaceId}/automations/${created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setRootError((err as Error).message ?? "Submit failed");
|
||||
|
|
@ -294,18 +291,18 @@ export function AutomationBuilderForm({
|
|||
return;
|
||||
}
|
||||
await updateAutomation({ automationId: automation.id, patch: parsed.data });
|
||||
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
|
||||
router.push(`/dashboard/${workspaceId}/automations/${automation.id}`);
|
||||
} else {
|
||||
const parsed = automationCreateRequest.safeParse({
|
||||
...jsonValue,
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
setJsonIssues(zodIssueList(parsed.error));
|
||||
return;
|
||||
}
|
||||
const created = await createAutomation(parsed.data);
|
||||
router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
|
||||
router.push(`/dashboard/${workspaceId}/automations/${created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setJsonIssues([(err as Error).message ?? "Submit failed"]);
|
||||
|
|
@ -396,7 +393,7 @@ export function AutomationBuilderForm({
|
|||
<TaskList
|
||||
tasks={form.tasks}
|
||||
errors={errors}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
onChange={(tasks) => patchForm({ tasks })}
|
||||
/>
|
||||
<UnattendedToggle
|
||||
|
|
@ -427,7 +424,7 @@ export function AutomationBuilderForm({
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<AutomationModelFields
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
value={resolvedModels}
|
||||
onChange={(patch) => patchForm({ models: { ...form.models, ...patch } })}
|
||||
/>
|
||||
|
|
@ -34,7 +34,7 @@ interface AutomationModelFieldsProps {
|
|||
/** Resolved (effective) ids — never `0` once defaults are seeded. */
|
||||
value: AutomationModelSelection;
|
||||
onChange: (patch: Partial<AutomationModelSelection>) => void;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
errors?: Partial<Record<keyof AutomationModelSelection, string>>;
|
||||
}
|
||||
|
||||
|
|
@ -47,11 +47,11 @@ interface AutomationModelFieldsProps {
|
|||
export function AutomationModelFields({
|
||||
value,
|
||||
onChange,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
errors,
|
||||
}: AutomationModelFieldsProps) {
|
||||
const { llm, image, vision, isLoading } = useAutomationEligibleModels();
|
||||
const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/models`;
|
||||
const rolesHref = `/dashboard/${workspaceId}/workspace-settings/models`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -20,7 +20,7 @@ import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MentionTaskInputProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
value: string;
|
||||
mentions: MentionedDocumentInfo[];
|
||||
onChange: (text: string, mentions: MentionedDocumentInfo[]) => void;
|
||||
|
|
@ -73,6 +73,9 @@ function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
|
|||
if (mention.kind === "folder") {
|
||||
return { id: mention.id, title: mention.title, kind: "folder" };
|
||||
}
|
||||
if (mention.kind === "thread") {
|
||||
return { id: mention.id, title: mention.title, kind: "thread" };
|
||||
}
|
||||
return {
|
||||
id: mention.id,
|
||||
title: mention.title,
|
||||
|
|
@ -95,7 +98,7 @@ function removeFirstToken(text: string, token: string): string {
|
|||
* so the builder can persist IDs for the run.
|
||||
*/
|
||||
export function MentionTaskInput({
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
value,
|
||||
mentions,
|
||||
onChange,
|
||||
|
|
@ -232,7 +235,7 @@ export function MentionTaskInput({
|
|||
<ComposerSuggestionPopoverContent side="bottom">
|
||||
<DocumentMentionPicker
|
||||
ref={pickerRef}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
onSelectionChange={handleSelection}
|
||||
onDone={closePopover}
|
||||
initialSelectedDocuments={mentions}
|
||||
|
|
@ -12,7 +12,7 @@ interface TaskItemProps {
|
|||
index: number;
|
||||
total: number;
|
||||
task: BuilderTask;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
error?: string;
|
||||
onChange: (patch: Partial<BuilderTask>) => void;
|
||||
onMoveUp: () => void;
|
||||
|
|
@ -31,7 +31,7 @@ export function TaskItem({
|
|||
index,
|
||||
total,
|
||||
task,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
error,
|
||||
onChange,
|
||||
onMoveUp,
|
||||
|
|
@ -89,7 +89,7 @@ export function TaskItem({
|
|||
hint="Type @ to reference files, folders, or connectors for extra context."
|
||||
>
|
||||
<MentionTaskInput
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
value={task.query}
|
||||
mentions={task.mentions}
|
||||
placeholder="What should the agent do? e.g. Summarize new docs in @Marketing since the last run."
|
||||
|
|
@ -7,7 +7,7 @@ import { TaskItem } from "./task-item";
|
|||
interface TaskListProps {
|
||||
tasks: BuilderTask[];
|
||||
errors: Record<string, string>;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
onChange: (tasks: BuilderTask[]) => void;
|
||||
}
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ interface TaskListProps {
|
|||
* Ordered list of agent tasks. Steps run sequentially in the order shown.
|
||||
* Reordering is done with up/down buttons to avoid a drag-and-drop dependency.
|
||||
*/
|
||||
export function TaskList({ tasks, errors, searchSpaceId, onChange }: TaskListProps) {
|
||||
export function TaskList({ tasks, errors, workspaceId, onChange }: TaskListProps) {
|
||||
function updateAt(index: number, patch: Partial<BuilderTask>) {
|
||||
onChange(tasks.map((task, i) => (i === index ? { ...task, ...patch } : task)));
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export function TaskList({ tasks, errors, searchSpaceId, onChange }: TaskListPro
|
|||
index={index}
|
||||
total={tasks.length}
|
||||
task={task}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
error={errors[`tasks.${index}.query`]}
|
||||
onChange={(patch) => updateAt(index, patch)}
|
||||
onMoveUp={() => move(index, -1)}
|
||||
|
|
@ -19,7 +19,7 @@ interface DeleteAutomationDialogProps {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
automationId: number;
|
||||
automationName: string;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
/**
|
||||
* Fired after a successful delete, before the dialog closes. The detail
|
||||
* page uses this to navigate back to the list (the row simply vanishes
|
||||
|
|
@ -38,7 +38,7 @@ export function DeleteAutomationDialog({
|
|||
onOpenChange,
|
||||
automationId,
|
||||
automationName,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
onDeleted,
|
||||
}: DeleteAutomationDialogProps) {
|
||||
const { mutateAsync: deleteAutomation } = useAtomValue(deleteAutomationMutationAtom);
|
||||
|
|
@ -47,7 +47,7 @@ export function DeleteAutomationDialog({
|
|||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await deleteAutomation({ automationId, searchSpaceId });
|
||||
await deleteAutomation({ automationId, workspaceId: workspaceId });
|
||||
onDeleted?.();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
|
|
@ -5,7 +5,7 @@ import { useAutomationPermissions } from "../hooks/use-automation-permissions";
|
|||
import { AutomationNewHeader } from "./components/automation-new-header";
|
||||
|
||||
interface AutomationNewContentProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -18,7 +18,7 @@ interface AutomationNewContentProps {
|
|||
* list eligible (premium/BYOK) models, surface a per-slot notice when none
|
||||
* exist, and block submit until each slot resolves.
|
||||
*/
|
||||
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
|
||||
export function AutomationNewContent({ workspaceId }: AutomationNewContentProps) {
|
||||
const perms = useAutomationPermissions();
|
||||
|
||||
if (perms.loading) {
|
||||
|
|
@ -31,7 +31,7 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
|
|||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You don't have permission to create automations in this search space.
|
||||
You don't have permission to create automations in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -40,9 +40,9 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
|
|||
return (
|
||||
<AutomationBuilderForm
|
||||
mode="create"
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
renderModeSwitcher={(modeSwitcher) => (
|
||||
<AutomationNewHeader searchSpaceId={searchSpaceId} modeSwitcher={modeSwitcher} />
|
||||
<AutomationNewHeader workspaceId={workspaceId} modeSwitcher={modeSwitcher} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
|
@ -5,17 +5,17 @@ import type { ReactNode } from "react";
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationNewHeaderProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
modeSwitcher?: ReactNode;
|
||||
}
|
||||
|
||||
export function AutomationNewHeader({ searchSpaceId, modeSwitcher }: AutomationNewHeaderProps) {
|
||||
export function AutomationNewHeader({ workspaceId, modeSwitcher }: AutomationNewHeaderProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/automations`}
|
||||
href={`/dashboard/${workspaceId}/automations`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
|
@ -3,13 +3,13 @@ import { AutomationNewContent } from "./automation-new-content";
|
|||
export default async function NewAutomationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
params: Promise<{ workspace_id: string }>;
|
||||
}) {
|
||||
const { search_space_id } = await params;
|
||||
const { workspace_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<AutomationNewContent searchSpaceId={Number(search_space_id)} />
|
||||
<AutomationNewContent workspaceId={Number(workspace_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,13 +3,13 @@ import { AutomationsContent } from "./automations-content";
|
|||
export default async function AutomationsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
params: Promise<{ workspace_id: string }>;
|
||||
}) {
|
||||
const { search_space_id } = await params;
|
||||
const { workspace_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl space-y-6">
|
||||
<AutomationsContent searchSpaceId={Number(search_space_id)} />
|
||||
<AutomationsContent workspaceId={Number(workspace_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,11 +6,11 @@ import { useEffect } from "react";
|
|||
export default function BuyPagesPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
const workspaceId = params?.workspace_id ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/${searchSpaceId}/buy-more`);
|
||||
}, [router, searchSpaceId]);
|
||||
router.replace(`/dashboard/${workspaceId}/buy-more`);
|
||||
}, [router, workspaceId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -6,11 +6,11 @@ import { useEffect } from "react";
|
|||
export default function BuyTokensPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
const workspaceId = params?.workspace_id ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/${searchSpaceId}/buy-more`);
|
||||
}, [router, searchSpaceId]);
|
||||
router.replace(`/dashboard/${workspaceId}/buy-more`);
|
||||
}, [router, workspaceId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
11
surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx
Normal file
11
surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { AllChatsWorkspaceContent } from "@/components/layout/ui/sidebar";
|
||||
|
||||
export default async function ChatsPage({ params }: { params: Promise<{ workspace_id: string }> }) {
|
||||
const { workspace_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full select-none">
|
||||
<AllChatsWorkspaceContent workspaceId={workspace_id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,7 +13,8 @@ import {
|
|||
modelConnectionsAtom,
|
||||
modelRolesAtom,
|
||||
} from "@/atoms/model-connections/model-connections-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { LayoutDataProvider } from "@/components/layout";
|
||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||
|
|
@ -25,17 +26,17 @@ import { isLlmOnboardingComplete } from "@/lib/onboarding";
|
|||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
searchSpaceId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const t = useTranslations("dashboard");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { search_space_id } = useParams();
|
||||
const activeSearchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
|
||||
const { workspace_id } = useParams();
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const setActiveWorkspaceIdState = useSetAtom(activeWorkspaceIdAtom);
|
||||
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
|
||||
|
||||
const { data: modelRoles = {}, isLoading: loading, error } = useAtomValue(modelRolesAtom);
|
||||
|
|
@ -52,12 +53,12 @@ export function DashboardClientLayout({
|
|||
|
||||
const isOnboardingPage = pathname?.includes("/onboard");
|
||||
const isOwner = access?.is_owner ?? false;
|
||||
const isSearchSpaceReady = activeSearchSpaceId === searchSpaceId;
|
||||
const isWorkspaceReady = activeWorkspaceId === workspaceId;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearchSpaceReady) return;
|
||||
if (isWorkspaceReady) return;
|
||||
setHasCheckedOnboarding(false);
|
||||
}, [isSearchSpaceReady]);
|
||||
}, [isWorkspaceReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnboardingPage) {
|
||||
|
|
@ -66,7 +67,7 @@ export function DashboardClientLayout({
|
|||
}
|
||||
|
||||
if (
|
||||
isSearchSpaceReady &&
|
||||
isWorkspaceReady &&
|
||||
!loading &&
|
||||
!accessLoading &&
|
||||
!globalConfigsLoading &&
|
||||
|
|
@ -75,7 +76,7 @@ export function DashboardClientLayout({
|
|||
!hasCheckedOnboarding
|
||||
) {
|
||||
// Onboarding is only relevant when no operator-provided
|
||||
// global_llm_config.yaml exists. When it does, search spaces inherit
|
||||
// global_llm_config.yaml exists. When it does, workspaces inherit
|
||||
// the global config and should never be forced into onboarding.
|
||||
if (globalConfigStatus?.exists) {
|
||||
setHasCheckedOnboarding(true);
|
||||
|
|
@ -98,11 +99,11 @@ export function DashboardClientLayout({
|
|||
return;
|
||||
}
|
||||
|
||||
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
||||
router.push(`/dashboard/${workspaceId}/onboard`);
|
||||
setHasCheckedOnboarding(true);
|
||||
}
|
||||
}, [
|
||||
isSearchSpaceReady,
|
||||
isWorkspaceReady,
|
||||
loading,
|
||||
accessLoading,
|
||||
globalConfigsLoading,
|
||||
|
|
@ -115,7 +116,7 @@ export function DashboardClientLayout({
|
|||
isOnboardingPage,
|
||||
isOwner,
|
||||
router,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
hasCheckedOnboarding,
|
||||
]);
|
||||
|
||||
|
|
@ -144,32 +145,32 @@ export function DashboardClientLayout({
|
|||
|
||||
useEffect(() => {
|
||||
const activeSeacrhSpaceId =
|
||||
typeof search_space_id === "string"
|
||||
? search_space_id
|
||||
: Array.isArray(search_space_id) && search_space_id.length > 0
|
||||
? search_space_id[0]
|
||||
typeof workspace_id === "string"
|
||||
? workspace_id
|
||||
: Array.isArray(workspace_id) && workspace_id.length > 0
|
||||
? workspace_id[0]
|
||||
: "";
|
||||
if (!activeSeacrhSpaceId) return;
|
||||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||
setActiveWorkspaceIdState(activeSeacrhSpaceId);
|
||||
|
||||
// Sync to Electron store if stored value is null (first navigation)
|
||||
if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
|
||||
const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
|
||||
if (electronAPI?.getActiveWorkspace && electronAPI.setActiveWorkspace) {
|
||||
const setActiveWorkspace = electronAPI.setActiveWorkspace;
|
||||
electronAPI
|
||||
.getActiveSearchSpace()
|
||||
.getActiveWorkspace()
|
||||
.then((stored: string | null) => {
|
||||
if (!stored) {
|
||||
setActiveSearchSpace(activeSeacrhSpaceId);
|
||||
setActiveWorkspace(activeSeacrhSpaceId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [search_space_id, setActiveSearchSpaceIdState, electronAPI]);
|
||||
}, [workspace_id, setActiveWorkspaceIdState, electronAPI]);
|
||||
|
||||
// Determine if we should show loading
|
||||
const shouldShowLoading =
|
||||
!hasCheckedOnboarding &&
|
||||
(!isSearchSpaceReady ||
|
||||
(!isWorkspaceReady ||
|
||||
loading ||
|
||||
accessLoading ||
|
||||
globalConfigsLoading ||
|
||||
|
|
@ -214,7 +215,10 @@ export function DashboardClientLayout({
|
|||
return (
|
||||
<DocumentUploadDialogProvider>
|
||||
<OnboardingTour />
|
||||
<LayoutDataProvider searchSpaceId={searchSpaceId}>{children}</LayoutDataProvider>
|
||||
<LayoutDataProvider workspaceId={workspaceId}>
|
||||
{children}
|
||||
<ConnectorIndicator showTrigger={false} />
|
||||
</LayoutDataProvider>
|
||||
</DocumentUploadDialogProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,9 +3,9 @@ import { OAUTH_RESULT_COOKIE, type OAuthCallbackResult } from "@/contracts/types
|
|||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ search_space_id: string }> }
|
||||
{ params }: { params: Promise<{ workspace_id: string }> }
|
||||
) {
|
||||
const { search_space_id } = await params;
|
||||
const { workspace_id } = await params;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
const payload: OAuthCallbackResult = {
|
||||
|
|
@ -19,7 +19,7 @@ export async function GET(
|
|||
const response = new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/dashboard/${search_space_id}/new-chat`,
|
||||
Location: `/dashboard/${workspace_id}/new-chat`,
|
||||
},
|
||||
});
|
||||
response.cookies.set(OAUTH_RESULT_COOKIE, result, {
|
||||
|
|
@ -7,10 +7,10 @@ export default function DashboardLayout({
|
|||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
params: Promise<{ workspace_id: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { search_space_id } = use(params);
|
||||
const { workspace_id } = use(params);
|
||||
|
||||
return <DashboardClientLayout searchSpaceId={search_space_id}>{children}</DashboardClientLayout>;
|
||||
return <DashboardClientLayout workspaceId={workspace_id}>{children}</DashboardClientLayout>;
|
||||
}
|
||||
|
|
@ -338,7 +338,7 @@ export default function LogsManagePage() {
|
|||
const t = useTranslations("logs");
|
||||
const id = useId();
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const workspaceId = Number(params.workspace_id);
|
||||
|
||||
const { mutateAsync: deleteLogMutation } = useAtomValue(deleteLogMutationAtom);
|
||||
const { mutateAsync: updateLogMutation } = useAtomValue(updateLogMutationAtom);
|
||||
|
|
@ -383,13 +383,13 @@ export default function LogsManagePage() {
|
|||
[deleteLogMutation]
|
||||
);
|
||||
|
||||
const { logs, loading: logsLoading, error: logsError, refreshLogs } = useLogs(searchSpaceId);
|
||||
const { logs, loading: logsLoading, error: logsError, refreshLogs } = useLogs(workspaceId);
|
||||
const {
|
||||
summary,
|
||||
loading: summaryLoading,
|
||||
error: summaryError,
|
||||
refreshSummary,
|
||||
} = useLogsSummary(searchSpaceId, 24);
|
||||
} = useLogsSummary(workspaceId, 24);
|
||||
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
|
@ -8,11 +8,11 @@ import { useEffect } from "react";
|
|||
export default function MorePagesPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
const workspaceId = params?.workspace_id ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/${searchSpaceId}/earn-credits`);
|
||||
}, [router, searchSpaceId]);
|
||||
router.replace(`/dashboard/${workspaceId}/earn-credits`);
|
||||
}, [router, workspaceId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -549,12 +549,12 @@ export default function NewChatPage() {
|
|||
|
||||
useMessagesSync(activeThreadId, handleSyncedMessagesUpdate);
|
||||
|
||||
// Extract search_space_id from URL params
|
||||
const searchSpaceId = useMemo(() => {
|
||||
const id = params.search_space_id;
|
||||
// Extract workspace_id from URL params
|
||||
const workspaceId = useMemo(() => {
|
||||
const id = params.workspace_id;
|
||||
const parsed = typeof id === "string" ? Number.parseInt(id, 10) : 0;
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}, [params.search_space_id]);
|
||||
}, [params.workspace_id]);
|
||||
|
||||
// Unified store for agent-action rows (the same react-query cache
|
||||
// the agent-actions dialog, the inline Revert button, and the
|
||||
|
|
@ -579,7 +579,7 @@ export default function NewChatPage() {
|
|||
error,
|
||||
flow,
|
||||
context: {
|
||||
searchSpaceId,
|
||||
workspaceId: workspaceId,
|
||||
threadId,
|
||||
},
|
||||
});
|
||||
|
|
@ -601,9 +601,9 @@ export default function NewChatPage() {
|
|||
message: normalized.userMessage,
|
||||
};
|
||||
if (normalized.telemetryEvent === "chat_blocked") {
|
||||
trackChatBlocked(searchSpaceId, threadId, telemetryPayload);
|
||||
trackChatBlocked(workspaceId, threadId, telemetryPayload);
|
||||
} else {
|
||||
trackChatErrorDetailed(searchSpaceId, threadId, telemetryPayload);
|
||||
trackChatErrorDetailed(workspaceId, threadId, telemetryPayload);
|
||||
}
|
||||
|
||||
if (normalized.channel === "silent") {
|
||||
|
|
@ -642,7 +642,7 @@ export default function NewChatPage() {
|
|||
|
||||
toast.error(normalized.userMessage);
|
||||
},
|
||||
[currentUser?.id, persistAssistantErrorMessage, searchSpaceId, setPremiumAlertForThread]
|
||||
[currentUser?.id, persistAssistantErrorMessage, workspaceId, setPremiumAlertForThread]
|
||||
);
|
||||
|
||||
const handleStreamTerminalError = useCallback(
|
||||
|
|
@ -717,7 +717,7 @@ export default function NewChatPage() {
|
|||
data: typeof threadMessagesQuery.data;
|
||||
}>({ threadId: null, data: undefined });
|
||||
|
||||
// Reset thread-local runtime state on route/search-space changes. Data fetching
|
||||
// Reset thread-local runtime state on route/workspace changes. Data fetching
|
||||
// is handled by React Query below so the chat shell can render immediately.
|
||||
useEffect(() => {
|
||||
const nextThreadId = urlChatId > 0 ? urlChatId : null;
|
||||
|
|
@ -754,13 +754,13 @@ export default function NewChatPage() {
|
|||
syncChatTab({
|
||||
chatId: thread.id,
|
||||
title: thread.title,
|
||||
chatUrl: `/dashboard/${thread.search_space_id ?? searchSpaceId}/new-chat/${thread.id}`,
|
||||
searchSpaceId: thread.search_space_id ?? searchSpaceId,
|
||||
chatUrl: `/dashboard/${thread.workspace_id ?? workspaceId}/new-chat/${thread.id}`,
|
||||
workspaceId: thread.workspace_id ?? workspaceId,
|
||||
visibility: thread.visibility,
|
||||
hasComments: thread.has_comments ?? false,
|
||||
});
|
||||
}
|
||||
}, [activeThreadId, searchSpaceId, syncChatTab, threadDetailQuery.data]);
|
||||
}, [activeThreadId, workspaceId, syncChatTab, threadDetailQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
const messagesResponse = threadMessagesQuery.data;
|
||||
|
|
@ -820,7 +820,7 @@ export default function NewChatPage() {
|
|||
if (loadError instanceof NotFoundError) {
|
||||
removeChatTab(activeThreadId);
|
||||
if (typeof window !== "undefined") {
|
||||
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
||||
window.history.replaceState(null, "", `/dashboard/${workspaceId}/new-chat`);
|
||||
}
|
||||
setThreadId(null);
|
||||
setCurrentThread(null);
|
||||
|
|
@ -833,7 +833,7 @@ export default function NewChatPage() {
|
|||
}, [
|
||||
activeThreadId,
|
||||
removeChatTab,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
threadDetailQuery.error,
|
||||
threadMessagesQuery.error,
|
||||
]);
|
||||
|
|
@ -841,10 +841,10 @@ export default function NewChatPage() {
|
|||
// Prefetch document titles for @ mention picker
|
||||
// Runs when user lands on page so data is ready when they type @
|
||||
useEffect(() => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
|
||||
const prefetchParams = {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
page: 0,
|
||||
page_size: 20,
|
||||
};
|
||||
|
|
@ -854,7 +854,7 @@ export default function NewChatPage() {
|
|||
queryFn: () => documentsApiService.searchDocumentTitles({ queryParams: prefetchParams }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}, [searchSpaceId, queryClient]);
|
||||
}, [workspaceId, queryClient]);
|
||||
|
||||
// Handle scroll to comment from URL query params (e.g., from inbox item click)
|
||||
// Read from window.location.search inside the effect instead of subscribing via
|
||||
|
|
@ -892,7 +892,7 @@ export default function NewChatPage() {
|
|||
}
|
||||
setCurrentThreadMetadata({
|
||||
id: null,
|
||||
searchSpaceId: null,
|
||||
workspaceId: null,
|
||||
visibility: null,
|
||||
hasComments: false,
|
||||
});
|
||||
|
|
@ -906,7 +906,7 @@ export default function NewChatPage() {
|
|||
|
||||
setCurrentThreadMetadata({
|
||||
id: currentThread.id,
|
||||
searchSpaceId: currentThread.search_space_id ?? searchSpaceId,
|
||||
workspaceId: currentThread.workspace_id ?? workspaceId,
|
||||
visibility,
|
||||
hasComments: currentThread.has_comments ?? false,
|
||||
});
|
||||
|
|
@ -915,7 +915,7 @@ export default function NewChatPage() {
|
|||
currentThread,
|
||||
currentThreadState.id,
|
||||
currentThreadState.visibility,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
setCurrentThreadMetadata,
|
||||
]);
|
||||
|
||||
|
|
@ -988,7 +988,7 @@ export default function NewChatPage() {
|
|||
let isNewThread = false;
|
||||
if (!currentThreadId) {
|
||||
try {
|
||||
const newThread = await createThread(searchSpaceId, "New Chat");
|
||||
const newThread = await createThread(workspaceId, "New Chat");
|
||||
currentThreadId = newThread.id;
|
||||
setThreadId(currentThreadId);
|
||||
// Set currentThread so share button in header appears immediately
|
||||
|
|
@ -997,7 +997,7 @@ export default function NewChatPage() {
|
|||
queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] });
|
||||
|
||||
// Track chat creation
|
||||
trackChatCreated(searchSpaceId, currentThreadId);
|
||||
trackChatCreated(workspaceId, currentThreadId);
|
||||
|
||||
isNewThread = true;
|
||||
// Update URL silently using browser API (not router.replace) to avoid
|
||||
|
|
@ -1005,7 +1005,7 @@ export default function NewChatPage() {
|
|||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`/dashboard/${searchSpaceId}/new-chat/${currentThreadId}`
|
||||
`/dashboard/${workspaceId}/new-chat/${currentThreadId}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[NewChatPage] Failed to create thread:", error);
|
||||
|
|
@ -1070,7 +1070,7 @@ export default function NewChatPage() {
|
|||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Track message sent
|
||||
trackChatMessageSent(searchSpaceId, currentThreadId, {
|
||||
trackChatMessageSent(workspaceId, currentThreadId, {
|
||||
hasAttachments: userImages.length > 0,
|
||||
hasMentionedDocuments:
|
||||
mentionPayload.document_ids.length > 0 ||
|
||||
|
|
@ -1122,7 +1122,7 @@ export default function NewChatPage() {
|
|||
let streamBatcher: FrameBatchedUpdater | null = null;
|
||||
|
||||
try {
|
||||
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
||||
const selection = await getAgentFilesystemSelection(workspaceId, {
|
||||
localFilesystemEnabled,
|
||||
});
|
||||
if (
|
||||
|
|
@ -1162,7 +1162,7 @@ export default function NewChatPage() {
|
|||
body: JSON.stringify({
|
||||
chat_id: currentThreadId,
|
||||
user_query: userQuery.trim(),
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
filesystem_mode: selection.filesystem_mode,
|
||||
client_platform: selection.client_platform,
|
||||
local_filesystem_mounts: selection.local_filesystem_mounts,
|
||||
|
|
@ -1235,7 +1235,7 @@ export default function NewChatPage() {
|
|||
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
|
||||
updateChatTabTitle({ chatId: currentThreadId, title: titleData.title });
|
||||
queryClient.setQueriesData<ThreadListResponse>(
|
||||
{ queryKey: ["threads", String(searchSpaceId)] },
|
||||
{ queryKey: ["threads", String(workspaceId)] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const updateTitle = (list: ThreadListItem[]) =>
|
||||
|
|
@ -1334,7 +1334,7 @@ export default function NewChatPage() {
|
|||
}
|
||||
|
||||
case "data-action-log": {
|
||||
applyActionLogSse(queryClient, currentThreadId, searchSpaceId, parsed.data);
|
||||
applyActionLogSse(queryClient, currentThreadId, workspaceId, parsed.data);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -1393,7 +1393,7 @@ export default function NewChatPage() {
|
|||
// refresh the sidebar so the freshly-bumped
|
||||
// ``thread.updated_at`` reorders this thread.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["threads", String(searchSpaceId)],
|
||||
queryKey: ["threads", String(workspaceId)],
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
|
@ -1443,7 +1443,7 @@ export default function NewChatPage() {
|
|||
// ``finally`` block. Nothing left for the FE to persist
|
||||
// here — track the response and unblock the UI.
|
||||
if (contentParts.length > 0 && !wasInterrupted) {
|
||||
trackChatResponseReceived(searchSpaceId, currentThreadId);
|
||||
trackChatResponseReceived(workspaceId, currentThreadId);
|
||||
}
|
||||
} catch (error) {
|
||||
streamBatcher?.dispose();
|
||||
|
|
@ -1491,7 +1491,7 @@ export default function NewChatPage() {
|
|||
},
|
||||
[
|
||||
threadId,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
messages,
|
||||
jotaiStore,
|
||||
mentionedDocuments,
|
||||
|
|
@ -1635,7 +1635,7 @@ export default function NewChatPage() {
|
|||
}
|
||||
|
||||
try {
|
||||
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
||||
const selection = await getAgentFilesystemSelection(workspaceId, {
|
||||
localFilesystemEnabled,
|
||||
});
|
||||
const response = await fetchWithTurnCancellingRetry(() =>
|
||||
|
|
@ -1645,7 +1645,7 @@ export default function NewChatPage() {
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
decisions,
|
||||
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||
filesystem_mode: selection.filesystem_mode,
|
||||
|
|
@ -1756,7 +1756,7 @@ export default function NewChatPage() {
|
|||
}
|
||||
|
||||
case "data-action-log": {
|
||||
applyActionLogSse(queryClient, resumeThreadId, searchSpaceId, parsed.data);
|
||||
applyActionLogSse(queryClient, resumeThreadId, workspaceId, parsed.data);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -1837,7 +1837,7 @@ export default function NewChatPage() {
|
|||
[
|
||||
pendingInterrupts,
|
||||
messages,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
localFilesystemEnabled,
|
||||
disabledTools,
|
||||
queryClient,
|
||||
|
|
@ -2039,7 +2039,7 @@ export default function NewChatPage() {
|
|||
? messageDocumentsMap[sourceUserMessageId]
|
||||
: [];
|
||||
try {
|
||||
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
||||
const selection = await getAgentFilesystemSelection(workspaceId, {
|
||||
localFilesystemEnabled,
|
||||
});
|
||||
// Partition the source mentions back into doc/folder id buckets
|
||||
|
|
@ -2060,7 +2060,7 @@ export default function NewChatPage() {
|
|||
.map((d) => d.id);
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
user_query: newUserQuery,
|
||||
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||
filesystem_mode: selection.filesystem_mode,
|
||||
|
|
@ -2177,7 +2177,7 @@ export default function NewChatPage() {
|
|||
switch (parsed.type) {
|
||||
case "data-action-log": {
|
||||
if (threadId !== null) {
|
||||
applyActionLogSse(queryClient, threadId, searchSpaceId, parsed.data);
|
||||
applyActionLogSse(queryClient, threadId, workspaceId, parsed.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -2297,7 +2297,7 @@ export default function NewChatPage() {
|
|||
// abort-mid-stream). FE only needs to track the
|
||||
// successful response here.
|
||||
if (contentParts.length > 0) {
|
||||
trackChatResponseReceived(searchSpaceId, threadId);
|
||||
trackChatResponseReceived(workspaceId, threadId);
|
||||
}
|
||||
} catch (error) {
|
||||
streamBatcher?.dispose();
|
||||
|
|
@ -2321,7 +2321,7 @@ export default function NewChatPage() {
|
|||
},
|
||||
[
|
||||
threadId,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
messages,
|
||||
disabledTools,
|
||||
localFilesystemEnabled,
|
||||
|
|
@ -2542,7 +2542,7 @@ export default function NewChatPage() {
|
|||
pendingInterrupts={pendingInterrupts}
|
||||
onSubmit={handleApprovalSubmit}
|
||||
>
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
<div key={workspaceId} className="flex h-full overflow-hidden">
|
||||
<div className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread hasActiveThread={!!activeThreadId} />
|
||||
{isThreadMessagesLoading ? (
|
||||
|
|
@ -20,7 +20,7 @@ import { hasEnabledChatModel, isLlmOnboardingComplete } from "@/lib/onboarding";
|
|||
export default function OnboardPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const workspaceId = Number(params.workspace_id);
|
||||
const session = useSession();
|
||||
const { data: globalConnections = [], isLoading: globalLoading } = useAtomValue(
|
||||
globalModelConnectionsAtom
|
||||
|
|
@ -55,9 +55,9 @@ export default function OnboardPage() {
|
|||
|
||||
useEffect(() => {
|
||||
if (shouldLeaveOnboarding) {
|
||||
router.replace(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
router.replace(`/dashboard/${workspaceId}/new-chat`);
|
||||
}
|
||||
}, [shouldLeaveOnboarding, router, searchSpaceId]);
|
||||
}, [shouldLeaveOnboarding, router, workspaceId]);
|
||||
|
||||
useGlobalLoadingEffect(isLoading || shouldLeaveOnboarding);
|
||||
|
||||
|
|
@ -74,14 +74,14 @@ export default function OnboardPage() {
|
|||
</p>
|
||||
</div>
|
||||
<ModelProviderConnectionsPanel
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
connections={connections}
|
||||
className="flex flex-col gap-6 text-left"
|
||||
footerAction={
|
||||
<Button
|
||||
className="min-w-[112px]"
|
||||
disabled={!onboardingComplete || !hasUsableChatModel}
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/new-chat`)}
|
||||
onClick={() => router.push(`/dashboard/${workspaceId}/new-chat`)}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
10
surfsense_web/app/dashboard/[workspace_id]/page.tsx
Normal file
10
surfsense_web/app/dashboard/[workspace_id]/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function WorkspaceDashboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace_id: string }>;
|
||||
}) {
|
||||
const { workspace_id } = await params;
|
||||
redirect(`/dashboard/${workspace_id}/new-chat`);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { PlaygroundRunner } from "../../components/playground-runner";
|
||||
|
||||
export default async function PlaygroundVerbPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace_id: string; platform: string; verb: string }>;
|
||||
}) {
|
||||
const { workspace_id, platform, verb } = await params;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<PlaygroundRunner workspaceId={Number(workspace_id)} platform={platform} verb={verb} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { ApiKeysSection } from "../components/api-keys-section";
|
||||
|
||||
export default async function PlaygroundApiKeysPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace_id: string }>;
|
||||
}) {
|
||||
const { workspace_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<ApiKeysSection workspaceId={Number(workspace_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateWorkspaceApiAccessMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
|
||||
|
||||
/**
|
||||
* One-stop API key management for the playground: the workspace API-access
|
||||
* toggle (otherwise buried in workspace settings) plus the personal API key
|
||||
* manager (otherwise buried in user settings).
|
||||
*/
|
||||
export function ApiKeysSection({ workspaceId }: { workspaceId: number }) {
|
||||
const {
|
||||
data: workspace,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
|
||||
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
const { mutateAsync: updateWorkspaceApiAccess } = useAtomValue(
|
||||
updateWorkspaceApiAccessMutationAtom
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await updateWorkspaceApiAccess({ id: workspaceId, api_access_enabled: enabled });
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error("Error updating API access:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update API access");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const apiAccessEnabled = !!workspace?.api_access_enabled;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">API Keys</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Enable API access for this workspace and manage the keys that use it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="playground-api-access">API key access</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow API keys to access this workspace.
|
||||
{!isLoading && !apiAccessEnabled && " Currently disabled — keys won't work here."}
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-5 w-9 rounded-full" />
|
||||
) : (
|
||||
<Switch
|
||||
id="playground-api-access"
|
||||
checked={apiAccessEnabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ApiKeyContent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { buildExamplePayload, buildSnippets } from "@/lib/playground/code-snippets";
|
||||
import type { FormField } from "@/lib/playground/json-schema";
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copy}
|
||||
className="absolute right-2 top-2 h-7 gap-1.5 px-2 text-xs"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<CopyButton text={code} />
|
||||
<pre className="max-h-[420px] overflow-auto rounded-md border border-border/60 bg-muted/20 p-3 pr-20 text-xs">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemaBlock({ title, schema }: { title: string; schema: Record<string, unknown> }) {
|
||||
const json = useMemo(() => JSON.stringify(schema, null, 2), [schema]);
|
||||
return (
|
||||
<details className="group rounded-md border border-border/60">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
|
||||
{title}
|
||||
</summary>
|
||||
<div className="relative border-t border-border/60">
|
||||
<CopyButton text={json} />
|
||||
<pre className="max-h-[360px] overflow-auto p-3 pr-20 text-xs">
|
||||
<code>{json}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiReference({
|
||||
workspaceId,
|
||||
platform,
|
||||
verb,
|
||||
fields,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
}: {
|
||||
workspaceId: number;
|
||||
platform: string;
|
||||
verb: string;
|
||||
fields: FormField[];
|
||||
inputSchema: Record<string, unknown>;
|
||||
/** Absent only when talking to a backend that predates output schemas. */
|
||||
outputSchema?: Record<string, unknown>;
|
||||
}) {
|
||||
const path = `/api/v1/workspaces/${workspaceId}/scrapers/${platform}/${verb}`;
|
||||
|
||||
// In proxy mode BACKEND_URL is intentionally empty (same-origin), so external
|
||||
// callers use this app's origin. Client component, so window is available.
|
||||
const baseUrl = BACKEND_URL || (typeof window !== "undefined" ? window.location.origin : "");
|
||||
|
||||
const snippets = useMemo(() => {
|
||||
const payload = buildExamplePayload(fields);
|
||||
return buildSnippets(baseUrl, path, payload);
|
||||
}, [fields, baseUrl, path]);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">API reference</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Call this API from your own project. Create a key under{" "}
|
||||
<span className="font-medium text-foreground">API Keys</span> (and enable API access for
|
||||
this workspace), then send it as a{" "}
|
||||
<code className="rounded bg-muted/40 px-1 py-0.5 text-xs">Authorization: Bearer</code>{" "}
|
||||
header.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="curl">
|
||||
<TabsList className="h-auto flex-wrap">
|
||||
{snippets.map((snippet) => (
|
||||
<TabsTrigger key={snippet.id} value={snippet.id}>
|
||||
{snippet.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{snippets.map((snippet) => (
|
||||
<TabsContent key={snippet.id} value={snippet.id}>
|
||||
<CodeBlock code={snippet.code} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<div className="space-y-2">
|
||||
<SchemaBlock title="Input schema (JSON)" schema={inputSchema} />
|
||||
{outputSchema && <SchemaBlock title="Output schema (JSON)" schema={outputSchema} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { downloadCsv, rowsToCsv } from "@/lib/playground/csv";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_TABLE_ROWS = 200;
|
||||
|
||||
function extractItems(data: unknown): Record<string, unknown>[] | null {
|
||||
if (
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"items" in data &&
|
||||
Array.isArray((data as { items: unknown }).items)
|
||||
) {
|
||||
const items = (data as { items: unknown[] }).items;
|
||||
if (items.every((item) => typeof item === "object" && item !== null)) {
|
||||
return items as Record<string, unknown>[];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cellText(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function ResultTable({ items }: { items: Record<string, unknown>[] }) {
|
||||
const columns = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
for (const item of items.slice(0, MAX_TABLE_ROWS)) {
|
||||
for (const key of Object.keys(item)) keys.add(key);
|
||||
}
|
||||
return Array.from(keys);
|
||||
}, [items]);
|
||||
|
||||
const rows = items.slice(0, MAX_TABLE_ROWS);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-border/60">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableHead key={col} className="whitespace-nowrap">
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: rows have no stable id
|
||||
<TableRow key={i}>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col} className="max-w-xs truncate align-top text-xs">
|
||||
{cellText(item[col])}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBase?: string }) {
|
||||
const items = useMemo(() => extractItems(data), [data]);
|
||||
const [view, setView] = useState<"table" | "json">(items ? "table" : "json");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const json = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
// Export the full item set (not just the display-capped rows) as CSV, with a
|
||||
// column per union key so nothing is dropped.
|
||||
const exportCsv = () => {
|
||||
if (!items || items.length === 0) return;
|
||||
const columns = Array.from(
|
||||
items.reduce((set, item) => {
|
||||
for (const key of Object.keys(item)) set.add(key);
|
||||
return set;
|
||||
}, new Set<string>())
|
||||
);
|
||||
downloadCsv(filenameBase ?? "output", rowsToCsv(items, columns));
|
||||
};
|
||||
|
||||
const truncated = items && items.length > MAX_TABLE_ROWS;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="inline-flex rounded-md border border-border/60 p-0.5">
|
||||
{items && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("table")}
|
||||
className={cn(
|
||||
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
view === "table"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Table
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("json")}
|
||||
className={cn(
|
||||
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
view === "json"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{items && items.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={exportCsv}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export CSV
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={copy} className="gap-1.5">
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy JSON"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items && items.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-border/60 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No items returned.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{view === "table" && items && items.length > 0 ? (
|
||||
<>
|
||||
<ResultTable items={items} />
|
||||
{truncated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing first {MAX_TABLE_ROWS} of {items.length} items. Use Copy JSON for the full
|
||||
output.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<pre className="max-h-[480px] overflow-auto rounded-md border border-border/60 bg-muted/20 p-3 text-xs">
|
||||
<code>{json}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, History, KeyRound } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
|
||||
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
|
||||
import { formatPricing } from "@/lib/playground/format";
|
||||
|
||||
export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
|
||||
const base = `/dashboard/${workspaceId}/playground`;
|
||||
|
||||
// The grid renders from the static catalog immediately; pricing fills in
|
||||
// once the capabilities fetch lands (blank while loading, never blocking).
|
||||
const { data: capabilities } = useScraperCapabilities(workspaceId);
|
||||
const pricingByName = useMemo(
|
||||
() => new Map(capabilities?.map((c) => [c.name, formatPricing(c.pricing)])),
|
||||
[capabilities]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">API Playground</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Manually run SurfSense's platform-native APIs and inspect their output. Every run is
|
||||
captured under Runs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Link
|
||||
href={`${base}/runs`}
|
||||
className="flex items-center justify-between rounded-lg border border-border/60 bg-accent/40 px-4 py-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<History className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Runs</p>
|
||||
<p className="text-xs text-muted-foreground">See every API run in this workspace</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`${base}/api-keys`}
|
||||
className="flex items-center justify-between rounded-lg border border-border/60 bg-accent/40 px-4 py-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">API Keys</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable workspace access and manage keys
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{PLAYGROUND_PLATFORMS.map((platform) => (
|
||||
<div key={platform.id} className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<platform.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">{platform.label}</h2>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{platform.verbs.map((verb) => (
|
||||
<Link
|
||||
key={verb.name}
|
||||
href={`${base}/${platform.id}/${verb.verb}`}
|
||||
className="group flex flex-col rounded-lg border border-border/60 p-4 transition-colors hover:border-border hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{verb.label}</span>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
<code className="mt-2 text-xs text-muted-foreground">{verb.name}</code>
|
||||
{pricingByName.has(verb.name) ? (
|
||||
<span className="mt-2 text-xs tabular-nums text-muted-foreground">
|
||||
{pricingByName.get(verb.name)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, Coins, Hash, Loader2, Play, Timer, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useRunStream } from "@/hooks/use-run-stream";
|
||||
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
|
||||
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
|
||||
import { AbortedError, AppError } from "@/lib/error";
|
||||
import { findVerb } from "@/lib/playground/catalog";
|
||||
import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format";
|
||||
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
|
||||
import { ApiReference } from "./api-reference";
|
||||
import { OutputViewer } from "./output-viewer";
|
||||
import { RunProgressPanel } from "./run-progress-panel";
|
||||
import { SchemaForm } from "./schema-form";
|
||||
|
||||
interface PlaygroundRunnerProps {
|
||||
workspaceId: number;
|
||||
platform: string;
|
||||
verb: string;
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_LINES = 200;
|
||||
|
||||
/** Parse the stored JSONL output into objects, capped for rendering. */
|
||||
function parseJsonlOutput(text: string | null | undefined): { items: unknown[] } {
|
||||
if (!text) return { items: [] };
|
||||
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
||||
const items: unknown[] = [];
|
||||
for (const line of lines.slice(0, MAX_OUTPUT_LINES)) {
|
||||
try {
|
||||
items.push(JSON.parse(line));
|
||||
} catch {
|
||||
items.push(line);
|
||||
}
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
|
||||
function RunStat({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: typeof Hash;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-border/60 px-2.5 py-1.5">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorPanel({ error, workspaceId }: { error: unknown; workspaceId: number }) {
|
||||
if (error instanceof AbortedError) {
|
||||
return null;
|
||||
}
|
||||
const status = error instanceof AppError ? error.status : undefined;
|
||||
|
||||
if (status === 402) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-medium text-destructive">Insufficient credits</p>
|
||||
<p className="mt-1 text-muted-foreground">You don't have enough credits to run this API.</p>
|
||||
<Button asChild size="sm" variant="outline" className="mt-3">
|
||||
<Link href={`/dashboard/${workspaceId}/buy-more`}>Buy credits</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const message =
|
||||
status === 422
|
||||
? "Invalid input. Check the fields above and try again."
|
||||
: error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Something went wrong running this API.";
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunnerProps) {
|
||||
const catalogVerb = findVerb(platform, verb);
|
||||
const {
|
||||
data: capabilities,
|
||||
isLoading,
|
||||
error: capabilitiesError,
|
||||
} = useScraperCapabilities(workspaceId);
|
||||
|
||||
const capability = useMemo(() => {
|
||||
if (!capabilities) return undefined;
|
||||
const name = catalogVerb?.name ?? `${platform}.${verb}`;
|
||||
return capabilities.find((c) => c.name === name);
|
||||
}, [capabilities, catalogVerb, platform, verb]);
|
||||
|
||||
const fields = useMemo(() => parseSchemaFields(capability?.input_schema), [capability]);
|
||||
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const run = useRunStream(workspaceId);
|
||||
const isRunning = run.status === "running";
|
||||
|
||||
// Seed form defaults once the schema is available.
|
||||
useEffect(() => {
|
||||
if (fields.length > 0) {
|
||||
setValues(initialFormValues(fields));
|
||||
}
|
||||
}, [fields]);
|
||||
|
||||
// Survive a refresh: if this verb's newest run is still ``running``, re-attach
|
||||
// to its live stream instead of showing an idle form.
|
||||
const reattachedRef = useRef(false);
|
||||
const { reattach } = run;
|
||||
useEffect(() => {
|
||||
if (reattachedRef.current || !capability) return;
|
||||
reattachedRef.current = true;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const recent = await scrapersApiService.listRuns(workspaceId, {
|
||||
capability: capability.name,
|
||||
limit: 1,
|
||||
});
|
||||
const top = recent[0];
|
||||
if (!cancelled && top && top.status === "running") {
|
||||
reattach(top.id);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort reattach; a fresh run still works.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [capability, workspaceId, reattach]);
|
||||
|
||||
const handleChange = (name: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleRun = useCallback(() => {
|
||||
const payload = buildPayload(fields, values);
|
||||
void run.start(platform, verb, payload);
|
||||
}, [fields, values, platform, verb, run]);
|
||||
|
||||
const output = useMemo(
|
||||
() => (run.detail ? parseJsonlOutput(run.detail.output_text) : null),
|
||||
[run.detail]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (capabilitiesError) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
Couldn't load API definitions
|
||||
{capabilitiesError.message ? `: ${capabilitiesError.message}` : "."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!capability || !catalogVerb) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Unknown API: {platform}.{verb}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{catalogVerb.label} <span className="text-muted-foreground">· {platform}</span>
|
||||
</h1>
|
||||
{capability.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{capability.description}</p>
|
||||
)}
|
||||
<code className="mt-2 inline-block rounded bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
POST /workspaces/{workspaceId}/scrapers/{platform}/{verb}
|
||||
</code>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Coins className="h-3.5 w-3.5" />
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{formatPricing(capability.pricing)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SchemaForm
|
||||
fields={fields}
|
||||
values={values}
|
||||
onChange={handleChange}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" onClick={handleRun} disabled={isRunning} className="gap-1.5">
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
{isRunning && (
|
||||
<Button type="button" variant="outline" onClick={run.cancel} className="gap-1.5">
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{run.status === "error" && <ErrorPanel error={run.error} workspaceId={workspaceId} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">Output</h2>
|
||||
{isRunning ? (
|
||||
<RunProgressPanel
|
||||
latest={run.latest}
|
||||
events={run.events}
|
||||
elapsedMs={run.elapsedMs}
|
||||
/>
|
||||
) : run.status === "cancelled" ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-md border border-border/60 px-4 text-center text-sm text-muted-foreground">
|
||||
Run cancelled.
|
||||
</div>
|
||||
) : run.status === "success" && output ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<RunStat
|
||||
icon={Hash}
|
||||
label="Items"
|
||||
value={String(run.detail?.item_count ?? output.items.length)}
|
||||
/>
|
||||
<RunStat
|
||||
icon={Timer}
|
||||
label="Time"
|
||||
value={formatDuration(run.detail?.duration_ms ?? run.elapsedMs)}
|
||||
/>
|
||||
<RunStat icon={Coins} label="Cost" value={formatCost(run.detail?.cost_micros)} />
|
||||
</div>
|
||||
<OutputViewer data={output} filenameBase={`${platform}-${verb}`} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed border-border/60 px-4 text-center text-sm text-muted-foreground">
|
||||
Run the API to see output here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApiReference
|
||||
workspaceId={workspaceId}
|
||||
platform={platform}
|
||||
verb={verb}
|
||||
fields={fields}
|
||||
inputSchema={capability.input_schema}
|
||||
outputSchema={capability.output_schema}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useScraperRun } from "@/hooks/use-scraper-runs";
|
||||
import { OutputViewer } from "./output-viewer";
|
||||
|
||||
const MAX_OUTPUT_LINES = 200;
|
||||
|
||||
/** One-line label for a persisted ``run.progress`` event object. */
|
||||
function progressLine(event: Record<string, unknown>): string {
|
||||
const message = typeof event.message === "string" ? event.message : undefined;
|
||||
const phase = typeof event.phase === "string" ? event.phase : undefined;
|
||||
const current = typeof event.current === "number" ? event.current : undefined;
|
||||
const total = typeof event.total === "number" ? event.total : undefined;
|
||||
const base = message || (phase ? phase.replace(/_/g, " ") : "step");
|
||||
if (current !== undefined) {
|
||||
return `${base} (${total !== undefined ? `${current}/${total}` : current})`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Parse the stored JSONL output into objects, capped for rendering. */
|
||||
function parseJsonl(text: string | null): { items: unknown[]; total: number } {
|
||||
if (!text) return { items: [], total: 0 };
|
||||
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
||||
const items: unknown[] = [];
|
||||
for (const line of lines.slice(0, MAX_OUTPUT_LINES)) {
|
||||
try {
|
||||
items.push(JSON.parse(line));
|
||||
} catch {
|
||||
items.push(line);
|
||||
}
|
||||
}
|
||||
return { items, total: lines.length };
|
||||
}
|
||||
|
||||
export function RunDetail({
|
||||
workspaceId,
|
||||
runId,
|
||||
}: {
|
||||
workspaceId: number;
|
||||
runId: string;
|
||||
}) {
|
||||
const { data: run, isLoading, error } = useScraperRun(workspaceId, runId);
|
||||
|
||||
const parsed = useMemo(() => parseJsonl(run?.output_text ?? null), [run?.output_text]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="p-4 text-sm text-destructive">
|
||||
Couldn't load run{error.message ? `: ${error.message}` : "."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!run) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t border-border/60 bg-muted/10 p-4">
|
||||
{run.error && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.progress && run.progress.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-1.5 text-xs font-medium text-muted-foreground">Progress</h4>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-border/60 bg-background p-3 font-mono text-xs text-muted-foreground">
|
||||
{run.progress.map((event, i) => (
|
||||
<div key={i} className="truncate">
|
||||
{progressLine(event)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="mb-1.5 text-xs font-medium text-muted-foreground">Input</h4>
|
||||
<pre className="max-h-64 overflow-auto rounded-md border border-border/60 bg-background p-3 text-xs">
|
||||
<code>{JSON.stringify(run.input ?? {}, null, 2)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-1.5 text-xs font-medium text-muted-foreground">
|
||||
Output {parsed.total > 0 && `(${parsed.total} items)`}
|
||||
</h4>
|
||||
{run.output_text ? (
|
||||
<>
|
||||
<OutputViewer
|
||||
data={{ items: parsed.items }}
|
||||
filenameBase={`${run.capability}-${run.id}`}
|
||||
/>
|
||||
{parsed.total > MAX_OUTPUT_LINES && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Showing first {MAX_OUTPUT_LINES} of {parsed.total} stored items.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No output stored for this run.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ScraperRunEvent } from "@/contracts/types/scraper.types";
|
||||
import { formatDuration } from "@/lib/playground/format";
|
||||
|
||||
/** One-line human label for a progress/lifecycle event. */
|
||||
function eventLabel(event: ScraperRunEvent): string {
|
||||
const base =
|
||||
event.message ||
|
||||
(event.phase ? event.phase.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase()) : "Working");
|
||||
if (event.current !== undefined && event.current !== null) {
|
||||
const counter =
|
||||
event.total !== undefined && event.total !== null
|
||||
? `${event.current}/${event.total}`
|
||||
: String(event.current);
|
||||
return `${base} (${counter})`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live progress for an in-flight async run: a headline status, a determinate
|
||||
* bar when counts are known, a running elapsed timer, and a scrolling event log.
|
||||
*/
|
||||
export function RunProgressPanel({
|
||||
latest,
|
||||
events,
|
||||
elapsedMs,
|
||||
}: {
|
||||
latest: ScraperRunEvent | null;
|
||||
events: ScraperRunEvent[];
|
||||
elapsedMs: number;
|
||||
}) {
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep the newest line in view as the log grows.
|
||||
useEffect(() => {
|
||||
const el = logRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [events.length]);
|
||||
|
||||
const total = latest?.total ?? undefined;
|
||||
const current = latest?.current ?? undefined;
|
||||
const pct =
|
||||
typeof current === "number" && typeof total === "number" && total > 0
|
||||
? Math.min(100, Math.round((current / total) * 100))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-border/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{latest ? eventLabel(latest) : "Starting…"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{formatDuration(elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pct !== null && (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<div
|
||||
ref={logRef}
|
||||
className="max-h-56 space-y-1 overflow-y-auto rounded border border-border/40 bg-muted/20 p-2 font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{events.map((event, i) => (
|
||||
<div key={`${event.ts ?? i}-${i}`} className="truncate">
|
||||
{eventLabel(event)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/** Scraper runs: ``running`` (async, in-flight), ``success``, ``error``, ``cancelled``. */
|
||||
export function RunStatusBadge({ status }: { status: string }) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (normalized === "running") {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 bg-blue-500/15 text-blue-600 dark:text-blue-400">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Running
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (normalized === "success") {
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||
Success
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (normalized === "error") {
|
||||
return <Badge variant="destructive">Error</Badge>;
|
||||
}
|
||||
if (normalized === "cancelled") {
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||||
Cancelled
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
"use client";
|
||||
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronRight, History } from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
|
||||
import { formatCost, formatDuration } from "@/lib/playground/format";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RunDetail } from "./run-detail";
|
||||
import { RunStatusBadge } from "./run-status-badge";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const ALL = "__all__";
|
||||
|
||||
const CAPABILITY_OPTIONS = PLAYGROUND_PLATFORMS.flatMap((platform) =>
|
||||
platform.verbs.map((verb) => verb.name)
|
||||
);
|
||||
|
||||
export function RunsTable({ workspaceId }: { workspaceId: number }) {
|
||||
const [capability, setCapability] = useState<string>(ALL);
|
||||
const [status, setStatus] = useState<string>(ALL);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const filters = {
|
||||
capability: capability === ALL ? undefined : capability,
|
||||
status: status === ALL ? undefined : status,
|
||||
};
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: [...cacheKeys.scrapers.runs(workspaceId), filters],
|
||||
queryFn: ({ pageParam }) =>
|
||||
scrapersApiService.listRuns(workspaceId, {
|
||||
limit: PAGE_SIZE,
|
||||
offset: pageParam,
|
||||
...filters,
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages) =>
|
||||
lastPage.length === PAGE_SIZE ? allPages.length * PAGE_SIZE : undefined,
|
||||
// Poll while any visible run is still in flight so it flips to a terminal
|
||||
// state without a manual refresh; idle otherwise.
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.pages.flat().some((r) => r.status === "running") ? 5000 : false,
|
||||
});
|
||||
|
||||
const runs = query.data?.pages.flat() ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Runs</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Every platform-native API run in this workspace — from the playground, API keys, and
|
||||
agents. Newest first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={capability} onValueChange={setCapability}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="All APIs" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All APIs</SelectItem>
|
||||
{CAPABILITY_OPTIONS.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All statuses</SelectItem>
|
||||
<SelectItem value="running">Running</SelectItem>
|
||||
<SelectItem value="success">Success</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Couldn't load runs{query.error.message ? `: ${query.error.message}` : "."}
|
||||
</p>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/60 bg-muted/20 px-4 py-12 text-center">
|
||||
<History className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden />
|
||||
<p className="mt-2 text-sm font-medium">No runs yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Run an API from the playground and it will show up here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-border/60">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>API</TableHead>
|
||||
<TableHead>Origin</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Items</TableHead>
|
||||
<TableHead className="text-right">Duration</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">When</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((run) => {
|
||||
const isOpen = expanded === run.id;
|
||||
return (
|
||||
<Fragment key={run.id}>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
onClick={() => setExpanded(isOpen ? null : run.id)}
|
||||
>
|
||||
<TableCell>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{run.capability}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{run.origin}</TableCell>
|
||||
<TableCell>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{run.item_count}</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatDuration(run.duration_ms)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatCost(run.cost_micros)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs text-muted-foreground">
|
||||
{formatRelativeDate(run.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isOpen && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={8} className="p-0">
|
||||
<RunDetail workspaceId={workspaceId} runId={run.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
disabled={query.isFetchingNextPage}
|
||||
className={cn(query.isFetchingNextPage && "opacity-70")}
|
||||
>
|
||||
{query.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { FormField } from "@/lib/playground/json-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SchemaFormProps {
|
||||
fields: FormField[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (name: string, value: unknown) => void;
|
||||
disabled?: boolean;
|
||||
/** Field names flagged by a 422 response, shown with error styling. */
|
||||
fieldErrors?: Record<string, string>;
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
invalid,
|
||||
}: {
|
||||
field: FormField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
disabled?: boolean;
|
||||
invalid?: boolean;
|
||||
}) {
|
||||
const id = `field-${field.name}`;
|
||||
|
||||
if (field.kind === "boolean") {
|
||||
return (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === "enum" && field.enumValues) {
|
||||
return (
|
||||
<Select
|
||||
value={value ? String(value) : undefined}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id={id} className={cn("w-full", invalid && "border-destructive")}>
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.enumValues.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === "string_array") {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="One value per line"
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
className={cn("font-mono text-xs", invalid && "border-destructive")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === "integer" || field.kind === "number") {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
value={value === undefined || value === null ? "" : String(value)}
|
||||
min={field.minimum}
|
||||
max={field.maximum}
|
||||
step={field.kind === "integer" ? 1 : "any"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={cn(invalid && "border-destructive")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={cn(invalid && "border-destructive")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
error,
|
||||
}: {
|
||||
field: FormField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`field-${field.name}`} className="text-sm font-medium">
|
||||
{field.title}
|
||||
</Label>
|
||||
{field.required && <span className="text-xs text-destructive">required</span>}
|
||||
</div>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
<FieldControl
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
invalid={!!error}
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SchemaForm({
|
||||
fields,
|
||||
values,
|
||||
onChange,
|
||||
disabled,
|
||||
fieldErrors,
|
||||
}: SchemaFormProps) {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const { primary, advanced } = useMemo(() => {
|
||||
const primaryFields = fields.filter((f) => f.required);
|
||||
const advancedFields = fields.filter((f) => !f.required);
|
||||
return { primary: primaryFields, advanced: advancedFields };
|
||||
}, [fields]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{primary.map((field) => (
|
||||
<FieldRow
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={values[field.name]}
|
||||
onChange={(value) => onChange(field.name, value)}
|
||||
disabled={disabled}
|
||||
error={fieldErrors?.[field.name]}
|
||||
/>
|
||||
))}
|
||||
|
||||
{advanced.length > 0 && (
|
||||
<div className="rounded-md border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-sm font-medium hover:bg-muted/30 transition-colors"
|
||||
aria-expanded={showAdvanced}
|
||||
>
|
||||
<span>Advanced ({advanced.length})</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-5 border-t border-border/60 px-4 py-4">
|
||||
{advanced.map((field) => (
|
||||
<FieldRow
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={values[field.name]}
|
||||
onChange={(value) => onChange(field.name, value)}
|
||||
disabled={disabled}
|
||||
error={fieldErrors?.[field.name]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { PlaygroundIndex } from "./components/playground-index";
|
||||
|
||||
export default async function PlaygroundPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace_id: string }>;
|
||||
}) {
|
||||
const { workspace_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
<PlaygroundIndex workspaceId={Number(workspace_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { RunsTable } from "../components/runs-table";
|
||||
|
||||
export default async function PlaygroundRunsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace_id: string }>;
|
||||
}) {
|
||||
const { workspace_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<RunsTable workspaceId={Number(workspace_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
|
||||
export default function PurchaseCancelPage() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = String(params.search_space_id ?? "");
|
||||
const workspaceId = String(params.workspace_id ?? "");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
|
|
@ -30,10 +30,10 @@ export default function PurchaseCancelPage() {
|
|||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/buy-more`}>Back to Pricing</Link>
|
||||
<Link href={`/dashboard/${workspaceId}/buy-more`}>Back to Pricing</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Back to Dashboard</Link>
|
||||
<Link href={`/dashboard/${workspaceId}/new-chat`}>Back to Dashboard</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
|
@ -31,7 +31,7 @@ const MAX_POLL_ATTEMPTS = 15; // ~30s total before falling back to the still_pen
|
|||
export default function PurchaseSuccessPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const searchSpaceId = String(params.search_space_id ?? "");
|
||||
const workspaceId = String(params.workspace_id ?? "");
|
||||
const sessionId = searchParams.get("session_id");
|
||||
|
||||
const [state, setState] = useState<FinalizeState>(
|
||||
|
|
@ -132,10 +132,10 @@ export default function PurchaseSuccessPage() {
|
|||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Back to Dashboard</Link>
|
||||
<Link href={`/dashboard/${workspaceId}/new-chat`}>Back to Dashboard</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/buy-more`}>Buy credits</Link>
|
||||
<Link href={`/dashboard/${workspaceId}/buy-more`}>Buy credits</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
11
surfsense_web/app/dashboard/[workspace_id]/team/page.tsx
Normal file
11
surfsense_web/app/dashboard/[workspace_id]/team/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { TeamContent } from "./team-content";
|
||||
|
||||
export default async function TeamPage({ params }: { params: Promise<{ workspace_id: string }> }) {
|
||||
const { workspace_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full select-none space-y-6">
|
||||
<TeamContent workspaceId={Number(workspace_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ import type { Role } from "@/contracts/types/roles.types";
|
|||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
|
||||
import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -119,10 +119,10 @@ const PAGE_SIZE = 5;
|
|||
const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`);
|
||||
|
||||
interface TeamContentProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
}
|
||||
|
||||
export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
||||
export function TeamContent({ workspaceId }: TeamContentProps) {
|
||||
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
|
|
@ -139,59 +139,59 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
const handleRevokeInvite = useCallback(
|
||||
async (inviteId: number): Promise<boolean> => {
|
||||
const request: DeleteInviteRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
invite_id: inviteId,
|
||||
};
|
||||
await revokeInvite(request);
|
||||
return true;
|
||||
},
|
||||
[revokeInvite, searchSpaceId]
|
||||
[revokeInvite, workspaceId]
|
||||
);
|
||||
|
||||
const handleCreateInvite = useCallback(
|
||||
async (inviteData: CreateInviteRequest["data"]) => {
|
||||
const request: CreateInviteRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
data: inviteData,
|
||||
};
|
||||
return await createInvite(request);
|
||||
},
|
||||
[createInvite, searchSpaceId]
|
||||
[createInvite, workspaceId]
|
||||
);
|
||||
|
||||
const handleUpdateMember = useCallback(
|
||||
async (membershipId: number, roleId: number | null): Promise<Membership> => {
|
||||
const request: UpdateMembershipRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
membership_id: membershipId,
|
||||
data: { role_id: roleId },
|
||||
};
|
||||
return (await updateMember(request)) as Membership;
|
||||
},
|
||||
[updateMember, searchSpaceId]
|
||||
[updateMember, workspaceId]
|
||||
);
|
||||
|
||||
const handleRemoveMember = useCallback(
|
||||
async (membershipId: number) => {
|
||||
const request: DeleteMembershipRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
membership_id: membershipId,
|
||||
};
|
||||
await deleteMember(request);
|
||||
return true;
|
||||
},
|
||||
[deleteMember, searchSpaceId]
|
||||
[deleteMember, workspaceId]
|
||||
);
|
||||
|
||||
const { data: roles = [], isLoading: rolesLoading } = useQuery({
|
||||
queryKey: cacheKeys.roles.all(searchSpaceId.toString()),
|
||||
queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.roles.all(workspaceId.toString()),
|
||||
queryFn: () => rolesApiService.getRoles({ workspace_id: workspaceId }),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
|
||||
const { data: invites = [], isLoading: invitesLoading } = useQuery({
|
||||
queryKey: cacheKeys.invites.all(searchSpaceId.toString()),
|
||||
queryFn: () => invitesApiService.getInvites({ search_space_id: searchSpaceId }),
|
||||
queryKey: cacheKeys.invites.all(workspaceId.toString()),
|
||||
queryFn: () => invitesApiService.getInvites({ workspace_id: workspaceId }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
|
|
@ -229,9 +229,9 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
useEffect(() => {
|
||||
if (members.length > 0 && !membersLoading) {
|
||||
const ownerCount = members.filter((m) => m.is_owner).length;
|
||||
trackSearchSpaceUsersViewed(searchSpaceId, members.length, ownerCount);
|
||||
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
|
||||
}
|
||||
}, [members, membersLoading, searchSpaceId]);
|
||||
}, [members, membersLoading, workspaceId]);
|
||||
|
||||
if (accessLoading || membersLoading) {
|
||||
return (
|
||||
|
|
@ -345,7 +345,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
<CreateInviteDialog
|
||||
roles={roles}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
{invitesLoading ? (
|
||||
|
|
@ -398,7 +398,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
{owners.map((member) => (
|
||||
<MemberRow
|
||||
key={`member-${member.id}`}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
member={member}
|
||||
roles={roles}
|
||||
canManageRoles={canManageRoles}
|
||||
|
|
@ -410,7 +410,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
{paginatedMembers.map((member) => (
|
||||
<MemberRow
|
||||
key={`member-${member.id}`}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
member={member}
|
||||
roles={roles}
|
||||
canManageRoles={canManageRoles}
|
||||
|
|
@ -487,7 +487,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
}
|
||||
|
||||
function MemberRow({
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
member,
|
||||
roles,
|
||||
canManageRoles,
|
||||
|
|
@ -495,7 +495,7 @@ function MemberRow({
|
|||
onUpdateRole,
|
||||
onRemoveMember,
|
||||
}: {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
member: Membership;
|
||||
roles: Role[];
|
||||
canManageRoles: boolean;
|
||||
|
|
@ -581,7 +581,7 @@ function MemberRow({
|
|||
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove <span className="font-medium">{member.user_email}</span>{" "}
|
||||
from this search space. They will lose access to all resources.
|
||||
from this workspace. They will lose access to all resources.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
@ -599,7 +599,7 @@ function MemberRow({
|
|||
<DropdownMenuSeparator className="bg-popover-border" />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/${searchSpaceId}/search-space-settings/team-roles`)
|
||||
router.push(`/dashboard/${workspaceId}/workspace-settings/team-roles`)
|
||||
}
|
||||
>
|
||||
Manage Roles
|
||||
|
|
@ -617,11 +617,11 @@ function MemberRow({
|
|||
function CreateInviteDialog({
|
||||
roles,
|
||||
onCreateInvite,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
roles: Role[];
|
||||
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
|
@ -654,7 +654,7 @@ function CreateInviteDialog({
|
|||
setCreatedInvite(invite);
|
||||
|
||||
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
|
||||
trackSearchSpaceInviteSent(searchSpaceId, {
|
||||
trackWorkspaceInviteSent(workspaceId, {
|
||||
roleName,
|
||||
hasExpiry: !!expiresAt,
|
||||
hasMaxUses: !!maxUses,
|
||||
|
|
@ -709,7 +709,7 @@ function CreateInviteDialog({
|
|||
Invite Created!
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share this link to invite people to your search space.
|
||||
Share this link to invite people to your workspace.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 md:py-4">
|
||||
|
|
@ -748,7 +748,7 @@ function CreateInviteDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Invite Members</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a link to invite people to this search space.
|
||||
Create a link to invite people to this workspace.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 md:py-4">
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue