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
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -20,10 +20,7 @@ interface PromptConfigManagerProps {
|
|||
}
|
||||
|
||||
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {
|
||||
const {
|
||||
data: searchSpace,
|
||||
isLoading: loading,
|
||||
} = useQuery({
|
||||
const { data: searchSpace, isLoading: loading } = useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
|
|
@ -56,8 +53,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
});
|
||||
toast.success("System instructions saved successfully");
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to save system instructions";
|
||||
const message = error instanceof Error ? error.message : "Failed to save system instructions";
|
||||
console.error("Error saving system instructions:", error);
|
||||
toast.error(message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
Unplug,
|
||||
Users,
|
||||
Video,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -126,6 +127,12 @@ const CATEGORY_CONFIG: Record<
|
|||
description: "Generate AI podcasts from content",
|
||||
order: 5,
|
||||
},
|
||||
automations: {
|
||||
label: "Automations",
|
||||
icon: Workflow,
|
||||
description: "Scheduled and event-driven agent tasks",
|
||||
order: 5.5,
|
||||
},
|
||||
connectors: {
|
||||
label: "Connectors",
|
||||
icon: Unplug,
|
||||
|
|
@ -200,6 +207,10 @@ const ROLE_PRESETS = {
|
|||
"podcasts:create",
|
||||
"podcasts:read",
|
||||
"podcasts:update",
|
||||
"automations:create",
|
||||
"automations:read",
|
||||
"automations:update",
|
||||
"automations:execute",
|
||||
"connectors:create",
|
||||
"connectors:read",
|
||||
"connectors:update",
|
||||
|
|
@ -220,6 +231,7 @@ const ROLE_PRESETS = {
|
|||
"comments:read",
|
||||
"llm_configs:read",
|
||||
"podcasts:read",
|
||||
"automations:read",
|
||||
"connectors:read",
|
||||
"logs:read",
|
||||
"members:view",
|
||||
|
|
@ -240,6 +252,10 @@ const ROLE_PRESETS = {
|
|||
"comments:read",
|
||||
"llm_configs:read",
|
||||
"podcasts:read",
|
||||
"automations:create",
|
||||
"automations:read",
|
||||
"automations:update",
|
||||
"automations:execute",
|
||||
"connectors:read",
|
||||
"logs:read",
|
||||
"members:view",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
"use client";
|
||||
import { CalendarClock, ChevronDown, ChevronRight, ListOrdered, Target } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
|
||||
interface DraftTrigger {
|
||||
type: string;
|
||||
params: Record<string, unknown>;
|
||||
static_inputs: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface DraftPlanStep {
|
||||
step_id: string;
|
||||
action: string;
|
||||
when?: string | null;
|
||||
}
|
||||
|
||||
interface AutomationDraft {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
definition: {
|
||||
goal?: string | null;
|
||||
plan: DraftPlanStep[];
|
||||
};
|
||||
triggers: DraftTrigger[];
|
||||
}
|
||||
|
||||
interface AutomationDraftPreviewProps {
|
||||
draft: AutomationDraft;
|
||||
/** Full unmodified args dict — surfaced as the "raw JSON" escape hatch. */
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured preview of a drafted automation rendered inside the chat
|
||||
* approval card.
|
||||
*
|
||||
* Three layers, top to bottom:
|
||||
* 1. Name + description (and goal when present).
|
||||
* 2. Triggers — humanised cron string + timezone + static_inputs hint.
|
||||
* 3. Plan steps — ordered list of ``step_id → action``.
|
||||
*
|
||||
* A "View raw JSON" toggle reveals the full payload for power users who
|
||||
* want to inspect every field; it's collapsed by default so the card
|
||||
* stays scannable for the common case.
|
||||
*/
|
||||
export function AutomationDraftPreview({ draft, raw }: AutomationDraftPreviewProps) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">{draft.name}</p>
|
||||
{draft.description && <p className="text-xs text-muted-foreground">{draft.description}</p>}
|
||||
</div>
|
||||
|
||||
{draft.definition.goal && (
|
||||
<Section icon={Target} label="Goal">
|
||||
<p className="text-xs text-foreground">{draft.definition.goal}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section icon={CalendarClock} label={`Triggers · ${draft.triggers.length}`}>
|
||||
{draft.triggers.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No triggers — automation will need one before it can run.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{draft.triggers.map((trigger) => (
|
||||
<li
|
||||
key={triggerKey(trigger)}
|
||||
className="rounded-md border border-border/60 bg-background/50 px-3 py-2 text-xs"
|
||||
>
|
||||
<TriggerLine trigger={trigger} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
icon={ListOrdered}
|
||||
label={`Plan · ${draft.definition.plan.length} step${draft.definition.plan.length === 1 ? "" : "s"}`}
|
||||
>
|
||||
<ol className="space-y-1 text-xs">
|
||||
{draft.definition.plan.map((step, idx) => (
|
||||
<li key={step.step_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">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-foreground">{step.step_id}</span>
|
||||
<span className="text-muted-foreground"> → </span>
|
||||
<code className="font-mono text-muted-foreground">{step.action}</code>
|
||||
{step.when && <span className="ml-2 text-muted-foreground">when {step.when}</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRaw((value) => !value)}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showRaw ? (
|
||||
<ChevronDown className="h-3 w-3" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" aria-hidden />
|
||||
)}
|
||||
{showRaw ? "Hide raw JSON" : "View raw JSON"}
|
||||
</button>
|
||||
{showRaw && (
|
||||
<pre className="rounded-md bg-muted/40 px-3 py-2 text-[11px] font-mono text-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-72">
|
||||
{JSON.stringify(raw, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable key derived from the trigger's identifying fields. Drafts are
|
||||
* static snapshots so collisions only happen if the LLM emits two literally
|
||||
* identical triggers — harmless in practice.
|
||||
*/
|
||||
function triggerKey(trigger: DraftTrigger): string {
|
||||
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : "";
|
||||
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "";
|
||||
return `${trigger.type}|${cron}|${tz}`;
|
||||
}
|
||||
|
||||
function TriggerLine({ trigger }: { trigger: DraftTrigger }) {
|
||||
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";
|
||||
const staticKeys = Object.keys(trigger.static_inputs ?? {});
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-foreground">{human}</span>
|
||||
<span className="text-muted-foreground">· {tz}</span>
|
||||
{!trigger.enabled && (
|
||||
<span className="rounded-md border border-border/60 px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cron && <code className="font-mono text-muted-foreground">{cron}</code>}
|
||||
{staticKeys.length > 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
Static inputs: <span className="text-foreground">{staticKeys.join(", ")}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="capitalize text-foreground">{trigger.type}</span>;
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon: Icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof Target;
|
||||
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,562 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil, Workflow } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AutomationModelFields,
|
||||
type AutomationModelSelection,
|
||||
} from "@/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { automationCreateRequest } from "@/contracts/types/automation.types";
|
||||
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
|
||||
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
|
||||
import { useAutomationEligibleModels } from "@/hooks/use-automation-eligible-models";
|
||||
import {
|
||||
trackAutomationChatApproved,
|
||||
trackAutomationChatCreateFailed,
|
||||
trackAutomationChatCreateSucceeded,
|
||||
trackAutomationChatDraftEdited,
|
||||
trackAutomationChatRejected,
|
||||
} from "@/lib/posthog/events";
|
||||
import { AutomationDraftPreview } from "./automation-draft-preview";
|
||||
|
||||
const editArgsSchema = automationCreateRequest.omit({ search_space_id: true });
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Result discrimination — mirrors the backend return shapes in
|
||||
// app/agents/multi_agent_chat/main_agent/tools/automation/create.py.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type AutomationCreateContext = {
|
||||
search_space_id?: number;
|
||||
};
|
||||
|
||||
interface SavedResult {
|
||||
status: "saved";
|
||||
automation_id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RejectedResult {
|
||||
status: "rejected";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface InvalidResult {
|
||||
status: "invalid";
|
||||
issues: string[];
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
interface ErrorResult {
|
||||
status: "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
type CreateAutomationResult =
|
||||
| InterruptResult<AutomationCreateContext>
|
||||
| SavedResult
|
||||
| RejectedResult
|
||||
| InvalidResult
|
||||
| ErrorResult;
|
||||
|
||||
function hasStatus(value: unknown, status: string): boolean {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"status" in value &&
|
||||
(value as { status: unknown }).status === status
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Approval card — pending → processing → complete / rejected.
|
||||
//
|
||||
// Edit toggle reuses the same primitives as the Create-via-JSON page: raw
|
||||
// textarea, Format, Zod validation against ``AutomationCreate`` (minus the
|
||||
// ``search_space_id`` field, which the backend injects). Approve dispatches
|
||||
// an ``edit`` decision with the parsed args when edits are pending, otherwise
|
||||
// a plain ``approve``. Multi-turn chat refinement still works as a fallback.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
interface ApprovalCardProps {
|
||||
args: Record<string, unknown>;
|
||||
interruptData: InterruptResult<AutomationCreateContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}
|
||||
|
||||
function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
|
||||
const reviewConfig = interruptData.review_configs[0];
|
||||
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
||||
const canApprove = allowedDecisions.includes("approve");
|
||||
const canReject = allowedDecisions.includes("reject");
|
||||
const canEdit = allowedDecisions.includes("edit");
|
||||
|
||||
const [pendingEdits, setPendingEdits] = useState<Record<string, unknown> | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const effectiveArgs = pendingEdits ?? args;
|
||||
const draft = useMemo(() => extractDraft(effectiveArgs), [effectiveArgs]);
|
||||
|
||||
// Per-automation model selection. The card always supplies models (chosen
|
||||
// here, not snapshotted from the search space), so Approve dispatches an
|
||||
// `edit` decision carrying `definition.models`.
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const eligibleModels = useAutomationEligibleModels();
|
||||
const [modelSelection, setModelSelection] = useState<AutomationModelSelection>({
|
||||
agentLlmId: 0,
|
||||
imageConfigId: 0,
|
||||
visionConfigId: 0,
|
||||
});
|
||||
// Resolve each slot during render: an explicit pick wins, else the eligible
|
||||
// default. No effect seeds async hook data into state.
|
||||
const resolvedModels = useMemo<AutomationModelSelection>(
|
||||
() => ({
|
||||
agentLlmId: modelSelection.agentLlmId || eligibleModels.llm.defaultId || 0,
|
||||
imageConfigId: modelSelection.imageConfigId || eligibleModels.image.defaultId || 0,
|
||||
visionConfigId: modelSelection.visionConfigId || eligibleModels.vision.defaultId || 0,
|
||||
}),
|
||||
[
|
||||
modelSelection,
|
||||
eligibleModels.llm.defaultId,
|
||||
eligibleModels.image.defaultId,
|
||||
eligibleModels.vision.defaultId,
|
||||
]
|
||||
);
|
||||
const modelsResolved =
|
||||
resolvedModels.agentLlmId !== 0 &&
|
||||
resolvedModels.imageConfigId !== 0 &&
|
||||
resolvedModels.visionConfigId !== 0;
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (phase !== "pending" || !canApprove || isEditing || !modelsResolved) return;
|
||||
setProcessing();
|
||||
const baseArgs = pendingEdits ?? args;
|
||||
const baseDefinition = (baseArgs.definition ?? {}) as Record<string, unknown>;
|
||||
const mergedArgs = {
|
||||
...baseArgs,
|
||||
definition: {
|
||||
...baseDefinition,
|
||||
models: {
|
||||
agent_llm_id: resolvedModels.agentLlmId,
|
||||
image_generation_config_id: resolvedModels.imageConfigId,
|
||||
vision_llm_config_id: resolvedModels.visionConfigId,
|
||||
},
|
||||
},
|
||||
};
|
||||
const plan = Array.isArray(baseDefinition.plan) ? baseDefinition.plan : [];
|
||||
const triggers = Array.isArray(baseArgs.triggers) ? baseArgs.triggers : [];
|
||||
trackAutomationChatApproved({
|
||||
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
|
||||
edited: pendingEdits !== null,
|
||||
task_count: plan.length,
|
||||
trigger_type:
|
||||
(triggers[0] as { type?: string } | undefined)?.type ??
|
||||
(triggers.length ? undefined : "none"),
|
||||
agent_llm_id: resolvedModels.agentLlmId,
|
||||
image_generation_config_id: resolvedModels.imageConfigId,
|
||||
vision_llm_config_id: resolvedModels.visionConfigId,
|
||||
});
|
||||
onDecision({
|
||||
type: "edit",
|
||||
edited_action: {
|
||||
name: interruptData.action_requests[0]?.name ?? "create_automation",
|
||||
args: mergedArgs,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
phase,
|
||||
canApprove,
|
||||
isEditing,
|
||||
modelsResolved,
|
||||
setProcessing,
|
||||
onDecision,
|
||||
interruptData,
|
||||
args,
|
||||
pendingEdits,
|
||||
resolvedModels,
|
||||
searchSpaceId,
|
||||
]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (phase !== "pending" || !canReject || isEditing) return;
|
||||
setRejected();
|
||||
trackAutomationChatRejected({
|
||||
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
|
||||
});
|
||||
onDecision({ type: "reject", message: "User rejected the automation draft." });
|
||||
}, [phase, canReject, isEditing, setRejected, onDecision, searchSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
handleApprove();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [handleApprove, isEditing]);
|
||||
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
|
||||
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-4 select-none">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Workflow className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{phase === "rejected"
|
||||
? "Automation cancelled"
|
||||
: phase === "processing"
|
||||
? "Saving automation"
|
||||
: phase === "complete"
|
||||
? "Automation saved"
|
||||
: "Create automation"}
|
||||
</p>
|
||||
{phase === "processing" ? (
|
||||
<TextShimmerLoader
|
||||
text={pendingEdits ? "Saving with your edits" : "Saving automation"}
|
||||
size="sm"
|
||||
/>
|
||||
) : phase === "complete" ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{pendingEdits
|
||||
? "Automation saved with your edits"
|
||||
: "Automation created from this draft"}
|
||||
</p>
|
||||
) : phase === "rejected" ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
No automation was saved — ask in chat to refine and try again.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{pendingEdits
|
||||
? "Showing your edits. Approve to save, or edit again."
|
||||
: "Review and approve to save. Edit for fine-tuning, or reply in chat for a redraft."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{phase === "pending" && canEdit && !isEditing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg text-muted-foreground -mt-1 -mr-2 shrink-0"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4">
|
||||
{isEditing ? (
|
||||
<JsonEditor
|
||||
initialValue={effectiveArgs}
|
||||
onSave={(parsed) => {
|
||||
setPendingEdits(parsed);
|
||||
setIsEditing(false);
|
||||
trackAutomationChatDraftEdited({
|
||||
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
|
||||
});
|
||||
}}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<AutomationDraftPreview draft={draft} raw={effectiveArgs} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{phase === "pending" && !isEditing && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4">
|
||||
<p className="mb-3 text-xs font-medium text-foreground">Models</p>
|
||||
<AutomationModelFields
|
||||
searchSpaceId={Number(searchSpaceId)}
|
||||
value={resolvedModels}
|
||||
onChange={(patch) => setModelSelection((prev) => ({ ...prev, ...patch }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
||||
{canApprove && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-lg gap-1.5"
|
||||
disabled={!modelsResolved}
|
||||
onClick={handleApprove}
|
||||
>
|
||||
Approve
|
||||
<CornerDownLeftIcon className="size-3 opacity-60" />
|
||||
</Button>
|
||||
)}
|
||||
{canReject && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg text-muted-foreground"
|
||||
onClick={handleReject}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface JsonEditorProps {
|
||||
initialValue: Record<string, unknown>;
|
||||
onSave: (parsed: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) {
|
||||
const [value, setValue] = useState<Record<string, unknown>>(initialValue);
|
||||
const [issues, setIssues] = useState<string[]>([]);
|
||||
|
||||
function handleSave() {
|
||||
setIssues([]);
|
||||
const result = editArgsSchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
setIssues(
|
||||
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSave(result.data as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
|
||||
<JsonView
|
||||
src={value}
|
||||
editable
|
||||
onChange={(next) => setValue(next as Record<string, unknown>)}
|
||||
collapsed={false}
|
||||
/>
|
||||
</div>
|
||||
{issues.length > 0 && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive">
|
||||
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
{issues.length} issue{issues.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-0.5 text-xs text-destructive/90 list-disc list-inside">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue} className="font-mono">
|
||||
{issue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleSave}>
|
||||
Save edits
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Terminal result cards.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function SavedCard({ result }: { result: SavedResult }) {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const tracked = useRef(false);
|
||||
useEffect(() => {
|
||||
if (tracked.current) return;
|
||||
tracked.current = true;
|
||||
trackAutomationChatCreateSucceeded({
|
||||
automation_id: result.automation_id,
|
||||
name: result.name,
|
||||
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
|
||||
});
|
||||
}, [result.automation_id, result.name, searchSpaceId]);
|
||||
|
||||
const detailHref = searchSpaceId
|
||||
? `/dashboard/${searchSpaceId}/automations/${result.automation_id}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="flex items-start gap-3 px-5 pt-5 pb-4">
|
||||
<Workflow className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">Automation saved</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{result.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
{detailHref && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-3">
|
||||
<Link
|
||||
href={detailHref}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
||||
Open automation #{result.automation_id}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InvalidCard({ result }: { result: InvalidResult }) {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const tracked = useRef(false);
|
||||
useEffect(() => {
|
||||
if (tracked.current) return;
|
||||
tracked.current = true;
|
||||
trackAutomationChatCreateFailed({
|
||||
reason: "invalid",
|
||||
issue_count: result.issues.length,
|
||||
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
|
||||
});
|
||||
}, [result.issues.length, searchSpaceId]);
|
||||
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<p className="text-sm font-semibold text-destructive">Couldn't draft this automation</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
The drafter produced output that didn't validate. I'll refine and retry.
|
||||
</p>
|
||||
</div>
|
||||
{result.issues.length > 0 && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<ul className="px-5 py-3 space-y-1 text-xs text-muted-foreground list-disc list-inside">
|
||||
{result.issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorCard({ result }: { result: ErrorResult }) {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const tracked = useRef(false);
|
||||
useEffect(() => {
|
||||
if (tracked.current) return;
|
||||
tracked.current = true;
|
||||
trackAutomationChatCreateFailed({
|
||||
reason: "error",
|
||||
message: result.message,
|
||||
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
|
||||
});
|
||||
}, [result.message, searchSpaceId]);
|
||||
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<p className="text-sm font-semibold text-destructive">Failed to create automation</p>
|
||||
</div>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Entry — dispatches between the approval card and terminal result cards.
|
||||
//
|
||||
// Rejection is special: we hide the standalone "rejected" card because the
|
||||
// approval card itself already transitions to a "rejected" phase inline. A
|
||||
// second message in the timeline would be noisy.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export const CreateAutomationToolUI = ({
|
||||
args,
|
||||
result,
|
||||
}: ToolCallMessagePartProps<{ intent: string }, CreateAutomationResult>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args as unknown as Record<string, unknown>}
|
||||
interruptData={result as InterruptResult<AutomationCreateContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasStatus(result, "rejected")) return null;
|
||||
if (hasStatus(result, "saved")) return <SavedCard result={result as SavedResult} />;
|
||||
if (hasStatus(result, "invalid")) return <InvalidCard result={result as InvalidResult} />;
|
||||
if (hasStatus(result, "error")) return <ErrorCard result={result as ErrorResult} />;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Project raw args into the shape ``AutomationDraftPreview`` expects.
|
||||
*
|
||||
* The args dict is the full ``AutomationCreate`` payload (minus
|
||||
* ``search_space_id`` which is injected server-side), so we trust the
|
||||
* top-level fields but defend against missing nested defaults.
|
||||
*/
|
||||
function extractDraft(args: Record<string, unknown>) {
|
||||
const definition = (args.definition ?? {}) as Record<string, unknown>;
|
||||
const planSteps = Array.isArray(definition.plan)
|
||||
? (definition.plan as Array<Record<string, unknown>>).map((step) => ({
|
||||
step_id: String(step.step_id ?? "(unnamed)"),
|
||||
action: String(step.action ?? ""),
|
||||
when: typeof step.when === "string" ? step.when : null,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const triggers = Array.isArray(args.triggers)
|
||||
? (args.triggers as Array<Record<string, unknown>>).map((trigger) => ({
|
||||
type: String(trigger.type ?? "schedule"),
|
||||
params: (trigger.params ?? {}) as Record<string, unknown>,
|
||||
static_inputs: (trigger.static_inputs ?? {}) as Record<string, unknown>,
|
||||
enabled: trigger.enabled !== false,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: String(args.name ?? "(unnamed automation)"),
|
||||
description: typeof args.description === "string" ? args.description : null,
|
||||
definition: {
|
||||
goal: typeof definition.goal === "string" ? definition.goal : null,
|
||||
plan: planSteps,
|
||||
},
|
||||
triggers,
|
||||
};
|
||||
}
|
||||
1
surfsense_web/components/tool-ui/automation/index.ts
Normal file
1
surfsense_web/components/tool-ui/automation/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { CreateAutomationToolUI } from "./create-automation";
|
||||
|
|
@ -16,6 +16,7 @@ import { baseApiService } from "@/lib/apis/base-api.service";
|
|||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
/**
|
||||
* Zod schemas for runtime validation
|
||||
*/
|
||||
|
|
@ -193,10 +194,10 @@ function PodcastPlayer({
|
|||
} else {
|
||||
// Authenticated view - fetch audio and details in parallel
|
||||
const [audioResponse, details] = await Promise.all([
|
||||
authenticatedFetch(
|
||||
`${BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
|
||||
{ method: "GET", signal: controller.signal }
|
||||
),
|
||||
authenticatedFetch(`${BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
export { Audio } from "./audio";
|
||||
export { CreateAutomationToolUI } from "./automation";
|
||||
export { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "./dropbox";
|
||||
export {
|
||||
type GenerateImageArgs,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { compileCheck, compileToComponent } from "@/lib/remotion/compile-check";
|
||||
import { FPS } from "@/lib/remotion/constants";
|
||||
import {
|
||||
|
|
@ -19,7 +20,6 @@ import {
|
|||
type CompiledSlide,
|
||||
} from "./combined-player";
|
||||
import { getPptxExportErrorToast, getVideoDownloadErrorToast } from "./errors";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const GenerateVideoPresentationArgsSchema = z.object({
|
||||
source_content: z.string(),
|
||||
|
|
|
|||
94
surfsense_web/components/ui/empty.tsx
Normal file
94
surfsense_web/components/ui/empty.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn("flex max-w-sm flex-col items-center gap-2 text-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };
|
||||
Loading…
Add table
Add a link
Reference in a new issue