mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: introduce citation components and functionality
- Added new citation components including `Citation` and `CitationList` for managing and displaying citations. - Implemented schemas for citation data validation using Zod. - Created utility functions for safe navigation and href sanitization. - Refactored the `ScrapeWebpageToolUI` to utilize the new citation components for rendering scraped content. - Enhanced the tool UI with loading and error states for improved user experience.
This commit is contained in:
parent
056d3c456b
commit
4cd83573a3
14 changed files with 1125 additions and 85 deletions
8
surfsense_web/components/tool-ui/citation/_adapter.tsx
Normal file
8
surfsense_web/components/tool-ui/citation/_adapter.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
export { cn } from "@/lib/utils";
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
464
surfsense_web/components/tool-ui/citation/citation-list.tsx
Normal file
464
surfsense_web/components/tool-ui/citation/citation-list.tsx
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
FileText,
|
||||
Globe,
|
||||
Code2,
|
||||
Newspaper,
|
||||
Database,
|
||||
File,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
import { Citation } from "./citation";
|
||||
import type {
|
||||
SerializableCitation,
|
||||
CitationType,
|
||||
CitationVariant,
|
||||
} from "./schema";
|
||||
import {
|
||||
openSafeNavigationHref,
|
||||
resolveSafeNavigationHref,
|
||||
} from "../shared/media";
|
||||
|
||||
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
|
||||
webpage: Globe,
|
||||
document: FileText,
|
||||
article: Newspaper,
|
||||
api: Database,
|
||||
code: Code2,
|
||||
other: File,
|
||||
};
|
||||
|
||||
function useHoverPopover(delay = 100) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(true), delay);
|
||||
}, [delay]);
|
||||
|
||||
const handleMouseLeave = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(false), delay);
|
||||
}, [delay]);
|
||||
|
||||
const handleFocus = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = React.useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null;
|
||||
if (containerRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) {
|
||||
return;
|
||||
}
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(false), delay);
|
||||
},
|
||||
[delay],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
containerRef,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CitationListProps {
|
||||
id: string;
|
||||
citations: SerializableCitation[];
|
||||
variant?: CitationVariant;
|
||||
maxVisible?: number;
|
||||
className?: string;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
}
|
||||
|
||||
export function CitationList(props: CitationListProps) {
|
||||
const {
|
||||
id,
|
||||
citations,
|
||||
variant = "default",
|
||||
maxVisible,
|
||||
className,
|
||||
onNavigate,
|
||||
} = props;
|
||||
|
||||
const shouldTruncate =
|
||||
maxVisible !== undefined && citations.length > maxVisible;
|
||||
const visibleCitations = shouldTruncate
|
||||
? citations.slice(0, maxVisible)
|
||||
: citations;
|
||||
const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
|
||||
const overflowCount = overflowCitations.length;
|
||||
|
||||
const wrapperClass =
|
||||
variant === "inline"
|
||||
? "flex flex-wrap items-center gap-1.5"
|
||||
: "flex flex-col gap-2";
|
||||
|
||||
// Stacked variant: overlapping favicons with popover
|
||||
if (variant === "stacked") {
|
||||
return (
|
||||
<StackedCitations
|
||||
id={id}
|
||||
citations={citations}
|
||||
className={className}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "default") {
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate flex flex-col gap-4", className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
>
|
||||
{visibleCitations.map((citation) => (
|
||||
<Citation
|
||||
key={citation.id}
|
||||
{...citation}
|
||||
variant="default"
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{shouldTruncate && (
|
||||
<OverflowIndicator
|
||||
citations={overflowCitations}
|
||||
count={overflowCount}
|
||||
variant="default"
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate", wrapperClass, className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
>
|
||||
{visibleCitations.map((citation) => (
|
||||
<Citation
|
||||
key={citation.id}
|
||||
{...citation}
|
||||
variant={variant}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{shouldTruncate && (
|
||||
<OverflowIndicator
|
||||
citations={overflowCitations}
|
||||
count={overflowCount}
|
||||
variant={variant}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverflowIndicatorProps {
|
||||
citations: SerializableCitation[];
|
||||
count: number;
|
||||
variant: CitationVariant;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
}
|
||||
|
||||
function OverflowIndicator({
|
||||
citations,
|
||||
count,
|
||||
variant,
|
||||
onNavigate,
|
||||
}: OverflowIndicatorProps) {
|
||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||
|
||||
const handleClick = (citation: SerializableCitation) => {
|
||||
const href = resolveSafeNavigationHref(citation.href);
|
||||
if (!href) return;
|
||||
if (onNavigate) {
|
||||
onNavigate(href, citation);
|
||||
} else {
|
||||
openSafeNavigationHref(href);
|
||||
}
|
||||
};
|
||||
|
||||
const popoverContent = (
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||
{citations.map((citation) => (
|
||||
<OverflowItem
|
||||
key={citation.id}
|
||||
citation={citation}
|
||||
onClick={() => handleClick(citation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm tabular-nums",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">+{count} more</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{popoverContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-xl px-4 py-3",
|
||||
"border-border bg-card border border-dashed",
|
||||
"transition-colors duration-150",
|
||||
"hover:border-foreground/25 hover:bg-muted/50",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
+{count} more sources
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{popoverContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverflowItemProps {
|
||||
citation: SerializableCitation;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function OverflowItem({ citation, onClick }: OverflowItemProps) {
|
||||
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
|
||||
>
|
||||
{citation.favicon ? (
|
||||
<Image
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={16}
|
||||
height={16}
|
||||
className="bg-muted size-4 shrink-0 rounded object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
|
||||
{citation.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{citation.domain}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StackedCitationsProps {
|
||||
id: string;
|
||||
citations: SerializableCitation[];
|
||||
className?: string;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
}
|
||||
|
||||
function StackedCitations({
|
||||
id,
|
||||
citations,
|
||||
className,
|
||||
onNavigate,
|
||||
}: StackedCitationsProps) {
|
||||
const {
|
||||
open,
|
||||
setOpen,
|
||||
containerRef,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleBlur,
|
||||
} = useHoverPopover();
|
||||
const maxIcons = 4;
|
||||
const visibleCitations = citations.slice(0, maxIcons);
|
||||
const remainingCount = Math.max(0, citations.length - maxIcons);
|
||||
|
||||
const handleClick = (citation: SerializableCitation) => {
|
||||
const href = resolveSafeNavigationHref(citation.href);
|
||||
if (!href) return;
|
||||
if (onNavigate) {
|
||||
onNavigate(href, citation);
|
||||
} else {
|
||||
openSafeNavigationHref(href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="inline-flex">
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
onBlur={handleBlur}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
|
||||
"bg-muted/40 outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted/70",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{visibleCitations.map((citation, index) => {
|
||||
const TypeIcon =
|
||||
TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||
return (
|
||||
<div
|
||||
key={citation.id}
|
||||
className={cn(
|
||||
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
|
||||
index > 0 && "-ml-2",
|
||||
)}
|
||||
style={{ zIndex: maxIcons - index }}
|
||||
>
|
||||
{citation.favicon ? (
|
||||
<Image
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 rounded-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon
|
||||
className="text-muted-foreground size-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{remainingCount > 0 && (
|
||||
<div
|
||||
className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
<span className="text-muted-foreground text-[10px] font-medium tracking-tight">
|
||||
•••
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{citations.length} source{citations.length !== 1 && "s"}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onBlur={handleBlur}
|
||||
onEscapeKeyDown={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||
{citations.map((citation) => (
|
||||
<OverflowItem
|
||||
key={citation.id}
|
||||
citation={citation}
|
||||
onClick={() => handleClick(citation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
surfsense_web/components/tool-ui/citation/citation.tsx
Normal file
259
surfsense_web/components/tool-ui/citation/citation.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
FileText,
|
||||
Globe,
|
||||
Code2,
|
||||
Newspaper,
|
||||
Database,
|
||||
File,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
|
||||
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
|
||||
import type {
|
||||
SerializableCitation,
|
||||
CitationType,
|
||||
CitationVariant,
|
||||
} from "./schema";
|
||||
|
||||
const FALLBACK_LOCALE = "en-US";
|
||||
|
||||
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
|
||||
webpage: Globe,
|
||||
document: FileText,
|
||||
article: Newspaper,
|
||||
api: Database,
|
||||
code: Code2,
|
||||
other: File,
|
||||
};
|
||||
|
||||
function extractDomain(url: string): string | undefined {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(isoString: string, locale: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function useHoverPopover(delay = 100) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(true), delay);
|
||||
}, [delay]);
|
||||
|
||||
const handleMouseLeave = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(false), delay);
|
||||
}, [delay]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { open, setOpen, handleMouseEnter, handleMouseLeave };
|
||||
}
|
||||
|
||||
export interface CitationProps extends SerializableCitation {
|
||||
variant?: CitationVariant;
|
||||
className?: string;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
}
|
||||
|
||||
export function Citation(props: CitationProps) {
|
||||
const { variant = "default", className, onNavigate, ...serializable } = props;
|
||||
|
||||
const {
|
||||
id,
|
||||
href: rawHref,
|
||||
title,
|
||||
snippet,
|
||||
domain: providedDomain,
|
||||
favicon,
|
||||
author,
|
||||
publishedAt,
|
||||
type = "webpage",
|
||||
locale: providedLocale,
|
||||
} = serializable;
|
||||
|
||||
const locale = providedLocale ?? FALLBACK_LOCALE;
|
||||
const sanitizedHref = sanitizeHref(rawHref);
|
||||
const domain = providedDomain ?? extractDomain(rawHref);
|
||||
|
||||
const citationData: SerializableCitation = {
|
||||
...serializable,
|
||||
href: sanitizedHref ?? rawHref,
|
||||
domain,
|
||||
locale,
|
||||
};
|
||||
|
||||
const TypeIcon = TYPE_ICONS[type] ?? Globe;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!sanitizedHref) return;
|
||||
if (onNavigate) {
|
||||
onNavigate(sanitizedHref, citationData);
|
||||
} else {
|
||||
openSafeNavigationHref(sanitizedHref);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
const iconElement = favicon ? (
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={14}
|
||||
height={14}
|
||||
className="bg-muted size-3.5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
||||
);
|
||||
|
||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||
|
||||
// Inline variant: compact chip with hover popover
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground">{domain}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-72 cursor-pointer p-0"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors">
|
||||
<div className="flex items-start gap-2">
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground text-xs">{domain}</span>
|
||||
</div>
|
||||
<p className="text-sm leading-snug font-medium">{title}</p>
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant: full card
|
||||
return (
|
||||
<article
|
||||
className={cn("relative w-full max-w-md min-w-72", className)}
|
||||
lang={locale}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
|
||||
"border-border bg-card border text-sm shadow-xs",
|
||||
"transition-colors duration-150",
|
||||
sanitizedHref && [
|
||||
"cursor-pointer",
|
||||
"hover:border-foreground/25",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
],
|
||||
)}
|
||||
onClick={sanitizedHref ? handleClick : undefined}
|
||||
role={sanitizedHref ? "link" : undefined}
|
||||
tabIndex={sanitizedHref ? 0 : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{iconElement}
|
||||
<span className="truncate font-medium">{domain}</span>
|
||||
{(author || publishedAt) && (
|
||||
<span className="opacity-70">
|
||||
<span className="opacity-60"> — </span>
|
||||
{author}
|
||||
{author && publishedAt && ", "}
|
||||
{publishedAt && (
|
||||
<time dateTime={publishedAt} className="tabular-nums">
|
||||
{formatDate(publishedAt, locale)}
|
||||
</time>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sanitizedHref && (
|
||||
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
|
||||
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
|
||||
{title}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
|
||||
<span className="line-clamp-3">{snippet}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
9
surfsense_web/components/tool-ui/citation/index.ts
Normal file
9
surfsense_web/components/tool-ui/citation/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { Citation } from "./citation";
|
||||
export type { CitationProps } from "./citation";
|
||||
export { CitationList } from "./citation-list";
|
||||
export type { CitationListProps } from "./citation-list";
|
||||
export type {
|
||||
SerializableCitation,
|
||||
CitationType,
|
||||
CitationVariant,
|
||||
} from "./schema";
|
||||
52
surfsense_web/components/tool-ui/citation/schema.ts
Normal file
52
surfsense_web/components/tool-ui/citation/schema.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { z } from "zod";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
|
||||
export const CitationTypeSchema = z.enum([
|
||||
"webpage",
|
||||
"document",
|
||||
"article",
|
||||
"api",
|
||||
"code",
|
||||
"other",
|
||||
]);
|
||||
|
||||
export type CitationType = z.infer<typeof CitationTypeSchema>;
|
||||
|
||||
export const CitationVariantSchema = z.enum(["default", "inline", "stacked"]);
|
||||
|
||||
export type CitationVariant = z.infer<typeof CitationVariantSchema>;
|
||||
|
||||
export const SerializableCitationSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
href: z.string().url(),
|
||||
title: z.string(),
|
||||
snippet: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
favicon: z.string().url().optional(),
|
||||
author: z.string().optional(),
|
||||
publishedAt: z.string().datetime().optional(),
|
||||
type: CitationTypeSchema.optional(),
|
||||
locale: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SerializableCitation = z.infer<typeof SerializableCitationSchema>;
|
||||
|
||||
const SerializableCitationSchemaContract = defineToolUiContract(
|
||||
"Citation",
|
||||
SerializableCitationSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableCitation: (
|
||||
input: unknown,
|
||||
) => SerializableCitation = SerializableCitationSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableCitation: (
|
||||
input: unknown,
|
||||
) => SerializableCitation | null = SerializableCitationSchemaContract.safeParse;
|
||||
|
|
@ -16,6 +16,15 @@ export {
|
|||
type SerializableArticle,
|
||||
} from "./article";
|
||||
export { Audio } from "./audio";
|
||||
export {
|
||||
Citation,
|
||||
type CitationProps,
|
||||
CitationList,
|
||||
type CitationListProps,
|
||||
type SerializableCitation,
|
||||
type CitationType,
|
||||
type CitationVariant,
|
||||
} from "./citation";
|
||||
export {
|
||||
type DeepAgentThinkingArgs,
|
||||
type DeepAgentThinkingResult,
|
||||
|
|
|
|||
|
|
@ -1,30 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, FileTextIcon } from "lucide-react";
|
||||
import { AlertCircleIcon, FileTextIcon, GlobeIcon } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Article,
|
||||
ArticleErrorBoundary,
|
||||
ArticleLoading,
|
||||
parseSerializableArticle,
|
||||
} from "@/components/tool-ui/article";
|
||||
import { Citation } from "@/components/tool-ui/citation";
|
||||
|
||||
// ============================================================================
|
||||
// Zod Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for scrape_webpage tool arguments
|
||||
*/
|
||||
const ScrapeWebpageArgsSchema = z.object({
|
||||
url: z.string(),
|
||||
max_length: z.number().nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for scrape_webpage tool result
|
||||
*/
|
||||
const ScrapeWebpageResultSchema = z.object({
|
||||
id: z.string(),
|
||||
assetId: z.string(),
|
||||
|
|
@ -42,16 +27,9 @@ const ScrapeWebpageResultSchema = z.object({
|
|||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type ScrapeWebpageArgs = z.infer<typeof ScrapeWebpageArgsSchema>;
|
||||
type ScrapeWebpageResult = z.infer<typeof ScrapeWebpageResultSchema>;
|
||||
|
||||
/**
|
||||
* Error state component shown when webpage scraping fails
|
||||
*/
|
||||
function ScrapeErrorState({ url, error }: { url: string; error: string }) {
|
||||
return (
|
||||
<div className="my-4 overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-4 max-w-md">
|
||||
|
|
@ -69,9 +47,6 @@ function ScrapeErrorState({ url, error }: { url: string; error: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled state component
|
||||
*/
|
||||
function ScrapeCancelledState({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="my-4 rounded-xl border border-muted p-4 text-muted-foreground max-w-md">
|
||||
|
|
@ -83,42 +58,50 @@ function ScrapeCancelledState({ url }: { url: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed Article component with error handling
|
||||
*/
|
||||
function ParsedArticle({ result }: { result: unknown }) {
|
||||
const { description, ...article } = parseSerializableArticle(result);
|
||||
|
||||
return <Article {...article} maxWidth="480px" />;
|
||||
function ScrapeLoadingState({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="my-4 max-w-md animate-pulse rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GlobeIcon className="size-3.5 text-muted-foreground" />
|
||||
<div className="h-3 w-24 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-4 w-3/4 rounded bg-muted" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3 truncate">{url}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildFaviconUrl(domain: string): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
|
||||
}
|
||||
|
||||
function ParsedCitation({ result }: { result: ScrapeWebpageResult }) {
|
||||
return (
|
||||
<Citation
|
||||
id={result.id}
|
||||
href={result.href}
|
||||
title={result.title}
|
||||
snippet={result.description ?? undefined}
|
||||
domain={result.domain ?? undefined}
|
||||
favicon={result.domain ? buildFaviconUrl(result.domain) : undefined}
|
||||
author={result.author ?? undefined}
|
||||
publishedAt={result.date ?? undefined}
|
||||
type="article"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrape Webpage Tool UI Component
|
||||
*
|
||||
* This component is registered with assistant-ui to render an article card
|
||||
* when the scrape_webpage tool is called by the agent.
|
||||
*
|
||||
* It displays scraped webpage content including:
|
||||
* - Title and description
|
||||
* - Author and date (if available)
|
||||
* - Word count
|
||||
* - Link to original source
|
||||
*/
|
||||
export const ScrapeWebpageToolUI = makeAssistantToolUI<ScrapeWebpageArgs, ScrapeWebpageResult>({
|
||||
toolName: "scrape_webpage",
|
||||
render: function ScrapeWebpageUI({ args, result, status }) {
|
||||
const url = args.url || "Unknown URL";
|
||||
|
||||
// Loading state - tool is still running
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<ArticleLoading title={`Scraping ${url}...`} />
|
||||
</div>
|
||||
);
|
||||
return <ScrapeLoadingState url={url} />;
|
||||
}
|
||||
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return <ScrapeCancelledState url={url} />;
|
||||
|
|
@ -133,26 +116,17 @@ export const ScrapeWebpageToolUI = makeAssistantToolUI<ScrapeWebpageArgs, Scrape
|
|||
}
|
||||
}
|
||||
|
||||
// No result yet
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<ArticleLoading title={`Extracting content from ${url}...`} />
|
||||
</div>
|
||||
);
|
||||
return <ScrapeLoadingState url={url} />;
|
||||
}
|
||||
|
||||
// Error result from the tool
|
||||
if (result.error) {
|
||||
return <ScrapeErrorState url={url} error={result.error} />;
|
||||
}
|
||||
|
||||
// Success - render the article card
|
||||
return (
|
||||
<div className="my-4">
|
||||
<ArticleErrorBoundary>
|
||||
<ParsedArticle result={result} />
|
||||
</ArticleErrorBoundary>
|
||||
<ParsedCitation result={result} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
19
surfsense_web/components/tool-ui/shared/contract.ts
Normal file
19
surfsense_web/components/tool-ui/shared/contract.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { z } from "zod";
|
||||
import { parseWithSchema, safeParseWithSchema } from "./parse";
|
||||
|
||||
export interface ToolUiContract<T> {
|
||||
schema: z.ZodType<T>;
|
||||
parse: (input: unknown) => T;
|
||||
safeParse: (input: unknown) => T | null;
|
||||
}
|
||||
|
||||
export function defineToolUiContract<T>(
|
||||
componentName: string,
|
||||
schema: z.ZodType<T>,
|
||||
): ToolUiContract<T> {
|
||||
return {
|
||||
schema,
|
||||
parse: (input: unknown) => parseWithSchema(schema, input, componentName),
|
||||
safeParse: (input: unknown) => safeParseWithSchema(schema, input),
|
||||
};
|
||||
}
|
||||
5
surfsense_web/components/tool-ui/shared/media/index.ts
Normal file
5
surfsense_web/components/tool-ui/shared/media/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { sanitizeHref } from "./sanitize-href";
|
||||
export {
|
||||
resolveSafeNavigationHref,
|
||||
openSafeNavigationHref,
|
||||
} from "./safe-navigation";
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { sanitizeHref } from "./sanitize-href";
|
||||
|
||||
export function resolveSafeNavigationHref(
|
||||
...candidates: Array<string | null | undefined>
|
||||
): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
const safeHref = sanitizeHref(candidate ?? undefined);
|
||||
if (safeHref) {
|
||||
return safeHref;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function openSafeNavigationHref(href: string | undefined): boolean {
|
||||
if (!href || typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
export function sanitizeHref(href?: string): string | undefined {
|
||||
if (!href) return undefined;
|
||||
const candidate = href.trim();
|
||||
if (!candidate) return undefined;
|
||||
|
||||
if (
|
||||
candidate.startsWith("/") ||
|
||||
candidate.startsWith("./") ||
|
||||
candidate.startsWith("../") ||
|
||||
candidate.startsWith("?") ||
|
||||
candidate.startsWith("#")
|
||||
) {
|
||||
if (candidate.startsWith("//")) return undefined;
|
||||
// eslint-disable-next-line no-control-regex -- intentionally matching control characters
|
||||
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return url.toString();
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
51
surfsense_web/components/tool-ui/shared/parse.ts
Normal file
51
surfsense_web/components/tool-ui/shared/parse.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { z } from "zod";
|
||||
|
||||
function formatZodPath(path: Array<string | number | symbol>): string {
|
||||
if (path.length === 0) return "root";
|
||||
return path
|
||||
.map((segment) =>
|
||||
typeof segment === "number" ? `[${segment}]` : String(segment),
|
||||
)
|
||||
.join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Zod errors into a compact `path: message` string.
|
||||
*/
|
||||
export function formatZodError(error: z.ZodError): string {
|
||||
const parts = error.issues.map((issue) => {
|
||||
const path = formatZodPath(issue.path);
|
||||
return `${path}: ${issue.message}`;
|
||||
});
|
||||
|
||||
return Array.from(new Set(parts)).join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unknown input and throw a readable error.
|
||||
*/
|
||||
export function parseWithSchema<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
name: string,
|
||||
): T {
|
||||
const res = schema.safeParse(input);
|
||||
if (!res.success) {
|
||||
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unknown input, returning `null` instead of throwing on failure.
|
||||
*
|
||||
* Use this in assistant-ui `render` functions where `args` stream in
|
||||
* incrementally and may be incomplete until the tool call finishes.
|
||||
*/
|
||||
export function safeParseWithSchema<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
): T | null {
|
||||
const res = schema.safeParse(input);
|
||||
return res.success ? res.data : null;
|
||||
}
|
||||
159
surfsense_web/components/tool-ui/shared/schema.ts
Normal file
159
surfsense_web/components/tool-ui/shared/schema.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { z } from "zod";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Tool UI conventions:
|
||||
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
|
||||
* - Schema: `SerializableXSchema`
|
||||
* - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
|
||||
* - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
|
||||
* - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
|
||||
* - Root attrs: `data-tool-ui-id` + `data-slot`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Schema for tool UI identity.
|
||||
*
|
||||
* Every tool UI should have a unique identifier that:
|
||||
* - Is stable across re-renders
|
||||
* - Is meaningful (not auto-generated)
|
||||
* - Is unique within the conversation
|
||||
*
|
||||
* Format recommendation: `{component-type}-{semantic-identifier}`
|
||||
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
|
||||
*/
|
||||
export const ToolUIIdSchema = z.string().min(1);
|
||||
|
||||
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
|
||||
|
||||
/**
|
||||
* Primary role of a Tool UI surface in a chat context.
|
||||
*/
|
||||
export const ToolUIRoleSchema = z.enum([
|
||||
"information",
|
||||
"decision",
|
||||
"control",
|
||||
"state",
|
||||
"composite",
|
||||
]);
|
||||
|
||||
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
|
||||
|
||||
export const ToolUIReceiptOutcomeSchema = z.enum([
|
||||
"success",
|
||||
"partial",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
|
||||
|
||||
/**
|
||||
* Optional receipt metadata: a durable summary of an outcome.
|
||||
*/
|
||||
export const ToolUIReceiptSchema = z.object({
|
||||
outcome: ToolUIReceiptOutcomeSchema,
|
||||
summary: z.string().min(1),
|
||||
identifiers: z.record(z.string(), z.string()).optional(),
|
||||
at: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
|
||||
|
||||
/**
|
||||
* Base schema for Tool UI payloads (id + optional role/receipt).
|
||||
*/
|
||||
export const ToolUISurfaceSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
});
|
||||
|
||||
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
|
||||
|
||||
export const ActionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
/**
|
||||
* Canonical narration the assistant can use after this action is taken.
|
||||
*
|
||||
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
|
||||
*/
|
||||
sentence: z.string().optional(),
|
||||
confirmLabel: z.string().optional(),
|
||||
variant: z
|
||||
.enum(["default", "destructive", "secondary", "ghost", "outline"])
|
||||
.optional(),
|
||||
icon: z.custom<ReactNode>().optional(),
|
||||
loading: z.boolean().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
shortcut: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>;
|
||||
export type LocalAction = Action;
|
||||
export type DecisionAction = Action;
|
||||
|
||||
export const DecisionResultSchema = z.object({
|
||||
kind: z.literal("decision"),
|
||||
version: z.literal(1),
|
||||
decisionId: z.string().min(1),
|
||||
actionId: z.string().min(1),
|
||||
actionLabel: z.string().min(1),
|
||||
at: z.string().datetime(),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type DecisionResult<
|
||||
TPayload extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
|
||||
payload?: TPayload;
|
||||
};
|
||||
|
||||
export function createDecisionResult<
|
||||
TPayload extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(args: {
|
||||
decisionId: string;
|
||||
action: { id: string; label: string };
|
||||
payload?: TPayload;
|
||||
}): DecisionResult<TPayload> {
|
||||
return {
|
||||
kind: "decision",
|
||||
version: 1,
|
||||
decisionId: args.decisionId,
|
||||
actionId: args.action.id,
|
||||
actionLabel: args.action.label,
|
||||
at: new Date().toISOString(),
|
||||
payload: args.payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const ActionButtonsPropsSchema = z.object({
|
||||
actions: z.array(ActionSchema).min(1),
|
||||
align: z.enum(["left", "center", "right"]).optional(),
|
||||
confirmTimeout: z.number().positive().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
|
||||
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
|
||||
actions: z.array(SerializableActionSchema),
|
||||
}).omit({ className: true });
|
||||
|
||||
export interface ActionsConfig {
|
||||
items: Action[];
|
||||
align?: "left" | "center" | "right";
|
||||
confirmTimeout?: number;
|
||||
}
|
||||
|
||||
export const SerializableActionsConfigSchema = z.object({
|
||||
items: z.array(SerializableActionSchema).min(1),
|
||||
align: z.enum(["left", "center", "right"]).optional(),
|
||||
confirmTimeout: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
export type SerializableActionsConfig = z.infer<
|
||||
typeof SerializableActionsConfigSchema
|
||||
>;
|
||||
|
||||
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
|
||||
|
|
@ -757,27 +757,7 @@
|
|||
"general_reset": "Reset Changes",
|
||||
"general_save": "Save Changes",
|
||||
"general_saving": "Saving",
|
||||
"general_unsaved_changes": "You have unsaved changes. Click \"Save Changes\" to apply them.",
|
||||
"nav_web_search": "Web Search",
|
||||
"nav_web_search_desc": "Built-in web search settings",
|
||||
"web_search_title": "Web Search",
|
||||
"web_search_description": "Web search is powered by a built-in SearXNG instance. All queries are proxied through your server — no data is sent to third parties.",
|
||||
"web_search_enabled_label": "Enable Web Search",
|
||||
"web_search_enabled_description": "When enabled, the AI agent can search the web for real-time information like news, prices, and current events.",
|
||||
"web_search_status_healthy": "Web search service is healthy",
|
||||
"web_search_status_unhealthy": "Web search service is unavailable",
|
||||
"web_search_status_not_configured": "Web search service is not configured",
|
||||
"web_search_engines_label": "Search Engines",
|
||||
"web_search_engines_placeholder": "google,brave,duckduckgo",
|
||||
"web_search_engines_description": "Comma-separated list of SearXNG engines to use. Leave empty for defaults.",
|
||||
"web_search_language_label": "Preferred Language",
|
||||
"web_search_language_placeholder": "en",
|
||||
"web_search_language_description": "IETF language tag (e.g. en, en-US). Leave empty for auto-detect.",
|
||||
"web_search_safesearch_label": "SafeSearch Level",
|
||||
"web_search_safesearch_description": "0 = off, 1 = moderate, 2 = strict",
|
||||
"web_search_save": "Save Web Search Settings",
|
||||
"web_search_saving": "Saving...",
|
||||
"web_search_saved": "Web search settings saved"
|
||||
"general_unsaved_changes": "You have unsaved changes. Click \"Save Changes\" to apply them."
|
||||
},
|
||||
"homepage": {
|
||||
"hero_title_part1": "The AI Workspace",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue