mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-20 23:21:06 +02:00
Merge remote-tracking branch 'upstream/main' into feature/elasticsearch-connector
This commit is contained in:
commit
929035f802
76 changed files with 3833 additions and 788 deletions
|
|
@ -45,6 +45,13 @@ const connectorCategories: ConnectorCategory[] = [
|
|||
icon: getConnectorIcon(EnumConnectorName.TAVILY_API, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "searxng",
|
||||
title: "SearxNG",
|
||||
description: "Use your own SearxNG meta-search instance for web results.",
|
||||
icon: getConnectorIcon(EnumConnectorName.SEARXNG_API, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "linkup-api",
|
||||
title: "Linkup API",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,364 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
const searxngFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
host: z
|
||||
.string({ required_error: "Host is required." })
|
||||
.url({ message: "Enter a valid SearxNG host URL (e.g. https://searxng.example.org)." }),
|
||||
api_key: z.string().optional(),
|
||||
engines: z.string().optional(),
|
||||
categories: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
safesearch: z
|
||||
.string()
|
||||
.regex(/^[0-2]?$/, { message: "SafeSearch must be 0, 1, or 2." })
|
||||
.optional(),
|
||||
verify_ssl: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type SearxngFormValues = z.infer<typeof searxngFormSchema>;
|
||||
|
||||
const parseCommaSeparated = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
const items = value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return items.length > 0 ? items : undefined;
|
||||
};
|
||||
|
||||
export default function SearxngConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
const form = useForm<SearxngFormValues>({
|
||||
resolver: zodResolver(searxngFormSchema),
|
||||
defaultValues: {
|
||||
name: "SearxNG Connector",
|
||||
host: "",
|
||||
api_key: "",
|
||||
engines: "",
|
||||
categories: "",
|
||||
language: "",
|
||||
safesearch: "",
|
||||
verify_ssl: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: SearxngFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const config: Record<string, unknown> = {
|
||||
SEARXNG_HOST: values.host.trim(),
|
||||
};
|
||||
|
||||
const apiKey = values.api_key?.trim();
|
||||
if (apiKey) config.SEARXNG_API_KEY = apiKey;
|
||||
|
||||
const engines = parseCommaSeparated(values.engines);
|
||||
if (engines) config.SEARXNG_ENGINES = engines;
|
||||
|
||||
const categories = parseCommaSeparated(values.categories);
|
||||
if (categories) config.SEARXNG_CATEGORIES = categories;
|
||||
|
||||
const language = values.language?.trim();
|
||||
if (language) config.SEARXNG_LANGUAGE = language;
|
||||
|
||||
const safesearch = values.safesearch?.trim();
|
||||
if (safesearch) {
|
||||
const parsed = Number(safesearch);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
config.SEARXNG_SAFESEARCH = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Include verify flag only when disabled to keep config minimal
|
||||
if (values.verify_ssl === false) {
|
||||
config.SEARXNG_VERIFY_SSL = false;
|
||||
}
|
||||
|
||||
await createConnector(
|
||||
{
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.SEARXNG_API,
|
||||
config,
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
|
||||
toast.success("SearxNG connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating SearxNG connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.SEARXNG_API, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect SearxNG</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Bring your self-hosted SearxNG meta-search engine into SurfSense.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect SearxNG</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate SurfSense with any SearxNG instance to broaden your search coverage while
|
||||
preserving privacy and control.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>SearxNG Instance Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You need access to a running SearxNG instance. Refer to the{" "}
|
||||
<a
|
||||
href="https://docs.searxng.org/admin/installation-docker.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
SearxNG installation guide
|
||||
</a>{" "}
|
||||
for setup instructions. If your instance requires an API key, include it below.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My SearxNG Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SearxNG Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://searxng.example.org" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Provide the full base URL to your SearxNG instance. Include the protocol
|
||||
(http/https).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter API key if your instance requires one"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Leave empty if your SearxNG instance does not enforce API keys.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="engines"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Engines (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="google,bing,duckduckgo" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list to target specific engines.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categories"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Categories (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="general,it,science" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list of SearxNG categories.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Preferred Language (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="en-US" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
IETF language tag (e.g. en, en-US). Leave blank to inherit defaults.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="safesearch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SafeSearch Level (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0 (off), 1 (moderate), 2 (strict)" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Set 0, 1, or 2 to adjust SafeSearch filtering. Leave blank to use the
|
||||
instance default.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="verify_ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div>
|
||||
<FormLabel>Verify SSL Certificates</FormLabel>
|
||||
<FormDescription>
|
||||
Disable only when connecting to instances with self-signed certificates.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<CardFooter className="flex justify-end px-0">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect SearxNG
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ export default function DocumentsTable() {
|
|||
created_at: true,
|
||||
});
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [sortKey, setSortKey] = useState<SortKey>("title");
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
|
|
|||
|
|
@ -122,6 +122,55 @@ const logStatusConfig = {
|
|||
FAILED: { icon: X, color: "text-red-600", bgColor: "bg-red-50" },
|
||||
} as const;
|
||||
|
||||
function MessageDetails({
|
||||
message,
|
||||
taskName,
|
||||
metadata,
|
||||
createdAt,
|
||||
children,
|
||||
}: {
|
||||
message: string;
|
||||
taskName?: string;
|
||||
metadata?: any;
|
||||
createdAt?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||
<AlertDialogContent className="max-w-3xl w-full">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<AlertDialogTitle className="text-lg">Log details</AlertDialogTitle>
|
||||
{createdAt && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(createdAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<AlertDialogCancel className="text-sm">Close</AlertDialogCancel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{taskName && (
|
||||
<div className="text-xs text-muted-foreground font-mono bg-muted/50 px-2 py-1 rounded inline-block">
|
||||
{taskName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-muted p-3 rounded max-h-[40vh] overflow-auto text-sm whitespace-pre-wrap">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter />
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: ColumnDef<Log>[] = [
|
||||
{
|
||||
id: "select",
|
||||
|
|
@ -219,18 +268,29 @@ const columns: ColumnDef<Log>[] = [
|
|||
cell: ({ row }) => {
|
||||
const message = row.getValue("message") as string;
|
||||
const taskName = row.original.log_metadata?.task_name;
|
||||
const createdAt = row.getValue("created_at") as string;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 max-w-[400px]">
|
||||
{taskName && (
|
||||
<div className="text-xs text-muted-foreground font-mono bg-muted/50 px-2 py-1 rounded">
|
||||
{taskName}
|
||||
<MessageDetails
|
||||
message={message}
|
||||
taskName={taskName}
|
||||
metadata={row.original.log_metadata}
|
||||
createdAt={createdAt}
|
||||
>
|
||||
<div className="flex flex-col gap-1 max-w-[400px] cursor-pointer">
|
||||
{taskName && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground font-mono bg-muted/50 px-2 py-1 rounded truncate"
|
||||
title={taskName}
|
||||
>
|
||||
{taskName}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm truncate" title={message}>
|
||||
{message.length > 100 ? `${message.substring(0, 100)}...` : message}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm">
|
||||
{message.length > 100 ? `${message.substring(0, 100)}...` : message}
|
||||
</div>
|
||||
</div>
|
||||
</MessageDetails>
|
||||
);
|
||||
},
|
||||
size: 400,
|
||||
|
|
@ -839,7 +899,7 @@ function LogsTable({
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<Table>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup: any) => (
|
||||
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
||||
|
|
@ -847,7 +907,11 @@ function LogsTable({
|
|||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: `${header.getSize()}px` }}
|
||||
className="h-12 px-4 py-3"
|
||||
className={cn(
|
||||
"h-12 px-4 py-3",
|
||||
// keep Created At header from wrapping and align it
|
||||
header.column.id === "created_at" ? "whitespace-nowrap text-right" : ""
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
||||
<Button
|
||||
|
|
@ -895,11 +959,24 @@ function LogsTable({
|
|||
row.getIsSelected() ? "bg-muted/50" : ""
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: any) => (
|
||||
<TableCell key={cell.id} className="px-4 py-3">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
{row.getVisibleCells().map((cell: any) => {
|
||||
const isCreatedAt = cell.column.id === "created_at";
|
||||
const isMessage = cell.column.id === "message";
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
"px-4 py-3 align-middle overflow-hidden",
|
||||
isCreatedAt
|
||||
? "whitespace-nowrap text-xs text-muted-foreground text-right"
|
||||
: "",
|
||||
isMessage ? "overflow-hidden" : ""
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</motion.tr>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -334,9 +334,13 @@ ResearchModeSelector.displayName = "ResearchModeSelector";
|
|||
const LLMSelector = React.memo(() => {
|
||||
const { search_space_id } = useParams();
|
||||
const searchSpaceId = Number(search_space_id);
|
||||
|
||||
|
||||
const { llmConfigs, loading: llmLoading, error } = useLLMConfigs(searchSpaceId);
|
||||
const { preferences, updatePreferences, loading: preferencesLoading } = useLLMPreferences(searchSpaceId);
|
||||
const {
|
||||
preferences,
|
||||
updatePreferences,
|
||||
loading: preferencesLoading,
|
||||
} = useLLMPreferences(searchSpaceId);
|
||||
|
||||
const isLoading = llmLoading || preferencesLoading;
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ export function SourceDetailSheet({
|
|||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||
|
||||
// Check if this is a source type that should render directly from node
|
||||
const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API";
|
||||
const isDirectRenderSource =
|
||||
sourceType === "TAVILY_API" || sourceType === "LINKUP_API" || sourceType === "SEARXNG_API";
|
||||
|
||||
useEffect(() => {
|
||||
if (open && chunkId && !isDirectRenderSource) {
|
||||
|
|
@ -108,7 +109,7 @@ export function SourceDetailSheet({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Direct render for TAVILY_API and LINKUP_API */}
|
||||
{/* Direct render for web search providers */}
|
||||
{isDirectRenderSource && (
|
||||
<ScrollArea className="h-[calc(100vh-10rem)]">
|
||||
<div className="px-6 py-4">
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ export const editConnectorSchema = z.object({
|
|||
NOTION_INTEGRATION_TOKEN: z.string().optional(),
|
||||
SERPER_API_KEY: z.string().optional(),
|
||||
TAVILY_API_KEY: z.string().optional(),
|
||||
SEARXNG_HOST: z.string().optional(),
|
||||
SEARXNG_API_KEY: z.string().optional(),
|
||||
SEARXNG_ENGINES: z.string().optional(),
|
||||
SEARXNG_CATEGORIES: z.string().optional(),
|
||||
SEARXNG_LANGUAGE: z.string().optional(),
|
||||
SEARXNG_SAFESEARCH: z.string().optional(),
|
||||
SEARXNG_VERIFY_SSL: z.string().optional(),
|
||||
LINEAR_API_KEY: z.string().optional(),
|
||||
LINKUP_API_KEY: z.string().optional(),
|
||||
DISCORD_BOT_TOKEN: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
|
|||
className="hidden rounded-full px-3 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center gap-1.5"
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">8.3k</span>
|
||||
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">9.5k</span>
|
||||
</Link>
|
||||
<ThemeTogglerComponent />
|
||||
<Link
|
||||
|
|
@ -161,7 +161,7 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
|
|||
>
|
||||
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
||||
8.3k
|
||||
9.5k
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,138 +1,153 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface InferenceParamsEditorProps {
|
||||
params: Record<string, number | string>;
|
||||
setParams: (newParams: Record<string, number | string>) => void;
|
||||
params: Record<string, number | string>;
|
||||
setParams: (newParams: Record<string, number | string>) => void;
|
||||
}
|
||||
|
||||
const PARAM_KEYS = ["temperature", "max_tokens", "top_k", "top_p"] as const;
|
||||
|
||||
export default function InferenceParamsEditor({
|
||||
params,
|
||||
setParams,
|
||||
}: InferenceParamsEditorProps) {
|
||||
const [selectedKey, setSelectedKey] = useState<string>("");
|
||||
const [value, setValue] = useState<string>("");
|
||||
export default function InferenceParamsEditor({ params, setParams }: InferenceParamsEditorProps) {
|
||||
const [selectedKey, setSelectedKey] = useState<string>("");
|
||||
const [value, setValue] = useState<string>("");
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!selectedKey || value === "") return;
|
||||
const handleAdd = () => {
|
||||
if (!selectedKey || value === "") return;
|
||||
|
||||
if (params[selectedKey]) {
|
||||
alert(`${selectedKey} already exists`);
|
||||
return;
|
||||
}
|
||||
if (params[selectedKey]) {
|
||||
alert(`${selectedKey} already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
const numericValue = Number(value);
|
||||
const numericValue = Number(value);
|
||||
|
||||
if ((selectedKey === "temperature" || selectedKey === "top_p") && (isNaN(numericValue) || numericValue < 0 || numericValue > 1)) {
|
||||
alert("Value must be a number between 0 and 1");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(selectedKey === "temperature" || selectedKey === "top_p") &&
|
||||
(isNaN(numericValue) || numericValue < 0 || numericValue > 1)
|
||||
) {
|
||||
alert("Value must be a number between 0 and 1");
|
||||
return;
|
||||
}
|
||||
|
||||
if ((selectedKey === "max_tokens" || selectedKey === "top_k") && (!Number.isInteger(numericValue) || numericValue < 0)) {
|
||||
alert("Value must be a non-negative integer");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(selectedKey === "max_tokens" || selectedKey === "top_k") &&
|
||||
(!Number.isInteger(numericValue) || numericValue < 0)
|
||||
) {
|
||||
alert("Value must be a non-negative integer");
|
||||
return;
|
||||
}
|
||||
|
||||
setParams({
|
||||
...params,
|
||||
[selectedKey]: isNaN(numericValue) ? value : numericValue,
|
||||
});
|
||||
setParams({
|
||||
...params,
|
||||
[selectedKey]: isNaN(numericValue) ? value : numericValue,
|
||||
});
|
||||
|
||||
setSelectedKey("");
|
||||
setValue("");
|
||||
};
|
||||
setSelectedKey("");
|
||||
setValue("");
|
||||
};
|
||||
|
||||
const handleDelete = (key: string) => {
|
||||
const newParams = { ...params };
|
||||
delete newParams[key];
|
||||
setParams(newParams);
|
||||
};
|
||||
const handleDelete = (key: string) => {
|
||||
const newParams = { ...params };
|
||||
delete newParams[key];
|
||||
setParams(newParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-2 sm:p-0">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[1fr_1fr_auto] md:gap-3 items-end">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Label htmlFor="param-key" className="text-sm font-medium">Parameter Key</Label>
|
||||
<Select value={selectedKey} onValueChange={setSelectedKey}>
|
||||
<SelectTrigger id="param-key" className="w-full">
|
||||
<SelectValue placeholder="Select parameter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARAM_KEYS.map((key) => (
|
||||
<SelectItem key={key} value={key}>{key}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6 p-2 sm:p-0">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[1fr_1fr_auto] md:gap-3 items-end">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Label htmlFor="param-key" className="text-sm font-medium">
|
||||
Parameter Key
|
||||
</Label>
|
||||
<Select value={selectedKey} onValueChange={setSelectedKey}>
|
||||
<SelectTrigger id="param-key" className="w-full">
|
||||
<SelectValue placeholder="Select parameter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARAM_KEYS.map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{key}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Label htmlFor="param-value" className="text-sm font-medium">Value</Label>
|
||||
<Input
|
||||
id="param-value"
|
||||
placeholder="Enter value (e.g., 0.7 or 512)"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Label htmlFor="param-value" className="text-sm font-medium">
|
||||
Value
|
||||
</Label>
|
||||
<Input
|
||||
id="param-value"
|
||||
placeholder="Enter value (e.g., 0.7 or 512)"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full md:w-auto h-10 mt-0"
|
||||
onClick={handleAdd}
|
||||
disabled={!selectedKey || value === ""}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Parameter
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full md:w-auto h-10 mt-0"
|
||||
onClick={handleAdd}
|
||||
disabled={!selectedKey || value === ""}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Parameter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
<hr className="my-4" />
|
||||
|
||||
{Object.keys(params).length > 0 && (
|
||||
<div className="border rounded-lg shadow-sm overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm divide-y divide-gray-200">
|
||||
<thead className="bg-black dark:bg-black">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300">Key</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300">Value</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300 sr-only md:not-sr-only">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-black dark:bg-black">
|
||||
{Object.entries(params).map(([key, val]) => (
|
||||
<tr key={key} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{key}</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{val.toString()}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-700 dark:text-red-500"
|
||||
onClick={() => handleDelete(key)}
|
||||
aria-label={`Delete parameter ${key}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{Object.keys(params).length > 0 && (
|
||||
<div className="border rounded-lg shadow-sm overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm divide-y divide-gray-200">
|
||||
<thead className="bg-black dark:bg-black">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300">
|
||||
Key
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300">
|
||||
Value
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300 sr-only md:not-sr-only">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-black dark:bg-black">
|
||||
{Object.entries(params).map(([key, val]) => (
|
||||
<tr key={key} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{key}</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{val.toString()}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-700 dark:text-red-500"
|
||||
onClick={() => handleDelete(key)}
|
||||
aria-label={`Delete parameter ${key}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LANGUAGES } from "@/contracts/enums/languages";
|
||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||
import { type CreateLLMConfig, useLLMConfigs } from "@/hooks/use-llm-configs";
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ export function AddProviderStep({
|
|||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
language: "English",
|
||||
litellm_params: {},
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
|
@ -70,6 +72,7 @@ export function AddProviderStep({
|
|||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
language: "English",
|
||||
litellm_params: {},
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
|
@ -119,6 +122,7 @@ export function AddProviderStep({
|
|||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Model: {config.model_name}
|
||||
{config.language && ` • Language: ${config.language}`}
|
||||
{config.api_base && ` • Base: ${config.api_base}`}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -169,7 +173,7 @@ export function AddProviderStep({
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Configuration Name *</Label>
|
||||
<Input
|
||||
|
|
@ -199,6 +203,26 @@ export function AddProviderStep({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* language */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">Language (Optional)</Label>
|
||||
<Select
|
||||
value={formData.language || "English"}
|
||||
onValueChange={(value) => handleInputChange("language", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.provider === "CUSTOM" && (
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LANGUAGES } from "@/contracts/enums/languages";
|
||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||
import { type CreateLLMConfig, type LLMConfig, useLLMConfigs } from "@/hooks/use-llm-configs";
|
||||
import InferenceParamsEditor from "../inference-params-editor";
|
||||
|
|
@ -65,6 +66,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
language: "English",
|
||||
litellm_params: {},
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
|
@ -80,6 +82,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
model_name: editingConfig.model_name,
|
||||
api_key: editingConfig.api_key,
|
||||
api_base: editingConfig.api_base || "",
|
||||
language: editingConfig.language || "English",
|
||||
litellm_params: editingConfig.litellm_params || {},
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
|
@ -90,6 +93,17 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Handle provider change with auto-fill API Base URL / 处理 Provider 变更并自动填充 API Base URL
|
||||
const handleProviderChange = (providerValue: string) => {
|
||||
const provider = LLM_PROVIDERS.find((p) => p.value === providerValue);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
provider: providerValue,
|
||||
// Auto-fill API Base URL if provider has a default / 如果提供商有默认值则自动填充
|
||||
api_base: provider?.apiBase || prev.api_base,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) {
|
||||
|
|
@ -118,6 +132,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
language: "English",
|
||||
litellm_params: {},
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
|
@ -323,6 +338,13 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{config.model_name}
|
||||
</p>
|
||||
{config.language && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.language}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -432,6 +454,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
language: "",
|
||||
litellm_params: {},
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
|
@ -466,10 +489,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider">Provider *</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(value) => handleInputChange("provider", value)}
|
||||
>
|
||||
<Select value={formData.provider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider">
|
||||
{formData.provider && (
|
||||
|
|
@ -524,6 +544,25 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">Language (Optional)</Label>
|
||||
<Select
|
||||
value={formData.language || "English"}
|
||||
onValueChange={(value) => handleInputChange("language", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_key">API Key *</Label>
|
||||
<Input
|
||||
|
|
@ -537,13 +576,39 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_base">API Base URL (Optional)</Label>
|
||||
<Label htmlFor="api_base">
|
||||
API Base URL
|
||||
{selectedProvider?.apiBase && (
|
||||
<span className="text-xs font-normal text-muted-foreground ml-2">
|
||||
(Auto-filled for {selectedProvider.label})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="api_base"
|
||||
placeholder="e.g., https://api.openai.com/v1"
|
||||
placeholder={selectedProvider?.apiBase || "e.g., https://api.openai.com/v1"}
|
||||
value={formData.api_base}
|
||||
onChange={(e) => handleInputChange("api_base", e.target.value)}
|
||||
/>
|
||||
{selectedProvider?.apiBase && formData.api_base === selectedProvider.apiBase && (
|
||||
<p className="text-xs text-green-600 flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Using recommended API endpoint for {selectedProvider.label}
|
||||
</p>
|
||||
)}
|
||||
{selectedProvider?.apiBase && !formData.api_base && (
|
||||
<p className="text-xs text-amber-600 flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
⚠️ API Base URL is required for {selectedProvider.label}. Click to auto-fill:
|
||||
<button
|
||||
type="button"
|
||||
className="underline font-medium"
|
||||
onClick={() => handleInputChange("api_base", selectedProvider.apiBase || "")}
|
||||
>
|
||||
{selectedProvider.apiBase}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional Inference Parameters */}
|
||||
|
|
@ -579,6 +644,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
language: "",
|
||||
litellm_params: {},
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,10 +85,10 @@ Before you begin, ensure you have:
|
|||
| RERANKERS_MODEL_NAME | Name of the reranker model (e.g., `ms-marco-MiniLM-L-12-v2`) |
|
||||
| RERANKERS_MODEL_TYPE | Type of reranker model (e.g., `flashrank`) |
|
||||
| TTS_SERVICE | Text-to-Speech API provider for Podcasts (e.g., `local/kokoro`, `openai/tts-1`). See [supported providers](https://docs.litellm.ai/docs/text_to_speech#supported-providers) |
|
||||
| TTS_SERVICE_API_KEY | API key for the Text-to-Speech service |
|
||||
| TTS_SERVICE_API_KEY | (Optional if local) API key for the Text-to-Speech service |
|
||||
| TTS_SERVICE_API_BASE | (Optional) Custom API base URL for the Text-to-Speech service |
|
||||
| STT_SERVICE | Speech-to-Text API provider for Podcasts (e.g., `openai/whisper-1`). See [supported providers](https://docs.litellm.ai/docs/audio_transcription#supported-providers) |
|
||||
| STT_SERVICE_API_KEY | API key for the Speech-to-Text service |
|
||||
| STT_SERVICE | Speech-to-Text API provider for Audio Files (e.g., `local/base`, `openai/whisper-1`). See [supported providers](https://docs.litellm.ai/docs/audio_transcription#supported-providers) |
|
||||
| STT_SERVICE_API_KEY | (Optional if local) API key for the Speech-to-Text service |
|
||||
| STT_SERVICE_API_BASE | (Optional) Custom API base URL for the Speech-to-Text service |
|
||||
| FIRECRAWL_API_KEY | API key for Firecrawl service for web crawling |
|
||||
| ETL_SERVICE | Document parsing service: `UNSTRUCTURED` (supports 34+ formats), `LLAMACLOUD` (supports 50+ formats including legacy document types), or `DOCLING` (local processing, supports PDF, Office docs, images, HTML, CSV) |
|
||||
|
|
|
|||
|
|
@ -62,12 +62,11 @@ Edit the `.env` file and set the following variables:
|
|||
| RERANKERS_MODEL_NAME | Name of the reranker model (e.g., `ms-marco-MiniLM-L-12-v2`) |
|
||||
| RERANKERS_MODEL_TYPE | Type of reranker model (e.g., `flashrank`) |
|
||||
| TTS_SERVICE | Text-to-Speech API provider for Podcasts (e.g., `local/kokoro`, `openai/tts-1`). See [supported providers](https://docs.litellm.ai/docs/text_to_speech#supported-providers) |
|
||||
| TTS_SERVICE_API_KEY | API key for the Text-to-Speech service |
|
||||
| TTS_SERVICE_API_BASE | (Optional) Custom API base URL for the Text-to-Speech service |
|
||||
| STT_SERVICE | Speech-to-Text API provider for Podcasts (e.g., `openai/whisper-1`). See [supported providers](https://docs.litellm.ai/docs/audio_transcription#supported-providers) |
|
||||
| STT_SERVICE_API_KEY | API key for the Speech-to-Text service |
|
||||
| STT_SERVICE_API_BASE | (Optional) Custom API base URL for the Speech-to-Text service |
|
||||
| FIRECRAWL_API_KEY | API key for Firecrawl service for web crawling |
|
||||
| TTS_SERVICE_API_KEY | (Optional if local) API key for the Text-to-Speech service |
|
||||
| TTS_SERVICE_API_BASE | (Optional) Custom API base URL for the Text-to-Speech service |
|
||||
| STT_SERVICE | Speech-to-Text API provider for Audio Files (e.g., `local/base`, `openai/whisper-1`). See [supported providers](https://docs.litellm.ai/docs/audio_transcription#supported-providers) |
|
||||
| STT_SERVICE_API_KEY | (Optional if local) API key for the Speech-to-Text service |
|
||||
| STT_SERVICE_API_BASE | (Optional) Custom API base URL for the Speech-to-Text service |
|
||||
| ETL_SERVICE | Document parsing service: `UNSTRUCTURED` (supports 34+ formats), `LLAMACLOUD` (supports 50+ formats including legacy document types), or `DOCLING` (local processing, supports PDF, Office docs, images, HTML, CSV) |
|
||||
| UNSTRUCTURED_API_KEY | API key for Unstructured.io service for document parsing (required if ETL_SERVICE=UNSTRUCTURED) |
|
||||
| LLAMA_CLOUD_API_KEY | API key for LlamaCloud service for document parsing (required if ETL_SERVICE=LLAMACLOUD) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export enum EnumConnectorName {
|
||||
SERPER_API = "SERPER_API",
|
||||
TAVILY_API = "TAVILY_API",
|
||||
SEARXNG_API = "SEARXNG_API",
|
||||
LINKUP_API = "LINKUP_API",
|
||||
SLACK_CONNECTOR = "SLACK_CONNECTOR",
|
||||
NOTION_CONNECTOR = "NOTION_CONNECTOR",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
|||
return <Link {...iconProps} />;
|
||||
case EnumConnectorName.TAVILY_API:
|
||||
return <IconWorldWww {...iconProps} />;
|
||||
case EnumConnectorName.SEARXNG_API:
|
||||
return <Globe {...iconProps} />;
|
||||
case EnumConnectorName.SLACK_CONNECTOR:
|
||||
return <IconBrandSlack {...iconProps} />;
|
||||
case EnumConnectorName.NOTION_CONNECTOR:
|
||||
|
|
|
|||
69
surfsense_web/contracts/enums/languages.ts
Normal file
69
surfsense_web/contracts/enums/languages.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
export interface Language {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const LANGUAGES: Language[] = [
|
||||
{ value: "English", label: "English" },
|
||||
{ value: "Spanish", label: "Spanish" },
|
||||
{ value: "French", label: "French" },
|
||||
{ value: "German", label: "German" },
|
||||
{ value: "Italian", label: "Italian" },
|
||||
{ value: "Portuguese", label: "Portuguese" },
|
||||
{ value: "Russian", label: "Russian" },
|
||||
{ value: "Chinese", label: "Chinese (Simplified)" },
|
||||
{ value: "Chinese-traditional", label: "Chinese (Traditional)" },
|
||||
{ value: "Japanese", label: "Japanese" },
|
||||
{ value: "Korean", label: "Korean" },
|
||||
{ value: "Arabic", label: "Arabic" },
|
||||
{ value: "Hindi", label: "Hindi" },
|
||||
{ value: "Dutch", label: "Dutch" },
|
||||
{ value: "Swedish", label: "Swedish" },
|
||||
{ value: "Norwegian", label: "Norwegian" },
|
||||
{ value: "Danish", label: "Danish" },
|
||||
{ value: "Finnish", label: "Finnish" },
|
||||
{ value: "Polish", label: "Polish" },
|
||||
{ value: "Czech", label: "Czech" },
|
||||
{ value: "Hungarian", label: "Hungarian" },
|
||||
{ value: "Romanian", label: "Romanian" },
|
||||
{ value: "Bulgarian", label: "Bulgarian" },
|
||||
{ value: "Croatian", label: "Croatian" },
|
||||
{ value: "Serbian", label: "Serbian" },
|
||||
{ value: "Slovenian", label: "Slovenian" },
|
||||
{ value: "Slovak", label: "Slovak" },
|
||||
{ value: "Lithuanian", label: "Lithuanian" },
|
||||
{ value: "Latvian", label: "Latvian" },
|
||||
{ value: "Estonian", label: "Estonian" },
|
||||
{ value: "Greek", label: "Greek" },
|
||||
{ value: "Turkish", label: "Turkish" },
|
||||
{ value: "Hebrew", label: "Hebrew" },
|
||||
{ value: "Thai", label: "Thai" },
|
||||
{ value: "Vietnamese", label: "Vietnamese" },
|
||||
{ value: "Indonesian", label: "Indonesian" },
|
||||
{ value: "Malay", label: "Malay" },
|
||||
{ value: "Tagalog", label: "Filipino/Tagalog" },
|
||||
{ value: "Bengali", label: "Bengali" },
|
||||
{ value: "Tamil", label: "Tamil" },
|
||||
{ value: "Telugu", label: "Telugu" },
|
||||
{ value: "Marathi", label: "Marathi" },
|
||||
{ value: "Gujarati", label: "Gujarati" },
|
||||
{ value: "Kannada", label: "Kannada" },
|
||||
{ value: "Malayalam", label: "Malayalam" },
|
||||
{ value: "Punjabi", label: "Punjabi" },
|
||||
{ value: "Urdu", label: "Urdu" },
|
||||
{ value: "Persian", label: "Persian/Farsi" },
|
||||
{ value: "Swahili", label: "Swahili" },
|
||||
{ value: "Afrikaans", label: "Afrikaans" },
|
||||
{ value: "Amharic", label: "Amharic" },
|
||||
{ value: "Ukrainian", label: "Ukrainian" },
|
||||
{ value: "Belarusian", label: "Belarusian" },
|
||||
{ value: "Georgian", label: "Georgian" },
|
||||
{ value: "Armenian", label: "Armenian" },
|
||||
{ value: "Azerbaijani", label: "Azerbaijani" },
|
||||
{ value: "Kazakh", label: "Kazakh" },
|
||||
{ value: "Uzbek", label: "Uzbek" },
|
||||
{ value: "Kyrgyz", label: "Kyrgyz" },
|
||||
{ value: "Tajik", label: "Tajik" },
|
||||
{ value: "Turkmen", label: "Turkmen" },
|
||||
{ value: "Mongolian", label: "Mongolian" },
|
||||
];
|
||||
|
|
@ -3,6 +3,7 @@ export interface LLMProvider {
|
|||
label: string;
|
||||
example: string;
|
||||
description: string;
|
||||
apiBase?: string; // Default API Base URL for the provider / 提供商的默认 API Base URL
|
||||
}
|
||||
|
||||
export const LLM_PROVIDERS: LLMProvider[] = [
|
||||
|
|
@ -90,6 +91,35 @@ export const LLM_PROVIDERS: LLMProvider[] = [
|
|||
example: "gpt-5-mini, claude-sonnet-4-5",
|
||||
description: "Access 500+ AI models through one unified API",
|
||||
},
|
||||
// Chinese LLM Providers / 国产 LLM 提供商
|
||||
{
|
||||
value: "DEEPSEEK",
|
||||
label: "DeepSeek",
|
||||
example: "deepseek-chat, deepseek-coder",
|
||||
description: "Chinese high-performance AI models",
|
||||
apiBase: "https://api.deepseek.com",
|
||||
},
|
||||
{
|
||||
value: "ALIBABA_QWEN",
|
||||
label: "Qwen",
|
||||
example: "qwen-max, qwen-plus, qwen-turbo",
|
||||
description: "Alibaba Cloud Qwen LLM",
|
||||
apiBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
},
|
||||
{
|
||||
value: "MOONSHOT",
|
||||
label: "Kimi",
|
||||
example: "moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k",
|
||||
description: "Moonshot AI Kimi models",
|
||||
apiBase: "https://api.moonshot.cn/v1",
|
||||
},
|
||||
{
|
||||
value: "ZHIPU",
|
||||
label: "GLM",
|
||||
example: "glm-4, glm-4-flash, glm-3-turbo",
|
||||
description: "Zhipu AI GLM series models",
|
||||
apiBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||
},
|
||||
{
|
||||
value: "CUSTOM",
|
||||
label: "Custom Provider",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './app/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
out: "./drizzle",
|
||||
schema: "./app/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,38 @@ import {
|
|||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
|
||||
const normalizeListInput = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item).trim()).filter((item) => item.length > 0);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const arraysEqual = (a: string[], b: string[]): boolean => {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((value, index) => value === b[index]);
|
||||
};
|
||||
|
||||
const normalizeBoolean = (value: unknown): boolean | null => {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "on"].includes(lowered)) return true;
|
||||
if (["false", "0", "no", "off"].includes(lowered)) return false;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (value === 1) return true;
|
||||
if (value === 0) return false;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function useConnectorEditPage(connectorId: number, searchSpaceId: string) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
|
|
@ -48,6 +80,13 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
NOTION_INTEGRATION_TOKEN: "",
|
||||
SERPER_API_KEY: "",
|
||||
TAVILY_API_KEY: "",
|
||||
SEARXNG_HOST: "",
|
||||
SEARXNG_API_KEY: "",
|
||||
SEARXNG_ENGINES: "",
|
||||
SEARXNG_CATEGORIES: "",
|
||||
SEARXNG_LANGUAGE: "",
|
||||
SEARXNG_SAFESEARCH: "",
|
||||
SEARXNG_VERIFY_SSL: "",
|
||||
LINEAR_API_KEY: "",
|
||||
DISCORD_BOT_TOKEN: "",
|
||||
CONFLUENCE_BASE_URL: "",
|
||||
|
|
@ -75,6 +114,23 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
NOTION_INTEGRATION_TOKEN: config.NOTION_INTEGRATION_TOKEN || "",
|
||||
SERPER_API_KEY: config.SERPER_API_KEY || "",
|
||||
TAVILY_API_KEY: config.TAVILY_API_KEY || "",
|
||||
SEARXNG_HOST: config.SEARXNG_HOST || "",
|
||||
SEARXNG_API_KEY: config.SEARXNG_API_KEY || "",
|
||||
SEARXNG_ENGINES: Array.isArray(config.SEARXNG_ENGINES)
|
||||
? config.SEARXNG_ENGINES.join(", ")
|
||||
: config.SEARXNG_ENGINES || "",
|
||||
SEARXNG_CATEGORIES: Array.isArray(config.SEARXNG_CATEGORIES)
|
||||
? config.SEARXNG_CATEGORIES.join(", ")
|
||||
: config.SEARXNG_CATEGORIES || "",
|
||||
SEARXNG_LANGUAGE: config.SEARXNG_LANGUAGE || "",
|
||||
SEARXNG_SAFESEARCH:
|
||||
config.SEARXNG_SAFESEARCH !== undefined && config.SEARXNG_SAFESEARCH !== null
|
||||
? String(config.SEARXNG_SAFESEARCH)
|
||||
: "",
|
||||
SEARXNG_VERIFY_SSL:
|
||||
config.SEARXNG_VERIFY_SSL !== undefined && config.SEARXNG_VERIFY_SSL !== null
|
||||
? String(config.SEARXNG_VERIFY_SSL)
|
||||
: "",
|
||||
LINEAR_API_KEY: config.LINEAR_API_KEY || "",
|
||||
LINKUP_API_KEY: config.LINKUP_API_KEY || "",
|
||||
DISCORD_BOT_TOKEN: config.DISCORD_BOT_TOKEN || "",
|
||||
|
|
@ -240,6 +296,88 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
newConfig = { TAVILY_API_KEY: formData.TAVILY_API_KEY };
|
||||
}
|
||||
break;
|
||||
case "SEARXNG_API": {
|
||||
const host = (formData.SEARXNG_HOST || "").trim();
|
||||
if (!host) {
|
||||
toast.error("SearxNG host is required.");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateConfig: Record<string, any> = { SEARXNG_HOST: host };
|
||||
let hasChanges = host !== (originalConfig.SEARXNG_HOST || "").trim();
|
||||
|
||||
const apiKey = (formData.SEARXNG_API_KEY || "").trim();
|
||||
const originalApiKey = (originalConfig.SEARXNG_API_KEY || "").trim();
|
||||
if (apiKey !== originalApiKey) {
|
||||
candidateConfig.SEARXNG_API_KEY = apiKey || null;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const newEngines = normalizeListInput(formData.SEARXNG_ENGINES || "");
|
||||
const originalEngines = normalizeListInput(originalConfig.SEARXNG_ENGINES);
|
||||
if (!arraysEqual(newEngines, originalEngines)) {
|
||||
candidateConfig.SEARXNG_ENGINES = newEngines;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const newCategories = normalizeListInput(formData.SEARXNG_CATEGORIES || "");
|
||||
const originalCategories = normalizeListInput(originalConfig.SEARXNG_CATEGORIES);
|
||||
if (!arraysEqual(newCategories, originalCategories)) {
|
||||
candidateConfig.SEARXNG_CATEGORIES = newCategories;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const language = (formData.SEARXNG_LANGUAGE || "").trim();
|
||||
const originalLanguage = (originalConfig.SEARXNG_LANGUAGE || "").trim();
|
||||
if (language !== originalLanguage) {
|
||||
candidateConfig.SEARXNG_LANGUAGE = language || null;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const safesearchRaw = (formData.SEARXNG_SAFESEARCH || "").trim();
|
||||
const originalSafesearch = originalConfig.SEARXNG_SAFESEARCH;
|
||||
if (safesearchRaw) {
|
||||
const parsed = Number(safesearchRaw);
|
||||
if (Number.isNaN(parsed) || !Number.isInteger(parsed) || parsed < 0 || parsed > 2) {
|
||||
toast.error("SearxNG SafeSearch must be 0, 1, or 2.");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
if (parsed !== Number(originalSafesearch)) {
|
||||
candidateConfig.SEARXNG_SAFESEARCH = parsed;
|
||||
hasChanges = true;
|
||||
}
|
||||
} else if (originalSafesearch !== undefined && originalSafesearch !== null) {
|
||||
candidateConfig.SEARXNG_SAFESEARCH = null;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const verifyRaw = (formData.SEARXNG_VERIFY_SSL || "").trim().toLowerCase();
|
||||
const originalVerifyBool = normalizeBoolean(originalConfig.SEARXNG_VERIFY_SSL);
|
||||
if (verifyRaw) {
|
||||
let parsedBool: boolean | null = null;
|
||||
if (["true", "1", "yes", "on"].includes(verifyRaw)) parsedBool = true;
|
||||
else if (["false", "0", "no", "off"].includes(verifyRaw)) parsedBool = false;
|
||||
if (parsedBool === null) {
|
||||
toast.error("SearxNG SSL verification must be true or false.");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
if (parsedBool !== originalVerifyBool) {
|
||||
candidateConfig.SEARXNG_VERIFY_SSL = parsedBool;
|
||||
hasChanges = true;
|
||||
}
|
||||
} else if (originalVerifyBool !== null) {
|
||||
candidateConfig.SEARXNG_VERIFY_SSL = null;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
newConfig = candidateConfig;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "LINEAR_CONNECTOR":
|
||||
if (formData.LINEAR_API_KEY !== originalConfig.LINEAR_API_KEY) {
|
||||
|
|
@ -379,6 +517,30 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
editForm.setValue("SERPER_API_KEY", newlySavedConfig.SERPER_API_KEY || "");
|
||||
} else if (connector.connector_type === "TAVILY_API") {
|
||||
editForm.setValue("TAVILY_API_KEY", newlySavedConfig.TAVILY_API_KEY || "");
|
||||
} else if (connector.connector_type === "SEARXNG_API") {
|
||||
editForm.setValue("SEARXNG_HOST", newlySavedConfig.SEARXNG_HOST || "");
|
||||
editForm.setValue("SEARXNG_API_KEY", newlySavedConfig.SEARXNG_API_KEY || "");
|
||||
editForm.setValue(
|
||||
"SEARXNG_ENGINES",
|
||||
normalizeListInput(newlySavedConfig.SEARXNG_ENGINES).join(", ")
|
||||
);
|
||||
editForm.setValue(
|
||||
"SEARXNG_CATEGORIES",
|
||||
normalizeListInput(newlySavedConfig.SEARXNG_CATEGORIES).join(", ")
|
||||
);
|
||||
editForm.setValue("SEARXNG_LANGUAGE", newlySavedConfig.SEARXNG_LANGUAGE || "");
|
||||
editForm.setValue(
|
||||
"SEARXNG_SAFESEARCH",
|
||||
newlySavedConfig.SEARXNG_SAFESEARCH === null ||
|
||||
newlySavedConfig.SEARXNG_SAFESEARCH === undefined
|
||||
? ""
|
||||
: String(newlySavedConfig.SEARXNG_SAFESEARCH)
|
||||
);
|
||||
const verifyValue = normalizeBoolean(newlySavedConfig.SEARXNG_VERIFY_SSL);
|
||||
editForm.setValue(
|
||||
"SEARXNG_VERIFY_SSL",
|
||||
verifyValue === null ? "" : String(verifyValue)
|
||||
);
|
||||
} else if (connector.connector_type === "LINEAR_CONNECTOR") {
|
||||
editForm.setValue("LINEAR_API_KEY", newlySavedConfig.LINEAR_API_KEY || "");
|
||||
} else if (connector.connector_type === "LINKUP_API") {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
|
|||
const typeMap: Record<string, string> = {
|
||||
SERPER_API: "Serper API",
|
||||
TAVILY_API: "Tavily API",
|
||||
// Add other connector types here as needed
|
||||
SEARXNG_API: "SearxNG",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface LLMConfig {
|
|||
model_name: string;
|
||||
api_key: string;
|
||||
api_base?: string;
|
||||
language?: string;
|
||||
litellm_params?: Record<string, any>;
|
||||
created_at: string;
|
||||
search_space_id: number;
|
||||
|
|
@ -31,6 +32,7 @@ export interface CreateLLMConfig {
|
|||
model_name: string;
|
||||
api_key: string;
|
||||
api_base?: string;
|
||||
language?: string;
|
||||
litellm_params?: Record<string, any>;
|
||||
search_space_id: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
|
|||
const typeMap: Record<string, string> = {
|
||||
SERPER_API: "Serper API",
|
||||
TAVILY_API: "Tavily API",
|
||||
SEARXNG_API: "SearxNG",
|
||||
SLACK_CONNECTOR: "Slack",
|
||||
NOTION_CONNECTOR: "Notion",
|
||||
GITHUB_CONNECTOR: "GitHub",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue