mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-02 19:55:18 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/whatsapp-gateway-integration
This commit is contained in:
commit
e3de7c4667
465 changed files with 29171 additions and 6994 deletions
|
|
@ -221,10 +221,7 @@ export default async function FreeHubPage() {
|
|||
<Separator className="my-12 max-w-4xl mx-auto" />
|
||||
|
||||
{/* In-content ad: above the model table */}
|
||||
<aside
|
||||
aria-label="Advertisement"
|
||||
className="max-w-4xl mx-auto mb-8 min-h-[100px]"
|
||||
>
|
||||
<aside aria-label="Advertisement" className="max-w-4xl mx-auto mb-8 min-h-[100px]">
|
||||
<AdUnit slot={ADSENSE_SLOTS.freeHubInContent} />
|
||||
</aside>
|
||||
|
||||
|
|
@ -353,10 +350,7 @@ export default async function FreeHubPage() {
|
|||
<Separator className="my-12 max-w-4xl mx-auto" />
|
||||
|
||||
{/* In-content ad: after CTA, before FAQ */}
|
||||
<aside
|
||||
aria-label="Advertisement"
|
||||
className="max-w-3xl mx-auto my-8 min-h-[100px]"
|
||||
>
|
||||
<aside aria-label="Advertisement" className="max-w-3xl mx-auto my-8 min-h-[100px]">
|
||||
<AdUnit slot={ADSENSE_SLOTS.freeHubBeforeFaq} />
|
||||
</aside>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import PricingBasic from "@/components/pricing/pricing-section";
|
|||
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing | SurfSense - Free AI Search Plans",
|
||||
title: "Pricing | SurfSense - Free AI Workspace, Automations & Agents",
|
||||
description:
|
||||
"Explore SurfSense plans and pricing. Start free with 500 pages & $5 in premium credits. Use ChatGPT, Claude AI, and premium AI models. Pay as you go at provider cost.",
|
||||
"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.",
|
||||
alternates: {
|
||||
canonical: "https://www.surfsense.com/pricing",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ export default function PrivacyPolicy() {
|
|||
</p>
|
||||
<p className="mt-4">
|
||||
By accessing or using the Service, you acknowledge that you have read and understood
|
||||
this Privacy Policy. If you do not agree with our policies and practices, do not use
|
||||
the Service. We may modify this policy from time to time; material changes will be
|
||||
reflected by updating the "Last updated" date above.
|
||||
this Privacy Policy. If you do not agree with our policies and practices, do not use the
|
||||
Service. We may modify this policy from time to time; material changes will be reflected
|
||||
by updating the "Last updated" date above.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -71,9 +71,9 @@ export default function PrivacyPolicy() {
|
|||
Notion, Confluence, GitHub, and others) under the scopes you authorize.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Billing Data</strong> includes information necessary to process payments
|
||||
(such as transaction identifiers and credit balances). Card details are handled by
|
||||
our payment processor and are not stored on our servers.
|
||||
<strong>Billing Data</strong> includes information necessary to process payments (such
|
||||
as transaction identifiers and credit balances). Card details are handled by our
|
||||
payment processor and are not stored on our servers.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Technical Data</strong> includes internet protocol (IP) address, browser type
|
||||
|
|
@ -126,8 +126,8 @@ export default function PrivacyPolicy() {
|
|||
incidents.
|
||||
</li>
|
||||
<li>
|
||||
To communicate with you about product updates, security notices, support requests,
|
||||
and (with your consent where required) marketing.
|
||||
To communicate with you about product updates, security notices, support requests, and
|
||||
(with your consent where required) marketing.
|
||||
</li>
|
||||
<li>
|
||||
To serve and measure advertising on pages where ads are shown (currently, our free
|
||||
|
|
@ -141,8 +141,8 @@ export default function PrivacyPolicy() {
|
|||
<h2 className="text-2xl font-semibold mb-4">4. Cookies and Tracking Technologies</h2>
|
||||
<p>
|
||||
We and our partners use cookies, local storage, and similar technologies to operate the
|
||||
Service, remember your preferences, measure usage, and serve advertising. The
|
||||
categories include:
|
||||
Service, remember your preferences, measure usage, and serve advertising. The categories
|
||||
include:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
<li>
|
||||
|
|
@ -179,9 +179,9 @@ export default function PrivacyPolicy() {
|
|||
</p>
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
<li>
|
||||
Google, as a third-party vendor, uses cookies (including the DoubleClick DART
|
||||
cookie) to serve ads to you based on your visits to our Service and other websites
|
||||
on the Internet.
|
||||
Google, as a third-party vendor, uses cookies (including the DoubleClick DART cookie)
|
||||
to serve ads to you based on your visits to our Service and other websites on the
|
||||
Internet.
|
||||
</li>
|
||||
<li>
|
||||
Google's use of advertising cookies enables it and its partners to serve ads to you
|
||||
|
|
@ -195,14 +195,12 @@ export default function PrivacyPolicy() {
|
|||
<a href="https://www.youronlinechoices.com/">youronlinechoices.com</a> (EU).
|
||||
</li>
|
||||
<li>
|
||||
For users in the European Economic Area, the United Kingdom, and Switzerland, we
|
||||
use a Google-certified Consent Management Platform to obtain your consent for
|
||||
personalized advertising before such cookies are set. You may change or withdraw
|
||||
your consent at any time through the consent banner.
|
||||
</li>
|
||||
<li>
|
||||
We do not knowingly serve personalized advertising to children. See Section 11.
|
||||
For users in the European Economic Area, the United Kingdom, and Switzerland, we use a
|
||||
Google-certified Consent Management Platform to obtain your consent for personalized
|
||||
advertising before such cookies are set. You may change or withdraw your consent at
|
||||
any time through the consent banner.
|
||||
</li>
|
||||
<li>We do not knowingly serve personalized advertising to children. See Section 11.</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
For more information about how Google uses data when you use our Service, see{" "}
|
||||
|
|
@ -217,8 +215,8 @@ export default function PrivacyPolicy() {
|
|||
<h2 className="text-2xl font-semibold mb-4">6. Data Security</h2>
|
||||
<p>
|
||||
We implement technical and organizational measures designed to protect your personal
|
||||
data against accidental loss, unauthorized access, alteration, and disclosure. Access
|
||||
to personal data is limited to personnel who need it to operate the Service.
|
||||
data against accidental loss, unauthorized access, alteration, and disclosure. Access to
|
||||
personal data is limited to personnel who need it to operate the Service.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
No system can be guaranteed to be fully secure. We cannot guarantee that personal data
|
||||
|
|
@ -232,10 +230,10 @@ export default function PrivacyPolicy() {
|
|||
<p>
|
||||
We retain personal data only for as long as necessary to provide the Service and to
|
||||
comply with our legal, accounting, and reporting obligations. Account data is retained
|
||||
for the life of your account; you can request deletion at any time. Aggregated data
|
||||
that no longer identifies you may be retained indefinitely for analytics and product
|
||||
improvement purposes. Anonymous chat sessions on our free pages are not retained in
|
||||
any user-linked database.
|
||||
for the life of your account; you can request deletion at any time. Aggregated data that
|
||||
no longer identifies you may be retained indefinitely for analytics and product
|
||||
improvement purposes. Anonymous chat sessions on our free pages are not retained in any
|
||||
user-linked database.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -243,8 +241,7 @@ export default function PrivacyPolicy() {
|
|||
<h2 className="text-2xl font-semibold mb-4">8. Third-Party Services</h2>
|
||||
<p>
|
||||
We rely on the following categories of third-party processors and providers to operate
|
||||
the Service. Each is bound by its own privacy policy, which we encourage you to
|
||||
review:
|
||||
the Service. Each is bound by its own privacy policy, which we encourage you to review:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
<li>
|
||||
|
|
@ -261,9 +258,9 @@ export default function PrivacyPolicy() {
|
|||
<strong>Advertising</strong>: Google AdSense (see Section 5).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Large language model providers</strong>: OpenAI, Anthropic, Google, and
|
||||
other LLM providers process the prompts and content you submit to the Service in
|
||||
order to generate responses.
|
||||
<strong>Large language model providers</strong>: OpenAI, Anthropic, Google, and other
|
||||
LLM providers process the prompts and content you submit to the Service in order to
|
||||
generate responses.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Integration providers</strong>: When you explicitly connect a third-party
|
||||
|
|
@ -278,9 +275,7 @@ export default function PrivacyPolicy() {
|
|||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
9. Your Legal Rights (Including GDPR)
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold mb-4">9. Your Legal Rights (Including GDPR)</h2>
|
||||
<p>
|
||||
Subject to applicable law, you have the following rights in relation to your personal
|
||||
data:
|
||||
|
|
@ -314,17 +309,17 @@ export default function PrivacyPolicy() {
|
|||
</p>
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
<li>
|
||||
The right to know what categories of personal information we have collected about
|
||||
you and how it is used and shared.
|
||||
The right to know what categories of personal information we have collected about you
|
||||
and how it is used and shared.
|
||||
</li>
|
||||
<li>The right to delete personal information we have collected from you.</li>
|
||||
<li>The right to correct inaccurate personal information.</li>
|
||||
<li>
|
||||
The right to opt out of the "sale" or "sharing" of personal information for
|
||||
cross-context behavioral advertising. We do not sell personal data; however,
|
||||
advertising cookies set by Google AdSense may be considered "sharing" under
|
||||
California law. To opt out, you can use the consent controls described in Section 5
|
||||
or enable a Global Privacy Control (GPC) signal in your browser, which we honor.
|
||||
advertising cookies set by Google AdSense may be considered "sharing" under California
|
||||
law. To opt out, you can use the consent controls described in Section 5 or enable a
|
||||
Global Privacy Control (GPC) signal in your browser, which we honor.
|
||||
</li>
|
||||
<li>The right not to be discriminated against for exercising your privacy rights.</li>
|
||||
</ul>
|
||||
|
|
@ -337,33 +332,32 @@ export default function PrivacyPolicy() {
|
|||
<h2 className="text-2xl font-semibold mb-4">11. Children's Privacy</h2>
|
||||
<p>
|
||||
The Service is not directed to children under 13 (or under 16 in the EEA, UK, and
|
||||
Switzerland). We do not knowingly collect personal data from children. If you believe
|
||||
a child has provided us with personal data, please contact us and we will take steps
|
||||
to delete it. We do not knowingly serve personalized advertising to children.
|
||||
Switzerland). We do not knowingly collect personal data from children. If you believe a
|
||||
child has provided us with personal data, please contact us and we will take steps to
|
||||
delete it. We do not knowingly serve personalized advertising to children.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">12. Changes to This Policy</h2>
|
||||
<p>
|
||||
We may update this Privacy Policy from time to time to reflect changes in our
|
||||
practices, technology, legal requirements, or for other operational reasons. When we
|
||||
make material changes, we will update the "Last updated" date at the top of this page
|
||||
and, where appropriate, provide additional notice (such as an in-product notification
|
||||
or email). Your continued use of the Service after the updated policy becomes
|
||||
effective constitutes your acceptance of the revised policy.
|
||||
We may update this Privacy Policy from time to time to reflect changes in our practices,
|
||||
technology, legal requirements, or for other operational reasons. When we make material
|
||||
changes, we will update the "Last updated" date at the top of this page and, where
|
||||
appropriate, provide additional notice (such as an in-product notification or email).
|
||||
Your continued use of the Service after the updated policy becomes effective constitutes
|
||||
your acceptance of the revised policy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">13. Contact Us</h2>
|
||||
<p>
|
||||
If you have questions about this Privacy Policy or our privacy practices, or if you
|
||||
want to exercise any of your rights, please contact us at:
|
||||
If you have questions about this Privacy Policy or our privacy practices, or if you want
|
||||
to exercise any of your rights, please contact us at:
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<strong>Email:</strong>{" "}
|
||||
<a href="mailto:rohan@surfsense.com">rohan@surfsense.com</a>
|
||||
<strong>Email:</strong> <a href="mailto:rohan@surfsense.com">rohan@surfsense.com</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { mustGetQuery } from "@rocicorp/zero";
|
||||
import { handleQueryRequest } from "@rocicorp/zero/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import type { Context } from "@/types/zero";
|
||||
import { queries } from "@/zero/queries";
|
||||
import { schema } from "@/zero/schema";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const backendURL = BACKEND_URL;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { useAutomation } from "@/hooks/use-automation";
|
||||
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
|
||||
import { AutomationDefinitionSection } from "./components/automation-definition-section";
|
||||
import { AutomationDetailHeader } from "./components/automation-detail-header";
|
||||
import { AutomationDetailLoading } from "./components/automation-detail-loading";
|
||||
import { AutomationNotFound } from "./components/automation-not-found";
|
||||
import { AutomationRunsSection } from "./components/automation-runs-section";
|
||||
import { AutomationTriggersSection } from "./components/automation-triggers-section";
|
||||
|
||||
interface AutomationDetailContentProps {
|
||||
searchSpaceId: number;
|
||||
automationId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client orchestrator for one automation's detail view. Branches:
|
||||
* - permissions loading → skeleton
|
||||
* - no read permission → access denied panel
|
||||
* - bad id (NaN) → not-found panel
|
||||
* - detail fetching → skeleton
|
||||
* - detail error / null → not-found panel (we don't distinguish 404
|
||||
* from 403 in the UI)
|
||||
* - detail loaded → header + definition + triggers
|
||||
*
|
||||
* Each child component is gated independently on the relevant permission
|
||||
* so the orchestrator stays thin.
|
||||
*/
|
||||
export function AutomationDetailContent({
|
||||
searchSpaceId,
|
||||
automationId,
|
||||
}: AutomationDetailContentProps) {
|
||||
const perms = useAutomationPermissions();
|
||||
const validId = Number.isInteger(automationId) && automationId > 0;
|
||||
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
|
||||
|
||||
if (perms.loading) {
|
||||
return <AutomationDetailLoading />;
|
||||
}
|
||||
|
||||
if (!perms.canRead) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!validId) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <AutomationDetailLoading />;
|
||||
}
|
||||
|
||||
if (error || !automation) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutomationDetailHeader
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
canUpdate={perms.canUpdate}
|
||||
canDelete={perms.canDelete}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 min-w-0 lg:col-span-2">
|
||||
<AutomationDefinitionSection definition={automation.definition} />
|
||||
<AutomationRunsSection automationId={automation.id} />
|
||||
</div>
|
||||
<div className="space-y-6 min-w-0">
|
||||
<AutomationTriggersSection
|
||||
triggers={automation.triggers}
|
||||
automationId={automation.id}
|
||||
canUpdate={perms.canUpdate}
|
||||
canDelete={perms.canDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
import { ListOrdered, Settings2, Tag, Target } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { AutomationDefinition } from "@/contracts/types/automation.types";
|
||||
import { ExecutionSummary } from "./execution-summary";
|
||||
import { InputsSchemaPreview } from "./inputs-schema-preview";
|
||||
import { PlanStepCard } from "./plan-step-card";
|
||||
|
||||
interface AutomationDefinitionSectionProps {
|
||||
definition: AutomationDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Definition card. Read view; editing happens on the sibling /edit
|
||||
* route (Edit button in the header). Layout is top-down:
|
||||
* goal → tags → execution defaults → inputs schema (if any) → plan
|
||||
*
|
||||
* The schema_version is rendered as a small badge next to the section
|
||||
* title so it's discoverable but doesn't fight for attention.
|
||||
*/
|
||||
export function AutomationDefinitionSection({ definition }: AutomationDefinitionSectionProps) {
|
||||
const hasTags = definition.metadata.tags.length > 0;
|
||||
const hasInputs = !!definition.inputs;
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-base font-semibold">Definition</CardTitle>
|
||||
<span className="text-xs font-mono text-muted-foreground border border-border/60 rounded px-1.5 py-0.5">
|
||||
v{definition.schema_version}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{definition.goal && (
|
||||
<Field icon={Target} label="Goal">
|
||||
<p className="text-sm text-foreground">{definition.goal}</p>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{hasTags && (
|
||||
<Field icon={Tag} label="Tags">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{definition.metadata.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field icon={Settings2} label="Execution defaults">
|
||||
<ExecutionSummary execution={definition.execution} />
|
||||
</Field>
|
||||
|
||||
{hasInputs && (
|
||||
<Field icon={Settings2} label="Inputs schema">
|
||||
{definition.inputs && <InputsSchemaPreview inputs={definition.inputs} />}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
icon={ListOrdered}
|
||||
label={`Plan · ${definition.plan.length} step${definition.plan.length === 1 ? "" : "s"}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{definition.plan.map((step, idx) => (
|
||||
<PlanStepCard key={step.step_id} step={step} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
icon: Icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof Target;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Pause, Pencil, Play, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { Automation } from "@/contracts/types/automation.types";
|
||||
import { AutomationStatusBadge } from "../../components/automation-status-badge";
|
||||
import { DeleteAutomationDialog } from "../../components/delete-automation-dialog";
|
||||
|
||||
interface AutomationDetailHeaderProps {
|
||||
automation: Automation;
|
||||
searchSpaceId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title bar for the detail page: back link, name, status badge,
|
||||
* description, and the two destructive-ish primary actions (pause /
|
||||
* resume + delete). Same mutation atoms as the list-row actions to
|
||||
* keep caches coherent.
|
||||
*
|
||||
* Archived automations hide the pause/resume toggle (we don't unarchive
|
||||
* here — that flow comes later if we need it).
|
||||
*/
|
||||
export function AutomationDetailHeader({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationDetailHeaderProps) {
|
||||
const router = useRouter();
|
||||
const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue(
|
||||
updateAutomationMutationAtom
|
||||
);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const canToggle = canUpdate && automation.status !== "archived";
|
||||
const nextStatus = automation.status === "active" ? "paused" : "active";
|
||||
const pauseLabel = automation.status === "active" ? "Pause" : "Resume";
|
||||
const PauseIcon = automation.status === "active" ? Pause : Play;
|
||||
|
||||
const handleDeleted = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/automations`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
async function handleTogglePause() {
|
||||
await updateAutomation({
|
||||
automationId: automation.id,
|
||||
patch: { status: nextStatus },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Back to automations
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground break-words">
|
||||
{automation.name}
|
||||
</h1>
|
||||
<AutomationStatusBadge status={automation.status} />
|
||||
</div>
|
||||
{automation.description && (
|
||||
<p className="text-sm text-muted-foreground max-w-3xl">{automation.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{canUpdate && (
|
||||
<Button asChild type="button" variant="outline" size="sm">
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/${automation.id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{canToggle && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTogglePause}
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? (
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
) : (
|
||||
<PauseIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{pauseLabel}
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canDelete && (
|
||||
<DeleteAutomationDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
automationId={automation.id}
|
||||
automationName={automation.name}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Skeleton for the detail page. Mirrors the loaded view's main/sidebar
|
||||
* grid (Definition + Runs on the left, Triggers on the right) so layout
|
||||
* doesn't reflow when data arrives.
|
||||
*/
|
||||
export function AutomationDetailLoading() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-64" />
|
||||
<Skeleton className="h-5 w-16 rounded-md" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 min-w-0 lg:col-span-2">
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-6 min-w-0">
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
import { ArrowLeft, FileWarning } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationNotFoundProps {
|
||||
searchSpaceId: number;
|
||||
error?: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered when the detail fetch fails (404 / 403 / network) or the id
|
||||
* is not a number. We don't distinguish "missing" from "forbidden" in the
|
||||
* UI on purpose — leaking that an id exists you can't read is worse than
|
||||
* a vague message.
|
||||
*/
|
||||
export function AutomationNotFound({ searchSpaceId, 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 />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">Automation not found</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
This automation doesn't exist or you don't have access to it.
|
||||
{error?.message ? ` (${error.message})` : null}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-6">
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to automations
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
import { History } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useAutomationRuns } from "@/hooks/use-automation-runs";
|
||||
import { RunRow } from "./run-row";
|
||||
import { RunsLoading } from "./runs-loading";
|
||||
|
||||
interface AutomationRunsSectionProps {
|
||||
automationId: number;
|
||||
}
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Run history card. Shows the most recent ``LIMIT`` runs; pagination is
|
||||
* intentionally deferred — for the foreseeable v1 surface (one-trigger
|
||||
* automations firing daily), 20 covers ~3 weeks of history which is
|
||||
* enough to tell whether things are working. Real "load more" lands if
|
||||
* we see usage spike past that.
|
||||
*/
|
||||
export function AutomationRunsSection({ automationId }: AutomationRunsSectionProps) {
|
||||
const { data, isLoading, error } = useAutomationRuns(automationId, { limit: LIMIT });
|
||||
const runs = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-semibold inline-flex items-center gap-2">
|
||||
<History className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
Recent runs
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Most recent first. Click a row to inspect step results, output and artifacts.
|
||||
</p>
|
||||
</div>
|
||||
{!isLoading && !error && data && (
|
||||
<span className="text-xs text-muted-foreground">{data.total} total</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<RunsLoading />
|
||||
) : error ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Couldn't load runs{error.message ? `: ${error.message}` : "."}
|
||||
</p>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center">
|
||||
<History className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden />
|
||||
<p className="mt-2 text-sm font-medium text-foreground">No runs yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This automation hasn't fired. Once a trigger fires (or you invoke it manually), runs
|
||||
will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} automationId={automationId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Trigger } from "@/contracts/types/automation.types";
|
||||
import { TriggerCard } from "./trigger-card";
|
||||
|
||||
interface AutomationTriggersSectionProps {
|
||||
triggers: Trigger[];
|
||||
automationId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Triggers card. Lists each attached trigger with its own enable
|
||||
* toggle and remove button. v1 attaches triggers at automation-creation
|
||||
* time only; there is no in-place "add trigger" affordance here.
|
||||
*/
|
||||
export function AutomationTriggersSection({
|
||||
triggers,
|
||||
automationId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationTriggersSectionProps) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold">Triggers</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When this automation fires. v1 supports scheduled triggers only.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{triggers.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center">
|
||||
<CalendarClock className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden />
|
||||
<p className="mt-2 text-sm font-medium text-foreground">No triggers attached</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This automation can still be invoked, but nothing will fire it on its own.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{triggers.map((trigger) => (
|
||||
<TriggerCard
|
||||
key={trigger.id}
|
||||
trigger={trigger}
|
||||
automationId={automationId}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { removeTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
interface DeleteTriggerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
automationId: number;
|
||||
triggerId: number;
|
||||
triggerLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm + detach one trigger from its automation. The automation itself
|
||||
* is untouched; only this trigger row is removed. The mutation atom
|
||||
* invalidates the parent automation detail so the page rerenders.
|
||||
*/
|
||||
export function DeleteTriggerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
automationId,
|
||||
triggerId,
|
||||
triggerLabel,
|
||||
}: DeleteTriggerDialogProps) {
|
||||
const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await removeTrigger({ automationId, triggerId });
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove this trigger?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span className="font-medium text-foreground">{triggerLabel}</span> will be detached.
|
||||
The automation itself stays, but it won't fire on this trigger anymore.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={submitting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Spinner size="xs" />
|
||||
Removing…
|
||||
</span>
|
||||
) : (
|
||||
"Remove"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
import type { Execution } from "@/contracts/types/automation.types";
|
||||
|
||||
interface ExecutionSummaryProps {
|
||||
execution: Execution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact view of an automation's execution defaults (wall-clock cap,
|
||||
* retries, backoff, concurrency, on_failure presence). Per-step overrides
|
||||
* are shown inside each PlanStepCard, not here.
|
||||
*/
|
||||
export function ExecutionSummary({ execution }: ExecutionSummaryProps) {
|
||||
return (
|
||||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-xs">
|
||||
<Item label="Timeout" value={`${execution.timeout_seconds}s`} />
|
||||
<Item label="Max retries" value={String(execution.max_retries)} />
|
||||
<Item label="Retry backoff" value={execution.retry_backoff} />
|
||||
<Item label="Concurrency" value={execution.concurrency} />
|
||||
{execution.on_failure.length > 0 && (
|
||||
<Item
|
||||
label="On failure"
|
||||
value={`${execution.on_failure.length} step${execution.on_failure.length === 1 ? "" : "s"}`}
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="text-foreground font-medium truncate">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import type { Inputs } from "@/contracts/types/automation.types";
|
||||
|
||||
interface InputsSchemaPreviewProps {
|
||||
inputs: Inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only preview of an automation's accepted-inputs schema. Most
|
||||
* automations don't define inputs (defaults are baked into the trigger's
|
||||
* static_inputs), so the parent skips rendering this card when ``inputs``
|
||||
* is null.
|
||||
*/
|
||||
export function InputsSchemaPreview({ inputs }: InputsSchemaPreviewProps) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 max-h-72 overflow-auto">
|
||||
<JsonView src={inputs.schema} collapsed={2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
import { ArrowRightCircle, GitCommitHorizontal } from "lucide-react";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import type { PlanStep } from "@/contracts/types/automation.types";
|
||||
|
||||
interface PlanStepCardProps {
|
||||
step: PlanStep;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of one plan step. Renders the step_id + action prominently,
|
||||
* then a definition list of the per-step knobs, and finally the params as
|
||||
* formatted JSON. Editable mode is out of scope here — definition edits live
|
||||
* on the (future) raw-JSON path.
|
||||
*/
|
||||
export function PlanStepCard({ step, index }: PlanStepCardProps) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/60 bg-muted/30">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">{step.step_id}</span>
|
||||
<ArrowRightCircle className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
<span className="text-xs font-mono text-muted-foreground">{step.action}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{(step.when ||
|
||||
step.output_as ||
|
||||
step.max_retries != null ||
|
||||
step.timeout_seconds != null) && (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
|
||||
{step.when && (
|
||||
<DefRow label="When" value={<code className="font-mono">{step.when}</code>} />
|
||||
)}
|
||||
{step.output_as && (
|
||||
<DefRow
|
||||
label="Output as"
|
||||
value={<code className="font-mono">{step.output_as}</code>}
|
||||
/>
|
||||
)}
|
||||
{step.max_retries != null && (
|
||||
<DefRow label="Max retries" value={String(step.max_retries)} />
|
||||
)}
|
||||
{step.timeout_seconds != null && (
|
||||
<DefRow label="Timeout" value={`${step.timeout_seconds}s`} />
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1.5">
|
||||
<GitCommitHorizontal className="h-3.5 w-3.5" aria-hidden />
|
||||
Params
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 overflow-auto">
|
||||
<JsonView src={step.params} collapsed={1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<dt className="text-muted-foreground shrink-0">{label}:</dt>
|
||||
<dd className="text-foreground min-w-0 truncate">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
"use client";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
FileOutput,
|
||||
GitCommitHorizontal,
|
||||
Package,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { RunStepResult } from "@/contracts/types/automation.types";
|
||||
import { useAutomationRun } from "@/hooks/use-automation-runs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RunStepResultCard } from "./run-step-result-card";
|
||||
|
||||
interface RunDetailsPanelProps {
|
||||
automationId: number;
|
||||
runId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded view of a single run. Fetches lazily — the parent only renders
|
||||
* this once the row is opened, so the list view stays cheap.
|
||||
*
|
||||
* We surface the run outcome readably: a run-level error first (when
|
||||
* present), then per-step cards that render the agent's markdown
|
||||
* ``final_message`` directly, and finally the structural artifacts/inputs.
|
||||
* The full ``definition_snapshot`` is omitted because it usually mirrors the
|
||||
* live definition — surfacing it would dominate the panel without informing
|
||||
* what the user is trying to learn ("did this work? what did it do?").
|
||||
*/
|
||||
export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
|
||||
const { data: run, isLoading, error } = useAutomationRun(automationId, runId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 bg-muted/20 p-4">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !run) {
|
||||
return (
|
||||
<div className="border-t border-border/60 bg-muted/20 p-4 text-xs text-muted-foreground">
|
||||
Couldn't load run details{error?.message ? `: ${error.message}` : "."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const runError = run.error && Object.keys(run.error).length > 0 ? run.error : null;
|
||||
const hasOutput = run.output && Object.keys(run.output).length > 0;
|
||||
const hasInputs = Object.keys(run.inputs ?? {}).length > 0;
|
||||
const steps = run.step_results as RunStepResult[];
|
||||
const hasDiagnostics = run.artifacts.length > 0 || hasInputs;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border/60 bg-muted/20 p-4">
|
||||
{runError ? <RunErrorSection error={runError} /> : null}
|
||||
|
||||
{hasOutput ? (
|
||||
<Section icon={FileOutput} label="Output">
|
||||
<JsonBlock value={run.output} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section icon={GitCommitHorizontal} label={`Step results · ${steps.length}`}>
|
||||
{steps.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No steps recorded.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{steps.map((step, index) => (
|
||||
<RunStepResultCard key={step.step_id ?? index} step={step} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{hasDiagnostics ? <Separator className="bg-border/60" /> : null}
|
||||
|
||||
{run.artifacts.length > 0 ? (
|
||||
<Section icon={Package} label={`Artifacts · ${run.artifacts.length}`}>
|
||||
<JsonBlock value={run.artifacts} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{hasInputs ? (
|
||||
<Section icon={Settings2} label="Resolved inputs">
|
||||
<JsonBlock value={run.inputs} />
|
||||
</Section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run-level error: a readable destructive alert when a message is present,
|
||||
* with the full structured error available behind a raw toggle.
|
||||
*/
|
||||
function RunErrorSection({ error }: { error: Record<string, unknown> }) {
|
||||
const [rawOpen, setRawOpen] = useState(false);
|
||||
const message = typeof error.message === "string" ? error.message : null;
|
||||
const type = typeof error.type === "string" ? error.type : "Run failed";
|
||||
|
||||
return (
|
||||
<Section icon={AlertCircle} label="Error" tone="destructive">
|
||||
{message ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertTitle>{type}</AlertTitle>
|
||||
<AlertDescription className="wrap-break-word">{message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Collapsible open={rawOpen} onOpenChange={setRawOpen} className="mt-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-fit px-2 text-xs text-muted-foreground"
|
||||
aria-expanded={rawOpen}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"transition-transform motion-reduce:transition-none",
|
||||
rawOpen && "rotate-180"
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{rawOpen ? "Hide raw" : "View raw"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="mt-2 max-h-64 rounded-md bg-muted/40 px-3 py-2">
|
||||
<JsonView src={error} collapsed={1} />
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon: Icon,
|
||||
label,
|
||||
tone = "default",
|
||||
children,
|
||||
}: {
|
||||
icon: typeof AlertCircle;
|
||||
label: string;
|
||||
tone?: "default" | "destructive";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider",
|
||||
tone === "destructive" ? "text-destructive" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" aria-hidden />
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonBlock({ value }: { value: unknown }) {
|
||||
return (
|
||||
<ScrollArea className="max-h-64 rounded-md bg-muted/40 px-3 py-2">
|
||||
<JsonView src={value} collapsed={1} />
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
import { ChevronDown, ChevronRight, Hand } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { RunSummary } from "@/contracts/types/automation.types";
|
||||
import { formatDuration } from "@/lib/automations/run-duration";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { RunDetailsPanel } from "./run-details-panel";
|
||||
import { RunStatusBadge } from "./run-status-badge";
|
||||
|
||||
interface RunRowProps {
|
||||
run: RunSummary;
|
||||
automationId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One run row. Click to expand → fetches the full run and shows the
|
||||
* details panel inline. State is local to each row so multiple panels
|
||||
* can be open at once (or none).
|
||||
*/
|
||||
export function RunRow({ run, automationId }: RunRowProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const duration = formatDuration(run.started_at, run.finished_at);
|
||||
const startedLabel = run.started_at
|
||||
? formatRelativeDate(run.started_at)
|
||||
: formatRelativeDate(run.created_at);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{open ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
|
||||
)}
|
||||
<RunStatusBadge status={run.status} />
|
||||
<span className="text-xs text-muted-foreground truncate">{startedLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 text-xs text-muted-foreground">
|
||||
{duration && <span className="font-mono">{duration}</span>}
|
||||
<TriggerSource triggerId={run.trigger_id ?? null} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && <RunDetailsPanel automationId={automationId} runId={run.id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerSource({ triggerId }: { triggerId: number | null }) {
|
||||
if (triggerId == null) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Hand className="h-3 w-3" aria-hidden />
|
||||
Manual
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>via trigger #{triggerId}</span>;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
import { AlertCircle, CheckCircle2, Clock, Loader2, TimerOff, XCircle } from "lucide-react";
|
||||
import type { RunStatus } from "@/contracts/types/automation.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_STYLES: Record<
|
||||
RunStatus,
|
||||
{ label: string; icon: typeof CheckCircle2; classes: string; spin?: boolean }
|
||||
> = {
|
||||
pending: {
|
||||
label: "Pending",
|
||||
icon: Clock,
|
||||
classes: "bg-muted text-muted-foreground border-border/60",
|
||||
},
|
||||
running: {
|
||||
label: "Running",
|
||||
icon: Loader2,
|
||||
classes: "bg-blue-500/10 text-blue-600 border-blue-500/20",
|
||||
spin: true,
|
||||
},
|
||||
succeeded: {
|
||||
label: "Succeeded",
|
||||
icon: CheckCircle2,
|
||||
classes: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
icon: XCircle,
|
||||
classes: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
},
|
||||
cancelled: {
|
||||
label: "Cancelled",
|
||||
icon: AlertCircle,
|
||||
classes: "bg-muted text-muted-foreground border-border/60",
|
||||
},
|
||||
timed_out: {
|
||||
label: "Timed out",
|
||||
icon: TimerOff,
|
||||
classes: "bg-amber-500/10 text-amber-600 border-amber-500/20",
|
||||
},
|
||||
};
|
||||
|
||||
export function RunStatusBadge({ status, className }: { status: RunStatus; className?: string }) {
|
||||
const { label, icon: Icon, classes, spin } = STATUS_STYLES[status];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
classes,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-3 w-3", spin && "animate-spin")} aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
import { CheckCircle2, ChevronDown, MinusCircle, XCircle } from "lucide-react";
|
||||
import { memo, useState } from "react";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { RunStepResult } from "@/contracts/types/automation.types";
|
||||
import { formatDuration } from "@/lib/automations/run-duration";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BadgeVariant = React.ComponentProps<typeof Badge>["variant"];
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
string,
|
||||
{ label: string; variant: BadgeVariant; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
succeeded: { label: "Succeeded", variant: "outline", icon: CheckCircle2 },
|
||||
failed: { label: "Failed", variant: "destructive", icon: XCircle },
|
||||
skipped: { label: "Skipped", variant: "secondary", icon: MinusCircle },
|
||||
};
|
||||
|
||||
function StepStatusBadge({ status }: { status: string }) {
|
||||
const meta = STATUS_BADGE[status] ?? {
|
||||
label: status,
|
||||
variant: "outline" as const,
|
||||
icon: MinusCircle,
|
||||
};
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Badge variant={meta.variant} className="shrink-0">
|
||||
<Icon aria-hidden />
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One step from a run's ``step_results``. Surfaces the agent's markdown
|
||||
* ``final_message`` first-class (rendered, not raw), shows step errors as a
|
||||
* readable alert, and keeps the full structured payload behind a "View raw"
|
||||
* collapsible escape hatch.
|
||||
*/
|
||||
export const RunStepResultCard = memo(function RunStepResultCard({
|
||||
step,
|
||||
}: {
|
||||
step: RunStepResult;
|
||||
}) {
|
||||
const [rawOpen, setRawOpen] = useState(false);
|
||||
|
||||
const duration = formatDuration(step.started_at, step.finished_at);
|
||||
const attempts = step.attempts ?? 0;
|
||||
const finalMessage =
|
||||
typeof step.result?.final_message === "string" ? step.result.final_message : null;
|
||||
const errorMessage = step.error?.message;
|
||||
const hasMeta = Boolean(duration) || attempts > 1;
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 shadow-none">
|
||||
<CardHeader className="gap-2 space-y-0 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs font-medium">{step.action}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{step.step_id}</span>
|
||||
</div>
|
||||
<StepStatusBadge status={step.status} />
|
||||
</div>
|
||||
{hasMeta ? (
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground tabular-nums">
|
||||
{duration ? <span>{duration}</span> : null}
|
||||
{attempts > 1 ? <span>{attempts} attempts</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3 p-3 pt-0">
|
||||
{errorMessage ? (
|
||||
<Alert variant="destructive">
|
||||
<XCircle aria-hidden />
|
||||
<AlertTitle>{step.error?.type ?? "Error"}</AlertTitle>
|
||||
<AlertDescription className="wrap-break-word">{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{finalMessage ? (
|
||||
<div className="min-w-0 wrap-break-word rounded-md border border-border/60 bg-background px-3 py-2">
|
||||
<MarkdownViewer content={finalMessage} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Collapsible open={rawOpen} onOpenChange={setRawOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-fit px-2 text-xs text-muted-foreground"
|
||||
aria-expanded={rawOpen}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"transition-transform motion-reduce:transition-none",
|
||||
rawOpen && "rotate-180"
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{rawOpen ? "Hide raw" : "View raw"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="mt-2 max-h-64 rounded-md bg-muted/40 px-3 py-2">
|
||||
<JsonView src={step} collapsed={1} />
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const ROW_KEYS = ["a", "b", "c"] as const;
|
||||
|
||||
export function RunsLoading() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{ROW_KEYS.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/60 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-5 w-20 rounded-md" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, CalendarClock, Clock, Pencil, Save, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { type Trigger, triggerUpdateRequest } from "@/contracts/types/automation.types";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
import { formatRelativeDate, formatRelativeFutureDate } from "@/lib/format-date";
|
||||
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
||||
|
||||
interface TriggerCardProps {
|
||||
trigger: Trigger;
|
||||
automationId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
interface TriggerDraft {
|
||||
params: Record<string, unknown>;
|
||||
static_inputs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function draftFromTrigger(trigger: Trigger): TriggerDraft {
|
||||
return {
|
||||
params: trigger.params,
|
||||
static_inputs: trigger.static_inputs ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger row in the Triggers section of the detail page. Renders:
|
||||
* - type icon + human-readable schedule + timezone
|
||||
* - last_fired_at / next_fire_at hints
|
||||
* - static_inputs as formatted JSON (when present)
|
||||
* - enable toggle + remove button + inline edit (each gated independently)
|
||||
*
|
||||
* Inline edit covers ``params`` and ``static_inputs`` — the two fields the
|
||||
* backend ``PATCH /triggers/[id]`` endpoint accepts beyond ``enabled``.
|
||||
* ``enabled`` stays on the Switch so the two surfaces don't fight.
|
||||
*/
|
||||
export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) {
|
||||
const { mutateAsync: updateTrigger, isPending: updating } =
|
||||
useAtomValue(updateTriggerMutationAtom);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<TriggerDraft>(() => draftFromTrigger(trigger));
|
||||
const [issues, setIssues] = useState<string[]>([]);
|
||||
|
||||
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
|
||||
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
|
||||
const human = cron ? describeCron(cron) : trigger.type;
|
||||
const triggerLabel = cron ? `${human} · ${tz}` : trigger.type;
|
||||
const hasStaticInputs = Object.keys(trigger.static_inputs ?? {}).length > 0;
|
||||
|
||||
async function handleToggle(checked: boolean) {
|
||||
await updateTrigger({
|
||||
automationId,
|
||||
triggerId: trigger.id,
|
||||
patch: { enabled: checked },
|
||||
});
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
setDraft(draftFromTrigger(trigger));
|
||||
setIssues([]);
|
||||
setIsEditing(true);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setIsEditing(false);
|
||||
setIssues([]);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
setIssues([]);
|
||||
const result = triggerUpdateRequest.safeParse(draft);
|
||||
if (!result.success) {
|
||||
setIssues(
|
||||
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateTrigger({
|
||||
automationId,
|
||||
triggerId: trigger.id,
|
||||
patch: result.data,
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
setIssues([(err as Error).message ?? "Update failed"]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border border-border/60 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 border-b border-border/60">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<CalendarClock className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">{human}</span>
|
||||
<span className="text-muted-foreground">· {tz}</span>
|
||||
</div>
|
||||
{cron && <code className="text-xs font-mono text-muted-foreground">{cron}</code>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{canUpdate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{trigger.enabled ? "Enabled" : "Off"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={trigger.enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={updating || isEditing}
|
||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canUpdate && !isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={startEdit}
|
||||
aria-label="Edit trigger"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={isEditing}
|
||||
aria-label="Remove trigger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-3 text-xs">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
|
||||
<JsonView
|
||||
src={draft}
|
||||
editable
|
||||
onChange={(next) => setDraft(next as TriggerDraft)}
|
||||
collapsed={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 font-medium text-destructive mb-1">
|
||||
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
|
||||
</div>
|
||||
<ul className="space-y-0.5 text-destructive list-disc list-inside">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelEdit}
|
||||
disabled={updating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={saveEdit} disabled={updating}>
|
||||
{updating ? (
|
||||
<Spinner size="xs" className="mr-1.5" />
|
||||
) : (
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(trigger.last_fired_at || trigger.next_fire_at) && (
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1">
|
||||
{trigger.next_fire_at && (
|
||||
<TimeRow
|
||||
label="Next fire"
|
||||
iso={trigger.next_fire_at}
|
||||
tense="future"
|
||||
highlight={trigger.enabled}
|
||||
/>
|
||||
)}
|
||||
{trigger.last_fired_at && (
|
||||
<TimeRow label="Last fired" iso={trigger.last_fired_at} tense="past" />
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{hasStaticInputs && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">Static inputs</div>
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 overflow-auto">
|
||||
<JsonView src={trigger.static_inputs} collapsed={1} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canDelete && (
|
||||
<DeleteTriggerDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
automationId={automationId}
|
||||
triggerId={trigger.id}
|
||||
triggerLabel={triggerLabel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeRow({
|
||||
label,
|
||||
iso,
|
||||
tense,
|
||||
highlight = false,
|
||||
}: {
|
||||
label: string;
|
||||
iso: string;
|
||||
tense: "past" | "future";
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
const formatted = tense === "future" ? formatRelativeFutureDate(iso) : formatRelativeDate(iso);
|
||||
return (
|
||||
<>
|
||||
<dt className="text-muted-foreground inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<Clock className="h-3 w-3" aria-hidden />
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={
|
||||
highlight
|
||||
? "text-foreground font-medium min-w-0 truncate"
|
||||
: "text-muted-foreground min-w-0 truncate"
|
||||
}
|
||||
title={new Date(iso).toLocaleString()}
|
||||
>
|
||||
{formatted}
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { useAutomation } from "@/hooks/use-automation";
|
||||
import { AutomationBuilderForm } from "../../components/builder/automation-builder-form";
|
||||
import { useAutomationPermissions } from "../../hooks/use-automation-permissions";
|
||||
import { AutomationDetailLoading } from "../components/automation-detail-loading";
|
||||
import { AutomationNotFound } from "../components/automation-not-found";
|
||||
import { AutomationEditHeader } from "./components/automation-edit-header";
|
||||
|
||||
interface AutomationEditContentProps {
|
||||
searchSpaceId: number;
|
||||
automationId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client orchestrator for the edit route. Mirrors detail-content's branch
|
||||
* 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) {
|
||||
const perms = useAutomationPermissions();
|
||||
const validId = Number.isInteger(automationId) && automationId > 0;
|
||||
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
|
||||
|
||||
if (perms.loading) {
|
||||
return <AutomationDetailLoading />;
|
||||
}
|
||||
|
||||
if (!perms.canUpdate) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!validId) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <AutomationDetailLoading />;
|
||||
}
|
||||
|
||||
if (error || !automation) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutomationEditHeader automation={automation} searchSpaceId={searchSpaceId} />
|
||||
<AutomationBuilderForm mode="edit" searchSpaceId={searchSpaceId} automation={automation} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Automation } from "@/contracts/types/automation.types";
|
||||
|
||||
interface AutomationEditHeaderProps {
|
||||
automation: Automation;
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEditHeaderProps) {
|
||||
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
|
||||
<Link href={detailHref} className="text-xs text-muted-foreground">
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Back to automation
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground wrap-break-word">
|
||||
Edit automation
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{automation.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { AutomationEditContent } from "./automation-edit-content";
|
||||
|
||||
export default async function AutomationEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string; automation_id: string }>;
|
||||
}) {
|
||||
const { search_space_id, automation_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<AutomationEditContent
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
automationId={Number(automation_id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { AutomationDetailContent } from "./automation-detail-content";
|
||||
|
||||
export default async function AutomationDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string; automation_id: string }>;
|
||||
}) {
|
||||
const { search_space_id, automation_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<AutomationDetailContent
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
automationId={Number(automation_id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { useAutomations } from "@/hooks/use-automations";
|
||||
import { AutomationsEmptyState } from "./components/automations-empty-state";
|
||||
import { AutomationsHeader } from "./components/automations-header";
|
||||
import { AutomationsTable } from "./components/automations-table";
|
||||
import { useAutomationPermissions } from "./hooks/use-automation-permissions";
|
||||
|
||||
interface AutomationsContentProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client orchestrator for the automations list page. Pulls the active
|
||||
* search space'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) {
|
||||
const { automations, total, loading, error } = useAutomations();
|
||||
const perms = useAutomationPermissions();
|
||||
|
||||
if (perms.loading) {
|
||||
// Permissions gate the entire page; defer everything until we know.
|
||||
return (
|
||||
<>
|
||||
<AutomationsHeader searchSpaceId={searchSpaceId} total={0} loading canCreate={false} />
|
||||
<AutomationsTable
|
||||
automations={[]}
|
||||
searchSpaceId={searchSpaceId}
|
||||
loading
|
||||
canUpdate={false}
|
||||
canDelete={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!perms.canRead) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<AutomationsHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
total={0}
|
||||
loading={false}
|
||||
canCreate={perms.canCreate}
|
||||
/>
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-8 text-center">
|
||||
<p className="text-sm text-destructive">Couldn't load automations. {error.message}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loading && automations.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<AutomationsHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
total={0}
|
||||
loading={false}
|
||||
canCreate={perms.canCreate}
|
||||
showCreateCta={false}
|
||||
/>
|
||||
<AutomationsEmptyState searchSpaceId={searchSpaceId} canCreate={perms.canCreate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutomationsHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
total={total}
|
||||
loading={loading}
|
||||
canCreate={perms.canCreate}
|
||||
/>
|
||||
<AutomationsTable
|
||||
automations={automations}
|
||||
searchSpaceId={searchSpaceId}
|
||||
loading={loading}
|
||||
canUpdate={perms.canUpdate}
|
||||
canDelete={perms.canDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { MoreHorizontal, Pause, Play, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { AutomationSummary } from "@/contracts/types/automation.types";
|
||||
import { DeleteAutomationDialog } from "./delete-automation-dialog";
|
||||
|
||||
interface AutomationRowActionsProps {
|
||||
automation: AutomationSummary;
|
||||
searchSpaceId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-dot menu on each row: pause/resume (if updatable) and delete
|
||||
* (if deletable). The menu itself is hidden when the user has neither
|
||||
* permission so we don't render an empty trigger.
|
||||
*/
|
||||
export function AutomationRowActions({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationRowActionsProps) {
|
||||
const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue(
|
||||
updateAutomationMutationAtom
|
||||
);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
if (!canUpdate && !canDelete) return null;
|
||||
|
||||
const nextStatus = automation.status === "active" ? "paused" : "active";
|
||||
const pauseLabel = automation.status === "active" ? "Pause" : "Resume";
|
||||
const PauseIcon = automation.status === "active" ? Pause : Play;
|
||||
const canToggle = canUpdate && automation.status !== "archived";
|
||||
|
||||
async function handleTogglePause() {
|
||||
await updateAutomation({
|
||||
automationId: automation.id,
|
||||
patch: { status: nextStatus },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={`Actions for ${automation.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{canToggle && (
|
||||
<DropdownMenuItem onSelect={handleTogglePause} disabled={updating}>
|
||||
<PauseIcon className="mr-2 h-4 w-4" />
|
||||
{pauseLabel}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canToggle && canDelete && <DropdownMenuSeparator />}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{canDelete && (
|
||||
<DeleteAutomationDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
automationId={automation.id}
|
||||
automationName={automation.name}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
import Link from "next/link";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import type { AutomationSummary } from "@/contracts/types/automation.types";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { AutomationRowActions } from "./automation-row-actions";
|
||||
import { AutomationStatusBadge } from "./automation-status-badge";
|
||||
|
||||
interface AutomationRowProps {
|
||||
automation: AutomationSummary;
|
||||
searchSpaceId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the automations table. The name links to the detail page;
|
||||
* actions are gated by ``canUpdate`` / ``canDelete``. Trigger summary
|
||||
* is intentionally left to the detail page — list responses don't
|
||||
* include triggers and we want to avoid N+1 detail fetches.
|
||||
*/
|
||||
export function AutomationRow({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationRowProps) {
|
||||
return (
|
||||
<TableRow className="border-b border-border/60 hover:bg-muted/40">
|
||||
<TableCell className="px-4 md:px-6 py-3 border-r border-border/60">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/automations/${automation.id}`}
|
||||
className="text-sm font-medium text-foreground hover:underline truncate"
|
||||
>
|
||||
{automation.name}
|
||||
</Link>
|
||||
{automation.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{automation.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 border-r border-border/60 w-32">
|
||||
<AutomationStatusBadge status={automation.status} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell px-4 py-3 border-r border-border/60 w-40 text-xs text-muted-foreground">
|
||||
{formatRelativeDate(automation.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 md:px-6 py-3 w-16 text-right">
|
||||
<AutomationRowActions
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
import { Archive, CircleDot, Pause } from "lucide-react";
|
||||
import type { AutomationStatus } from "@/contracts/types/automation.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AutomationStatusBadgeProps {
|
||||
status: AutomationStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Color + icon per status. Active = green, paused = amber, archived = muted.
|
||||
const STATUS_STYLES: Record<
|
||||
AutomationStatus,
|
||||
{ label: string; icon: typeof CircleDot; classes: string }
|
||||
> = {
|
||||
active: {
|
||||
label: "Active",
|
||||
icon: CircleDot,
|
||||
classes:
|
||||
"bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900/50",
|
||||
},
|
||||
paused: {
|
||||
label: "Paused",
|
||||
icon: Pause,
|
||||
classes:
|
||||
"bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900/50",
|
||||
},
|
||||
archived: {
|
||||
label: "Archived",
|
||||
icon: Archive,
|
||||
classes: "bg-muted text-muted-foreground border border-border/60",
|
||||
},
|
||||
};
|
||||
|
||||
export function AutomationStatusBadge({ status, className }: AutomationStatusBadgeProps) {
|
||||
const { label, icon: Icon, classes } = STATUS_STYLES[status];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium",
|
||||
classes,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
import { CalendarClock, Pause } from "lucide-react";
|
||||
import type { Trigger } from "@/contracts/types/automation.types";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
|
||||
interface AutomationTriggersSummaryProps {
|
||||
triggers: Trigger[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line summary of an automation's triggers for the list view.
|
||||
*
|
||||
* v1 only registers ``schedule`` so this stays compact:
|
||||
* - 0 triggers → "No triggers"
|
||||
* - 1 schedule trigger → "Mon–Fri at 09:00 · UTC" + disabled badge if off
|
||||
* - >1 → "N triggers"
|
||||
*
|
||||
* The detail page renders the full per-trigger editor.
|
||||
*/
|
||||
export function AutomationTriggersSummary({ triggers }: AutomationTriggersSummaryProps) {
|
||||
if (triggers.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground">No triggers</span>;
|
||||
}
|
||||
|
||||
if (triggers.length > 1) {
|
||||
return <span className="text-xs text-muted-foreground">{triggers.length} triggers</span>;
|
||||
}
|
||||
|
||||
const [trigger] = triggers;
|
||||
|
||||
if (trigger.type === "schedule") {
|
||||
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
|
||||
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
|
||||
const human = cron ? describeCron(cron) : "Schedule";
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
<span className="text-foreground">{human}</span>
|
||||
<span className="text-muted-foreground">· {tz}</span>
|
||||
{!trigger.enabled && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-border/60 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Pause className="h-2.5 w-2.5" aria-hidden />
|
||||
Off
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-xs text-muted-foreground capitalize">{trigger.type}</span>;
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
import { MessageSquarePlus, SquarePen, Workflow } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationsEmptyStateProps {
|
||||
searchSpaceId: number;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-state for the automations list. The primary CTA points to a new
|
||||
* chat — creation happens via the ``create_automation`` HITL tool, not a
|
||||
* "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) {
|
||||
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">
|
||||
<Workflow className="h-6 w-6" aria-hidden />
|
||||
</div>
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">No automations yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Automations let SurfSense run agent tasks on a schedule. Describe what you want in chat and
|
||||
SurfSense drafts the automation for your approval.
|
||||
</p>
|
||||
{canCreate ? (
|
||||
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
|
||||
<Button asChild>
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
Create via chat
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
import { MessageSquarePlus, SquarePen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationsHeaderProps {
|
||||
searchSpaceId: number;
|
||||
total: number;
|
||||
loading: boolean;
|
||||
canCreate: boolean;
|
||||
/**
|
||||
* Render the header's Create CTA. Defaults to true; the empty state owns
|
||||
* the primary CTA on its own card, so the orchestrator turns this off
|
||||
* there to avoid a duplicate button.
|
||||
*/
|
||||
showCreateCta?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page header: title + count + "Create via chat" CTA. Creation is intent-driven
|
||||
* (the create_automation tool runs inside chat with a HITL approval card), so
|
||||
* the CTA links to a new chat rather than opening a form. Model eligibility is
|
||||
* handled per-automation in the builder + approval card, not gated here.
|
||||
*/
|
||||
export function AutomationsHeader({
|
||||
searchSpaceId,
|
||||
total,
|
||||
loading,
|
||||
canCreate,
|
||||
showCreateCta = true,
|
||||
}: AutomationsHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">Automations</h1>
|
||||
{!loading && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{total} {total === 1 ? "automation" : "automations"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canCreate && showCreateCta && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
Create manually
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
Create via chat
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
|
||||
const ROW_KEYS = ["sk-1", "sk-2", "sk-3"];
|
||||
|
||||
/**
|
||||
* Skeleton rows for the automations table. Number of rows is fixed since
|
||||
* we don't know the count ahead of time and three placeholders is enough
|
||||
* to communicate "loading" without flashing too much chrome.
|
||||
*/
|
||||
export function AutomationsLoadingRows() {
|
||||
return (
|
||||
<>
|
||||
{ROW_KEYS.map((key) => (
|
||||
<TableRow key={key} className="border-b border-border/60 hover:bg-transparent">
|
||||
<TableCell className="px-4 md:px-6 py-3 border-r border-border/60">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-56" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 border-r border-border/60 w-32">
|
||||
<Skeleton className="h-5 w-16 rounded-md" />
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell px-4 py-3 border-r border-border/60 w-40">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="px-4 md:px-6 py-3 w-16">
|
||||
<Skeleton className="h-8 w-8 rounded-md ml-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
import { Activity, CalendarDays, Workflow } from "lucide-react";
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import type { AutomationSummary } from "@/contracts/types/automation.types";
|
||||
import { AutomationRow } from "./automation-row";
|
||||
import { AutomationsLoadingRows } from "./automations-loading";
|
||||
|
||||
interface AutomationsTableProps {
|
||||
automations: AutomationSummary[];
|
||||
searchSpaceId: number;
|
||||
loading: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table shell + header. Rows render below — loading state renders skeleton
|
||||
* rows in the same shell so the layout doesn't shift on data arrival.
|
||||
*/
|
||||
export function AutomationsTable({
|
||||
automations,
|
||||
searchSpaceId,
|
||||
loading,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationsTableProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/60">
|
||||
<TableHead className="px-4 md:px-6 border-r border-border/60">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<Workflow size={14} className="opacity-60 text-muted-foreground" />
|
||||
Name
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="border-r border-border/60 w-32">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<Activity size={14} className="opacity-60 text-muted-foreground" />
|
||||
Status
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell border-r border-border/60 w-40">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<CalendarDays size={14} className="opacity-60 text-muted-foreground" />
|
||||
Updated
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4 md:px-6 w-16">
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<AutomationsLoadingRows />
|
||||
) : (
|
||||
automations.map((automation) => (
|
||||
<AutomationRow
|
||||
key={automation.id}
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BuilderExecution } from "@/lib/automations/builder-schema";
|
||||
import { Field } from "./form-field";
|
||||
|
||||
interface AdvancedSectionProps {
|
||||
execution: BuilderExecution;
|
||||
tags: string[];
|
||||
onExecutionChange: (patch: Partial<BuilderExecution>) => void;
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
}
|
||||
|
||||
const BACKOFF_OPTIONS: ReadonlyArray<{ value: BuilderExecution["retryBackoff"]; label: string }> = [
|
||||
{ value: "exponential", label: "Exponential" },
|
||||
{ value: "linear", label: "Linear" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
const CONCURRENCY_OPTIONS: ReadonlyArray<{
|
||||
value: BuilderExecution["concurrency"];
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "drop_if_running", label: "Skip if already running" },
|
||||
{ value: "queue", label: "Queue the next run" },
|
||||
{ value: "always", label: "Always run" },
|
||||
];
|
||||
|
||||
function clampInt(raw: string, min: number, fallback: number): number {
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(value)) return fallback;
|
||||
return Math.max(min, value);
|
||||
}
|
||||
|
||||
export function AdvancedSection({
|
||||
execution,
|
||||
tags,
|
||||
onExecutionChange,
|
||||
onTagsChange,
|
||||
}: AdvancedSectionProps) {
|
||||
const [tagsText, setTagsText] = useState(tags.join(", "));
|
||||
|
||||
function commitTags(text: string) {
|
||||
const next = text
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
onTagsChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="Timeout (seconds)" hint="Wall-clock cap for the whole run.">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={execution.timeoutSeconds}
|
||||
onChange={(e) =>
|
||||
onExecutionChange({ timeoutSeconds: clampInt(e.target.value, 1, 600) })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max retries" hint="Per-step retry budget.">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={execution.maxRetries}
|
||||
onChange={(e) => onExecutionChange({ maxRetries: clampInt(e.target.value, 0, 2) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Retry backoff">
|
||||
<Select
|
||||
value={execution.retryBackoff}
|
||||
onValueChange={(value) =>
|
||||
onExecutionChange({ retryBackoff: value as BuilderExecution["retryBackoff"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BACKOFF_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="If already running">
|
||||
<Select
|
||||
value={execution.concurrency}
|
||||
onValueChange={(value) =>
|
||||
onExecutionChange({ concurrency: value as BuilderExecution["concurrency"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONCURRENCY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Tags" hint="Comma-separated. Optional.">
|
||||
<Input
|
||||
value={tagsText}
|
||||
placeholder="research, weekly"
|
||||
onChange={(e) => setTagsText(e.target.value)}
|
||||
onBlur={(e) => commitTags(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,551 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Code2, LayoutList, Save } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
addTriggerMutationAtom,
|
||||
createAutomationMutationAtom,
|
||||
removeTriggerMutationAtom,
|
||||
updateAutomationMutationAtom,
|
||||
updateTriggerMutationAtom,
|
||||
} from "@/atoms/automations/automations-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type Automation,
|
||||
automationCreateRequest,
|
||||
automationUpdateRequest,
|
||||
} from "@/contracts/types/automation.types";
|
||||
import { useAutomationEligibleModels } from "@/hooks/use-automation-eligible-models";
|
||||
import {
|
||||
type BuilderForm,
|
||||
type BuilderModels,
|
||||
buildCreatePayload,
|
||||
builderFormSchema,
|
||||
buildScheduleTrigger,
|
||||
buildUpdatePayload,
|
||||
createEmptyForm,
|
||||
formFromAutomation,
|
||||
type HydratableTrigger,
|
||||
hasResolvedModels,
|
||||
hydrateForm,
|
||||
} from "@/lib/automations/builder-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AdvancedSection } from "./advanced-section";
|
||||
import { AutomationModelFields } from "./automation-model-fields";
|
||||
import { BasicsSection } from "./basics-section";
|
||||
import { BuilderSummary } from "./builder-summary";
|
||||
import { JsonModePanel } from "./json-mode-panel";
|
||||
import { ScheduleSection } from "./schedule-section";
|
||||
import { TaskList } from "./task-list";
|
||||
import { UnattendedToggle } from "./unattended-toggle";
|
||||
|
||||
interface AutomationBuilderFormProps {
|
||||
mode: "create" | "edit";
|
||||
searchSpaceId: number;
|
||||
/** Required in edit mode; seeds the form and trigger reconciliation. */
|
||||
automation?: Automation;
|
||||
/**
|
||||
* Optional extra create-mode block reason (composed with the form's own
|
||||
* model-eligibility gate). Shown as the submit button's tooltip. Model
|
||||
* eligibility itself is now owned by the in-form pickers.
|
||||
*/
|
||||
submitDisabledReason?: string;
|
||||
}
|
||||
|
||||
type Mode = "form" | "json";
|
||||
|
||||
function mapFormErrors(error: z.ZodError): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const path = issue.path;
|
||||
let key: string;
|
||||
if (path[0] === "tasks" && typeof path[1] === "number") key = `tasks.${path[1]}.query`;
|
||||
else if (path[0] === "schedule") key = "schedule";
|
||||
else key = String(path[0] ?? "_root");
|
||||
if (!out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function AutomationBuilderForm({
|
||||
mode,
|
||||
searchSpaceId,
|
||||
automation,
|
||||
submitDisabledReason,
|
||||
}: AutomationBuilderFormProps) {
|
||||
const router = useRouter();
|
||||
const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom);
|
||||
const { mutateAsync: updateAutomation } = useAtomValue(updateAutomationMutationAtom);
|
||||
const { mutateAsync: addTrigger } = useAtomValue(addTriggerMutationAtom);
|
||||
const { mutateAsync: updateTrigger } = useAtomValue(updateTriggerMutationAtom);
|
||||
const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom);
|
||||
|
||||
// Initial state: create starts empty in form mode; edit hydrates, falling
|
||||
// back to JSON mode when the definition can't be represented in the form.
|
||||
const initial = useMemo(() => {
|
||||
if (mode === "edit" && automation) {
|
||||
const result = formFromAutomation(automation);
|
||||
if (result.formable) {
|
||||
return { mode: "form" as Mode, form: result.form, notice: undefined };
|
||||
}
|
||||
return {
|
||||
mode: "json" as Mode,
|
||||
form: createEmptyForm(),
|
||||
notice: `This automation ${result.reason}, which the form can't show. Edit it as JSON below.`,
|
||||
};
|
||||
}
|
||||
return { mode: "form" as Mode, form: createEmptyForm(), notice: undefined };
|
||||
}, [mode, automation]);
|
||||
|
||||
const [activeMode, setActiveMode] = useState<Mode>(initial.mode);
|
||||
const [form, setForm] = useState<BuilderForm>(initial.form);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [rootError, setRootError] = useState<string | null>(null);
|
||||
|
||||
const [jsonValue, setJsonValue] = useState<Record<string, unknown>>(() =>
|
||||
initial.mode === "json" ? jsonFromAutomation(automation) : {}
|
||||
);
|
||||
const [jsonIssues, setJsonIssues] = useState<string[]>([]);
|
||||
const [jsonNotice, setJsonNotice] = useState<string | undefined>(initial.notice);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const cancelHref =
|
||||
mode === "edit" && automation
|
||||
? `/dashboard/${searchSpaceId}/automations/${automation.id}`
|
||||
: `/dashboard/${searchSpaceId}/automations`;
|
||||
|
||||
// Eligible models + the search-space-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();
|
||||
|
||||
// Resolve each slot during render: an explicit (non-zero) pick wins,
|
||||
// otherwise fall back to the eligible default. No effect copies async hook
|
||||
// data into state, so there's no flicker/loop and the user's pick is sticky.
|
||||
const resolvedModels = useMemo<BuilderModels>(
|
||||
() => ({
|
||||
agentLlmId: form.models.agentLlmId || eligibleModels.llm.defaultId || 0,
|
||||
imageConfigId: form.models.imageConfigId || eligibleModels.image.defaultId || 0,
|
||||
visionConfigId: form.models.visionConfigId || eligibleModels.vision.defaultId || 0,
|
||||
}),
|
||||
[
|
||||
form.models,
|
||||
eligibleModels.llm.defaultId,
|
||||
eligibleModels.image.defaultId,
|
||||
eligibleModels.vision.defaultId,
|
||||
]
|
||||
);
|
||||
|
||||
// The form with resolved models folded in — what every payload builder reads.
|
||||
const formForPayload = useMemo<BuilderForm>(
|
||||
() => ({ ...form, models: resolvedModels }),
|
||||
[form, resolvedModels]
|
||||
);
|
||||
|
||||
function patchForm(patch: Partial<BuilderForm>) {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
|
||||
function jsonFromCurrentForm(): Record<string, unknown> {
|
||||
if (mode === "edit" && automation) {
|
||||
return { ...buildUpdatePayload(formForPayload), status: automation.status };
|
||||
}
|
||||
const { search_space_id: _ignored, ...rest } = buildCreatePayload(
|
||||
formForPayload,
|
||||
searchSpaceId
|
||||
);
|
||||
return rest;
|
||||
}
|
||||
|
||||
function switchToJson() {
|
||||
setJsonValue(jsonFromCurrentForm());
|
||||
setJsonIssues([]);
|
||||
setJsonNotice(undefined);
|
||||
setActiveMode("json");
|
||||
}
|
||||
|
||||
function switchToForm() {
|
||||
const result = tryJsonToForm();
|
||||
if (result.ok) {
|
||||
setForm(result.form);
|
||||
setErrors({});
|
||||
setRootError(null);
|
||||
setActiveMode("form");
|
||||
return;
|
||||
}
|
||||
setJsonIssues(result.issues);
|
||||
setJsonNotice(result.notice);
|
||||
}
|
||||
|
||||
function tryJsonToForm():
|
||||
| { ok: true; form: BuilderForm }
|
||||
| { ok: false; issues: string[]; notice?: string } {
|
||||
// Read the raw tree defensively rather than strict-validating: an
|
||||
// incomplete JSON edit should still round-trip into the form, where the
|
||||
// form's own validation enforces completeness on submit.
|
||||
const definition = jsonValue.definition;
|
||||
if (!definition || typeof definition !== "object") {
|
||||
return { ok: false, issues: [], notice: "Add a definition before switching to the form." };
|
||||
}
|
||||
|
||||
const name =
|
||||
typeof jsonValue.name === "string"
|
||||
? jsonValue.name
|
||||
: mode === "edit" && automation
|
||||
? automation.name
|
||||
: "";
|
||||
const description = typeof jsonValue.description === "string" ? jsonValue.description : null;
|
||||
const triggers =
|
||||
mode === "edit" && automation
|
||||
? (automation.triggers ?? [])
|
||||
: extractTriggers(jsonValue.triggers);
|
||||
|
||||
const h = hydrateForm(name, description, definition, triggers);
|
||||
return h.formable
|
||||
? { ok: true, form: h.form }
|
||||
: { ok: false, issues: [], notice: `Can't show in the form: it ${h.reason}.` };
|
||||
}
|
||||
|
||||
function validateForm(): Record<string, string> | null {
|
||||
const result = builderFormSchema.safeParse(form);
|
||||
const next = result.success ? {} : mapFormErrors(result.error);
|
||||
|
||||
// The schedule model fields aren't deeply validated by the schema.
|
||||
if (form.schedule?.mode === "preset") {
|
||||
const m = form.schedule.model;
|
||||
if (m.frequency === "weekly" && m.daysOfWeek.length === 0) {
|
||||
next.schedule = "Pick at least one day for the weekly schedule";
|
||||
}
|
||||
} else if (form.schedule?.mode === "cron" && !form.schedule.cron.trim()) {
|
||||
next.schedule = "Enter a schedule expression";
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : null;
|
||||
}
|
||||
|
||||
async function reconcileTriggers(automationId: number) {
|
||||
const desired = buildScheduleTrigger(form);
|
||||
const existing = (automation?.triggers ?? [])[0];
|
||||
if (!existing && desired) {
|
||||
await addTrigger({ automationId, payload: desired });
|
||||
} else if (existing && !desired) {
|
||||
await removeTrigger({ automationId, triggerId: existing.id });
|
||||
} else if (existing && desired) {
|
||||
await updateTrigger({
|
||||
automationId,
|
||||
triggerId: existing.id,
|
||||
patch: { params: desired.params, enabled: desired.enabled },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
setRootError(null);
|
||||
const formErrors = validateForm();
|
||||
if (formErrors) {
|
||||
setErrors(formErrors);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === "edit" && automation) {
|
||||
const payload = buildUpdatePayload(formForPayload);
|
||||
const parsed = automationUpdateRequest.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
setRootError(zodIssueList(parsed.error).join("; "));
|
||||
return;
|
||||
}
|
||||
await updateAutomation({ automationId: automation.id, patch: parsed.data });
|
||||
await reconcileTriggers(automation.id);
|
||||
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
|
||||
} else {
|
||||
const payload = buildCreatePayload(formForPayload, searchSpaceId);
|
||||
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}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setRootError((err as Error).message ?? "Submit failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitJson() {
|
||||
setJsonIssues([]);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === "edit" && automation) {
|
||||
const parsed = automationUpdateRequest.safeParse(jsonValue);
|
||||
if (!parsed.success) {
|
||||
setJsonIssues(zodIssueList(parsed.error));
|
||||
return;
|
||||
}
|
||||
await updateAutomation({ automationId: automation.id, patch: parsed.data });
|
||||
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
|
||||
} else {
|
||||
const parsed = automationCreateRequest.safeParse({
|
||||
...jsonValue,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
setJsonIssues(zodIssueList(parsed.error));
|
||||
return;
|
||||
}
|
||||
const created = await createAutomation(parsed.data);
|
||||
router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setJsonIssues([(err as Error).message ?? "Submit failed"]);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const submitLabel = mode === "edit" ? "Save changes" : "Create automation";
|
||||
// Block creation until every model slot resolves to an eligible id. The
|
||||
// per-field Alert already explains *why* a slot is empty; this just guards
|
||||
// submit. `submitDisabledReason` (from the caller) still composes in.
|
||||
const modelsUnresolved =
|
||||
mode === "create" && !eligibleModels.isLoading && !hasResolvedModels(resolvedModels);
|
||||
const effectiveDisabledReason =
|
||||
submitDisabledReason ??
|
||||
(modelsUnresolved
|
||||
? "Set up a premium or your own (BYOK) agent, image, and vision model in role settings before creating an automation."
|
||||
: undefined);
|
||||
// Only gate creation; editing an existing automation isn't blocked here.
|
||||
const submitBlocked = mode === "create" && !!effectiveDisabledReason;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="inline-flex rounded-md border border-border/60 p-0.5">
|
||||
<ModeButton
|
||||
active={activeMode === "form"}
|
||||
icon={LayoutList}
|
||||
label="Form"
|
||||
onClick={() => (activeMode === "form" ? undefined : switchToForm())}
|
||||
/>
|
||||
<ModeButton
|
||||
active={activeMode === "json"}
|
||||
icon={Code2}
|
||||
label="Edit as JSON"
|
||||
onClick={() => (activeMode === "json" ? undefined : switchToJson())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeMode === "json" ? (
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardContent className="pt-6">
|
||||
<JsonModePanel
|
||||
value={jsonValue}
|
||||
issues={jsonIssues}
|
||||
notice={jsonNotice}
|
||||
onChange={setJsonValue}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Basics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BasicsSection
|
||||
name={form.name}
|
||||
description={form.description}
|
||||
errors={errors}
|
||||
onChange={patchForm}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Tasks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<TaskList
|
||||
tasks={form.tasks}
|
||||
errors={errors}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onChange={(tasks) => patchForm({ tasks })}
|
||||
/>
|
||||
<UnattendedToggle
|
||||
checked={form.unattended}
|
||||
onChange={(unattended) => patchForm({ unattended })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Schedule</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScheduleSection
|
||||
schedule={form.schedule}
|
||||
timezone={form.timezone}
|
||||
errors={errors}
|
||||
onScheduleChange={(schedule) => patchForm({ schedule })}
|
||||
onTimezoneChange={(timezone) => patchForm({ timezone })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AutomationModelFields
|
||||
searchSpaceId={searchSpaceId}
|
||||
value={resolvedModels}
|
||||
onChange={(patch) => patchForm({ models: { ...form.models, ...patch } })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AdvancedSection
|
||||
execution={form.execution}
|
||||
tags={form.tags}
|
||||
onExecutionChange={(patch) =>
|
||||
patchForm({ execution: { ...form.execution, ...patch } })
|
||||
}
|
||||
onTagsChange={(tags) => patchForm({ tags })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="border-border/60 bg-accent lg:sticky lg:top-4">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BuilderSummary form={form} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rootError && <p className="text-right text-xs text-destructive">{rootError}</p>}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button asChild type="button" variant="ghost" size="sm">
|
||||
<Link href={cancelHref}>Cancel</Link>
|
||||
</Button>
|
||||
{submitBlocked ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* aria-disabled keeps the button focusable so the tooltip is
|
||||
reachable by hover and keyboard; onClick is a no-op. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-disabled
|
||||
className="cursor-not-allowed opacity-50"
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{effectiveDisabledReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
|
||||
>
|
||||
{submitting ? (
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: typeof Code2;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function extractTriggers(raw: unknown): HydratableTrigger[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((entry) => {
|
||||
const obj = entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
|
||||
return {
|
||||
type: typeof obj.type === "string" ? obj.type : "",
|
||||
params:
|
||||
obj.params && typeof obj.params === "object" ? (obj.params as Record<string, unknown>) : {},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function zodIssueList(error: z.ZodError): string[] {
|
||||
return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
||||
}
|
||||
|
||||
function jsonFromAutomation(automation: Automation | undefined): Record<string, unknown> {
|
||||
if (!automation) return {};
|
||||
return {
|
||||
name: automation.name,
|
||||
description: automation.description ?? null,
|
||||
status: automation.status,
|
||||
definition: automation.definition,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
"use client";
|
||||
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo, useId } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
type EligibleModelKind,
|
||||
type EligibleModelOption,
|
||||
useAutomationEligibleModels,
|
||||
} from "@/hooks/use-automation-eligible-models";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { Field } from "./form-field";
|
||||
|
||||
export interface AutomationModelSelection {
|
||||
agentLlmId: number;
|
||||
imageConfigId: number;
|
||||
visionConfigId: number;
|
||||
}
|
||||
|
||||
interface AutomationModelFieldsProps {
|
||||
/** Resolved (effective) ids — never `0` once defaults are seeded. */
|
||||
value: AutomationModelSelection;
|
||||
onChange: (patch: Partial<AutomationModelSelection>) => void;
|
||||
searchSpaceId: number;
|
||||
errors?: Partial<Record<keyof AutomationModelSelection, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three eligible-only model pickers (Agent LLM / Image / Vision) for the
|
||||
* automation builder + chat approval card. Options come from
|
||||
* {@link useAutomationEligibleModels} (premium globals + BYOK only); selection
|
||||
* is validated + snapshotted onto `definition.models` at create time.
|
||||
*/
|
||||
export function AutomationModelFields({
|
||||
value,
|
||||
onChange,
|
||||
searchSpaceId,
|
||||
errors,
|
||||
}: AutomationModelFieldsProps) {
|
||||
const { llm, image, vision, isLoading } = useAutomationEligibleModels();
|
||||
const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/roles`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ModelSelectField
|
||||
label="Agent model"
|
||||
kind={llm}
|
||||
value={value.agentLlmId}
|
||||
isLoading={isLoading}
|
||||
rolesHref={rolesHref}
|
||||
error={errors?.agentLlmId}
|
||||
onChange={(id) => onChange({ agentLlmId: id })}
|
||||
/>
|
||||
<ModelSelectField
|
||||
label="Image model"
|
||||
kind={image}
|
||||
value={value.imageConfigId}
|
||||
isLoading={isLoading}
|
||||
rolesHref={rolesHref}
|
||||
error={errors?.imageConfigId}
|
||||
onChange={(id) => onChange({ imageConfigId: id })}
|
||||
/>
|
||||
<ModelSelectField
|
||||
label="Vision model"
|
||||
kind={vision}
|
||||
value={value.visionConfigId}
|
||||
isLoading={isLoading}
|
||||
rolesHref={rolesHref}
|
||||
error={errors?.visionConfigId}
|
||||
onChange={(id) => onChange({ visionConfigId: id })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelSelectFieldProps {
|
||||
label: string;
|
||||
kind: EligibleModelKind;
|
||||
value: number;
|
||||
isLoading: boolean;
|
||||
rolesHref: string;
|
||||
error?: string;
|
||||
onChange: (id: number) => void;
|
||||
}
|
||||
|
||||
const ModelSelectField = memo(function ModelSelectField({
|
||||
label,
|
||||
kind,
|
||||
value,
|
||||
isLoading,
|
||||
rolesHref,
|
||||
error,
|
||||
onChange,
|
||||
}: ModelSelectFieldProps) {
|
||||
const triggerId = useId();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind.options.length === 0) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<Alert>
|
||||
<TriangleAlert aria-hidden />
|
||||
<AlertTitle>No eligible models</AlertTitle>
|
||||
<AlertDescription>
|
||||
Automations need a premium or your own (BYOK) model. Set one up in{" "}
|
||||
<Link href={rolesHref} className="font-medium underline underline-offset-2">
|
||||
role settings
|
||||
</Link>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const premium = kind.options.filter((o) => !o.isBYOK);
|
||||
const byok = kind.options.filter((o) => o.isBYOK);
|
||||
const selected = value ? kind.byId.get(value) : undefined;
|
||||
|
||||
return (
|
||||
<Field label={label} htmlFor={triggerId} error={error}>
|
||||
<Select value={value ? String(value) : undefined} onValueChange={(v) => onChange(Number(v))}>
|
||||
<SelectTrigger
|
||||
id={triggerId}
|
||||
aria-label={label}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className="w-full"
|
||||
>
|
||||
{selected ? (
|
||||
<span className="flex items-center gap-2">
|
||||
{getProviderIcon(selected.provider)}
|
||||
<span className="truncate">{selected.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="Select a model" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{premium.length > 0 ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Premium</SelectLabel>
|
||||
{premium.map((option) => (
|
||||
<ModelOption key={option.id} option={option} badge="Premium" />
|
||||
))}
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
{premium.length > 0 && byok.length > 0 ? <SelectSeparator /> : null}
|
||||
{byok.length > 0 ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Your models</SelectLabel>
|
||||
{byok.map((option) => (
|
||||
<ModelOption key={option.id} option={option} badge="BYOK" />
|
||||
))}
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
});
|
||||
|
||||
function ModelOption({
|
||||
option,
|
||||
badge,
|
||||
}: {
|
||||
option: EligibleModelOption;
|
||||
badge: "Premium" | "BYOK";
|
||||
}) {
|
||||
return (
|
||||
<SelectItem value={String(option.id)} description={option.modelName}>
|
||||
<span className="flex items-center gap-2">
|
||||
{getProviderIcon(option.provider)}
|
||||
<span className="truncate">{option.name}</span>
|
||||
<Badge variant={badge === "Premium" ? "secondary" : "outline"}>{badge}</Badge>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Field } from "./form-field";
|
||||
|
||||
interface BasicsSectionProps {
|
||||
name: string;
|
||||
description: string | null;
|
||||
errors: Record<string, string>;
|
||||
onChange: (patch: { name?: string; description?: string | null }) => void;
|
||||
}
|
||||
|
||||
export function BasicsSection({ name, description, errors, onChange }: BasicsSectionProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Field label="Name" htmlFor="automation-name" required error={errors.name}>
|
||||
<Input
|
||||
id="automation-name"
|
||||
value={name}
|
||||
maxLength={200}
|
||||
placeholder="Weekly competitor digest"
|
||||
onChange={(e) => onChange({ name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Description"
|
||||
htmlFor="automation-description"
|
||||
hint="Optional. A short note about what this automation is for."
|
||||
error={errors.description}
|
||||
>
|
||||
<Textarea
|
||||
id="automation-description"
|
||||
value={description ?? ""}
|
||||
rows={2}
|
||||
placeholder="Summarize what changed and email me the highlights."
|
||||
onChange={(e) => onChange({ description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
import { CalendarClock, CheckCircle2, ListOrdered, type LucideIcon, XCircle } from "lucide-react";
|
||||
import { type BuilderForm, scheduleToCron } from "@/lib/automations/builder-schema";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
|
||||
interface BuilderSummaryProps {
|
||||
form: BuilderForm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live, read-only mirror of what will be created. Mirrors the layout of the
|
||||
* chat ``AutomationDraftPreview`` so the two creation paths feel consistent.
|
||||
*/
|
||||
export function BuilderSummary({ form }: BuilderSummaryProps) {
|
||||
const scheduleLabel = form.schedule
|
||||
? `${describeCron(scheduleToCron(form.schedule))} · ${form.timezone}`
|
||||
: "No schedule — won't run automatically";
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">{form.name.trim() || "Untitled automation"}</p>
|
||||
{form.description?.trim() && (
|
||||
<p className="text-xs text-muted-foreground">{form.description.trim()}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Section icon={CalendarClock} label="Schedule">
|
||||
<p className="text-xs text-foreground">{scheduleLabel}</p>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
icon={ListOrdered}
|
||||
label={`Tasks · ${form.tasks.length} step${form.tasks.length === 1 ? "" : "s"}`}
|
||||
>
|
||||
<ol className="space-y-1.5 text-xs">
|
||||
{form.tasks.map((task, index) => (
|
||||
<li key={task.id} className="flex items-start gap-2">
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground shrink-0 mt-0.5">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="block text-foreground line-clamp-2">
|
||||
{task.query.trim() || (
|
||||
<span className="text-muted-foreground">No instructions yet</span>
|
||||
)}
|
||||
</span>
|
||||
{task.mentions.length > 0 && (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{task.mentions.map((mention) => (
|
||||
<span
|
||||
key={`${mention.kind}:${mention.id}`}
|
||||
className="inline-flex max-w-[140px] items-center truncate rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary/70"
|
||||
>
|
||||
@{mention.title}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{form.unattended ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" aria-hidden />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{form.unattended ? "Runs without approval prompts" : "Will reject approval prompts"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon: Icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<Icon className="h-3 w-3" aria-hidden />
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FieldProps {
|
||||
label?: string;
|
||||
htmlFor?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label + control + (hint | inline error) stack shared by every builder
|
||||
* section. Keeps spacing and error styling consistent so individual sections
|
||||
* stay focused on their inputs.
|
||||
*/
|
||||
export function Field({ label, htmlFor, hint, error, required, className, children }: FieldProps) {
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{label && (
|
||||
<Label htmlFor={htmlFor} className="text-xs font-medium text-foreground">
|
||||
{label}
|
||||
{required && <span className="text-muted-foreground">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
{children}
|
||||
{error ? (
|
||||
<p className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
) : hint ? (
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
|
||||
interface JsonModePanelProps {
|
||||
value: Record<string, unknown>;
|
||||
issues: string[];
|
||||
notice?: string;
|
||||
onChange: (next: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw-JSON escape hatch. Edits the same payload the form produces; the
|
||||
* orchestrator validates it against the contract schema on submit. Shown when
|
||||
* the user opts into "Edit as JSON" or when an existing definition uses
|
||||
* features the form can't represent.
|
||||
*/
|
||||
export function JsonModePanel({ value, issues, notice, onChange }: JsonModePanelProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{notice && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-144 overflow-auto">
|
||||
<JsonView
|
||||
src={value}
|
||||
editable
|
||||
onChange={(next) => onChange(next as Record<string, unknown>)}
|
||||
collapsed={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive mb-1.5">
|
||||
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
|
||||
</div>
|
||||
<ul className="space-y-0.5 text-xs text-destructive list-disc list-inside">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
type InlineMentionEditorRef,
|
||||
type MentionChipInput,
|
||||
type MentionedDocument,
|
||||
type SuggestionAnchorRect,
|
||||
type SuggestionTriggerInfo,
|
||||
} from "@/components/assistant-ui/inline-mention-editor";
|
||||
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
|
||||
import {
|
||||
DocumentMentionPicker,
|
||||
type DocumentMentionPickerRef,
|
||||
} from "@/components/new-chat/document-mention-picker";
|
||||
import { Popover, PopoverAnchor } from "@/components/ui/popover";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MentionTaskInputProps {
|
||||
searchSpaceId: number;
|
||||
value: string;
|
||||
mentions: MentionedDocumentInfo[];
|
||||
onChange: (text: string, mentions: MentionedDocumentInfo[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type AnchorPoint = { left: number; top: number };
|
||||
|
||||
// Mirror of thread.tsx's getComposerSuggestionAnchorPoint -- kept local so the
|
||||
// chat composer stays untouched.
|
||||
function getAnchorPoint(rect: SuggestionAnchorRect | null): AnchorPoint | null {
|
||||
if (!rect) return null;
|
||||
return { left: rect.left, top: rect.bottom };
|
||||
}
|
||||
|
||||
/** Project the editor's chip shape into the canonical mention info union. */
|
||||
function toMentionInfo(doc: MentionedDocument): MentionedDocumentInfo {
|
||||
if (doc.kind === "connector") {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
kind: "connector",
|
||||
connector_type: doc.connector_type ?? "UNKNOWN",
|
||||
account_name: doc.account_name ?? doc.title,
|
||||
};
|
||||
}
|
||||
if (doc.kind === "folder") {
|
||||
return { id: doc.id, title: doc.title, kind: "folder" };
|
||||
}
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type ?? "UNKNOWN",
|
||||
kind: "doc",
|
||||
};
|
||||
}
|
||||
|
||||
/** Project a mention info into the editor's chip-insertion shape. */
|
||||
function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
|
||||
if (mention.kind === "connector") {
|
||||
return {
|
||||
id: mention.id,
|
||||
title: mention.title,
|
||||
kind: "connector",
|
||||
connector_type: mention.connector_type,
|
||||
account_name: mention.account_name,
|
||||
};
|
||||
}
|
||||
if (mention.kind === "folder") {
|
||||
return { id: mention.id, title: mention.title, kind: "folder" };
|
||||
}
|
||||
return {
|
||||
id: mention.id,
|
||||
title: mention.title,
|
||||
kind: "doc",
|
||||
document_type: mention.document_type,
|
||||
};
|
||||
}
|
||||
|
||||
function removeFirstToken(text: string, token: string): string {
|
||||
const index = text.indexOf(token);
|
||||
if (index === -1) return text;
|
||||
return text.slice(0, index) + text.slice(index + token.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Task input that reuses the chat ``@`` mention experience -- the same
|
||||
* ``InlineMentionEditor`` + ``DocumentMentionPicker`` as the composer. The
|
||||
* editor is the source of truth while mounted; ``onChange`` reports both the
|
||||
* plain text (chips rendered as ``@Title``) and the structured mention list
|
||||
* so the builder can persist IDs for the run.
|
||||
*/
|
||||
export function MentionTaskInput({
|
||||
searchSpaceId,
|
||||
value,
|
||||
mentions,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: MentionTaskInputProps) {
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const pickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [anchorPoint, setAnchorPoint] = useState<AnchorPoint | null>(null);
|
||||
|
||||
// One-shot hydration of existing mentions into real chips. ``initialText``
|
||||
// seeds the literal ``@Title`` text; here we strip those tokens and
|
||||
// re-insert them as chips so the editor reports the structured docs (and
|
||||
// editing can't silently drop the mention IDs). Position isn't preserved
|
||||
// on re-hydration -- chips append after the remaining prose.
|
||||
const didHydrateRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didHydrateRef.current) return;
|
||||
didHydrateRef.current = true;
|
||||
if (mentions.length === 0) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
let baseText = value;
|
||||
for (const mention of mentions) {
|
||||
baseText = removeFirstToken(baseText, `@${mention.title}`);
|
||||
}
|
||||
baseText = baseText.replace(/[ \t]{2,}/g, " ").trim();
|
||||
editor.setText(baseText);
|
||||
for (const mention of mentions) {
|
||||
editor.insertMentionChip(toChipInput(mention), { removeTriggerText: false });
|
||||
}
|
||||
}, [mentions, value]);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setShowPopover(false);
|
||||
setMentionQuery("");
|
||||
setAnchorPoint(null);
|
||||
}, []);
|
||||
|
||||
const handleEditorChange = useCallback(
|
||||
(text: string, docs: MentionedDocument[]) => {
|
||||
onChange(text, docs.map(toMentionInfo));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleMentionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
|
||||
const point = getAnchorPoint(trigger.anchorRect);
|
||||
if (!point) {
|
||||
setShowPopover(false);
|
||||
setMentionQuery("");
|
||||
setAnchorPoint(null);
|
||||
return;
|
||||
}
|
||||
setAnchorPoint((current) => current ?? point);
|
||||
setShowPopover(true);
|
||||
setMentionQuery(trigger.query);
|
||||
}, []);
|
||||
|
||||
const handleMentionClose = useCallback(() => {
|
||||
setShowPopover((open) => {
|
||||
if (open) {
|
||||
setMentionQuery("");
|
||||
setAnchorPoint(null);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePopoverOpenChange = useCallback((open: boolean) => {
|
||||
setShowPopover(open);
|
||||
if (!open) {
|
||||
setMentionQuery("");
|
||||
setAnchorPoint(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!showPopover) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
pickerRef.current?.moveDown();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
pickerRef.current?.moveUp();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
pickerRef.current?.selectHighlighted();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (pickerRef.current?.goBack()) return;
|
||||
closePopover();
|
||||
}
|
||||
},
|
||||
[showPopover, closePopover]
|
||||
);
|
||||
|
||||
const handleSelection = useCallback(
|
||||
(picked: MentionedDocumentInfo[]) => {
|
||||
const editor = editorRef.current;
|
||||
const existing = new Set(
|
||||
(editor?.getMentionedDocuments() ?? []).map((doc) => getMentionDocKey(doc))
|
||||
);
|
||||
for (const mention of picked) {
|
||||
const key = getMentionDocKey(mention);
|
||||
if (existing.has(key)) continue;
|
||||
editor?.insertMentionChip(toChipInput(mention));
|
||||
existing.add(key);
|
||||
}
|
||||
closePopover();
|
||||
},
|
||||
[closePopover]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-popover-border focus-within:border-ring focus-within:ring-ring/50 dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<Popover open={showPopover} onOpenChange={handlePopoverOpenChange}>
|
||||
{anchorPoint ? (
|
||||
<>
|
||||
<PopoverAnchor
|
||||
className="pointer-events-none fixed size-0"
|
||||
style={{ left: anchorPoint.left, top: anchorPoint.top }}
|
||||
/>
|
||||
<ComposerSuggestionPopoverContent side="bottom">
|
||||
<DocumentMentionPicker
|
||||
ref={pickerRef}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSelectionChange={handleSelection}
|
||||
onDone={closePopover}
|
||||
initialSelectedDocuments={mentions}
|
||||
externalSearch={mentionQuery}
|
||||
/>
|
||||
</ComposerSuggestionPopoverContent>
|
||||
</>
|
||||
) : null}
|
||||
</Popover>
|
||||
<InlineMentionEditor
|
||||
ref={editorRef}
|
||||
initialText={value}
|
||||
placeholder={placeholder ?? "Type @ to reference files, folders, or connectors"}
|
||||
disabled={disabled}
|
||||
onChange={handleEditorChange}
|
||||
onMentionTrigger={handleMentionTrigger}
|
||||
onMentionClose={handleMentionClose}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
"use client";
|
||||
import { CalendarClock, CalendarOff, Plus, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { type BuilderSchedule, scheduleToCron } from "@/lib/automations/builder-schema";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
import {
|
||||
DEFAULT_SCHEDULE,
|
||||
FREQUENCY_OPTIONS,
|
||||
fromCron,
|
||||
type ScheduleFrequency,
|
||||
type ScheduleModel,
|
||||
toCron,
|
||||
WEEKDAY_OPTIONS,
|
||||
} from "@/lib/automations/schedule-builder";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Field } from "./form-field";
|
||||
import { TimezoneCombobox } from "./timezone-combobox";
|
||||
|
||||
interface ScheduleSectionProps {
|
||||
schedule: BuilderSchedule | null;
|
||||
timezone: string;
|
||||
errors: Record<string, string>;
|
||||
onScheduleChange: (schedule: BuilderSchedule | null) => void;
|
||||
onTimezoneChange: (timezone: string) => void;
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
export function ScheduleSection({
|
||||
schedule,
|
||||
timezone,
|
||||
errors,
|
||||
onScheduleChange,
|
||||
onTimezoneChange,
|
||||
}: ScheduleSectionProps) {
|
||||
if (schedule === null) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-6 text-center">
|
||||
<CalendarOff className="mx-auto h-7 w-7 text-muted-foreground" aria-hidden />
|
||||
<p className="mt-2 text-sm text-foreground">No schedule</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
This automation won't run automatically until you add one.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => onScheduleChange({ mode: "preset", model: { ...DEFAULT_SCHEDULE } })}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Add a schedule
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cron = scheduleToCron(schedule);
|
||||
const label = describeCron(cron);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border border-border/60 bg-background px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||
<CalendarClock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium text-foreground truncate">{label}</span>
|
||||
<span className="text-muted-foreground shrink-0">· {timezone}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label="Remove schedule"
|
||||
onClick={() => onScheduleChange(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{schedule.mode === "preset" ? (
|
||||
<PresetEditor
|
||||
model={schedule.model}
|
||||
onChange={(model) => onScheduleChange({ mode: "preset", model })}
|
||||
onSwitchToCron={() => onScheduleChange({ mode: "cron", cron: toCron(schedule.model) })}
|
||||
/>
|
||||
) : (
|
||||
<CronEditor
|
||||
cron={schedule.cron}
|
||||
error={errors.schedule}
|
||||
onChange={(value) => onScheduleChange({ mode: "cron", cron: value })}
|
||||
onSwitchToPreset={() =>
|
||||
onScheduleChange({
|
||||
mode: "preset",
|
||||
model: fromCron(schedule.cron) ?? { ...DEFAULT_SCHEDULE },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Field label="Timezone">
|
||||
<TimezoneCombobox value={timezone} onChange={onTimezoneChange} />
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PresetEditorProps {
|
||||
model: ScheduleModel;
|
||||
onChange: (model: ScheduleModel) => void;
|
||||
onSwitchToCron: () => void;
|
||||
}
|
||||
|
||||
function PresetEditor({ model, onChange, onSwitchToCron }: PresetEditorProps) {
|
||||
const weeklyNoDays = model.frequency === "weekly" && model.daysOfWeek.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="Frequency">
|
||||
<Select
|
||||
value={model.frequency}
|
||||
onValueChange={(value) => onChange({ ...model, frequency: value as ScheduleFrequency })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FREQUENCY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{model.frequency === "hourly" ? (
|
||||
<Field label="At minute">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={model.minute}
|
||||
onChange={(e) => onChange({ ...model, minute: clampInt(e.target.value, 0, 59) })}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="At time">
|
||||
<Input
|
||||
type="time"
|
||||
value={`${pad(model.hour)}:${pad(model.minute)}`}
|
||||
onChange={(e) => {
|
||||
const [h, m] = e.target.value.split(":");
|
||||
onChange({
|
||||
...model,
|
||||
hour: clampInt(h, 0, 23),
|
||||
minute: clampInt(m, 0, 59),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{model.frequency === "weekly" && (
|
||||
<Field label="On days" error={weeklyNoDays ? "Pick at least one day" : undefined}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WEEKDAY_OPTIONS.map((day) => {
|
||||
const active = model.daysOfWeek.includes(day.value);
|
||||
return (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() =>
|
||||
onChange({ ...model, daysOfWeek: toggleDay(model.daysOfWeek, day.value) })
|
||||
}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border/60 bg-background text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{day.short}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{model.frequency === "monthly" && (
|
||||
<Field label="Day of month" hint={"1\u201331."}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={model.dayOfMonth}
|
||||
onChange={(e) => onChange({ ...model, dayOfMonth: clampInt(e.target.value, 1, 31) })}
|
||||
className="w-24"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToCron}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Advanced: enter a schedule expression
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CronEditorProps {
|
||||
cron: string;
|
||||
error?: string;
|
||||
onChange: (cron: string) => void;
|
||||
onSwitchToPreset: () => void;
|
||||
}
|
||||
|
||||
function CronEditor({ cron, error, onChange, onSwitchToPreset }: CronEditorProps) {
|
||||
const trimmed = cron.trim();
|
||||
const label = trimmed ? describeCron(trimmed) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Field
|
||||
label="Schedule expression"
|
||||
hint="Five-field cron, e.g. 0 9 * * 1-5 (minute hour day month weekday)."
|
||||
error={error}
|
||||
>
|
||||
<Input
|
||||
value={cron}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className="font-mono"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{label && label !== trimmed && <p className="text-xs text-muted-foreground">Runs: {label}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToPreset}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Use the simple picker
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampInt(raw: string, min: number, max: number): number {
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function toggleDay(days: number[], value: number): number[] {
|
||||
return days.includes(value)
|
||||
? days.filter((day) => day !== value)
|
||||
: [...days, value].sort((a, b) => a - b);
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { BuilderTask } from "@/lib/automations/builder-schema";
|
||||
import { Field } from "./form-field";
|
||||
import { MentionTaskInput } from "./mention-task-input";
|
||||
|
||||
interface TaskItemProps {
|
||||
index: number;
|
||||
total: number;
|
||||
task: BuilderTask;
|
||||
searchSpaceId: number;
|
||||
error?: string;
|
||||
onChange: (patch: Partial<BuilderTask>) => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function parseOptionalInt(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return null;
|
||||
const value = Number.parseInt(trimmed, 10);
|
||||
return Number.isNaN(value) ? null : value;
|
||||
}
|
||||
|
||||
export function TaskItem({
|
||||
index,
|
||||
total,
|
||||
task,
|
||||
searchSpaceId,
|
||||
error,
|
||||
onChange,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
}: TaskItemProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-background p-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
Task {index + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
disabled={index === 0}
|
||||
aria-label="Move task up"
|
||||
onClick={onMoveUp}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
disabled={index === total - 1}
|
||||
aria-label="Move task down"
|
||||
onClick={onMoveDown}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
disabled={total === 1}
|
||||
aria-label="Remove task"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
error={error}
|
||||
hint="Type @ to reference files, folders, or connectors for extra context."
|
||||
>
|
||||
<MentionTaskInput
|
||||
searchSpaceId={searchSpaceId}
|
||||
value={task.query}
|
||||
mentions={task.mentions}
|
||||
placeholder="What should the agent do? e.g. Summarize new docs in @Marketing since the last run."
|
||||
onChange={(query, mentions) => onChange({ query, mentions })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="advanced" className="border-b-0">
|
||||
<AccordionTrigger className="py-1.5 text-xs text-muted-foreground hover:no-underline">
|
||||
Advanced
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-1">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Max retries" hint="Leave blank to use the default.">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={task.maxRetries ?? ""}
|
||||
placeholder="default"
|
||||
onChange={(e) => onChange({ maxRetries: parseOptionalInt(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Timeout (seconds)" hint="Leave blank to use the default.">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={task.timeoutSeconds ?? ""}
|
||||
placeholder="default"
|
||||
onChange={(e) => onChange({ timeoutSeconds: parseOptionalInt(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type BuilderTask, emptyTask } from "@/lib/automations/builder-schema";
|
||||
import { TaskItem } from "./task-item";
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: BuilderTask[];
|
||||
errors: Record<string, string>;
|
||||
searchSpaceId: number;
|
||||
onChange: (tasks: BuilderTask[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
function updateAt(index: number, patch: Partial<BuilderTask>) {
|
||||
onChange(tasks.map((task, i) => (i === index ? { ...task, ...patch } : task)));
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
onChange(tasks.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function move(index: number, direction: -1 | 1) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= tasks.length) return;
|
||||
const next = [...tasks];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task, index) => (
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
index={index}
|
||||
total={tasks.length}
|
||||
task={task}
|
||||
searchSpaceId={searchSpaceId}
|
||||
error={errors[`tasks.${index}.query`]}
|
||||
onChange={(patch) => updateAt(index, patch)}
|
||||
onMoveUp={() => move(index, -1)}
|
||||
onMoveDown={() => move(index, 1)}
|
||||
onRemove={() => removeAt(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{errors.tasks && <p className="text-xs text-destructive">{errors.tasks}</p>}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange([...tasks, emptyTask()])}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Add task
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { getTimezones } from "@/lib/automations/builder-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TimezoneComboboxProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable IANA timezone picker. The full ``Intl.supportedValuesOf`` list is
|
||||
* long, so it lives behind a Command search instead of a flat Select.
|
||||
*/
|
||||
export function TimezoneCombobox({ value, onChange }: TimezoneComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const timezones = useMemo(() => getTimezones(), []);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">{value || "Select timezone"}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search timezone..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No timezone found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{timezones.map((tz) => (
|
||||
<CommandItem
|
||||
key={tz}
|
||||
value={tz}
|
||||
onSelect={() => {
|
||||
onChange(tz);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", value === tz ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
{tz}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
import { Info } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface UnattendedToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps to ``auto_approve_all`` on every agent task. Automations run with no one
|
||||
* watching, so this defaults ON; turning it off means any approval prompt the
|
||||
* agent raises is rejected and the step can stall.
|
||||
*/
|
||||
export function UnattendedToggle({ checked, onChange }: UnattendedToggleProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background px-3 py-3">
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Run without asking for approvals
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" aria-label="More info" className="text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Automations run unattended. With this off, any approval the agent asks for is
|
||||
rejected, which can stall a step.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-approve actions the agent would normally pause to confirm.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
aria-label="Run without asking for approvals"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { deleteAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
interface DeleteAutomationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
automationId: number;
|
||||
automationName: string;
|
||||
searchSpaceId: 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
|
||||
* on the list page so no callback is needed there).
|
||||
*/
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm + delete one automation. FK cascade on the backend wipes attached
|
||||
* triggers and runs, so we mention it explicitly. List re-fetch is handled
|
||||
* by the mutation atom's onSuccess.
|
||||
*/
|
||||
export function DeleteAutomationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
automationId,
|
||||
automationName,
|
||||
searchSpaceId,
|
||||
onDeleted,
|
||||
}: DeleteAutomationDialogProps) {
|
||||
const { mutateAsync: deleteAutomation } = useAtomValue(deleteAutomationMutationAtom);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await deleteAutomation({ automationId, searchSpaceId });
|
||||
onDeleted?.();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this automation?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span className="font-medium text-foreground">{automationName}</span> and all of its
|
||||
triggers and run history will be removed. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={submitting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Spinner size="xs" />
|
||||
Deleting…
|
||||
</span>
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { canPerform, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
|
||||
/**
|
||||
* Centralized RBAC gates for the automations slice. Co-located with the
|
||||
* route so adding/removing surfaces stays a one-file change. Backed by
|
||||
* the same ``myAccessAtom`` the rest of the app uses; owners short-circuit
|
||||
* to ``true`` for every action.
|
||||
*
|
||||
* Mirrors backend permissions in ``app.db.permissions`` (automations:*).
|
||||
*/
|
||||
export interface AutomationPermissions {
|
||||
loading: boolean;
|
||||
canCreate: boolean;
|
||||
canRead: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canExecute: boolean;
|
||||
}
|
||||
|
||||
export function useAutomationPermissions(): AutomationPermissions {
|
||||
const { data: access, isLoading } = useAtomValue(myAccessAtom);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
loading: isLoading,
|
||||
canCreate: canPerform(access, "automations:create"),
|
||||
canRead: canPerform(access, "automations:read"),
|
||||
canUpdate: canPerform(access, "automations:update"),
|
||||
canDelete: canPerform(access, "automations:delete"),
|
||||
canExecute: canPerform(access, "automations:execute"),
|
||||
}),
|
||||
[access, isLoading]
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { AutomationBuilderForm } from "../components/builder/automation-builder-form";
|
||||
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
|
||||
import { AutomationNewHeader } from "./components/automation-new-header";
|
||||
|
||||
interface AutomationNewContentProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrator for the create route. Gates on ``automations:create`` so users
|
||||
* who can't create don't even see the form; same panel as the detail page's
|
||||
* access-denied state for consistency. The builder defaults to the friendly
|
||||
* form with a raw-JSON escape hatch.
|
||||
*
|
||||
* Model eligibility is no longer gated here — the builder's own model pickers
|
||||
* 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) {
|
||||
const perms = useAutomationPermissions();
|
||||
|
||||
if (perms.loading) {
|
||||
return <div className="h-32 rounded-md border border-border/60 bg-muted/10 animate-pulse" />;
|
||||
}
|
||||
|
||||
if (!perms.canCreate) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutomationNewHeader searchSpaceId={searchSpaceId} />
|
||||
<AutomationBuilderForm mode="create" searchSpaceId={searchSpaceId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
import { ArrowLeft, MessageSquarePlus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationNewHeaderProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
export function AutomationNewHeader({ searchSpaceId }: AutomationNewHeaderProps) {
|
||||
return (
|
||||
<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`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Back to automations
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">New automation</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Set up a task and a schedule. Prefer natural language? Use chat instead.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
Switch to chat
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { AutomationNewContent } from "./automation-new-content";
|
||||
|
||||
export default async function NewAutomationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
}) {
|
||||
const { search_space_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<AutomationNewContent searchSpaceId={Number(search_space_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { AutomationsContent } from "./automations-content";
|
||||
|
||||
export default async function AutomationsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
}) {
|
||||
const { search_space_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<AutomationsContent searchSpaceId={Number(search_space_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -69,11 +69,11 @@ import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier";
|
||||
import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import {
|
||||
convertToThreadMessage,
|
||||
reconcileInterruptedAssistantMessages,
|
||||
} from "@/lib/chat/message-utils";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import {
|
||||
isPodcastGenerating,
|
||||
looksLikePodcastRequest,
|
||||
|
|
@ -110,6 +110,7 @@ import {
|
|||
extractUserTurnForNewChatApi,
|
||||
type NewChatUserImagePayload,
|
||||
} from "@/lib/chat/user-turn-api-parts";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { NotFoundError } from "@/lib/error";
|
||||
import {
|
||||
trackChatBlocked,
|
||||
|
|
@ -119,7 +120,7 @@ import {
|
|||
trackChatResponseReceived,
|
||||
} from "@/lib/posthog/events";
|
||||
import Loading from "../loading";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const MobileEditorPanel = dynamic(
|
||||
() =>
|
||||
import("@/components/editor-panel/editor-panel").then((m) => ({
|
||||
|
|
@ -739,15 +740,6 @@ export default function NewChatPage() {
|
|||
queryFn: () => documentsApiService.searchDocumentTitles({ queryParams: prefetchParams }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["surfsense-docs-mention", "", false],
|
||||
queryFn: () =>
|
||||
documentsApiService.getSurfsenseDocs({
|
||||
queryParams: { page: 0, page_size: 20 },
|
||||
}),
|
||||
staleTime: 3 * 60 * 1000,
|
||||
});
|
||||
}, [searchSpaceId, queryClient]);
|
||||
|
||||
// Handle scroll to comment from URL query params (e.g., from inbox item click)
|
||||
|
|
@ -948,7 +940,6 @@ export default function NewChatPage() {
|
|||
trackChatMessageSent(searchSpaceId, currentThreadId, {
|
||||
hasAttachments: userImages.length > 0,
|
||||
hasMentionedDocuments:
|
||||
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
|
||||
mentionedDocumentIds.document_ids.length > 0 ||
|
||||
mentionedDocumentIds.folder_ids.length > 0 ||
|
||||
mentionedDocumentIds.connector_ids.length > 0,
|
||||
|
|
@ -1026,12 +1017,11 @@ export default function NewChatPage() {
|
|||
|
||||
// Get mentioned document IDs for context (separate fields for backend)
|
||||
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
|
||||
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
|
||||
const hasFolderIds = mentionedDocumentIds.folder_ids.length > 0;
|
||||
const hasConnectorIds = mentionedDocumentIds.connector_ids.length > 0;
|
||||
|
||||
// Clear mentioned documents after capturing them
|
||||
if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds || hasConnectorIds) {
|
||||
if (hasDocumentIds || hasFolderIds || hasConnectorIds) {
|
||||
setMentionedDocuments([]);
|
||||
}
|
||||
|
||||
|
|
@ -1053,9 +1043,6 @@ export default function NewChatPage() {
|
|||
mentioned_document_ids: hasDocumentIds
|
||||
? mentionedDocumentIds.document_ids
|
||||
: undefined,
|
||||
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
|
||||
? mentionedDocumentIds.surfsense_doc_ids
|
||||
: undefined,
|
||||
mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_ids : undefined,
|
||||
mentioned_connector_ids: hasConnectorIds
|
||||
? mentionedDocumentIds.connector_ids
|
||||
|
|
@ -1946,18 +1933,14 @@ export default function NewChatPage() {
|
|||
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
||||
localFilesystemEnabled,
|
||||
});
|
||||
// Partition the source mentions back into doc/surfsense_doc/folder
|
||||
// id buckets so the regenerate route can pass them to
|
||||
// ``stream_new_chat`` and the priority middleware sees the
|
||||
// same ``[USER-MENTIONED]`` priority entries the original
|
||||
// turn did. Without this partition the regenerate flow
|
||||
// silently dropped the agent's mention awareness — same
|
||||
// architectural bug we fixed on the new-chat path.
|
||||
const regenerateSurfsenseDocIds = sourceMentionedDocs
|
||||
.filter((d) => d.kind === "doc" && d.document_type === "SURFSENSE_DOCS")
|
||||
.map((d) => d.id);
|
||||
// Partition the source mentions back into doc/folder id buckets
|
||||
// so the regenerate route can pass them to ``stream_new_chat``
|
||||
// and the priority middleware sees the same ``[USER-MENTIONED]``
|
||||
// priority entries the original turn did. Without this partition
|
||||
// the regenerate flow silently dropped the agent's mention
|
||||
// awareness — same architectural bug we fixed on the new-chat path.
|
||||
const regenerateDocIds = sourceMentionedDocs
|
||||
.filter((d) => d.kind === "doc" && d.document_type !== "SURFSENSE_DOCS")
|
||||
.filter((d) => d.kind === "doc")
|
||||
.map((d) => d.id);
|
||||
const regenerateFolderIds = sourceMentionedDocs
|
||||
.filter((d) => d.kind === "folder")
|
||||
|
|
@ -1972,19 +1955,15 @@ export default function NewChatPage() {
|
|||
client_platform: selection.client_platform,
|
||||
local_filesystem_mounts: selection.local_filesystem_mounts,
|
||||
mentioned_document_ids: regenerateDocIds.length > 0 ? regenerateDocIds : undefined,
|
||||
mentioned_surfsense_doc_ids:
|
||||
regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined,
|
||||
mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
|
||||
mentioned_connector_ids:
|
||||
regenerateConnectors.length > 0 ? regenerateConnectors.map((d) => d.id) : undefined,
|
||||
mentioned_connectors:
|
||||
regenerateConnectors.length > 0 ? regenerateConnectors : undefined,
|
||||
mentioned_connectors: regenerateConnectors.length > 0 ? regenerateConnectors : undefined,
|
||||
// Full mention metadata for the regenerate-specific
|
||||
// source list. Only meaningful for edit (the BE only
|
||||
// re-persists a user row when ``user_query`` is set);
|
||||
// reload reuses the original turn's mentioned_documents.
|
||||
mentioned_documents:
|
||||
sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined,
|
||||
mentioned_documents: sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined,
|
||||
};
|
||||
if (isEdit) {
|
||||
requestBody.user_images = editExtras?.userImages ?? [];
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
deleteMemberMutationAtom,
|
||||
updateMemberMutationAtom,
|
||||
} from "@/atoms/members/members-mutation.atoms";
|
||||
import { membersAtom, myAccessAtom, canPerform } from "@/atoms/members/members-query.atoms";
|
||||
import { canPerform, membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
|
|||
223
surfsense_web/atoms/automations/automations-mutation.atoms.ts
Normal file
223
surfsense_web/atoms/automations/automations-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
AutomationCreateRequest,
|
||||
AutomationUpdateRequest,
|
||||
TriggerCreateRequest,
|
||||
TriggerUpdateRequest,
|
||||
} from "@/contracts/types/automation.types";
|
||||
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||
import {
|
||||
trackAutomationCreated,
|
||||
trackAutomationCreateFailed,
|
||||
trackAutomationDeleted,
|
||||
trackAutomationDeleteFailed,
|
||||
trackAutomationStatusChanged,
|
||||
trackAutomationTriggerAdded,
|
||||
trackAutomationTriggerAddFailed,
|
||||
trackAutomationTriggerRemoved,
|
||||
trackAutomationTriggerRemoveFailed,
|
||||
trackAutomationTriggerUpdated,
|
||||
trackAutomationTriggerUpdateFailed,
|
||||
trackAutomationUpdated,
|
||||
trackAutomationUpdateFailed,
|
||||
} from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
// Cache invalidation strategy:
|
||||
// - Automation writes invalidate the search-space list + the touched detail.
|
||||
// - Trigger writes only invalidate the parent automation detail (triggers
|
||||
// come back inline in AutomationDetail).
|
||||
// We deliberately invalidate the whole "automations" prefix on the list side
|
||||
// because list is keyed by (searchSpaceId, limit, offset) and we don't track
|
||||
// the active pagination in this layer.
|
||||
|
||||
function invalidateList(searchSpaceId: number) {
|
||||
queryClient.invalidateQueries({ queryKey: ["automations", "list", searchSpaceId] });
|
||||
}
|
||||
|
||||
function invalidateDetail(automationId: number) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.automations.detail(automationId),
|
||||
});
|
||||
}
|
||||
|
||||
export const createAutomationMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: AutomationCreateRequest) => {
|
||||
return automationsApiService.createAutomation(request);
|
||||
},
|
||||
onSuccess: (automation, variables) => {
|
||||
invalidateList(variables.search_space_id);
|
||||
toast.success("Automation created");
|
||||
trackAutomationCreated({
|
||||
search_space_id: variables.search_space_id,
|
||||
automation_id: automation.id,
|
||||
task_count: variables.definition.plan.length,
|
||||
trigger_type: variables.triggers?.[0]?.type ?? "none",
|
||||
has_schedule: (variables.triggers?.length ?? 0) > 0,
|
||||
agent_llm_id: variables.definition.models?.agent_llm_id,
|
||||
image_generation_config_id: variables.definition.models?.image_generation_config_id,
|
||||
vision_llm_config_id: variables.definition.models?.vision_llm_config_id,
|
||||
tags_count: variables.definition.metadata?.tags?.length,
|
||||
});
|
||||
},
|
||||
onError: (error: Error, variables) => {
|
||||
console.error("Error creating automation:", error);
|
||||
toast.error("Failed to create automation");
|
||||
trackAutomationCreateFailed({
|
||||
search_space_id: variables.search_space_id,
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export const updateAutomationMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (vars: { automationId: number; patch: AutomationUpdateRequest }) => {
|
||||
return automationsApiService.updateAutomation(vars.automationId, vars.patch);
|
||||
},
|
||||
onSuccess: (automation, vars) => {
|
||||
invalidateDetail(vars.automationId);
|
||||
invalidateList(automation.search_space_id);
|
||||
toast.success("Automation updated");
|
||||
// A status-only patch (pause/resume/archive) is a distinct action from a
|
||||
// definition/name edit, so split it into its own event.
|
||||
if (vars.patch.status && !vars.patch.definition) {
|
||||
trackAutomationStatusChanged({
|
||||
automation_id: vars.automationId,
|
||||
search_space_id: automation.search_space_id,
|
||||
next_status: vars.patch.status,
|
||||
});
|
||||
} else {
|
||||
trackAutomationUpdated({
|
||||
automation_id: vars.automationId,
|
||||
search_space_id: automation.search_space_id,
|
||||
has_definition_change: !!vars.patch.definition,
|
||||
has_name_change: vars.patch.name != null,
|
||||
has_description_change: vars.patch.description !== undefined,
|
||||
task_count: vars.patch.definition?.plan?.length,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error updating automation:", error);
|
||||
toast.error("Failed to update automation");
|
||||
trackAutomationUpdateFailed({
|
||||
automation_id: vars.automationId,
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export const deleteAutomationMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (vars: { automationId: number; searchSpaceId: number }) => {
|
||||
await automationsApiService.deleteAutomation(vars.automationId);
|
||||
return vars;
|
||||
},
|
||||
onSuccess: (vars) => {
|
||||
invalidateList(vars.searchSpaceId);
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Automation deleted");
|
||||
trackAutomationDeleted({
|
||||
automation_id: vars.automationId,
|
||||
search_space_id: vars.searchSpaceId,
|
||||
});
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error deleting automation:", error);
|
||||
toast.error("Failed to delete automation");
|
||||
trackAutomationDeleteFailed({
|
||||
automation_id: vars.automationId,
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export const addTriggerMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
|
||||
return automationsApiService.addTrigger(vars.automationId, vars.payload);
|
||||
},
|
||||
onSuccess: (trigger, vars) => {
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Trigger added");
|
||||
trackAutomationTriggerAdded({
|
||||
automation_id: vars.automationId,
|
||||
trigger_id: trigger.id,
|
||||
trigger_type: trigger.type,
|
||||
enabled: trigger.enabled,
|
||||
has_cron: !!trigger.params?.cron,
|
||||
});
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error adding trigger:", error);
|
||||
toast.error("Failed to add trigger");
|
||||
trackAutomationTriggerAddFailed({
|
||||
automation_id: vars.automationId,
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export const updateTriggerMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (vars: {
|
||||
automationId: number;
|
||||
triggerId: number;
|
||||
patch: TriggerUpdateRequest;
|
||||
}) => {
|
||||
return automationsApiService.updateTrigger(vars.automationId, vars.triggerId, vars.patch);
|
||||
},
|
||||
onSuccess: (_, vars) => {
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Trigger updated");
|
||||
const change: "enabled" | "params" | "other" = vars.patch.params
|
||||
? "params"
|
||||
: vars.patch.enabled !== undefined && vars.patch.enabled !== null
|
||||
? "enabled"
|
||||
: "other";
|
||||
trackAutomationTriggerUpdated({
|
||||
automation_id: vars.automationId,
|
||||
trigger_id: vars.triggerId,
|
||||
change,
|
||||
enabled: vars.patch.enabled ?? undefined,
|
||||
});
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error updating trigger:", error);
|
||||
toast.error("Failed to update trigger");
|
||||
trackAutomationTriggerUpdateFailed({
|
||||
automation_id: vars.automationId,
|
||||
trigger_id: vars.triggerId,
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export const removeTriggerMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (vars: { automationId: number; triggerId: number }) => {
|
||||
await automationsApiService.removeTrigger(vars.automationId, vars.triggerId);
|
||||
return vars;
|
||||
},
|
||||
onSuccess: (vars) => {
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Trigger removed");
|
||||
trackAutomationTriggerRemoved({
|
||||
automation_id: vars.automationId,
|
||||
trigger_id: vars.triggerId,
|
||||
});
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error removing trigger:", error);
|
||||
toast.error("Failed to remove trigger");
|
||||
trackAutomationTriggerRemoveFailed({
|
||||
automation_id: vars.automationId,
|
||||
trigger_id: vars.triggerId,
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
}));
|
||||
31
surfsense_web/atoms/automations/automations-query.atoms.ts
Normal file
31
surfsense_web/atoms/automations/automations-query.atoms.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
// First page of the active search space's automations.
|
||||
// Detail + paginated/parameterized reads live in hooks (see use-automation.ts,
|
||||
// use-automation-runs.ts) so atoms stay tied to "current scope" and don't
|
||||
// proliferate atom families for every (id, limit, offset) tuple.
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const DEFAULT_OFFSET = 0;
|
||||
|
||||
export const automationsListAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.automations.list(Number(searchSpaceId ?? 0), DEFAULT_LIMIT, DEFAULT_OFFSET),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!searchSpaceId) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
return automationsApiService.listAutomations({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
limit: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -102,10 +102,7 @@ export const mentionedDocumentIdsAtom = atom((get) => {
|
|||
const folders = deduped.filter((m) => m.kind === "folder");
|
||||
const connectors = deduped.filter((m) => m.kind === "connector");
|
||||
return {
|
||||
surfsense_doc_ids: docs
|
||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
document_ids: docs.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
document_ids: docs.map((doc) => doc.id),
|
||||
folder_ids: folders.map((f) => f.id),
|
||||
connector_ids: connectors.map((c) => c.id),
|
||||
connectors: connectors.map((c) => ({
|
||||
|
|
|
|||
|
|
@ -42,11 +42,11 @@ export const myAccessAtom = atomWithQuery((get) => {
|
|||
|
||||
/**
|
||||
* Helper function to check if the current user has a specific permission.
|
||||
*
|
||||
*
|
||||
* @param access - The access object from useAtomValue(myAccessAtom)
|
||||
* @param permission - The permission string to check
|
||||
* @returns boolean indicating if the user has the permission
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const access = useAtomValue(myAccessAtom);
|
||||
* if (canPerform(access, 'manage_members')) { ... }
|
||||
|
|
@ -63,10 +63,10 @@ export function canPerform(
|
|||
/**
|
||||
* Hook wrapper for canPerform that reads from myAccessAtom internally.
|
||||
* Use this if you want to avoid calling useAtomValue(myAccessAtom) separately.
|
||||
*
|
||||
*
|
||||
* @param permission - The permission string to check
|
||||
* @returns boolean indicating if the user has the permission
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const canManageMembers = usePermissionGate('manage_members');
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -118,6 +118,12 @@ export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
|
|||
cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
|
||||
(old: Record<string, unknown> | undefined) => ({ ...old, ...request.data })
|
||||
);
|
||||
// Automation eligibility is derived from these model preferences
|
||||
// (agent/image/vision). Invalidate it so the automations gate alert
|
||||
// reflects the new selection without a manual refresh.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.automations.modelEligibility(Number(searchSpaceId)),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update LLM preferences");
|
||||
|
|
|
|||
76
surfsense_web/changelog/content/2026-05-31.mdx
Normal file
76
surfsense_web/changelog/content/2026-05-31.mdx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
title: "SurfSense v0.0.26 - AI Automations: Build, Schedule & Event-Trigger AI Agents From Chat"
|
||||
description: "SurfSense v0.0.26 introduces open source AI automations across your connectors: describe a workflow in plain English and SurfSense builds it, run AI agents on a schedule, or trigger them when a document lands in a folder, working across Notion, Slack, Google Drive, Gmail, GitHub, Linear, Jira and Confluence. Plus connector @-mentions in chat and a faster anonymous chat experience."
|
||||
date: "2026-05-31"
|
||||
tags: ["Automations", "AI Agents", "Workflow Automation", "Scheduled Workflows", "Event Triggers", "Connectors", "Notion", "Slack", "Open Source"]
|
||||
version: "0.0.26"
|
||||
---
|
||||
|
||||
## What's New in v0.0.26
|
||||
|
||||
v0.0.26 is our biggest workflow release yet: **AI automations** land in SurfSense. If you've been looking for **open source AI automation**, a **self-hosted AI agent** that runs on a schedule, or **document workflow automation** that reacts the instant a file shows up, this release is for you. Describe what you want in plain English, let SurfSense draft the automation, approve it, and your agent runs on its own: on a **cron schedule** or triggered by real events in your knowledge base. Best of all, automations work **across your connectors**, so one workflow can search and act on **Notion**, **Slack**, **Google Drive**, **Gmail**, **GitHub**, **Linear**, **Jira** and **Confluence**. This release also brings connector **@-mentions** into chat, a faster anonymous chat experience, and a redesigned homepage.
|
||||
|
||||
### AI Automations
|
||||
|
||||
Turn one-off prompts into repeatable, hands-off **AI agent workflows**.
|
||||
|
||||
- **Build Automations From Chat**: Describe an automation in plain English and SurfSense drafts it for you. Review the generated workflow in a human-in-the-loop approval card, tweak it, and save. No config files, no code.
|
||||
- **Scheduled AI Workflows**: Run an agent on a **cron schedule** for daily briefs, weekly digests, and recurring reports. SurfSense computes the next run time and fires it automatically in the background.
|
||||
- **Event-Triggered Automations**: Kick off an agent the moment a document enters a folder. SurfSense's new in-process event bus watches your knowledge base and launches the right automation as soon as the event happens.
|
||||
- **Agent Task Action**: Every automation runs a full multi-agent chat turn, so your scheduled and event-driven runs have the same reasoning, search, and tool access as a live chat, with an auto-approve loop so they finish unattended.
|
||||
- **Automations Across Your Connectors**: Because each automation runs a full agent turn, your scheduled and event-triggered workflows can search and act across all 25+ connected sources, including **Notion**, **Slack**, **Google Drive**, **Gmail**, **GitHub**, **Linear**, **Jira**, **Confluence**, and your **Obsidian** vault.
|
||||
- **Automations Dashboard**: A dedicated list view shows every automation with its status, plus one-click pause, resume, and delete. Find it in the sidebar under Inbox.
|
||||
- **Detail & Run History**: Open any automation to inspect its definition, manage triggers inline, and browse a complete run history with inputs, outputs, and clear error reporting.
|
||||
- **Power-User JSON Mode**: Create or edit an automation directly as raw JSON with a unified JSON viewer and editor for full control over the workflow definition.
|
||||
- **Role-Based Access**: Automations are governed by SurfSense's RBAC system, with a dedicated permissions family so teams control exactly who can create, run, and manage them.
|
||||
|
||||
### Connector Mentions in Chat
|
||||
|
||||
- **@-Mention Your Connectors**: Mention connectors directly in the composer to scope a question to a specific source like **Notion**, **Slack**, **Google Drive**, **Gmail**, **GitHub**, **Linear**, **Jira**, or **Confluence**, alongside existing document mentions.
|
||||
- **Recent Mentions**: SurfSense now remembers your recent mentions so the sources you use most are always one keystroke away.
|
||||
- **Smoother Mention Picker**: A refreshed mention picker with loading skeletons, clearer connector definitions, and a better inline editing experience.
|
||||
|
||||
### Faster, Friendlier Chat
|
||||
|
||||
- **Anonymous Chat, Reworked**: The free, no-login chat experience has been rebuilt for a faster first impression and a cleaner anonymous-to-account path.
|
||||
- **Better Long-Running Turns**: Improved task management and timeout handling in multi-agent chat keep complex, tool-heavy conversations reliable.
|
||||
- **Leaner Toolset**: Retired the legacy in-product docs search tool to keep agent reasoning focused and fast.
|
||||
|
||||
### Homepage & Marketing
|
||||
|
||||
- **Redesigned Use-Case Showcase**: The homepage now groups demos into clear categories (Desktop App, Deliverable Studio, Search & Chat, Connectors & Integrations, and Automations) so visitors immediately see what SurfSense can do.
|
||||
- **Desktop App, Front and Center**: The desktop experience is highlighted as a set of native extras on top of everything SurfSense already does, not a separate product.
|
||||
|
||||
<Accordion type="multiple" className="w-full not-prose">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Bug Fixes</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>Bulk-moving documents now uses ORM objects so folder events fire correctly and trigger automations</li>
|
||||
<li>Automation enum columns now persist Postgres enum values instead of names</li>
|
||||
<li>Automation agent tasks use an in-memory checkpointer to avoid Celery pool timeouts</li>
|
||||
<li>The API client now handles 204 No Content responses without errors</li>
|
||||
<li>The model role manager now stays in sync when preferences are updated</li>
|
||||
<li>The JSON editor now coerces numeric strings to numbers on edit</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Under the Hood</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>New in-process domain event bus with an event catalog and a <code>document.entered_folder</code> event</li>
|
||||
<li>SQLAlchemy session hooks publish folder events automatically, registered at app startup</li>
|
||||
<li>Cron schedule triggers backed by croniter and a Celery beat tick task</li>
|
||||
<li>Sandboxed template engine with an allowlisted filter and test set for safe workflow templating</li>
|
||||
<li>Automations reorganized into a vertical-slice architecture (actions and triggers grouped by domain)</li>
|
||||
<li>Extensive new test coverage locking automation schemas, dispatch, runtime, triggers, and templating</li>
|
||||
<li>Model eligibility checks when creating automations, so only valid models are selectable</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
v0.0.26 turns SurfSense from a place you ask questions into a place that does the work for you. Whether you want **scheduled AI workflows**, **event-driven document automation**, or a **self-hosted, open source AI agent** you fully control, this release lets you build it from a single sentence.
|
||||
|
||||
SurfSense connects all your knowledge sources in one place.
|
||||
|
|
@ -52,7 +52,8 @@ export function AdUnit({
|
|||
// sets data-adsbygoogle-status="done" once it has filled a slot.
|
||||
if (el.getAttribute("data-adsbygoogle-status")) return;
|
||||
try {
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
window.adsbygoogle = window.adsbygoogle || [];
|
||||
window.adsbygoogle.push({});
|
||||
} catch {
|
||||
// AdSense throws if pushed before the script has loaded or on
|
||||
// duplicate pushes. The script processes pending pushes when it
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -49,7 +50,18 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
|
|||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Card className="group relative transition-all duration-200 hover:shadow-md">
|
||||
<Card className="group relative overflow-hidden transition-all duration-200 hover:shadow-md">
|
||||
{announcement.image && (
|
||||
<div className="relative aspect-video w-full overflow-hidden border-b bg-muted">
|
||||
<Image
|
||||
src={announcement.image.src}
|
||||
alt={announcement.image.alt}
|
||||
fill
|
||||
sizes="(max-width: 768px) 95vw, 600px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
|
|
|
|||
101
surfsense_web/components/announcements/AnnouncementSpotlight.tsx
Normal file
101
surfsense_web/components/announcements/AnnouncementSpotlight.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
|
||||
/**
|
||||
* Proactively shows important "spotlight" announcements in a blocking dialog.
|
||||
*
|
||||
* Behaviour:
|
||||
* - On load, the first active, audience-matched, unread spotlight announcement
|
||||
* is shown automatically.
|
||||
* - The user must explicitly acknowledge it ("Got it" or the CTA link), which
|
||||
* marks it as read so it never shows again.
|
||||
* - Closing via the X / Escape / outside-click only hides it for the current
|
||||
* session; it reappears on the next load until the user marks it as seen.
|
||||
*/
|
||||
export function AnnouncementSpotlight() {
|
||||
const { announcements, markRead } = useAnnouncements();
|
||||
const [sessionDismissed, setSessionDismissed] = useState<Set<string>>(() => new Set());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// Short delay so the spotlight doesn't flash during initial hydration/layout.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setReady(true), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const current = useMemo(
|
||||
() =>
|
||||
announcements.find(
|
||||
(a) => a.spotlight && a.isImportant && !a.isRead && !sessionDismissed.has(a.id)
|
||||
) ?? null,
|
||||
[announcements, sessionDismissed]
|
||||
);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const handleAcknowledge = () => {
|
||||
markRead(current.id);
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
setSessionDismissed((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.add(current.id);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={ready} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0">
|
||||
{current.image && (
|
||||
<div className="relative aspect-video w-full border-b bg-muted">
|
||||
<Image
|
||||
src={current.image.src}
|
||||
alt={current.image.alt}
|
||||
fill
|
||||
sizes="(max-width: 768px) 95vw, 448px"
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 p-6">
|
||||
<DialogTitle className="text-xl">{current.title}</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-relaxed text-muted-foreground">
|
||||
{current.description}
|
||||
</DialogDescription>
|
||||
<DialogFooter className="mt-2">
|
||||
{current.link && (
|
||||
<Button variant="outline" asChild className="gap-1.5" onClick={handleAcknowledge}>
|
||||
<Link
|
||||
href={current.link.url}
|
||||
target={current.link.url.startsWith("http") ? "_blank" : undefined}
|
||||
>
|
||||
{current.link.label}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleAcknowledge}>Got it</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -70,8 +70,10 @@ export function AnnouncementToastProvider() {
|
|||
const outerTimer = setTimeout(() => {
|
||||
const authed = isAuthenticated();
|
||||
const active = getActiveAnnouncements(announcements, authed);
|
||||
// Spotlight announcements are handled by the blocking spotlight dialog,
|
||||
// so skip them here to avoid double-notifying the user.
|
||||
const importantUntoasted = active.filter(
|
||||
(a) => a.isImportant && !isAnnouncementToasted(a.id)
|
||||
(a) => a.isImportant && !a.spotlight && !isAnnouncementToasted(a.id)
|
||||
);
|
||||
|
||||
for (let i = 0; i < importantUntoasted.length; i++) {
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import {
|
|||
CheckIcon,
|
||||
ClipboardPaste,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
Dot,
|
||||
DownloadIcon,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
MessageCircleReply,
|
||||
|
|
|
|||
|
|
@ -6,14 +6,13 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { useApiKey } from "@/hooks/use-api-key";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const PLUGIN_RELEASES_URL =
|
||||
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
|
||||
|
||||
|
||||
/**
|
||||
* Obsidian connect form for the plugin-only architecture.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
export interface CirclebackConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
|
|||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { MCPServiceConfig } from "../components/mcp-service-config";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
const VISION_LLM_CONNECTOR_TYPES = new Set<SearchSourceConnector["connector_type"]>([
|
||||
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { cn } from "@/lib/utils";
|
|||
import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
|
||||
import { useConnectorStatus } from "../hooks/use-connector-status";
|
||||
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
||||
|
||||
interface ConnectorAccountsListViewProps {
|
||||
connectorType: string;
|
||||
connectorTitle: string;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { ExternalLink, FileText } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { FileText } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { CitationPanelContent } from "@/components/citation-panel/citation-panel";
|
||||
import { Citation } from "@/components/tool-ui/citation";
|
||||
import { CitationHoverPopover } from "@/components/tool-ui/citation/citation-hover-popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
|
|
@ -19,21 +16,8 @@ import {
|
|||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
// Lazily load MarkdownViewer here to break the static import cycle:
|
||||
// `markdown-viewer.tsx` → `citation-renderer.tsx` → `inline-citation.tsx`
|
||||
// would otherwise pull `markdown-viewer.tsx` back in at module-init time.
|
||||
// Only `SurfsenseDocCitation` (popover body) ever renders this viewer, so
|
||||
// the lazy boundary is invisible to most call paths.
|
||||
const MarkdownViewer = dynamic(
|
||||
() => import("@/components/markdown-viewer").then((m) => m.MarkdownViewer),
|
||||
{ ssr: false, loading: () => <Spinner size="xs" /> }
|
||||
);
|
||||
|
||||
interface InlineCitationProps {
|
||||
chunkId: number;
|
||||
|
|
@ -41,9 +25,7 @@ interface InlineCitationProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Inline citation badge for knowledge-base chunks (numeric chunk IDs) and
|
||||
* Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
|
||||
* a static "doc" pill (anonymous/synthetic uploads).
|
||||
* Inline citation badge for knowledge-base chunks (numeric chunk IDs).
|
||||
*
|
||||
* Numeric KB chunks: clicking opens the citation panel in the right
|
||||
* sidebar (alongside the chat — does not replace it). The panel shows
|
||||
|
|
@ -51,12 +33,13 @@ interface InlineCitationProps {
|
|||
* `chunk_window`), with the cited one highlighted and an option to
|
||||
* expand the window or jump into the full document via the editor panel.
|
||||
*
|
||||
* Surfsense docs chunks: rendered as a hover-controlled shadcn Popover that
|
||||
* lazily fetches and previews the cited chunk inline, since those docs aren't
|
||||
* indexed into the user's search space and have no tab to open.
|
||||
* Negative chunk IDs and legacy SurfSense-docs chunks (`isDocsChunk`) render
|
||||
* as a static, non-interactive "doc" pill. The SurfSense product-docs feature
|
||||
* was removed, so those markers are inert (no fetch, no preview) — they only
|
||||
* survive in old persisted messages.
|
||||
*/
|
||||
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk = false }) => {
|
||||
if (chunkId < 0) {
|
||||
if (chunkId < 0 || isDocsChunk) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -68,15 +51,13 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
|
|||
doc
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Uploaded document</TooltipContent>
|
||||
<TooltipContent>
|
||||
{isDocsChunk ? "Documentation reference" : "Uploaded document"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDocsChunk) {
|
||||
return <SurfsenseDocCitation chunkId={chunkId} />;
|
||||
}
|
||||
|
||||
return <NumericChunkCitation chunkId={chunkId} />;
|
||||
};
|
||||
|
||||
|
|
@ -127,128 +108,6 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||
const isTouchLike = useMediaQuery("(hover: none), (pointer: coarse)");
|
||||
const [mobilePreviewOpen, setMobilePreviewOpen] = useState(false);
|
||||
const docQuery = useSurfsenseDocPreviewQuery(chunkId, mobilePreviewOpen);
|
||||
|
||||
const handleMobileClick = () => {
|
||||
setMobilePreviewOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CitationHoverPopover
|
||||
id={`doc-${chunkId}`}
|
||||
contentClassName="w-96 max-w-[calc(100vw-2rem)] p-0"
|
||||
align="start"
|
||||
trigger={(hoverProps) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={null}
|
||||
onClick={isTouchLike ? handleMobileClick : undefined}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
|
||||
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
|
||||
title="Surfsense documentation"
|
||||
{...hoverProps}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
doc
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SurfsenseDocPreview chunkId={chunkId} />
|
||||
</CitationHoverPopover>
|
||||
<Drawer
|
||||
open={mobilePreviewOpen}
|
||||
onOpenChange={setMobilePreviewOpen}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent className="max-h-[85vh] z-80" overlayClassName="z-80">
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="pb-0">
|
||||
<DrawerTitle>Surfsense documentation</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<SurfsenseDocPreviewContent
|
||||
chunkId={chunkId}
|
||||
query={docQuery}
|
||||
contentClassName="max-h-[60vh]"
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function useSurfsenseDocPreviewQuery(chunkId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
|
||||
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
type SurfsenseDocPreviewQuery = ReturnType<typeof useSurfsenseDocPreviewQuery>;
|
||||
|
||||
const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||
const query = useSurfsenseDocPreviewQuery(chunkId);
|
||||
|
||||
return <SurfsenseDocPreviewContent chunkId={chunkId} query={query} />;
|
||||
};
|
||||
|
||||
const SurfsenseDocPreviewContent: FC<{
|
||||
chunkId: number;
|
||||
query: SurfsenseDocPreviewQuery;
|
||||
contentClassName?: string;
|
||||
}> = ({ chunkId, query, contentClassName = "max-h-72" }) => {
|
||||
const { data, isLoading, error } = query;
|
||||
|
||||
const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{data?.title ?? "Surfsense documentation"}</p>
|
||||
<p className="text-[11px] text-muted-foreground">Chunk #{chunkId}</p>
|
||||
</div>
|
||||
{data?.public_url && (
|
||||
<a
|
||||
href={data.public_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-primary/10"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
Open
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${contentClassName} overflow-auto px-3 py-2 text-sm`}>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<Spinner size="xs" />
|
||||
<span className="text-xs">Loading…</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="py-4 text-xs text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load chunk"}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading && !error && citedChunk?.content && (
|
||||
<MarkdownViewer content={citedChunk.content} maxLength={1500} enableCitations />
|
||||
)}
|
||||
{!isLoading && !error && !citedChunk?.content && (
|
||||
<p className="py-4 text-xs text-muted-foreground">No content available.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
import { tryGetHostname } from "@/lib/url";
|
||||
|
||||
interface UrlCitationProps {
|
||||
|
|
|
|||
|
|
@ -97,7 +97,12 @@ interface InlineMentionEditorProps {
|
|||
onActionClose?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
||||
onDocumentRemove?: (docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => void;
|
||||
onDocumentRemove?: (
|
||||
docId: number,
|
||||
docType?: string,
|
||||
kind?: MentionKind,
|
||||
connectorType?: string
|
||||
) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
|
|
@ -171,9 +176,10 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
|
|||
{isFolder ? (
|
||||
<FolderIcon className="h-3 w-3" />
|
||||
) : isConnector ? (
|
||||
getConnectorIcon(element.connector_type ?? element.document_type ?? "UNKNOWN", "h-3 w-3") ?? (
|
||||
<PlugIcon className="h-3 w-3" />
|
||||
)
|
||||
(getConnectorIcon(
|
||||
element.connector_type ?? element.document_type ?? "UNKNOWN",
|
||||
"h-3 w-3"
|
||||
) ?? <PlugIcon className="h-3 w-3" />)
|
||||
) : (
|
||||
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
|
||||
)}
|
||||
|
|
@ -357,7 +363,11 @@ function getSelectionAnchorRect(root: HTMLElement | null): SuggestionAnchorRect
|
|||
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
|
||||
if (rect.width > 0 || rect.height > 0) return rectToAnchor(rect);
|
||||
|
||||
if (range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
|
||||
if (
|
||||
range.collapsed &&
|
||||
range.startContainer.nodeType === Node.TEXT_NODE &&
|
||||
range.startOffset > 0
|
||||
) {
|
||||
const fallbackRange = range.cloneRange();
|
||||
fallbackRange.setStart(range.startContainer, range.startOffset - 1);
|
||||
fallbackRange.setEnd(range.startContainer, range.startOffset);
|
||||
|
|
|
|||
|
|
@ -67,12 +67,8 @@ import {
|
|||
} from "@/components/assistant-ui/inline-mention-editor";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||
import { ChatExamplePrompts } from "@/components/new-chat/chat-example-prompts";
|
||||
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
|
||||
import {
|
||||
DocumentMentionPicker,
|
||||
promoteRecentMention,
|
||||
type DocumentMentionPickerRef,
|
||||
} from "../new-chat/document-mention-picker";
|
||||
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -112,6 +108,11 @@ import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
|
|||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DocumentMentionPicker,
|
||||
type DocumentMentionPickerRef,
|
||||
promoteRecentMention,
|
||||
} from "../new-chat/document-mention-picker";
|
||||
|
||||
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
|
||||
|
||||
|
|
@ -601,21 +602,24 @@ const Composer: FC = () => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const handleActionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
|
||||
const anchorPoint = getComposerSuggestionAnchorPoint(
|
||||
trigger.anchorRect,
|
||||
clipboardInitialText ? "bottom" : "top"
|
||||
);
|
||||
if (!anchorPoint) {
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
return;
|
||||
}
|
||||
setSuggestionAnchorPoint((current) => current ?? anchorPoint);
|
||||
setShowPromptPicker(true);
|
||||
setActionQuery(trigger.query);
|
||||
}, [clipboardInitialText]);
|
||||
const handleActionTrigger = useCallback(
|
||||
(trigger: SuggestionTriggerInfo) => {
|
||||
const anchorPoint = getComposerSuggestionAnchorPoint(
|
||||
trigger.anchorRect,
|
||||
clipboardInitialText ? "bottom" : "top"
|
||||
);
|
||||
if (!anchorPoint) {
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
return;
|
||||
}
|
||||
setSuggestionAnchorPoint((current) => current ?? anchorPoint);
|
||||
setShowPromptPicker(true);
|
||||
setActionQuery(trigger.query);
|
||||
},
|
||||
[clipboardInitialText]
|
||||
);
|
||||
|
||||
const handleActionClose = useCallback(() => {
|
||||
if (showPromptPicker) {
|
||||
|
|
@ -654,6 +658,15 @@ const Composer: FC = () => {
|
|||
[actionQuery, aui]
|
||||
);
|
||||
|
||||
const handleExampleSelect = useCallback(
|
||||
(prompt: string) => {
|
||||
editorRef.current?.setText(prompt);
|
||||
aui.composer().setText(prompt);
|
||||
editorRef.current?.focus();
|
||||
},
|
||||
[aui]
|
||||
);
|
||||
|
||||
const handleQuickAskSelect = useCallback(
|
||||
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
||||
if (!clipboardInitialText) return;
|
||||
|
|
@ -754,7 +767,12 @@ const Composer: FC = () => {
|
|||
]);
|
||||
|
||||
const handleDocumentRemove = useCallback(
|
||||
(docId: number, docType?: string, kind?: "doc" | "folder" | "connector", connectorType?: string) => {
|
||||
(
|
||||
docId: number,
|
||||
docType?: string,
|
||||
kind?: "doc" | "folder" | "connector",
|
||||
connectorType?: string
|
||||
) => {
|
||||
setMentionedDocuments((prev) => {
|
||||
const removedKey = getMentionDocKey({
|
||||
id: docId,
|
||||
|
|
@ -768,27 +786,30 @@ const Composer: FC = () => {
|
|||
[setMentionedDocuments]
|
||||
);
|
||||
|
||||
const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => {
|
||||
const parsedSearchSpaceId = Number(search_space_id);
|
||||
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
|
||||
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
|
||||
const handleDocumentsMention = useCallback(
|
||||
(mentions: MentionedDocumentInfo[]) => {
|
||||
const parsedSearchSpaceId = Number(search_space_id);
|
||||
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
|
||||
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
|
||||
|
||||
for (const mention of mentions) {
|
||||
const key = getMentionDocKey(mention);
|
||||
if (editorDocKeys.has(key)) continue;
|
||||
editorRef.current?.insertMentionChip(mention);
|
||||
if (Number.isFinite(parsedSearchSpaceId)) {
|
||||
promoteRecentMention(parsedSearchSpaceId, mention);
|
||||
for (const mention of mentions) {
|
||||
const key = getMentionDocKey(mention);
|
||||
if (editorDocKeys.has(key)) continue;
|
||||
editorRef.current?.insertMentionChip(mention);
|
||||
if (Number.isFinite(parsedSearchSpaceId)) {
|
||||
promoteRecentMention(parsedSearchSpaceId, mention);
|
||||
}
|
||||
// Track within the loop so a duplicate-in-batch can't double-insert.
|
||||
editorDocKeys.add(key);
|
||||
}
|
||||
// Track within the loop so a duplicate-in-batch can't double-insert.
|
||||
editorDocKeys.add(key);
|
||||
}
|
||||
|
||||
// Atom is reconciled by ``handleEditorChange`` via the editor's
|
||||
// onChange — no second write path here.
|
||||
setMentionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
}, [search_space_id]);
|
||||
// Atom is reconciled by ``handleEditorChange`` via the editor's
|
||||
// onChange — no second write path here.
|
||||
setMentionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
},
|
||||
[search_space_id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
|
|
@ -905,6 +926,7 @@ const Composer: FC = () => {
|
|||
isThreadEmpty={isThreadEmpty}
|
||||
onVisibleChange={setConnectToolsTrayVisible}
|
||||
/>
|
||||
{isThreadEmpty && <ChatExamplePrompts onSelect={handleExampleSelect} />}
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
|
|
@ -1582,7 +1604,7 @@ interface ToolGroup {
|
|||
const TOOL_GROUPS: ToolGroup[] = [
|
||||
{
|
||||
label: "Research",
|
||||
tools: ["search_surfsense_docs", "scrape_webpage"],
|
||||
tools: ["scrape_webpage"],
|
||||
},
|
||||
{
|
||||
label: "Generate",
|
||||
|
|
|
|||
|
|
@ -104,9 +104,9 @@ const UserTextPart: FC = () => {
|
|||
const icon = isFolder ? (
|
||||
<FolderIcon className="size-3.5" />
|
||||
) : isConnector ? (
|
||||
getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
|
||||
(getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
|
||||
<Plug className="size-3.5" />
|
||||
)
|
||||
))
|
||||
) : (
|
||||
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
|
||||
);
|
||||
|
|
@ -123,7 +123,9 @@ const UserTextPart: FC = () => {
|
|||
: segment.doc.title
|
||||
}
|
||||
onClick={
|
||||
isFolder || isConnector ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)
|
||||
isFolder || isConnector
|
||||
? undefined
|
||||
: () => handleOpenDoc(segment.doc.id, segment.doc.title)
|
||||
}
|
||||
className="mx-0.5"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { useElectronAPI } from "@/hooks/use-platform";
|
|||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const PlateEditor = dynamic(
|
||||
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
|
||||
{ ssr: false, loading: () => <EditorPanelSkeleton /> }
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ import { Button } from "@/components/ui/button";
|
|||
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
|
||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||
import { readSSEStream } from "@/lib/chat/streaming-state";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QuotaBar } from "./quota-bar";
|
||||
import { QuotaWarningBanner } from "./quota-warning-banner";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
|
|
@ -80,19 +81,16 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
|
|||
content: m.content,
|
||||
}));
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/public/anon-chat/stream`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
model_slug: modelSlug,
|
||||
messages: chatHistory,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
model_slug: modelSlug,
|
||||
messages: chatHistory,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type TokenUsageData,
|
||||
TokenUsageProvider,
|
||||
} from "@/components/assistant-ui/token-usage-context";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
import { TimelineDataUI } from "@/features/chat-messages/timeline";
|
||||
import {
|
||||
|
|
@ -101,11 +102,16 @@ export function FreeChatPage() {
|
|||
const anonMode = useAnonymousMode();
|
||||
const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
|
||||
const resetKey = anonMode.isAnonymous ? anonMode.resetKey : 0;
|
||||
const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true;
|
||||
|
||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [tokenUsageStore] = useState(() => createTokenUsageStore());
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
// Mirror the latest messages into a ref so onNew stays a stable callback
|
||||
// (it reads history on demand instead of depending on the array).
|
||||
const messagesRef = useRef<ThreadMessageLike[]>([]);
|
||||
messagesRef.current = messages;
|
||||
|
||||
// Turnstile CAPTCHA state
|
||||
const [captchaRequired, setCaptchaRequired] = useState(false);
|
||||
|
|
@ -152,6 +158,7 @@ export function FreeChatPage() {
|
|||
model_slug: modelSlug,
|
||||
messages: messageHistory,
|
||||
};
|
||||
if (!webSearchEnabled) reqBody.disabled_tools = ["web_search"];
|
||||
if (turnstileToken) reqBody.turnstile_token = turnstileToken;
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, {
|
||||
|
|
@ -301,7 +308,7 @@ export function FreeChatPage() {
|
|||
throw err;
|
||||
}
|
||||
},
|
||||
[modelSlug, tokenUsageStore]
|
||||
[modelSlug, tokenUsageStore, webSearchEnabled]
|
||||
);
|
||||
|
||||
const onNew = useCallback(
|
||||
|
|
@ -345,7 +352,7 @@ export function FreeChatPage() {
|
|||
},
|
||||
]);
|
||||
|
||||
const messageHistory = messages
|
||||
const messageHistory = messagesRef.current
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((m) => {
|
||||
let text = "";
|
||||
|
|
@ -395,7 +402,7 @@ export function FreeChatPage() {
|
|||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[messages, doStream]
|
||||
[modelSlug, anonMode, doStream]
|
||||
);
|
||||
|
||||
/** Called when Turnstile resolves successfully. Stores the token and auto-retries. */
|
||||
|
|
@ -481,19 +488,21 @@ export function FreeChatPage() {
|
|||
</div>
|
||||
|
||||
{captchaRequired && TURNSTILE_SITE_KEY && (
|
||||
<div className="flex flex-col items-center gap-3 border-b border-border/40 bg-muted/30 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
<span>Quick verification to continue chatting</span>
|
||||
</div>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={TURNSTILE_SITE_KEY}
|
||||
onSuccess={handleTurnstileSuccess}
|
||||
onError={() => turnstileRef.current?.reset()}
|
||||
onExpire={() => turnstileRef.current?.reset()}
|
||||
options={{ theme: "auto", size: "normal" }}
|
||||
/>
|
||||
<div className="flex justify-center border-b bg-muted/30 px-4 py-4">
|
||||
<Alert className="w-auto max-w-md">
|
||||
<ShieldCheck />
|
||||
<AlertTitle>Quick verification to continue chatting</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={TURNSTILE_SITE_KEY}
|
||||
onSuccess={handleTurnstileSuccess}
|
||||
onError={() => turnstileRef.current?.reset()}
|
||||
onExpire={() => turnstileRef.current?.reset()}
|
||||
options={{ theme: "auto", size: "normal" }}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { type FC, useCallback, useRef, useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
|
|
@ -71,10 +72,11 @@ export const FreeComposer: FC = () => {
|
|||
const { gate } = useLoginGate();
|
||||
const anonMode = useAnonymousMode();
|
||||
const [text, setText] = useState("");
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasUploadedDoc = anonMode.isAnonymous && anonMode.uploadedDoc !== null;
|
||||
const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true;
|
||||
const setWebSearchEnabled = anonMode.isAnonymous ? anonMode.setWebSearchEnabled : () => {};
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
|
|
@ -189,14 +191,11 @@ export const FreeComposer: FC = () => {
|
|||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleUploadClick}
|
||||
className={cn(
|
||||
"h-auto gap-1.5 rounded-md px-2 py-1 text-xs transition-colors",
|
||||
"text-muted-foreground hover:text-accent-foreground hover:bg-accent",
|
||||
hasUploadedDoc && "text-primary"
|
||||
)}
|
||||
className={cn(hasUploadedDoc && "text-primary")}
|
||||
>
|
||||
<Paperclip className="size-3.5" />
|
||||
<Paperclip data-icon="inline-start" />
|
||||
{hasUploadedDoc ? "1/1" : "Upload"}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -207,13 +206,13 @@ export const FreeComposer: FC = () => {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="h-4 w-px bg-border/60" />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label
|
||||
htmlFor="free-web-search-toggle"
|
||||
className="flex items-center gap-1.5 cursor-pointer select-none rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-accent-foreground hover:bg-accent transition-colors"
|
||||
className="flex cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
<span className="hidden sm:inline">Web</span>
|
||||
|
|
@ -221,7 +220,6 @@ export const FreeComposer: FC = () => {
|
|||
id="free-web-search-toggle"
|
||||
checked={webSearchEnabled}
|
||||
onCheckedChange={setWebSearchEnabled}
|
||||
className="scale-75"
|
||||
/>
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { Bot, Check, ChevronDown, Search } from "lucide-react";
|
||||
import { Bot, Check, ChevronDown } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
import type { AnonModel } from "@/contracts/types/anonymous-chat.types";
|
||||
|
|
@ -19,21 +27,18 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
|||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [models, setModels] = useState<AnonModel[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
anonymousChatApiService.getModels().then(setModels).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
if (next) {
|
||||
setSearchQuery("");
|
||||
setFocusedIndex(-1);
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
}
|
||||
setOpen(next);
|
||||
const controller = new AbortController();
|
||||
anonymousChatApiService
|
||||
.getModels()
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) setModels(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!controller.signal.aborted) console.error(err);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const currentModel = useMemo(
|
||||
|
|
@ -41,22 +46,12 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
|||
[models, currentSlug]
|
||||
);
|
||||
|
||||
// Free models first, premium last; immutable sort to avoid mutating state.
|
||||
const sortedModels = useMemo(
|
||||
() => [...models].sort((a, b) => Number(a.is_premium) - Number(b.is_premium)),
|
||||
() => models.toSorted((a, b) => Number(a.is_premium) - Number(b.is_premium)),
|
||||
[models]
|
||||
);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchQuery.trim()) return sortedModels;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return sortedModels.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.model_name.toLowerCase().includes(q) ||
|
||||
m.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [sortedModels, searchQuery]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(model: AnonModel) => {
|
||||
setOpen(false);
|
||||
|
|
@ -70,42 +65,15 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
|||
[currentSlug, anonMode, router]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const count = filteredModels.length;
|
||||
if (count === 0) return;
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setFocusedIndex((p) => (p < count - 1 ? p + 1 : 0));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setFocusedIndex((p) => (p > 0 ? p - 1 : count - 1));
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (focusedIndex >= 0 && focusedIndex < count) {
|
||||
handleSelect(filteredModels[focusedIndex]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[filteredModels, focusedIndex, handleSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"h-8 gap-2 px-3 text-sm bg-muted hover:bg-muted/80 border-0 select-none",
|
||||
className
|
||||
)}
|
||||
className={cn("gap-2 bg-muted hover:bg-muted/80", className)}
|
||||
>
|
||||
{currentModel ? (
|
||||
<>
|
||||
|
|
@ -118,90 +86,47 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
|||
<span className="text-muted-foreground">Select Model</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0" />
|
||||
<ChevronDown className="ml-1 size-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[320px] p-0 rounded-lg shadow-lg overflow-hidden select-none"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search models"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full pl-8 pr-3 py-2.5 text-sm bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-[320px] py-1 space-y-0.5">
|
||||
{filteredModels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 px-4">
|
||||
<Search className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No models found</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredModels.map((model, index) => {
|
||||
const isSelected = model.seo_slug === currentSlug;
|
||||
const isFocused = focusedIndex === index;
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => handleSelect(model)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect(model);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer",
|
||||
"transition-colors duration-150 mx-2",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isFocused && "bg-accent text-accent-foreground",
|
||||
isSelected && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(model.provider, { className: "size-5" })}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-sm truncate">{model.name}</span>
|
||||
{model.is_premium ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
|
||||
>
|
||||
Premium
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
<PopoverContent className="w-[320px] p-0" align="start" sideOffset={8}>
|
||||
<Command
|
||||
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
|
||||
>
|
||||
<CommandInput placeholder="Search models" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sortedModels.map((model) => {
|
||||
const isSelected = model.seo_slug === currentSlug;
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={`${model.name} ${model.model_name} ${model.provider}`}
|
||||
onSelect={() => handleSelect(model)}
|
||||
className="gap-2.5"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(model.provider, { className: "size-5" })}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{model.model_name}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && <Check className="size-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{model.name}</span>
|
||||
<Badge variant={model.is_premium ? "default" : "secondary"}>
|
||||
{model.is_premium ? "Premium" : "Free"}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{model.model_name}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && <Check className="size-4 shrink-0 text-primary" />}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ import { Lock } from "lucide-react";
|
|||
import Link from "next/link";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
|
||||
interface GatedTabProps {
|
||||
title: string;
|
||||
|
|
@ -11,16 +19,20 @@ interface GatedTabProps {
|
|||
}
|
||||
|
||||
const GatedTab: FC<GatedTabProps> = ({ title, description }) => (
|
||||
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Lock className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-[200px]">{description}</p>
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/register">Create Free Account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Lock />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{title}</EmptyTitle>
|
||||
<EmptyDescription>{description}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/register">Create Free Account</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
);
|
||||
|
||||
export const ReportsGatedPlaceholder: FC = () => (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { OctagonAlert, Orbit } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -19,38 +20,30 @@ export function QuotaBar({ used, limit, warningThreshold, className }: QuotaBarP
|
|||
const isExceeded = used >= limit;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{used.toLocaleString()} / {limit.toLocaleString()} tokens
|
||||
</span>
|
||||
{isExceeded ? (
|
||||
<span className="font-medium text-red-500">Limit reached</span>
|
||||
<span className="font-medium text-destructive">Limit reached</span>
|
||||
) : isWarning ? (
|
||||
<span className="font-medium text-amber-500 flex items-center gap-1">
|
||||
<OctagonAlert className="h-3 w-3" />
|
||||
<span className="flex items-center gap-1 font-medium text-highlight">
|
||||
<OctagonAlert className="size-3" />
|
||||
{remaining.toLocaleString()} remaining
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{percentage.toFixed(0)}%</span>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={percentage}
|
||||
className={cn(
|
||||
"h-1.5",
|
||||
isExceeded && "[&>div]:bg-red-500",
|
||||
isWarning && !isExceeded && "[&>div]:bg-amber-500"
|
||||
)}
|
||||
/>
|
||||
<Progress value={percentage} className="h-1.5" />
|
||||
{isExceeded && (
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex items-center justify-center gap-1.5 rounded-md bg-linear-to-r from-purple-600 to-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Orbit className="h-3 w-3" />
|
||||
Create free account for 5M more tokens
|
||||
</Link>
|
||||
<Button asChild size="sm" className="mt-0.5 w-full">
|
||||
<Link href="/register">
|
||||
<Orbit data-icon="inline-start" />
|
||||
Create free account for 5M more tokens
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { OctagonAlert, Orbit, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -27,61 +28,46 @@ export function QuotaWarningBanner({
|
|||
|
||||
if (isExceeded) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/50 p-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<OctagonAlert className="h-5 w-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Free token limit reached
|
||||
</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-300">
|
||||
You've used all {limit.toLocaleString()} free tokens. Create a free account to
|
||||
get $5 of premium credit and access to all models.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-linear-to-r from-purple-600 to-blue-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Orbit className="h-4 w-4" />
|
||||
<Alert variant="destructive" className={className}>
|
||||
<OctagonAlert />
|
||||
<AlertTitle>Free token limit reached</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You've used all {limit.toLocaleString()} free tokens. Create a free account to get
|
||||
$5 of premium credit and access to all models.
|
||||
</p>
|
||||
<Button asChild size="sm" className="mt-1">
|
||||
<Link href="/register">
|
||||
<Orbit data-icon="inline-start" />
|
||||
Create Free Account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/50 p-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<OctagonAlert className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<p className="flex-1 text-xs text-amber-700 dark:text-amber-300">
|
||||
You've used {used.toLocaleString()} of {limit.toLocaleString()} free tokens.{" "}
|
||||
<Link href="/register" className="font-medium underline hover:no-underline">
|
||||
Create an account
|
||||
</Link>{" "}
|
||||
for $5 of premium credit.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="size-6 text-amber-400 hover:bg-transparent hover:text-amber-600 dark:hover:text-amber-200"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Alert variant="warning" className={cn("pr-10", className)}>
|
||||
<OctagonAlert />
|
||||
<AlertTitle>Running low on free tokens</AlertTitle>
|
||||
<AlertDescription>
|
||||
You've used {used.toLocaleString()} of {limit.toLocaleString()} free tokens.{" "}
|
||||
<Link href="/register" className="font-medium underline hover:no-underline">
|
||||
Create an account
|
||||
</Link>{" "}
|
||||
for $5 of premium credit.
|
||||
</AlertDescription>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="Dismiss"
|
||||
className="absolute top-2 right-2 size-6"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
IconBinaryTree,
|
||||
IconBolt,
|
||||
IconMessage,
|
||||
IconMicrophone,
|
||||
IconSearch,
|
||||
|
|
@ -709,6 +710,236 @@ const AiSortIllustration = () => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const AutomationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-indigo-50 via-violet-50 to-fuchsia-50 dark:from-indigo-950/20 dark:via-violet-950/20 dark:to-fuchsia-950/20 p-4">
|
||||
<svg viewBox="0 0 800 200" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>
|
||||
AI automation flow illustration showing a trigger starting an AI agent that acts across
|
||||
connectors
|
||||
</title>
|
||||
|
||||
{/* Animated flow connectors */}
|
||||
<g
|
||||
className="stroke-violet-500 dark:stroke-violet-400"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
>
|
||||
<path d="M 215 100 L 320 100" strokeDasharray="6,6">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="12"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<path d="M 480 100 L 585 100" strokeDasharray="6,6">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="12"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
<g className="fill-violet-500 dark:fill-violet-400" opacity="0.7">
|
||||
<polygon points="320,100 312,95 312,105" />
|
||||
<polygon points="585,100 577,95 577,105" />
|
||||
</g>
|
||||
|
||||
{/* Trigger node */}
|
||||
<g>
|
||||
<rect
|
||||
x="40"
|
||||
y="60"
|
||||
width="175"
|
||||
height="80"
|
||||
rx="14"
|
||||
className="fill-white dark:fill-neutral-800 stroke-indigo-300 dark:stroke-indigo-700"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x="127"
|
||||
y="50"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
className="fill-indigo-600 dark:fill-indigo-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Trigger
|
||||
</text>
|
||||
{/* Schedule chip */}
|
||||
<g transform="translate(58, 80)">
|
||||
<rect
|
||||
width="64"
|
||||
height="22"
|
||||
rx="11"
|
||||
className="fill-indigo-100 dark:fill-indigo-900/50"
|
||||
/>
|
||||
<circle
|
||||
cx="14"
|
||||
cy="11"
|
||||
r="6"
|
||||
className="fill-none stroke-indigo-500 dark:stroke-indigo-400"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="14"
|
||||
y1="11"
|
||||
x2="14"
|
||||
y2="7"
|
||||
className="stroke-indigo-500 dark:stroke-indigo-400"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="14"
|
||||
y1="11"
|
||||
x2="17"
|
||||
y2="13"
|
||||
className="stroke-indigo-500 dark:stroke-indigo-400"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x="38"
|
||||
y="15"
|
||||
fontSize="9"
|
||||
fontWeight="500"
|
||||
className="fill-indigo-700 dark:fill-indigo-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Cron
|
||||
</text>
|
||||
</g>
|
||||
{/* Event chip */}
|
||||
<g transform="translate(58, 108)">
|
||||
<rect
|
||||
width="64"
|
||||
height="22"
|
||||
rx="11"
|
||||
className="fill-fuchsia-100 dark:fill-fuchsia-900/40"
|
||||
/>
|
||||
<path
|
||||
d="M 13 5 L 9 13 L 14 13 L 11 19 L 18 10 L 13 10 Z"
|
||||
className="fill-fuchsia-500 dark:fill-fuchsia-400"
|
||||
/>
|
||||
<text
|
||||
x="40"
|
||||
y="15"
|
||||
fontSize="9"
|
||||
fontWeight="500"
|
||||
className="fill-fuchsia-700 dark:fill-fuchsia-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Event
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* AI Agent core */}
|
||||
<g>
|
||||
<rect
|
||||
x="320"
|
||||
y="50"
|
||||
width="160"
|
||||
height="100"
|
||||
rx="16"
|
||||
className="fill-white dark:fill-neutral-800 stroke-violet-400 dark:stroke-violet-500"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<text
|
||||
x="400"
|
||||
y="40"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
className="fill-violet-600 dark:fill-violet-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
AI Agent
|
||||
</text>
|
||||
{/* Sparkle */}
|
||||
<g transform="translate(400, 92)">
|
||||
<path
|
||||
d="M 0,-22 L 5,-7 L 20,-5 L 7,5 L 10,20 L 0,12 L -10,20 L -7,5 L -20,-5 L -5,-7 Z"
|
||||
className="fill-violet-500 dark:fill-violet-400"
|
||||
opacity="0.9"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0"
|
||||
to="360"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<circle cx="0" cy="0" r="4" className="fill-white dark:fill-violet-200">
|
||||
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Actions across connectors */}
|
||||
<g>
|
||||
<rect
|
||||
x="585"
|
||||
y="60"
|
||||
width="175"
|
||||
height="80"
|
||||
rx="14"
|
||||
className="fill-white dark:fill-neutral-800 stroke-fuchsia-300 dark:stroke-fuchsia-700"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x="672"
|
||||
y="50"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
className="fill-fuchsia-600 dark:fill-fuchsia-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Act on Connectors
|
||||
</text>
|
||||
<g>
|
||||
<circle cx="618" cy="100" r="13" className="fill-indigo-100 dark:fill-indigo-900/50" />
|
||||
<circle cx="650" cy="100" r="13" className="fill-violet-100 dark:fill-violet-900/50" />
|
||||
<circle cx="682" cy="100" r="13" className="fill-fuchsia-100 dark:fill-fuchsia-900/50" />
|
||||
<circle cx="714" cy="100" r="13" className="fill-pink-100 dark:fill-pink-900/40" />
|
||||
<text
|
||||
x="730"
|
||||
y="104"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
className="fill-fuchsia-600 dark:fill-fuchsia-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
25+
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Sparkle accents */}
|
||||
<g className="opacity-60">
|
||||
<circle cx="270" cy="70" r="2" className="fill-violet-400">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="530" cy="130" r="2" className="fill-fuchsia-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Find, Ask, Act",
|
||||
|
|
@ -749,4 +980,12 @@ const items = [
|
|||
className: "md:col-span-1",
|
||||
icon: <IconMessage className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Automate Your Workflows",
|
||||
description:
|
||||
"Describe an AI agent in plain English and SurfSense builds it. Run it on a schedule or trigger it when a document lands, acting across all your connectors hands-free.",
|
||||
header: <AutomationIllustration />,
|
||||
className: "md:col-span-3",
|
||||
icon: <IconBolt className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
"use client";
|
||||
import { ChevronDown, Download, Monitor } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
CornerDownLeft,
|
||||
Download,
|
||||
Lightbulb,
|
||||
Monitor,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import Balancer from "react-wrap-balancer";
|
||||
|
|
@ -11,7 +19,18 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { ExpandedMediaOverlay, useExpandedMedia } from "@/components/ui/expanded-gif-overlay";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
GITHUB_RELEASES_URL,
|
||||
|
|
@ -50,96 +69,215 @@ const GoogleLogo = ({ className }: { className?: string }) => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
const TAB_ITEMS = [
|
||||
type HeroUseCase = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
src: string | null;
|
||||
comingSoon?: boolean;
|
||||
examples?: string[];
|
||||
};
|
||||
|
||||
type HeroCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
desktopOnly?: boolean;
|
||||
useCases: HeroUseCase[];
|
||||
};
|
||||
|
||||
const HERO_TUTORIAL = "/homepage/hero_tutorial";
|
||||
const HERO_REALTIME = "/homepage/hero_realtime";
|
||||
|
||||
const CATEGORIES: HeroCategory[] = [
|
||||
{
|
||||
title: "General Assist",
|
||||
description: "Launch SurfSense instantly from any application.",
|
||||
src: "/homepage/hero_tutorial/general_assist.mp4",
|
||||
featured: true,
|
||||
id: "desktop",
|
||||
label: "Desktop App",
|
||||
desktopOnly: true,
|
||||
useCases: [
|
||||
{
|
||||
id: "general",
|
||||
title: "General Assist",
|
||||
description: "Launch SurfSense instantly from any application with a global shortcut.",
|
||||
src: `${HERO_TUTORIAL}/general_assist.mp4`,
|
||||
},
|
||||
{
|
||||
id: "quick",
|
||||
title: "Quick Assist",
|
||||
description: "Select text anywhere, then ask AI to explain, rewrite, or act on it.",
|
||||
src: `${HERO_TUTORIAL}/quick_assist.mp4`,
|
||||
},
|
||||
{
|
||||
id: "screenshot",
|
||||
title: "Screenshot Assist",
|
||||
description: "Capture any region of your screen and ask AI about what’s in it.",
|
||||
src: `${HERO_TUTORIAL}/screenshot_assist.mp4`,
|
||||
},
|
||||
{
|
||||
id: "watch-folder",
|
||||
title: "Watch Local Folder",
|
||||
description: "Auto-sync a local folder to your knowledge base. Great for Obsidian vaults.",
|
||||
src: `${HERO_TUTORIAL}/folder_watch.mp4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Quick Assist",
|
||||
description: "Select text anywhere, then ask AI to explain, rewrite, or act on it.",
|
||||
src: "/homepage/hero_tutorial/quick_assist.mp4",
|
||||
featured: true,
|
||||
id: "deliverables",
|
||||
label: "Deliverable Studio",
|
||||
useCases: [
|
||||
{
|
||||
id: "report",
|
||||
title: "AI Report Generator",
|
||||
description:
|
||||
"Generate cited research reports from your documents, then export to PDF or Markdown.",
|
||||
src: `${HERO_TUTORIAL}/ReportGenGif_compressed.mp4`,
|
||||
},
|
||||
{
|
||||
id: "podcast",
|
||||
title: "AI Podcast Generator",
|
||||
description: "Turn any document or folder into a two-host AI podcast in under 20 seconds.",
|
||||
src: `${HERO_TUTORIAL}/PodcastGenGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "presentation",
|
||||
title: "AI Presentation & Video Maker",
|
||||
description: "Create editable slide decks and narrated video overviews from your sources.",
|
||||
src: `${HERO_TUTORIAL}/video_gen_surf.mp4`,
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
title: "AI Image Generator",
|
||||
description: "Generate high-quality images straight from your chats and documents.",
|
||||
src: `${HERO_TUTORIAL}/ImageGenGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "resume",
|
||||
title: "AI Resume Builder",
|
||||
description: "Tailor your existing resume to any job description and beat the ATS.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Tailor my resume to this job description so it gets past ATS and lands an interview.",
|
||||
"Optimize my resume for ATS by matching the keywords in this job posting.",
|
||||
"Rewrite my resume bullet points to highlight the skills this role is asking for.",
|
||||
"Compare my resume against this job description and list the gaps to fix.",
|
||||
"Write a matching cover letter from my resume and this job description.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Screenshot Assist",
|
||||
description:
|
||||
"Use a global shortcut to select a region on your screen and attach it to your chat message.",
|
||||
src: "/homepage/hero_tutorial/screenshot_assist.mp4",
|
||||
featured: true,
|
||||
id: "automations",
|
||||
label: "Automations",
|
||||
useCases: [
|
||||
{
|
||||
id: "schedule",
|
||||
title: "Scheduled AI Workflows",
|
||||
description: "Run an agent on a schedule: daily briefs, weekly digests, recurring reports.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Email me a daily brief of new documents in my knowledge base every morning.",
|
||||
"Generate a weekly status report from my Slack and Gmail every Friday.",
|
||||
"Run a monthly competitor analysis report and save it to my workspace.",
|
||||
"Summarize my GitHub and Linear activity into a daily standup update.",
|
||||
"Create a recurring weekly research report on the topics I track.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "event",
|
||||
title: "Event-Triggered Automations",
|
||||
description:
|
||||
"Fire an agent the moment a document lands in a folder, then post the result to your tools.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"When a PDF lands in my Research folder, generate a cited AI summary.",
|
||||
"When new meeting notes are added, turn them into meeting minutes with action items.",
|
||||
"When an invoice is uploaded, extract the vendor, total, and due date into a table.",
|
||||
"When a contract enters my Legal folder, flag key terms and renewal dates.",
|
||||
"When a resume is added to Candidates, screen it against the job description.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chat-built",
|
||||
title: "Chat-Built Automations",
|
||||
description: "Describe an automation in plain English and SurfSense builds it for you.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Build an AI agent that emails me a summary of new Notion pages each morning.",
|
||||
"Create a no-code automation that posts a weekly research digest to Slack.",
|
||||
"Set up an AI note taker that turns new meeting notes into minutes.",
|
||||
"Make a workflow that extracts action items from meeting notes and assigns owners.",
|
||||
"Automate a daily email brief from my Gmail and Google Drive.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Watch Local Folder",
|
||||
description:
|
||||
"Watch a local folder and automatically sync file changes to your knowledge base. Works great with Obsidian vaults.",
|
||||
src: "/homepage/hero_tutorial/folder_watch.mp4",
|
||||
featured: true,
|
||||
},
|
||||
// {
|
||||
// title: "Connect & Sync",
|
||||
// description:
|
||||
// "Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.",
|
||||
// src: "/homepage/hero_tutorial/ConnectorFlowGif.mp4",
|
||||
// featured: true,
|
||||
// },
|
||||
// {
|
||||
// title: "Upload Documents",
|
||||
// description: "Upload documents directly, from images to massive PDFs.",
|
||||
// src: "/homepage/hero_tutorial/DocUploadGif.mp4",
|
||||
// featured: true,
|
||||
// },
|
||||
{
|
||||
title: "Video & Presentations",
|
||||
description:
|
||||
"Create short videos and editable presentations with AI-generated visuals and narration from your sources.",
|
||||
src: "/homepage/hero_tutorial/video_gen_surf.mp4",
|
||||
featured: false,
|
||||
id: "search-chat",
|
||||
label: "Search & Chat",
|
||||
useCases: [
|
||||
{
|
||||
id: "chat-docs",
|
||||
title: "Chat With Your PDFs & Docs",
|
||||
description: "Ask questions across all your files and get answers with inline citations.",
|
||||
src: `${HERO_TUTORIAL}/BQnaGif_compressed.mp4`,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
title: "AI Search With Citations",
|
||||
description: "Hybrid semantic and keyword search across your entire knowledge base.",
|
||||
src: `${HERO_TUTORIAL}/BSNCGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "collab",
|
||||
title: "Collaborative AI Chat",
|
||||
description: "Work on AI conversations with your team in real time.",
|
||||
src: `${HERO_REALTIME}/RealTimeChatGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "comments",
|
||||
title: "Comments & Mentions",
|
||||
description: "Comment and tag teammates on any AI message.",
|
||||
src: `${HERO_REALTIME}/RealTimeCommentsFlow.mp4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Search & Citation",
|
||||
description: "Ask questions and get cited responses from your knowledge base.",
|
||||
src: "/homepage/hero_tutorial/BSNCGif.mp4",
|
||||
featured: false,
|
||||
id: "connectors",
|
||||
label: "Connectors & Integrations",
|
||||
useCases: [
|
||||
{
|
||||
id: "connect",
|
||||
title: "Connect & Sync Your Tools",
|
||||
description:
|
||||
"Sync Notion, Slack, Google Drive, Gmail, GitHub, Linear and 25+ sources into one searchable corpus.",
|
||||
src: `${HERO_TUTORIAL}/ConnectorFlowGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "upload",
|
||||
title: "Chat With Uploaded Files",
|
||||
description: "Drop in PDFs, Office docs, images and audio. Instantly searchable.",
|
||||
src: `${HERO_TUTORIAL}/DocUploadGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "write-back",
|
||||
title: "Connector Write-Back",
|
||||
description: "Let the agent post results back to Notion, Slack, Linear and Drive.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Post this research summary to my Notion workspace.",
|
||||
"Send these meeting action items to our team Slack channel.",
|
||||
"Create a Jira ticket from this bug report.",
|
||||
"Open a Linear issue from this feature request.",
|
||||
"Save this generated report to Google Drive as a doc.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Document Q&A",
|
||||
description: "Mention specific documents in chat for targeted answers.",
|
||||
src: "/homepage/hero_tutorial/BQnaGif_compressed.mp4",
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
description: "Generate reports from your sources in many formats.",
|
||||
src: "/homepage/hero_tutorial/ReportGenGif_compressed.mp4",
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
title: "Podcasts",
|
||||
description: "Turn anything into a podcast in under 20 seconds.",
|
||||
src: "/homepage/hero_tutorial/PodcastGenGif.mp4",
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
title: "Image Generation",
|
||||
description: "Generate high-quality images easily from your conversations.",
|
||||
src: "/homepage/hero_tutorial/ImageGenGif.mp4",
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
title: "Collaborative Chat",
|
||||
description: "Collaborate on AI-powered conversations in realtime with your team.",
|
||||
src: "/homepage/hero_realtime/RealTimeChatGif.mp4",
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
title: "Comments",
|
||||
description: "Add comments and tag teammates on any message.",
|
||||
src: "/homepage/hero_realtime/RealTimeCommentsFlow.mp4",
|
||||
featured: false,
|
||||
},
|
||||
] as const;
|
||||
];
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
|
|
@ -279,117 +417,15 @@ function DownloadButton() {
|
|||
);
|
||||
}
|
||||
|
||||
const BrowserWindow = () => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const selectedItem = TAB_ITEMS[selectedIndex];
|
||||
const { expanded, open, close } = useExpandedMedia();
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div className="relative my-4 flex w-full flex-col items-start justify-start overflow-hidden rounded-2xl shadow-2xl md:my-12">
|
||||
<div className="flex w-full items-center justify-start overflow-hidden bg-gray-200 py-4 pl-4 dark:bg-neutral-800">
|
||||
<div className="mr-6 flex items-center gap-2">
|
||||
<div className="size-3 rounded-full bg-red-500" />
|
||||
<div className="size-3 rounded-full bg-yellow-500" />
|
||||
<div className="size-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
<div className="no-visible-scrollbar flex min-w-0 shrink flex-row items-center justify-start gap-2 overflow-x-auto mask-l-from-98% py-0.5 pr-2 pl-2 md:pl-4">
|
||||
{TAB_ITEMS.map((item, index) => (
|
||||
<React.Fragment key={item.title}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
"h-auto shrink-0 gap-1.5 rounded-md px-2 py-1 text-xs transition duration-150 hover:bg-white sm:text-sm dark:hover:bg-neutral-950",
|
||||
selectedIndex === index &&
|
||||
!item.featured &&
|
||||
"bg-white shadow ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-900",
|
||||
selectedIndex === index &&
|
||||
item.featured &&
|
||||
"bg-amber-50 shadow ring-1 shadow-amber-200/50 ring-amber-400/60 dark:bg-amber-950/40 dark:shadow-amber-900/30 dark:ring-amber-500/50",
|
||||
item.featured &&
|
||||
selectedIndex !== index &&
|
||||
"hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
{item.featured && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0 items-center justify-center rounded border border-amber-300 bg-amber-100 p-0.5 text-amber-700 dark:border-amber-700 dark:bg-amber-900/50 dark:text-amber-400">
|
||||
<Monitor className="size-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Desktop app only</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button>
|
||||
{index !== TAB_ITEMS.length - 1 && (
|
||||
<div className="h-4 w-px shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full overflow-hidden bg-gray-100/50 px-4 pt-4 perspective-distant dark:bg-neutral-950">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.99,
|
||||
filter: "blur(10px)",
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
filter: "blur(10px)",
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
key={selectedItem.title}
|
||||
className="relative overflow-hidden rounded-tl-xl rounded-tr-xl bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 will-change-transform dark:bg-neutral-950"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-lg dark:text-white">
|
||||
{selectedItem.title}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{selectedItem.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full cursor-pointer rounded-none bg-neutral-50 p-2 hover:bg-neutral-50 sm:p-3 dark:bg-neutral-950 dark:hover:bg-neutral-950"
|
||||
onClick={open}
|
||||
>
|
||||
<TabVideo key={selectedItem.src} src={selectedItem.src} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<ExpandedMediaOverlay src={selectedItem.src} alt={selectedItem.title} onClose={close} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TabVideo = memo(function TabVideo({ src }: { src: string }) {
|
||||
const TabVideo = memo(function TabVideo({
|
||||
src,
|
||||
title,
|
||||
reduceMotion,
|
||||
}: {
|
||||
src: string;
|
||||
title: string;
|
||||
reduceMotion: boolean;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
|
||||
|
|
@ -398,8 +434,11 @@ const TabVideo = memo(function TabVideo({ src }: { src: string }) {
|
|||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.currentTime = 0;
|
||||
video.play().catch(() => {});
|
||||
}, []);
|
||||
// Respect reduced-motion: show the first frame and expose controls instead of autoplaying.
|
||||
if (!reduceMotion) {
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
}, [reduceMotion]);
|
||||
|
||||
const handleCanPlay = useCallback(() => {
|
||||
setHasLoaded(true);
|
||||
|
|
@ -411,7 +450,10 @@ const TabVideo = memo(function TabVideo({ src }: { src: string }) {
|
|||
ref={videoRef}
|
||||
key={src}
|
||||
src={src}
|
||||
preload="auto"
|
||||
preload={reduceMotion ? "metadata" : "auto"}
|
||||
aria-label={`${title} demo`}
|
||||
autoPlay={!reduceMotion}
|
||||
controls={reduceMotion}
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
|
|
@ -419,8 +461,233 @@ const TabVideo = memo(function TabVideo({ src }: { src: string }) {
|
|||
className="aspect-video w-full rounded-lg sm:rounded-xl"
|
||||
/>
|
||||
{!hasLoaded && (
|
||||
<div className="absolute inset-0 aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||
<Skeleton className="absolute inset-0 aspect-video w-full rounded-lg bg-neutral-100 motion-reduce:animate-none sm:rounded-xl dark:bg-neutral-800" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const UseCasePlaceholder = ({ title }: { title: string }) => (
|
||||
<Empty className="size-full justify-center rounded-lg border border-dashed bg-muted/30 sm:rounded-xl">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Clock aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Demo coming soon</EmptyTitle>
|
||||
<EmptyDescription className="text-pretty">{`A walkthrough of ${title} is on the way.`}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
|
||||
const UseCaseExamples = ({ examples }: { examples: string[] }) => (
|
||||
<div className="flex size-full flex-col gap-3 rounded-lg border border-dashed bg-muted/30 p-4 sm:rounded-xl sm:p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-foreground">Try prompts like these today</p>
|
||||
</div>
|
||||
<ul className="flex min-w-0 flex-col gap-2">
|
||||
{examples.map((example) => (
|
||||
<li key={example}>
|
||||
<div className="flex items-start gap-2.5 rounded-md border bg-background px-3 py-2">
|
||||
<CornerDownLeft
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70"
|
||||
/>
|
||||
<span className="min-w-0 text-sm text-pretty text-muted-foreground">{example}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DesktopBadge = () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-0.5 inline-flex items-center text-amber-600 dark:text-amber-400">
|
||||
<Monitor aria-hidden="true" className="size-3.5" />
|
||||
<span className="sr-only">Desktop app only</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Desktop app only</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const UseCasePane = memo(function UseCasePane({
|
||||
useCase,
|
||||
reduceMotion,
|
||||
}: {
|
||||
useCase: HeroUseCase;
|
||||
reduceMotion: boolean;
|
||||
}) {
|
||||
const { expanded, open, close } = useExpandedMedia();
|
||||
const hasVideo = !useCase.comingSoon && Boolean(useCase.src);
|
||||
|
||||
const media = hasVideo ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={open}
|
||||
aria-label={`Expand ${useCase.title} demo`}
|
||||
className="h-auto w-full cursor-pointer rounded-none bg-neutral-50 p-2 hover:bg-neutral-50 sm:p-3 dark:bg-neutral-950 dark:hover:bg-neutral-950"
|
||||
>
|
||||
<TabVideo src={useCase.src as string} title={useCase.title} reduceMotion={reduceMotion} />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950">
|
||||
{useCase.examples && useCase.examples.length > 0 ? (
|
||||
<UseCaseExamples examples={useCase.examples} />
|
||||
) : (
|
||||
<div className="aspect-video w-full">
|
||||
<UseCasePlaceholder title={useCase.title} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const card = (
|
||||
<div className="relative overflow-hidden rounded-tl-xl rounded-tr-xl bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-950">
|
||||
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-lg dark:text-white">
|
||||
{useCase.title}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-500 text-pretty dark:text-neutral-400">
|
||||
{useCase.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{media}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{reduceMotion ? (
|
||||
card
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.99, filter: "blur(10px)" }}
|
||||
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="will-change-transform"
|
||||
>
|
||||
{card}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{expanded && hasVideo && (
|
||||
<ExpandedMediaOverlay
|
||||
src={useCase.src as string}
|
||||
alt={`${useCase.title} demo`}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const CategoryPanel = memo(function CategoryPanel({
|
||||
category,
|
||||
reduceMotion,
|
||||
}: {
|
||||
category: HeroCategory;
|
||||
reduceMotion: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{category.desktopOnly && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-300/60 bg-amber-50 px-3 py-2 text-xs text-amber-800 sm:text-sm dark:border-amber-500/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<Sparkles aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||
<span className="text-pretty">
|
||||
The desktop app includes everything in SurfSense, plus these native-only superpowers.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
defaultValue={category.useCases[0]?.id}
|
||||
orientation="vertical"
|
||||
className="flex w-full flex-col gap-3 md:flex-row md:gap-4"
|
||||
>
|
||||
<ScrollArea className="w-full md:w-56 md:shrink-0">
|
||||
<TabsList className="flex h-auto w-max gap-1 bg-transparent p-0 md:w-full md:flex-col md:items-stretch">
|
||||
{category.useCases.map((useCase) => (
|
||||
<TabsTrigger
|
||||
key={useCase.id}
|
||||
value={useCase.id}
|
||||
className="h-auto shrink-0 touch-manipulation justify-start rounded-md px-3 py-2 text-left text-xs whitespace-normal data-[state=active]:bg-background data-[state=active]:shadow-sm sm:text-sm md:w-full"
|
||||
>
|
||||
{useCase.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<ScrollBar orientation="horizontal" className="md:hidden" />
|
||||
</ScrollArea>
|
||||
<div className="min-w-0 flex-1">
|
||||
{category.useCases.map((useCase) => (
|
||||
<TabsContent key={useCase.id} value={useCase.id} className="mt-0">
|
||||
<UseCasePane useCase={useCase} reduceMotion={reduceMotion} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const BrowserWindow = () => {
|
||||
const [activeCategory, setActiveCategory] = useState(CATEGORIES[0].id);
|
||||
const reduceMotion = useReducedMotion() ?? false;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeCategory}
|
||||
onValueChange={setActiveCategory}
|
||||
className="relative my-4 flex w-full flex-col items-start justify-start gap-0 overflow-hidden rounded-2xl shadow-2xl md:my-12"
|
||||
>
|
||||
<div className="flex w-full items-center justify-start overflow-hidden bg-gray-200 py-4 pl-4 dark:bg-neutral-800">
|
||||
<div className="mr-6 flex items-center gap-2">
|
||||
<div className="size-3 rounded-full bg-red-500" />
|
||||
<div className="size-3 rounded-full bg-yellow-500" />
|
||||
<div className="size-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
<ScrollArea className="min-w-0 flex-1">
|
||||
<TabsList className="flex h-auto w-max items-center gap-1 bg-transparent p-0 pr-4">
|
||||
{CATEGORIES.map((category, index) => (
|
||||
<React.Fragment key={category.id}>
|
||||
<TabsTrigger
|
||||
value={category.id}
|
||||
className={cn(
|
||||
"h-auto shrink-0 touch-manipulation gap-1.5 rounded-md px-2.5 py-1 text-xs sm:text-sm",
|
||||
category.desktopOnly
|
||||
? "bg-amber-100/70 text-amber-800 hover:bg-amber-100 data-[state=active]:bg-amber-200/80 data-[state=active]:text-amber-900 data-[state=active]:shadow-sm dark:bg-amber-950/40 dark:text-amber-200 dark:hover:bg-amber-900/40 dark:data-[state=active]:bg-amber-900/60 dark:data-[state=active]:text-amber-50"
|
||||
: "data-[state=active]:bg-background data-[state=active]:shadow"
|
||||
)}
|
||||
>
|
||||
{category.label}
|
||||
{category.desktopOnly && <DesktopBadge />}
|
||||
</TabsTrigger>
|
||||
{index !== CATEGORIES.length - 1 && (
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="h-4 bg-neutral-300 dark:bg-neutral-700"
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TabsList>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="w-full overflow-hidden bg-gray-100/50 px-4 pt-4 dark:bg-neutral-950">
|
||||
{CATEGORIES.map((category) => (
|
||||
<TabsContent key={category.id} value={category.id} className="mt-0">
|
||||
<CategoryPanel category={category} reduceMotion={reduceMotion} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -348,6 +348,11 @@ const comparisonRows: {
|
|||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
{
|
||||
feature: "AI Automations & Agents",
|
||||
notebookLm: false,
|
||||
surfSense: "Scheduled & event-triggered",
|
||||
},
|
||||
{
|
||||
feature: "AI File Sorting",
|
||||
notebookLm: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { FileJson } from "lucide-react";
|
||||
import React from "react";
|
||||
import { defaultStyles, JsonView } from "react-json-view-lite";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -10,7 +10,6 @@ import {
|
|||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import "react-json-view-lite/dist/index.css";
|
||||
|
||||
interface JsonMetadataViewerProps {
|
||||
title: string;
|
||||
|
|
@ -56,13 +55,13 @@ export function JsonMetadataViewer({
|
|||
{title} - Metadata
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm">
|
||||
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<JsonView data={jsonData} style={defaultStyles} />
|
||||
<JsonView src={jsonData} collapsed={2} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
@ -87,8 +86,8 @@ export function JsonMetadataViewer({
|
|||
{title} - Metadata
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm">
|
||||
<JsonView data={jsonData} style={defaultStyles} />
|
||||
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm overflow-auto">
|
||||
<JsonView src={jsonData} collapsed={2} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
110
surfsense_web/components/json-view.tsx
Normal file
110
surfsense_web/components/json-view.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import ReactJson, { type InteractionProps } from "@microlink/react-json-view";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Shared JSON viewer/editor wrapper around @microlink/react-json-view.
|
||||
*
|
||||
* One component, dual mode: passing ``editable`` + ``onChange`` enables
|
||||
* inline value editing, key renaming, add and delete. Omitting them
|
||||
* yields a read-only viewer. The underlying library is uncontrolled — it
|
||||
* mutates its own internal copy of ``src`` and surfaces the final tree on
|
||||
* each interaction via ``updated_src``, which we forward to ``onChange``.
|
||||
*
|
||||
* Theme follows ``next-themes``: a dark base-16 palette in dark mode, the
|
||||
* library's neutral default in light mode. Defaults are tuned for our
|
||||
* compact UI surfaces (no data-type labels, no key quotes, triangle icons,
|
||||
* tight indent).
|
||||
*/
|
||||
export interface JsonViewProps {
|
||||
/** The JSON value to display. Primitives are wrapped under ``{ value }``
|
||||
* because the underlying library requires an object root. */
|
||||
src: unknown;
|
||||
/** Enables value/key editing + add + delete. Requires ``onChange`` to
|
||||
* observe the result; without it the toggle is silently a no-op. */
|
||||
editable?: boolean;
|
||||
/** Called with the full updated tree on every accepted interaction. */
|
||||
onChange?: (next: unknown) => void;
|
||||
/** Collapse depth. ``true`` collapses everything past the root; a number
|
||||
* collapses from that depth onward. */
|
||||
collapsed?: boolean | number;
|
||||
/** Root label. Default ``false`` (no label — saves vertical space). */
|
||||
name?: string | false;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Recursively coerce string values that are valid JSON numbers back to numbers.
|
||||
* react-json-view's text input always yields strings; this restores the
|
||||
* correct type so filters like ``{ "folder_id": 56 }`` survive editing. */
|
||||
function coerceNumbers(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
const n = Number(value);
|
||||
return !Number.isNaN(n) && value.trim() !== "" ? n : value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(coerceNumbers);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, coerceNumbers(v)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const DARK_THEME = "monokai" as const;
|
||||
const LIGHT_THEME = "rjv-default" as const;
|
||||
|
||||
const SHARED_DEFAULTS = {
|
||||
iconStyle: "triangle" as const,
|
||||
indentWidth: 2,
|
||||
enableClipboard: true,
|
||||
displayDataTypes: false,
|
||||
displayObjectSize: true,
|
||||
quotesOnKeys: false,
|
||||
collapseStringsAfterLength: 80,
|
||||
};
|
||||
|
||||
export function JsonView({
|
||||
src,
|
||||
editable = false,
|
||||
onChange,
|
||||
collapsed = 2,
|
||||
name = false,
|
||||
className,
|
||||
}: JsonViewProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const theme = resolvedTheme === "dark" ? DARK_THEME : LIGHT_THEME;
|
||||
|
||||
// The library throws on non-object roots. Wrap primitives and null/undefined.
|
||||
const safeSrc = useMemo(() => {
|
||||
if (src && typeof src === "object") return src as object;
|
||||
return { value: src };
|
||||
}, [src]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(interaction: InteractionProps) => {
|
||||
onChange?.(coerceNumbers(interaction.updated_src));
|
||||
return true;
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const interactive = editable && onChange ? handleChange : (false as const);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactJson
|
||||
src={safeSrc}
|
||||
name={name}
|
||||
theme={theme}
|
||||
collapsed={collapsed}
|
||||
onEdit={interactive}
|
||||
onAdd={interactive}
|
||||
onDelete={interactive}
|
||||
style={{ backgroundColor: "transparent", fontSize: 12, fontFamily: "var(--font-mono)" }}
|
||||
{...SHARED_DEFAULTS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertTriangle, Inbox, LibraryBig } from "lucide-react";
|
||||
import { AlertTriangle, Inbox, LibraryBig, Workflow } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -18,6 +18,7 @@ import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms
|
|||
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog";
|
||||
import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight";
|
||||
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -334,9 +335,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
}, [threadsData, searchSpaceId]);
|
||||
|
||||
// Navigation items
|
||||
// Inbox is rendered explicitly below "New chat" in the sidebar (it is also
|
||||
// surfaced in the icon rail's collapsed mode via this list). Announcements
|
||||
// has been moved to the avatar dropdown and is no longer a nav item.
|
||||
// Inbox, Automations, and Documents are rendered explicitly below "New chat"
|
||||
// in the sidebar (also surfaced in the icon rail's collapsed mode via this
|
||||
// list). Announcements has been moved to the avatar dropdown.
|
||||
const isAutomationsActive = pathname?.includes("/automations") === true;
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() =>
|
||||
(
|
||||
|
|
@ -348,6 +350,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
isActive: isInboxSidebarOpen,
|
||||
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
||||
},
|
||||
{
|
||||
title: "Automations",
|
||||
url: `/dashboard/${searchSpaceId}/automations`,
|
||||
icon: Workflow,
|
||||
isActive: isAutomationsActive,
|
||||
},
|
||||
isMobile
|
||||
? {
|
||||
title: "Documents",
|
||||
|
|
@ -358,7 +366,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
: null,
|
||||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[isMobile, isInboxSidebarOpen, isDocumentsSidebarOpen, totalUnreadCount]
|
||||
[
|
||||
isMobile,
|
||||
isInboxSidebarOpen,
|
||||
isDocumentsSidebarOpen,
|
||||
totalUnreadCount,
|
||||
searchSpaceId,
|
||||
isAutomationsActive,
|
||||
]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
@ -659,12 +674,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const isUserSettingsPage = pathname?.includes("/user-settings") === true;
|
||||
const isSearchSpaceSettingsPage = pathname?.includes("/search-space-settings") === true;
|
||||
const isTeamPage = pathname?.endsWith("/team") === true;
|
||||
const isAutomationsPage = pathname?.includes("/automations") === true;
|
||||
const useWorkspacePanel =
|
||||
pathname?.endsWith("/buy-more") === true ||
|
||||
pathname?.endsWith("/more-pages") === true ||
|
||||
isUserSettingsPage ||
|
||||
isSearchSpaceSettingsPage ||
|
||||
isTeamPage;
|
||||
isTeamPage ||
|
||||
isAutomationsPage;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -704,12 +721,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
isChatPage={isChatPage}
|
||||
useWorkspacePanel={useWorkspacePanel}
|
||||
workspacePanelViewportClassName={
|
||||
isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage
|
||||
isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage || isAutomationsPage
|
||||
? "items-start justify-center px-6 py-8 md:px-10 md:py-10"
|
||||
: undefined
|
||||
}
|
||||
workspacePanelContentClassName={
|
||||
isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage ? "max-w-5xl" : undefined
|
||||
isAutomationsPage
|
||||
? "max-w-none"
|
||||
: isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage
|
||||
? "max-w-5xl"
|
||||
: undefined
|
||||
}
|
||||
isLoadingChats={isLoadingThreads}
|
||||
activeSlideoutPanel={activeSlideoutPanel}
|
||||
|
|
@ -889,6 +910,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
/>
|
||||
|
||||
<AnnouncementsDialog />
|
||||
<AnnouncementSpotlight />
|
||||
|
||||
{/* Agent action log + revert dialog */}
|
||||
<ActionLogDialog />
|
||||
|
|
|
|||
|
|
@ -193,11 +193,7 @@ export function SearchSpaceAvatar({
|
|||
|
||||
// If delete or settings handlers are provided, expose them through a dropdown menu.
|
||||
if (onDelete || onSettings) {
|
||||
const trigger = (
|
||||
<DropdownMenuTrigger asChild>
|
||||
{avatarButton(true)}
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
const trigger = <DropdownMenuTrigger asChild>{avatarButton(true)}</DropdownMenuTrigger>;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
|
|
|
|||
|
|
@ -78,22 +78,18 @@ import { foldersApiService } from "@/lib/apis/folders-api.service";
|
|||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { uploadFolderScan } from "@/lib/folder-sync-upload";
|
||||
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
||||
import { queries } from "@/zero/queries/index";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const DesktopLocalTabContent = dynamic(
|
||||
() => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = [
|
||||
"SURFSENSE_DOCS",
|
||||
"USER_MEMORY",
|
||||
"TEAM_MEMORY",
|
||||
];
|
||||
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["USER_MEMORY", "TEAM_MEMORY"];
|
||||
const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [
|
||||
{
|
||||
id: -1001,
|
||||
|
|
|
|||
|
|
@ -140,16 +140,26 @@ export function Sidebar({
|
|||
const t = useTranslations("sidebar");
|
||||
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
|
||||
|
||||
// Inbox and Documents are rendered explicitly right below New Chat. Pull
|
||||
// them out of the nav items list so they don't also appear in the bottom
|
||||
// NavSection. Documents is only present in navItems on mobile.
|
||||
// Inbox, Automations, and Documents are rendered explicitly right below
|
||||
// New Chat. Pull them out of the nav items list so they don't also appear
|
||||
// in the bottom NavSection. Documents is only present in navItems on
|
||||
// mobile; Automations is identified by URL suffix so the same code path
|
||||
// works across search spaces.
|
||||
const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]);
|
||||
const automationsItem = useMemo(
|
||||
() => navItems.find((item) => item.url.endsWith("/automations")),
|
||||
[navItems]
|
||||
);
|
||||
const documentsItem = useMemo(
|
||||
() => navItems.find((item) => item.url === "#documents"),
|
||||
[navItems]
|
||||
);
|
||||
const footerNavItems = useMemo(
|
||||
() => navItems.filter((item) => item.url !== "#inbox" && item.url !== "#documents"),
|
||||
() =>
|
||||
navItems.filter(
|
||||
(item) =>
|
||||
item.url !== "#inbox" && item.url !== "#documents" && !item.url.endsWith("/automations")
|
||||
),
|
||||
[navItems]
|
||||
);
|
||||
|
||||
|
|
@ -227,6 +237,16 @@ export function Sidebar({
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{automationsItem && (
|
||||
<SidebarButton
|
||||
icon={automationsItem.icon}
|
||||
label={automationsItem.title}
|
||||
onClick={() => onNavItemClick?.(automationsItem)}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={automationsItem.isActive}
|
||||
tooltipContent={isCollapsed ? automationsItem.title : undefined}
|
||||
/>
|
||||
)}
|
||||
{documentsItem && (
|
||||
<SidebarButton
|
||||
icon={documentsItem.icon}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
interface DocumentContent {
|
||||
|
|
|
|||
75
surfsense_web/components/new-chat/chat-example-prompts.tsx
Normal file
75
surfsense_web/components/new-chat/chat-example-prompts.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { CornerDownLeft, Lightbulb } from "lucide-react";
|
||||
import { memo, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { CHAT_EXAMPLE_CATEGORIES } from "@/lib/chat/example-prompts";
|
||||
|
||||
interface ChatExamplePromptsProps {
|
||||
/** Called with the chosen prompt text; the caller prefills the composer. */
|
||||
onSelect: (prompt: string) => void;
|
||||
}
|
||||
|
||||
const ExamplePromptButton = memo(function ExamplePromptButton({
|
||||
prompt,
|
||||
onSelect,
|
||||
}: {
|
||||
prompt: string;
|
||||
onSelect: (prompt: string) => void;
|
||||
}) {
|
||||
const handleClick = useCallback(() => onSelect(prompt), [prompt, onSelect]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClick}
|
||||
className="h-auto w-full items-start justify-start gap-2.5 whitespace-normal rounded-md border bg-background px-3 py-2 text-left font-normal text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CornerDownLeft
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70"
|
||||
/>
|
||||
<span className="min-w-0 text-pretty text-sm">{prompt}</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
export function ChatExamplePrompts({ onSelect }: ChatExamplePromptsProps) {
|
||||
return (
|
||||
<div className="mt-3 w-full select-none rounded-xl border border-dashed bg-muted/30 p-3 sm:p-4">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<Lightbulb aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Not sure where to start? Try one of these
|
||||
</p>
|
||||
</div>
|
||||
<Tabs defaultValue={CHAT_EXAMPLE_CATEGORIES[0].id} className="w-full">
|
||||
<div className="overflow-x-auto pb-1">
|
||||
<TabsList className="h-9 w-max">
|
||||
{CHAT_EXAMPLE_CATEGORIES.map((category) => (
|
||||
<TabsTrigger key={category.id} value={category.id} className="text-xs">
|
||||
{category.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
{CHAT_EXAMPLE_CATEGORIES.map((category) => (
|
||||
<TabsContent key={category.id} value={category.id} className="mt-3">
|
||||
<ScrollArea className="max-h-48">
|
||||
<ul className="flex flex-col gap-2 pr-2">
|
||||
{category.prompts.map((prompt) => (
|
||||
<li key={prompt}>
|
||||
<ExamplePromptButton prompt={prompt} onSelect={onSelect} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -117,7 +117,10 @@ const ComposerSuggestionItem = React.forwardRef<
|
|||
));
|
||||
ComposerSuggestionItem.displayName = "ComposerSuggestionItem";
|
||||
|
||||
function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||
function ComposerSuggestionSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<div className={cn("my-0.5 px-2.5", className)}>
|
||||
<Separator className="bg-popover-border" {...props} />
|
||||
|
|
|
|||
|
|
@ -2,14 +2,8 @@
|
|||
|
||||
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Files,
|
||||
Folder as FolderIcon,
|
||||
Unplug,
|
||||
} from "lucide-react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ChevronLeft, ChevronRight, Files, Folder as FolderIcon, Unplug } from "lucide-react";
|
||||
import {
|
||||
Fragment,
|
||||
forwardRef,
|
||||
|
|
@ -22,7 +16,6 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { getConnectorTitle } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
|
||||
import { getConnectorDisplayName } from "@/components/assistant-ui/connector-popup/tabs/all-connectors-tab";
|
||||
|
|
@ -67,7 +60,6 @@ const RECENTS_STORAGE_PREFIX = "surfsense:composer-mention-recents:v1:";
|
|||
|
||||
type BrowseView =
|
||||
| { kind: "root" }
|
||||
| { kind: "surfsense-docs" }
|
||||
| { kind: "files-folders" }
|
||||
| { kind: "connectors" }
|
||||
| { kind: "connector-type"; connectorType: string; title: string };
|
||||
|
|
@ -178,7 +170,9 @@ function useDebounced<T>(value: T, delay = DEBOUNCE_MS) {
|
|||
return debounced;
|
||||
}
|
||||
|
||||
function makeDocMention(doc: Pick<Document, "id" | "title" | "document_type">): MentionedDocumentInfo {
|
||||
function makeDocMention(
|
||||
doc: Pick<Document, "id" | "title" | "document_type">
|
||||
): MentionedDocumentInfo {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
|
|
@ -187,9 +181,10 @@ function makeDocMention(doc: Pick<Document, "id" | "title" | "document_type">):
|
|||
};
|
||||
}
|
||||
|
||||
function makeFolderMention(
|
||||
folder: { id: number; title: string }
|
||||
): Extract<MentionedDocumentInfo, { kind: "folder" }> {
|
||||
function makeFolderMention(folder: {
|
||||
id: number;
|
||||
title: string;
|
||||
}): Extract<MentionedDocumentInfo, { kind: "folder" }> {
|
||||
return {
|
||||
id: folder.id,
|
||||
title: folder.title,
|
||||
|
|
@ -281,15 +276,6 @@ export const DocumentMentionPicker = forwardRef<
|
|||
[searchSpaceId, debouncedSearch, isSearchValid]
|
||||
);
|
||||
|
||||
const surfsenseDocsQueryParams = useMemo(() => {
|
||||
const params: { page: number; page_size: number; title?: string } = {
|
||||
page: 0,
|
||||
page_size: PAGE_SIZE,
|
||||
};
|
||||
if (isSearchValid) params.title = debouncedSearch.trim();
|
||||
return params;
|
||||
}, [debouncedSearch, isSearchValid]);
|
||||
|
||||
const { data: titleSearchResults, isLoading: isTitleSearchLoading } = useQuery({
|
||||
queryKey: ["document-titles", titleSearchParams],
|
||||
queryFn: ({ signal }) =>
|
||||
|
|
@ -299,15 +285,6 @@ export const DocumentMentionPicker = forwardRef<
|
|||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({
|
||||
queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid],
|
||||
queryFn: ({ signal }) =>
|
||||
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
|
||||
staleTime: 3 * 60 * 1000,
|
||||
enabled: !hasSearch || isSearchValid,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const filterBySearchTerm = useCallback(
|
||||
(docs: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||
if (!isSearchValid) return docs;
|
||||
|
|
@ -319,25 +296,15 @@ export const DocumentMentionPicker = forwardRef<
|
|||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 0) return;
|
||||
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
|
||||
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
|
||||
|
||||
if (surfsenseDocs?.items) {
|
||||
for (const doc of surfsenseDocs.items) {
|
||||
combinedDocs.push({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: "SURFSENSE_DOCS",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (titleSearchResults?.items) {
|
||||
combinedDocs.push(...titleSearchResults.items);
|
||||
setHasMore(titleSearchResults.has_more);
|
||||
}
|
||||
|
||||
if (titleSearchResults?.items) {
|
||||
combinedDocs.push(...titleSearchResults.items);
|
||||
setHasMore(titleSearchResults.has_more);
|
||||
}
|
||||
|
||||
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
|
||||
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
|
||||
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
|
||||
}, [titleSearchResults, currentPage, filterBySearchTerm]);
|
||||
|
||||
const loadNextPage = useCallback(async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
|
|
@ -352,9 +319,11 @@ export const DocumentMentionPicker = forwardRef<
|
|||
page_size: PAGE_SIZE,
|
||||
...(isSearchValid ? { title: debouncedSearch.trim() } : {}),
|
||||
};
|
||||
const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles({
|
||||
queryParams,
|
||||
});
|
||||
const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles(
|
||||
{
|
||||
queryParams,
|
||||
}
|
||||
);
|
||||
|
||||
setAccumulatedDocuments((prev) => [...prev, ...response.items]);
|
||||
setHasMore(response.has_more);
|
||||
|
|
@ -372,14 +341,6 @@ export const DocumentMentionPicker = forwardRef<
|
|||
return accumulatedDocuments.filter((doc) => doc.title.toLowerCase().includes(searchLower));
|
||||
}, [accumulatedDocuments, deferredSearch, isSingleCharSearch]);
|
||||
|
||||
const surfsenseDocsList = useMemo(
|
||||
() => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"),
|
||||
[actualDocuments]
|
||||
);
|
||||
const userDocsList = useMemo(
|
||||
() => actualDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS"),
|
||||
[actualDocuments]
|
||||
);
|
||||
const folderMentions = useMemo(() => {
|
||||
const all = (zeroFolders ?? []).map((f) => makeFolderMention({ id: f.id, title: f.name }));
|
||||
if (!hasSearch) return all;
|
||||
|
|
@ -431,14 +392,19 @@ export const DocumentMentionPicker = forwardRef<
|
|||
)
|
||||
.filter((mention): mention is MentionedDocumentInfo => mention !== null)
|
||||
.slice(0, RECENTS_LIMIT),
|
||||
[activeConnectors, hasHydratedRecentDocs, recentMentions, recentValidationDocuments, zeroFolders]
|
||||
[
|
||||
activeConnectors,
|
||||
hasHydratedRecentDocs,
|
||||
recentMentions,
|
||||
recentValidationDocuments,
|
||||
zeroFolders,
|
||||
]
|
||||
);
|
||||
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
|
||||
[initialSelectedDocuments]
|
||||
);
|
||||
const showSurfsenseDocsRoot = surfsenseDocsList.length > 0;
|
||||
|
||||
const selectMention = useCallback(
|
||||
(mention: MentionedDocumentInfo) => {
|
||||
|
|
@ -460,47 +426,36 @@ export const DocumentMentionPicker = forwardRef<
|
|||
[visibleRecentMentions, selectedKeys]
|
||||
);
|
||||
|
||||
const rootNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(
|
||||
() => {
|
||||
const nodes: ComposerSuggestionNode<ResourceNodeValue>[] = [...recentRootNodes];
|
||||
if (showSurfsenseDocsRoot) {
|
||||
nodes.push({
|
||||
id: "surfsense-docs",
|
||||
label: "SurfSense Docs",
|
||||
subtitle: "Browse product documentation",
|
||||
icon: <BookOpen className="size-4" />,
|
||||
type: "branch",
|
||||
value: { kind: "view", view: { kind: "surfsense-docs" } },
|
||||
});
|
||||
}
|
||||
nodes.push(
|
||||
{
|
||||
id: "files-folders",
|
||||
label: "Files & Folders",
|
||||
subtitle: "Browse your knowledge base",
|
||||
icon: <Files className="size-4" />,
|
||||
type: "branch",
|
||||
value: { kind: "view", view: { kind: "files-folders" } },
|
||||
},
|
||||
{
|
||||
id: "connectors",
|
||||
label: "Connectors",
|
||||
subtitle: activeConnectors.length
|
||||
? "Choose the exact account for tool use"
|
||||
: "No connected accounts yet",
|
||||
const rootNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
|
||||
const nodes: ComposerSuggestionNode<ResourceNodeValue>[] = [...recentRootNodes];
|
||||
nodes.push(
|
||||
{
|
||||
id: "files-folders",
|
||||
label: "Files & Folders",
|
||||
subtitle: "Browse your knowledge base",
|
||||
icon: <Files className="size-4" />,
|
||||
type: "branch",
|
||||
value: { kind: "view", view: { kind: "files-folders" } },
|
||||
},
|
||||
{
|
||||
id: "connectors",
|
||||
label: "Connectors",
|
||||
subtitle: activeConnectors.length
|
||||
? "Choose the exact account for tool use"
|
||||
: "No connected accounts yet",
|
||||
icon: <Unplug className="size-4" />,
|
||||
type: "branch",
|
||||
disabled: activeConnectors.length === 0,
|
||||
value: { kind: "view", view: { kind: "connectors" } },
|
||||
}
|
||||
);
|
||||
return nodes;
|
||||
},
|
||||
[activeConnectors.length, recentRootNodes, showSurfsenseDocsRoot]
|
||||
);
|
||||
type: "branch",
|
||||
disabled: activeConnectors.length === 0,
|
||||
value: { kind: "view", view: { kind: "connectors" } },
|
||||
}
|
||||
);
|
||||
return nodes;
|
||||
}, [activeConnectors.length, recentRootNodes]);
|
||||
|
||||
const searchNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
|
||||
const searchLower = (isSingleCharSearch ? deferredSearch : debouncedSearch).trim().toLowerCase();
|
||||
const searchLower = (isSingleCharSearch ? deferredSearch : debouncedSearch)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const docNodes = actualDocuments.map((doc) => {
|
||||
const mention = makeDocMention(doc);
|
||||
return {
|
||||
|
|
@ -558,19 +513,6 @@ export const DocumentMentionPicker = forwardRef<
|
|||
|
||||
const browseNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
|
||||
if (view.kind === "root") return rootNodes;
|
||||
if (view.kind === "surfsense-docs") {
|
||||
return surfsenseDocsList.map((doc) => {
|
||||
const mention = makeDocMention(doc);
|
||||
return {
|
||||
id: getMentionDocKey(mention),
|
||||
label: doc.title,
|
||||
icon: getConnectorIcon(doc.document_type, "size-4"),
|
||||
type: "item" as const,
|
||||
disabled: selectedKeys.has(getMentionDocKey(mention)),
|
||||
value: { kind: "mention" as const, mention },
|
||||
};
|
||||
});
|
||||
}
|
||||
if (view.kind === "files-folders") {
|
||||
const folders = folderMentions.map((mention) => ({
|
||||
id: getMentionDocKey(mention),
|
||||
|
|
@ -581,7 +523,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
disabled: selectedKeys.has(getMentionDocKey(mention)),
|
||||
value: { kind: "mention" as const, mention },
|
||||
}));
|
||||
const docs = userDocsList.map((doc) => {
|
||||
const docs = actualDocuments.map((doc) => {
|
||||
const mention = makeDocMention(doc);
|
||||
return {
|
||||
id: getMentionDocKey(mention),
|
||||
|
|
@ -619,20 +561,21 @@ export const DocumentMentionPicker = forwardRef<
|
|||
id: getMentionDocKey(mention),
|
||||
label: getConnectorDisplayName(connector.name),
|
||||
subtitle: `${view.title} account`,
|
||||
icon: getConnectorIcon(connector.connector_type, "size-4") ?? <Unplug className="size-4" />,
|
||||
icon: getConnectorIcon(connector.connector_type, "size-4") ?? (
|
||||
<Unplug className="size-4" />
|
||||
),
|
||||
type: "item" as const,
|
||||
disabled: selectedKeys.has(getMentionDocKey(mention)),
|
||||
value: { kind: "mention" as const, mention },
|
||||
};
|
||||
});
|
||||
}, [
|
||||
actualDocuments,
|
||||
activeConnectors,
|
||||
connectorTypeEntries,
|
||||
folderMentions,
|
||||
rootNodes,
|
||||
selectedKeys,
|
||||
surfsenseDocsList,
|
||||
userDocsList,
|
||||
view,
|
||||
]);
|
||||
|
||||
|
|
@ -682,27 +625,23 @@ export const DocumentMentionPicker = forwardRef<
|
|||
|
||||
const isRootBrowseView = !hasSearch && view.kind === "root";
|
||||
const isVisibleViewLoading = hasSearch
|
||||
? isTitleSearchLoading || isSurfsenseDocsLoading || isConnectorsLoading
|
||||
: view.kind === "surfsense-docs"
|
||||
? isSurfsenseDocsLoading
|
||||
: view.kind === "files-folders"
|
||||
? isTitleSearchLoading
|
||||
: view.kind === "connectors" || view.kind === "connector-type"
|
||||
? isConnectorsLoading
|
||||
: false;
|
||||
? isTitleSearchLoading || isConnectorsLoading
|
||||
: view.kind === "files-folders"
|
||||
? isTitleSearchLoading
|
||||
: view.kind === "connectors" || view.kind === "connector-type"
|
||||
? isConnectorsLoading
|
||||
: false;
|
||||
const actualLoading =
|
||||
isVisibleViewLoading && !isSingleCharSearch && visibleNodes.length === 0 && !isRootBrowseView;
|
||||
|
||||
const title =
|
||||
hasSearch || view.kind === "root"
|
||||
? null
|
||||
: view.kind === "surfsense-docs"
|
||||
? "SurfSense Docs"
|
||||
: view.kind === "files-folders"
|
||||
? "Files & Folders"
|
||||
: view.kind === "connectors"
|
||||
? "Connectors"
|
||||
: view.title;
|
||||
: view.kind === "files-folders"
|
||||
? "Files & Folders"
|
||||
: view.kind === "connectors"
|
||||
? "Connectors"
|
||||
: view.title;
|
||||
|
||||
return (
|
||||
<ComposerSuggestionList
|
||||
|
|
@ -733,7 +672,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
icon={
|
||||
<span className="-ml-0.5 flex size-4.5 items-center justify-center">
|
||||
<ChevronLeft className="size-3.5" />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="flex-1 truncate">{title}</span>
|
||||
|
|
@ -759,7 +698,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
return (
|
||||
<Fragment key={node.id}>
|
||||
{showRecentsSeparator ? <ComposerSuggestionSeparator /> : null}
|
||||
<ComposerSuggestionItem
|
||||
<ComposerSuggestionItem
|
||||
ref={navigator.getItemRef(index)}
|
||||
icon={node.icon}
|
||||
selected={index === navigator.highlightedIndex}
|
||||
|
|
@ -776,11 +715,11 @@ export const DocumentMentionPicker = forwardRef<
|
|||
{node.subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{node.type === "branch" ? (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</ComposerSuggestionItem>
|
||||
</ComposerSuggestionItem>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,9 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
|||
{isLoading ? (
|
||||
<ComposerSuggestionSkeleton rows={8} mobileRows={8} />
|
||||
) : isError ? (
|
||||
<ComposerSuggestionMessage variant="destructive">Failed to load prompts</ComposerSuggestionMessage>
|
||||
<ComposerSuggestionMessage variant="destructive">
|
||||
Failed to load prompts
|
||||
</ComposerSuggestionMessage>
|
||||
) : filtered.length === 0 ? (
|
||||
<ComposerSuggestionMessage>No matching prompts</ComposerSuggestionMessage>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { AnimatePresence, motion } from "motion/react";
|
|||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Pricing } from "@/components/pricing";
|
||||
import { FAQJsonLd } from "@/components/seo/json-ld";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -19,6 +20,8 @@ const demoPlans = [
|
|||
"500 pages included to start",
|
||||
"$5 in premium credits for paid AI models and premium AI features",
|
||||
"Includes access to OpenAI text, audio and image models",
|
||||
"AI automations and agents: scheduled and event-triggered workflows",
|
||||
"Desktop app: Quick, General and Screenshot Assist plus local folder sync",
|
||||
"Realtime Collaborative Group Chats with teammates",
|
||||
"Community support on Discord",
|
||||
],
|
||||
|
|
@ -37,6 +40,7 @@ const demoPlans = [
|
|||
"Everything in Free",
|
||||
"Buy 1,000-page packs or $1 in premium credits at $1 each",
|
||||
"Use premium AI models like GPT-5.4, Claude Sonnet 4.6, Gemini 2.5 Pro & 100+ more via OpenRouter",
|
||||
"Connector write-back to Notion, Slack, Linear & Jira",
|
||||
"Priority support on Discord",
|
||||
],
|
||||
description: "",
|
||||
|
|
@ -52,6 +56,7 @@ const demoPlans = [
|
|||
billingText: "",
|
||||
features: [
|
||||
"Everything in Pay As You Go",
|
||||
"Custom automation and agent workflows",
|
||||
"On-prem or VPC deployment",
|
||||
"Audit logs and compliance",
|
||||
"SSO, OIDC & SAML",
|
||||
|
|
@ -158,6 +163,31 @@ const faqData: FAQSection[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Automations & Agents",
|
||||
items: [
|
||||
{
|
||||
question: "What can AI automations and agents do?",
|
||||
answer:
|
||||
"AI automations let you run agents on your knowledge base without writing code. You can schedule recurring workflows like daily briefs, weekly status reports, and competitor analysis, or trigger an agent the moment a document lands in a folder. Agents can read across your connected tools, generate summaries and reports, and write results back to Notion, Slack, Linear, and Jira.",
|
||||
},
|
||||
{
|
||||
question: "Do automations and agents cost extra?",
|
||||
answer:
|
||||
"No. There is no separate subscription or add-on fee for automations. Agents use the same page credits and premium credits as the rest of SurfSense. Indexing documents consumes page credits, and premium AI model usage during a workflow consumes premium credits at provider cost. If a workflow only uses free models, it does not touch your premium credits.",
|
||||
},
|
||||
{
|
||||
question: "How do event-triggered automations work?",
|
||||
answer:
|
||||
"Event-triggered automations fire when something happens in your knowledge base, most commonly when a new document enters a folder you are watching. For example, when a PDF lands in your Research folder you can auto-generate a cited summary, or when an invoice is uploaded you can extract the vendor, total, and due date. The agent runs automatically and can post the result to your connected tools.",
|
||||
},
|
||||
{
|
||||
question: "Can I build an automation without code?",
|
||||
answer:
|
||||
"Yes. You can describe the workflow automation you want in plain English in chat, and SurfSense builds the automation for you. For example, ask it to email you a summary of new Notion pages each morning, or post a weekly research digest to Slack, and it sets up the scheduled or event-triggered agent without any code.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Self-Hosting",
|
||||
items: [
|
||||
|
|
@ -250,6 +280,7 @@ function PricingFAQ() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl overflow-hidden px-4 py-20 md:px-8 md:py-32">
|
||||
<FAQJsonLd questions={faqData.flatMap((section) => section.items)} />
|
||||
<div className="text-center">
|
||||
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Frequently Asked Questions
|
||||
|
|
@ -341,7 +372,7 @@ function PricingBasic() {
|
|||
<Pricing
|
||||
plans={demoPlans}
|
||||
title="SurfSense Pricing"
|
||||
description="Start free with 500 pages & $5 in premium credits. Pay as you go."
|
||||
description="Start free with 500 pages & $5 in premium credits. Run AI automations and agents, and pay as you go."
|
||||
/>
|
||||
<PricingFAQ />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ export function SoftwareApplicationJsonLd() {
|
|||
"Free access to ChatGPT, Claude AI, and any AI model",
|
||||
"AI-powered semantic search across all connected tools",
|
||||
"Federated search across Slack, Google Drive, Notion, Confluence, GitHub",
|
||||
"AI automations and agents (scheduled and event-triggered workflows)",
|
||||
"Connector write-back to Notion, Slack, Linear, Jira",
|
||||
"Native desktop app with Quick, General, and Screenshot Assist",
|
||||
"No data limits with open source self-hosting",
|
||||
"Real-time collaborative team chats",
|
||||
"Document Q&A with citations",
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import { Label } from "@/components/ui/label";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
interface GeneralSettingsManagerProps {
|
||||
searchSpaceId: number;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
RefreshCw,
|
||||
ScanEye,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
|
|
@ -135,18 +135,39 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const [assignments, setAssignments] = useState(() => ({
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
vision_llm_config_id: preferences.vision_llm_config_id ?? "",
|
||||
const [assignments, setAssignments] = useState<Record<string, number | null>>(() => ({
|
||||
agent_llm_id: preferences.agent_llm_id ?? null,
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? null,
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? null,
|
||||
vision_llm_config_id: preferences.vision_llm_config_id ?? null,
|
||||
}));
|
||||
|
||||
// Sync local state when preferences load/change. Without this, the selects
|
||||
// stay on their initial (often empty) value while the query is in flight,
|
||||
// so a saved assignment — including Auto mode (id 0) — never appears.
|
||||
useEffect(() => {
|
||||
setAssignments({
|
||||
agent_llm_id: preferences.agent_llm_id ?? null,
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? null,
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? null,
|
||||
vision_llm_config_id: preferences.vision_llm_config_id ?? null,
|
||||
});
|
||||
}, [
|
||||
preferences.agent_llm_id,
|
||||
preferences.document_summary_llm_id,
|
||||
preferences.image_generation_config_id,
|
||||
preferences.vision_llm_config_id,
|
||||
]);
|
||||
|
||||
const [savingRole, setSavingRole] = useState<string | null>(null);
|
||||
|
||||
const handleRoleAssignment = useCallback(
|
||||
async (prefKey: string, configId: string) => {
|
||||
const value = configId === "unassigned" ? "" : parseInt(configId);
|
||||
// "unassigned" clears the role (null). Every other option — including
|
||||
// Auto mode, whose config id is 0 — must be sent as-is. Using a falsy
|
||||
// check here (e.g. `value || undefined`) would drop id 0 and silently
|
||||
// fail to persist Auto mode.
|
||||
const value = configId === "unassigned" ? null : Number(configId);
|
||||
|
||||
setAssignments((prev) => ({ ...prev, [prefKey]: value }));
|
||||
setSavingRole(prefKey);
|
||||
|
|
@ -154,7 +175,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { [prefKey]: value || undefined },
|
||||
data: { [prefKey]: value },
|
||||
});
|
||||
toast.success("Role assignment updated");
|
||||
} finally {
|
||||
|
|
@ -325,7 +346,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
Configuration
|
||||
</Label>
|
||||
<Select
|
||||
value={isAssigned ? currentAssignment.toString() : "unassigned"}
|
||||
value={assignedConfig ? assignedConfig.id.toString() : "unassigned"}
|
||||
onValueChange={(value) => handleRoleAssignment(role.prefKey, value)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-9 md:h-10 text-xs md:text-sm">
|
||||
|
|
|
|||
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