feat(i18n): Add next-intl framework with full bilingual support (EN/ZH)

- Implement next-intl framework for scalable i18n
- Add complete Chinese (Simplified) localization
- Support 400+ translated strings across all pages
- Add language switcher with persistent preference
- Zero breaking changes to existing functionality

Framework additions:
- i18n routing and middleware
- LocaleContext for client-side state
- LanguageSwitcher component
- Translation files (en.json, zh.json)

Translated components:
- Homepage: Hero, features, CTA, navbar
- Auth: Login, register
- Dashboard: Main page, layout
- Connectors: Management, add page (all categories)
- Documents: Upload, manage, filters
- Settings: LLM configs, role assignments
- Onboarding: Add provider, assign roles
- Logs: Task logs viewer

Adding a new language now requires only:
1. Create messages/<locale>.json
2. Add locale to i18n/routing.ts
This commit is contained in:
Differ 2025-10-26 14:05:46 +08:00
parent 8aeaf419d0
commit f58c7e4602
37 changed files with 2267 additions and 542 deletions

View file

@ -47,3 +47,6 @@ next-env.d.ts
# source
/.source/
.pnpm-store/

View file

@ -1,10 +1,13 @@
"use client";
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { AmbientBackground } from "./AmbientBackground";
export function GoogleLoginButton() {
const t = useTranslations('auth');
const handleGoogleLogin = () => {
// Redirect to Google OAuth authorization URL
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
@ -31,7 +34,7 @@ export function GoogleLoginButton() {
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
Welcome Back
{t('welcome_back')}
</h1>
<motion.div
@ -65,14 +68,14 @@ export function GoogleLoginButton() {
</svg>
<div className="ml-1">
<p className="text-sm font-medium">
SurfSense Cloud is currently in development. Check{" "}
{t('cloud_dev_notice')}{" "}
<a
href="/docs"
className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Docs
{t('docs')}
</a>{" "}
for more information on Self-Hosted version.
{t('cloud_dev_self_hosted')}
</p>
</div>
</motion.div>
@ -91,7 +94,7 @@ export function GoogleLoginButton() {
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
</div>
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
<span className="text-base font-medium">Continue with Google</span>
<span className="text-base font-medium">{t('continue_with_google')}</span>
</motion.button>
</div>
</div>

View file

@ -5,9 +5,12 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
export function LocalLoginForm() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
@ -29,7 +32,7 @@ export function LocalLoginForm() {
setErrorTitle(null);
// Show loading toast
const loadingToast = toast.loading("Signing you in...");
const loadingToast = toast.loading(tCommon('loading'));
try {
// Create form data for the API request
@ -56,7 +59,7 @@ export function LocalLoginForm() {
}
// Success toast
toast.success("Login successful!", {
toast.success(t('login_success'), {
id: loadingToast,
description: "Redirecting to dashboard...",
duration: 2000,
@ -167,84 +170,84 @@ export function LocalLoginForm() {
</div>
</motion.div>
)}
</AnimatePresence>
</AnimatePresence>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Email
</label>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{t('email')}
</label>
<input
id="email"
type="email"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{t('password')}
</label>
<div className="relative">
<input
id="email"
type="email"
id="password"
type={showPassword ? "text" : "password"}
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label={showPassword ? t('hide_password') : t('show_password')}
>
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{isLoading ? tCommon('loading') : t('sign_in')}
</button>
</form>
{authType === "LOCAL" && (
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Register here
</Link>
</p>
</div>
)}
{authType === "LOCAL" && (
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
{t('dont_have_account')}{" "}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
{t('sign_up')}
</Link>
</p>
</div>
)}
</div>
);
}

View file

@ -5,6 +5,7 @@ import { AnimatePresence, motion } from "motion/react";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { AmbientBackground } from "./AmbientBackground";
@ -12,6 +13,8 @@ import { GoogleLoginButton } from "./GoogleLoginButton";
import { LocalLoginForm } from "./LocalLoginForm";
function LoginContent() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const [authType, setAuthType] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
@ -26,15 +29,15 @@ function LoginContent() {
// Show registration success message
if (registered === "true") {
toast.success("Registration successful!", {
description: "You can now sign in with your credentials",
toast.success(t('register_success'), {
description: t('login_subtitle'),
duration: 5000,
});
}
// Show logout confirmation
if (logout === "true") {
toast.success("Logged out successfully", {
toast.success(tCommon('success'), {
description: "You have been securely logged out",
duration: 3000,
});
@ -93,7 +96,7 @@ function LoginContent() {
<Logo className="rounded-md" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
<span className="text-muted-foreground">{tCommon('loading')}</span>
</div>
</div>
</div>
@ -110,7 +113,7 @@ function LoginContent() {
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
Sign In
{t('sign_in')}
</h1>
{/* URL Error Display */}

View file

@ -5,11 +5,14 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@ -31,10 +34,10 @@ export default function RegisterPage() {
// Form validation
if (password !== confirmPassword) {
setError("Passwords do not match");
setErrorTitle("Password Mismatch");
toast.error("Password Mismatch", {
description: "The passwords you entered do not match",
setError(t('passwords_no_match'));
setErrorTitle(t('password_mismatch'));
toast.error(t('password_mismatch'), {
description: t('passwords_no_match_desc'),
duration: 4000,
});
return;
@ -45,7 +48,7 @@ export default function RegisterPage() {
setErrorTitle(null);
// Show loading toast
const loadingToast = toast.loading("Creating your account...");
const loadingToast = toast.loading(t('creating_account'));
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
@ -83,9 +86,9 @@ export default function RegisterPage() {
}
// Success toast
toast.success("Account created successfully!", {
toast.success(t('register_success'), {
id: loadingToast,
description: "Redirecting to login page...",
description: t('redirecting_login'),
duration: 2000,
});
@ -120,7 +123,7 @@ export default function RegisterPage() {
// Add retry action if the error is retryable
if (shouldRetry(errorCode)) {
toastOptions.action = {
label: "Retry",
label: tCommon('retry'),
onClick: () => handleSubmit(e),
};
}
@ -137,7 +140,7 @@ export default function RegisterPage() {
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
Create an Account
{t('create_account')}
</h1>
<div className="w-full max-w-md">
@ -209,7 +212,7 @@ export default function RegisterPage() {
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Email
{t('email')}
</label>
<input
id="email"
@ -231,7 +234,7 @@ export default function RegisterPage() {
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password
{t('password')}
</label>
<input
id="password"
@ -253,7 +256,7 @@ export default function RegisterPage() {
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Confirm Password
{t('confirm_password')}
</label>
<input
id="confirmPassword"
@ -275,18 +278,18 @@ export default function RegisterPage() {
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{isLoading ? "Creating account..." : "Register"}
{isLoading ? t('creating_account_btn') : t('register')}
</button>
</form>
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
Already have an account?{" "}
{t('already_have_account')}{" "}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Sign in
{t('sign_in')}
</Link>
</p>
</div>

View file

@ -3,7 +3,8 @@
import { Loader2 } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import type React from "react";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
@ -11,6 +12,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { useLLMPreferences } from "@/hooks/use-llm-configs";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
export function DashboardClientLayout({
children,
@ -23,6 +25,7 @@ export function DashboardClientLayout({
navSecondary: any[];
navMain: any[];
}) {
const t = useTranslations('dashboard');
const router = useRouter();
const pathname = usePathname();
const searchSpaceIdNum = Number(searchSpaceId);
@ -33,6 +36,26 @@ export function DashboardClientLayout({
// Skip onboarding check if we're already on the onboarding page
const isOnboardingPage = pathname?.includes("/onboard");
// Translate navigation items
const tNavMenu = useTranslations('nav_menu');
const translatedNavMain = useMemo(() => {
return navMain.map((item) => ({
...item,
title: tNavMenu(item.title.toLowerCase().replace(/ /g, '_')),
items: item.items?.map((subItem: any) => ({
...subItem,
title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, '_')),
})),
}));
}, [navMain, tNavMenu]);
const translatedNavSecondary = useMemo(() => {
return navSecondary.map((item) => ({
...item,
title: item.title === 'All Search Spaces' ? tNavMenu('all_search_spaces') : item.title,
}));
}, [navSecondary, tNavMenu]);
const [open, setOpen] = useState<boolean>(() => {
try {
const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/);
@ -75,8 +98,8 @@ export function DashboardClientLayout({
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Loading Configuration</CardTitle>
<CardDescription>Checking your LLM preferences...</CardDescription>
<CardTitle className="text-xl font-medium">{t('loading_config')}</CardTitle>
<CardDescription>{t('checking_llm_prefs')}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
@ -93,9 +116,9 @@ export function DashboardClientLayout({
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium text-destructive">
Configuration Error
{t('config_error')}
</CardTitle>
<CardDescription>Failed to load your LLM configuration</CardDescription>
<CardDescription>{t('failed_load_llm_config')}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{error}</p>
@ -110,8 +133,8 @@ export function DashboardClientLayout({
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
<AppSidebarProvider
searchSpaceId={searchSpaceId}
navSecondary={navSecondary}
navMain={navMain}
navSecondary={translatedNavSecondary}
navMain={translatedNavMain}
/>
<SidebarInset>
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
@ -121,7 +144,10 @@ export function DashboardClientLayout({
<Separator orientation="vertical" className="h-6" />
<DashboardBreadcrumb />
</div>
<ThemeTogglerComponent />
<div className="flex items-center gap-2">
<LanguageSwitcher />
<ThemeTogglerComponent />
</div>
</div>
</header>
{children}

View file

@ -14,6 +14,7 @@ import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import {
AlertDialog,
AlertDialogAction,
@ -61,21 +62,23 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
import { cn } from "@/lib/utils";
// Helper function to format date with time
const formatDateTime = (dateString: string | null): string => {
if (!dateString) return "Never";
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
export default function ConnectorsPage() {
const t = useTranslations('connectors');
const tCommon = useTranslations('common');
// Helper function to format date with time
const formatDateTime = (dateString: string | null): string => {
if (!dateString) return t('never');
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
@ -104,10 +107,10 @@ export default function ConnectorsPage() {
useEffect(() => {
if (error) {
toast.error("Failed to load connectors");
toast.error(t('failed_load'));
console.error("Error fetching connectors:", error);
}
}, [error]);
}, [error, t]);
// Handle connector deletion
const handleDeleteConnector = async () => {
@ -115,10 +118,10 @@ export default function ConnectorsPage() {
try {
await deleteConnector(connectorToDelete);
toast.success("Connector deleted successfully");
toast.success(t('delete_success'));
} catch (error) {
console.error("Error deleting connector:", error);
toast.error("Failed to delete connector");
toast.error(t('delete_failed'));
} finally {
setConnectorToDelete(null);
}
@ -142,10 +145,10 @@ export default function ConnectorsPage() {
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
await indexConnector(selectedConnectorForIndexing, searchSpaceId, startDateStr, endDateStr);
toast.success("Connector content indexing started");
toast.success(t('indexing_started'));
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
toast.error(error instanceof Error ? error.message : t('indexing_failed'));
} finally {
setIndexingConnectorId(null);
setSelectedConnectorForIndexing(null);
@ -159,10 +162,10 @@ export default function ConnectorsPage() {
setIndexingConnectorId(connectorId);
try {
await indexConnector(connectorId, searchSpaceId);
toast.success("Connector content indexing started");
toast.success(t('indexing_started'));
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
toast.error(error instanceof Error ? error.message : t('indexing_failed'));
} finally {
setIndexingConnectorId(null);
}
@ -255,21 +258,21 @@ export default function ConnectorsPage() {
className="mb-8 flex items-center justify-between"
>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connectors</h1>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground mt-2">
Manage your connected services and data sources.
{t('subtitle')}
</p>
</div>
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
<Plus className="mr-2 h-4 w-4" />
Add Connector
{t('add_connector')}
</Button>
</motion.div>
<Card>
<CardHeader className="pb-3">
<CardTitle>Your Connectors</CardTitle>
<CardDescription>View and manage all your connected services.</CardDescription>
<CardTitle>{t('your_connectors')}</CardTitle>
<CardDescription>{t('view_manage')}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
@ -281,13 +284,13 @@ export default function ConnectorsPage() {
</div>
) : connectors.length === 0 ? (
<div className="text-center py-12">
<h3 className="text-lg font-medium mb-2">No connectors found</h3>
<h3 className="text-lg font-medium mb-2">{t('no_connectors')}</h3>
<p className="text-muted-foreground mb-6">
You haven't added any connectors yet. Add one to enhance your search capabilities.
{t('no_connectors_desc')}
</p>
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
<Plus className="mr-2 h-4 w-4" />
Add Your First Connector
{t('add_first')}
</Button>
</div>
) : (
@ -295,11 +298,11 @@ export default function ConnectorsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Last Indexed</TableHead>
<TableHead>Periodic</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t('name')}</TableHead>
<TableHead>{t('type')}</TableHead>
<TableHead>{t('last_indexed')}</TableHead>
<TableHead>{t('periodic')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -310,7 +313,7 @@ export default function ConnectorsPage() {
<TableCell>
{connector.is_indexable
? formatDateTime(connector.last_indexed_at)
: "Not indexable"}
: t('not_indexable')}
</TableCell>
<TableCell>
{connector.is_indexable ? (
@ -365,11 +368,11 @@ export default function ConnectorsPage() {
) : (
<CalendarIcon className="h-4 w-4" />
)}
<span className="sr-only">Index with Date Range</span>
<span className="sr-only">{t('index_date_range')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Index with Date Range</p>
<p>{t('index_date_range')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -387,11 +390,11 @@ export default function ConnectorsPage() {
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="sr-only">Quick Index</span>
<span className="sr-only">{t('quick_index')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Quick Index (Auto Date Range)</p>
<p>{t('quick_index_auto')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -426,7 +429,7 @@ export default function ConnectorsPage() {
}
>
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
<span className="sr-only">{tCommon('edit')}</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
@ -437,26 +440,25 @@ export default function ConnectorsPage() {
onClick={() => setConnectorToDelete(connector.id)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
<span className="sr-only">{tCommon('delete')}</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Connector</AlertDialogTitle>
<AlertDialogTitle>{t('delete_connector')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this connector? This action cannot
be undone.
{t('delete_confirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
Cancel
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteConnector}
>
Delete
{tCommon('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -476,15 +478,15 @@ export default function ConnectorsPage() {
<Dialog open={datePickerOpen} onOpenChange={setDatePickerOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Select Date Range for Indexing</DialogTitle>
<DialogTitle>{t('select_date_range')}</DialogTitle>
<DialogDescription>
Choose the start and end dates for indexing content. Leave empty to use default range.
{t('select_date_range_desc')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<Label htmlFor="start-date">{t('start_date')}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
@ -496,7 +498,7 @@ export default function ConnectorsPage() {
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? format(startDate, "PPP") : "Pick a date"}
{startDate ? format(startDate, "PPP") : t('pick_date')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
@ -510,7 +512,7 @@ export default function ConnectorsPage() {
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<Label htmlFor="end-date">{t('end_date')}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
@ -522,7 +524,7 @@ export default function ConnectorsPage() {
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{endDate ? format(endDate, "PPP") : "Pick a date"}
{endDate ? format(endDate, "PPP") : t('pick_date')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
@ -540,7 +542,7 @@ export default function ConnectorsPage() {
setEndDate(undefined);
}}
>
Clear Dates
{t('clear_dates')}
</Button>
<Button
variant="outline"
@ -552,7 +554,7 @@ export default function ConnectorsPage() {
setEndDate(today);
}}
>
Last 30 Days
{t('last_30_days')}
</Button>
<Button
variant="outline"
@ -564,7 +566,7 @@ export default function ConnectorsPage() {
setEndDate(today);
}}
>
Last Year
{t('last_year')}
</Button>
</div>
</div>
@ -578,9 +580,9 @@ export default function ConnectorsPage() {
setEndDate(undefined);
}}
>
Cancel
{tCommon('cancel')}
</Button>
<Button onClick={handleIndexConnector}>Start Indexing</Button>
<Button onClick={handleIndexConnector}>{t('start_indexing')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -10,6 +10,7 @@ import { AnimatePresence, motion, type Variants } from "motion/react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
@ -36,40 +37,40 @@ interface ConnectorCategory {
const connectorCategories: ConnectorCategory[] = [
{
id: "search-engines",
title: "Search Engines",
title: "search_engines",
connectors: [
{
id: "tavily-api",
title: "Tavily API",
description: "Search the web using the Tavily API",
description: "tavily_desc",
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.",
description: "searxng_desc",
icon: getConnectorIcon(EnumConnectorName.SEARXNG_API, "h-6 w-6"),
status: "available",
},
{
id: "linkup-api",
title: "Linkup API",
description: "Search the web using the Linkup API",
description: "linkup_desc",
icon: getConnectorIcon(EnumConnectorName.LINKUP_API, "h-6 w-6"),
status: "available",
},
{
id: "elasticsearch-connector",
title: "Elasticsearch",
description: "Connect to Elasticsearch to index and search documents, logs and metrics.",
description: "elasticsearch_desc",
icon: getConnectorIcon(EnumConnectorName.ELASTICSEARCH_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "baidu-search-api",
title: "Baidu Search",
description: "Search the Chinese web using Baidu AI Search API",
description: "baidu_desc",
icon: getConnectorIcon(EnumConnectorName.BAIDU_SEARCH_API, "h-6 w-6"),
status: "available",
},
@ -77,26 +78,26 @@ const connectorCategories: ConnectorCategory[] = [
},
{
id: "team-chats",
title: "Team Chats",
title: "team_chats",
connectors: [
{
id: "slack-connector",
title: "Slack",
description: "Connect to your Slack workspace to access messages and channels.",
description: "slack_desc",
icon: getConnectorIcon(EnumConnectorName.SLACK_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "ms-teams",
title: "Microsoft Teams",
description: "Connect to Microsoft Teams to access your team's conversations.",
description: "teams_desc",
icon: <IconBrandWindows className="h-6 w-6" />,
status: "coming-soon",
},
{
id: "discord-connector",
title: "Discord",
description: "Connect to Discord servers to access messages and channels.",
description: "discord_desc",
icon: getConnectorIcon(EnumConnectorName.DISCORD_CONNECTOR, "h-6 w-6"),
status: "available",
},
@ -104,26 +105,26 @@ const connectorCategories: ConnectorCategory[] = [
},
{
id: "project-management",
title: "Project Management",
title: "project_management",
connectors: [
{
id: "linear-connector",
title: "Linear",
description: "Connect to Linear to search issues, comments and project data.",
description: "linear_desc",
icon: getConnectorIcon(EnumConnectorName.LINEAR_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "jira-connector",
title: "Jira",
description: "Connect to Jira to search issues, tickets and project data.",
description: "jira_desc",
icon: getConnectorIcon(EnumConnectorName.JIRA_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "clickup-connector",
title: "ClickUp",
description: "Connect to ClickUp to search tasks, comments and project data.",
description: "clickup_desc",
icon: getConnectorIcon(EnumConnectorName.CLICKUP_CONNECTOR, "h-6 w-6"),
status: "available",
},
@ -131,40 +132,40 @@ const connectorCategories: ConnectorCategory[] = [
},
{
id: "knowledge-bases",
title: "Knowledge Bases",
title: "knowledge_bases",
connectors: [
{
id: "notion-connector",
title: "Notion",
description: "Connect to your Notion workspace to access pages and databases.",
description: "notion_desc",
icon: getConnectorIcon(EnumConnectorName.NOTION_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "github-connector",
title: "GitHub",
description: "Connect a GitHub PAT to index code and docs from accessible repositories.",
description: "github_desc",
icon: getConnectorIcon(EnumConnectorName.GITHUB_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "confluence-connector",
title: "Confluence",
description: "Connect to Confluence to search pages, comments and documentation.",
description: "confluence_desc",
icon: getConnectorIcon(EnumConnectorName.CONFLUENCE_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "airtable-connector",
title: "Airtable",
description: "Connect to Airtable to search records, tables and database content.",
description: "airtable_desc",
icon: getConnectorIcon(EnumConnectorName.AIRTABLE_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "luma-connector",
title: "Luma",
description: "Connect to Luma to search events",
description: "luma_desc",
icon: getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6"),
status: "available",
},
@ -172,26 +173,26 @@ const connectorCategories: ConnectorCategory[] = [
},
{
id: "communication",
title: "Communication",
title: "communication",
connectors: [
{
id: "google-calendar-connector",
title: "Google Calendar",
description: "Connect to Google Calendar to search events, meetings and schedules.",
description: "calendar_desc",
icon: getConnectorIcon(EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "google-gmail-connector",
title: "Gmail",
description: "Connect to your Gmail account to search through your emails.",
description: "gmail_desc",
icon: getConnectorIcon(EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "zoom",
title: "Zoom",
description: "Connect to Zoom to access meeting recordings and transcripts.",
description: "zoom_desc",
icon: <IconBrandZoom className="h-6 w-6" />,
status: "coming-soon",
},
@ -238,6 +239,7 @@ const cardVariants: Variants = {
};
export default function ConnectorsPage() {
const t = useTranslations('add_connector');
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [expandedCategories, setExpandedCategories] = useState<string[]>([
@ -266,10 +268,10 @@ export default function ConnectorsPage() {
className="mb-12 text-center"
>
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
Connect Your Tools
{t('title')}
</h1>
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
Integrate with your favorite services to enhance your research capabilities.
{t('subtitle')}
</p>
</motion.div>
@ -291,7 +293,7 @@ export default function ConnectorsPage() {
className="w-full"
>
<div className="flex items-center justify-between space-x-4 p-4">
<h3 className="text-xl font-semibold">{category.title}</h3>
<h3 className="text-xl font-semibold">{t(category.title)}</h3>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-9 p-0 hover:bg-muted">
<motion.div
@ -341,7 +343,7 @@ export default function ConnectorsPage() {
variant="outline"
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
>
Coming soon
{t('coming_soon')}
</Badge>
)}
{connector.status === "connected" && (
@ -349,7 +351,7 @@ export default function ConnectorsPage() {
variant="outline"
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
>
Connected
{t('connected')}
</Badge>
)}
</div>
@ -357,7 +359,7 @@ export default function ConnectorsPage() {
</CardHeader>
<CardContent className="pb-4">
<p className="text-sm text-muted-foreground">{connector.description}</p>
<p className="text-sm text-muted-foreground">{t(connector.description)}</p>
</CardContent>
<CardFooter className="mt-auto pt-2">
@ -367,7 +369,7 @@ export default function ConnectorsPage() {
className="w-full"
>
<Button variant="default" className="w-full group">
<span>Connect</span>
<span>{t('connect')}</span>
<motion.div
className="ml-1"
initial={{ x: 0 }}
@ -385,7 +387,7 @@ export default function ConnectorsPage() {
)}
{connector.status === "coming-soon" && (
<Button variant="outline" disabled className="w-full opacity-70">
Coming Soon
{t('coming_soon')}
</Button>
)}
{connector.status === "connected" && (
@ -393,7 +395,7 @@ export default function ConnectorsPage() {
variant="outline"
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
>
Manage
{t('manage')}
</Button>
)}
</CardFooter>

View file

@ -3,6 +3,7 @@
import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import React, { useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import {
AlertDialog,
AlertDialogAction,
@ -55,6 +56,7 @@ export function DocumentsFilters({
columnVisibility: ColumnVisibility;
onToggleColumn: (id: keyof ColumnVisibility, checked: boolean) => void;
}) {
const t = useTranslations('documents');
const id = React.useId();
const inputRef = useRef<HTMLInputElement>(null);
@ -90,9 +92,9 @@ export function DocumentsFilters({
className="peer min-w-60 ps-9"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}
placeholder="Filter by title..."
placeholder={t('filter_placeholder')}
type="text"
aria-label="Filter by title"
aria-label={t('filter_placeholder')}
/>
<motion.div
className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"

View file

@ -3,6 +3,7 @@
import { ChevronDown, ChevronUp, FileX } from "lucide-react";
import { motion } from "motion/react";
import React from "react";
import { useTranslations } from "next-intl";
import { DocumentViewer } from "@/components/document-viewer";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -66,6 +67,7 @@ export function DocumentsTableShell({
sortDesc: boolean;
onSortChange: (key: SortKey) => void;
}) {
const t = useTranslations('documents');
const sorted = React.useMemo(
() => sortDocuments(documents, sortKey, sortDesc),
[documents, sortKey, sortDesc]
@ -101,15 +103,15 @@ export function DocumentsTableShell({
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
<p className="text-sm text-muted-foreground">Loading documents...</p>
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</div>
</div>
) : error ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-destructive">Error loading documents</p>
<p className="text-sm text-destructive">{t('error_loading')}</p>
<Button variant="outline" size="sm" onClick={() => onRefresh()} className="mt-2">
Retry
{t('retry')}
</Button>
</div>
</div>
@ -117,7 +119,7 @@ export function DocumentsTableShell({
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<FileX className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No documents found</p>
<p className="text-sm text-muted-foreground">{t('no_documents')}</p>
</div>
</div>
) : (
@ -140,7 +142,7 @@ export function DocumentsTableShell({
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
onClick={() => onSortHeader("title")}
>
Title
{t('title')}
{sortKey === "title" ? (
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
@ -158,7 +160,7 @@ export function DocumentsTableShell({
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
onClick={() => onSortHeader("document_type")}
>
Type
{t('type')}
{sortKey === "document_type" ? (
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
@ -170,7 +172,7 @@ export function DocumentsTableShell({
</TableHead>
)}
{columnVisibility.content && (
<TableHead style={{ width: 300 }}>Content Summary</TableHead>
<TableHead style={{ width: 300 }}>{t('content_summary')}</TableHead>
)}
{columnVisibility.created_at && (
<TableHead style={{ width: 120 }}>
@ -264,7 +266,7 @@ export function DocumentsTableShell({
content={doc.content}
trigger={
<Button variant="ghost" size="sm" className="w-fit text-xs">
View Full Content
{t('view_full')}
</Button>
}
/>
@ -335,7 +337,7 @@ export function DocumentsTableShell({
size="sm"
className="w-fit text-xs p-0 h-auto"
>
View Full Content
{t('view_full')}
</Button>
}
/>

View file

@ -2,6 +2,7 @@
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination";
@ -38,6 +39,7 @@ export function PaginationControls({
canNext: boolean;
id: string;
}) {
const t = useTranslations('documents');
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min((pageIndex + 1) * pageSize, total);
@ -50,7 +52,7 @@ export function PaginationControls({
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<Label htmlFor={id} className="max-sm:sr-only">
Rows per page
{t('rows_per_page')}
</Label>
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
<SelectTrigger id={id} className="w-fit whitespace-nowrap">

View file

@ -4,6 +4,7 @@ import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { useDocuments } from "@/hooks/use-documents";
@ -22,6 +23,7 @@ function useDebounced<T>(value: T, delay = 250) {
}
export default function DocumentsTable() {
const t = useTranslations('documents');
const id = useId();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
@ -120,21 +122,21 @@ export default function DocumentsTable() {
const onBulkDelete = async () => {
if (selectedIds.size === 0) {
toast.error("No rows selected");
toast.error(t('no_rows_selected'));
return;
}
try {
const results = await Promise.all(Array.from(selectedIds).map((id) => deleteDocument?.(id)));
const okCount = results.filter((r) => r === true).length;
if (okCount === selectedIds.size)
toast.success(`Successfully deleted ${okCount} document(s)`);
else toast.error("Some documents could not be deleted");
toast.success(t('delete_success_count', { count: okCount }));
else toast.error(t('delete_partial_failed'));
// Refetch the current page with appropriate method
await refreshCurrentView();
setSelectedIds(new Set());
} catch (e) {
console.error(e);
toast.error("Error deleting documents");
toast.error(t('delete_error'));
}
};

View file

@ -6,6 +6,7 @@ import { useParams, useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -39,6 +40,7 @@ function GridPattern() {
}
export default function FileUploader() {
const t = useTranslations('upload_documents');
const params = useParams();
const search_space_id = params.search_space_id as string;
@ -274,16 +276,16 @@ export default function FileUploader() {
await response.json();
toast("Upload Task Initiated", {
description: "Files Uploading Initiated",
toast(t('upload_initiated'), {
description: t('upload_initiated_desc'),
});
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setIsUploading(false);
setUploadProgress(0);
toast("Upload Error", {
description: `Error uploading files: ${error.message}`,
toast(t('upload_error'), {
description: `${t('upload_error_desc')}: ${error.message}`,
});
}
};
@ -330,19 +332,17 @@ export default function FileUploader() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload Documents
{t('title')}
</CardTitle>
<CardDescription>
Upload your files to make them searchable and accessible through AI-powered
conversations.
{t('subtitle')}
</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Maximum file size: 50MB per file. Supported formats vary based on your ETL service
configuration.
{t('file_size_limit')}
</AlertDescription>
</Alert>
</CardContent>
@ -371,7 +371,7 @@ export default function FileUploader() {
className="flex flex-col items-center gap-4"
>
<Upload className="h-12 w-12 text-primary" />
<p className="text-lg font-medium text-primary">Drop files here</p>
<p className="text-lg font-medium text-primary">{t('drop_files')}</p>
</motion.div>
) : (
<motion.div
@ -381,8 +381,8 @@ export default function FileUploader() {
>
<Upload className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<p className="text-lg font-medium">Drag & drop files here</p>
<p className="text-sm text-muted-foreground mt-1">or click to browse</p>
<p className="text-lg font-medium">{t('drag_drop')}</p>
<p className="text-sm text-muted-foreground mt-1">{t('or_browse')}</p>
</div>
</motion.div>
)}
@ -400,7 +400,7 @@ export default function FileUploader() {
if (input) input.click();
}}
>
Browse Files
{t('browse_files')}
</Button>
</div>
</div>
@ -422,9 +422,9 @@ export default function FileUploader() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Selected Files ({files.length})</CardTitle>
<CardTitle>{t('selected_files', { count: files.length })}</CardTitle>
<CardDescription>
Total size: {formatFileSize(getTotalFileSize())}
{t('total_size')}: {formatFileSize(getTotalFileSize())}
</CardDescription>
</div>
<Button
@ -433,7 +433,7 @@ export default function FileUploader() {
onClick={() => setFiles([])}
disabled={isUploading}
>
Clear all
{t('clear_all')}
</Button>
</div>
</CardHeader>
@ -490,7 +490,7 @@ export default function FileUploader() {
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Uploading files...</span>
<span>{t('uploading_files')}</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
@ -521,7 +521,7 @@ export default function FileUploader() {
>
<Upload className="h-5 w-5" />
</motion.div>
<span>Uploading...</span>
<span>{t('uploading')}</span>
</motion.div>
) : (
<motion.div
@ -531,7 +531,7 @@ export default function FileUploader() {
>
<CheckCircle2 className="h-5 w-5" />
<span>
Upload {files.length} {files.length === 1 ? "file" : "files"}
{t('upload_button', { count: files.length })}
</span>
</motion.div>
)}
@ -549,10 +549,10 @@ export default function FileUploader() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
Supported File Types
{t('supported_file_types')}
</CardTitle>
<CardDescription>
These file types are supported based on your current ETL service configuration.
{t('file_types_desc')}
</CardDescription>
</CardHeader>
<CardContent>

View file

@ -5,6 +5,7 @@ import { Globe, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Card,
@ -20,6 +21,7 @@ import { Label } from "@/components/ui/label";
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;
export default function WebpageCrawler() {
const t = useTranslations('add_webpage');
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
@ -38,14 +40,14 @@ export default function WebpageCrawler() {
const handleSubmit = async () => {
// Validate that we have at least one URL
if (urlTags.length === 0) {
setError("Please add at least one URL");
setError(t('error_no_url'));
return;
}
// Validate all URLs
const invalidUrls = urlTags.filter((tag) => !isValidUrl(tag.text));
if (invalidUrls.length > 0) {
setError(`Invalid URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
setError(t('error_invalid_urls', { urls: invalidUrls.map((tag) => tag.text).join(", ") }));
return;
}
@ -53,8 +55,8 @@ export default function WebpageCrawler() {
setIsSubmitting(true);
try {
toast("URL Crawling", {
description: "Starting URL crawling process...",
toast(t('crawling_toast'), {
description: t('crawling_toast_desc'),
});
// Extract URLs from tags
@ -83,16 +85,16 @@ export default function WebpageCrawler() {
await response.json();
toast("Crawling Successful", {
description: "URLs have been submitted for crawling",
toast(t('success_toast'), {
description: t('success_toast_desc'),
});
// Redirect to documents page
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setError(error.message || "An error occurred while crawling URLs");
toast("Crawling Error", {
description: `Error crawling URLs: ${error.message}`,
setError(error.message || t('error_generic'));
toast(t('error_toast'), {
description: `${t('error_toast_desc')}: ${error.message}`,
});
} finally {
setIsSubmitting(false);
@ -103,16 +105,16 @@ export default function WebpageCrawler() {
const handleAddTag = (text: string) => {
// Basic URL validation
if (!isValidUrl(text)) {
toast("Invalid URL", {
description: "Please enter a valid URL",
toast(t('invalid_url_toast'), {
description: t('invalid_url_toast_desc'),
});
return;
}
// Check for duplicates
if (urlTags.some((tag) => tag.text === text)) {
toast("Duplicate URL", {
description: "This URL has already been added",
toast(t('duplicate_url_toast'), {
description: t('duplicate_url_toast_desc'),
});
return;
}
@ -132,19 +134,19 @@ export default function WebpageCrawler() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Add Webpages for Crawling
{t('title')}
</CardTitle>
<CardDescription>Enter URLs to crawl and add to your document collection</CardDescription>
<CardDescription>{t('subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="url-input">Enter URLs to crawl</Label>
<Label htmlFor="url-input">{t('label')}</Label>
<TagInput
id="url-input"
tags={urlTags}
setTags={setUrlTags}
placeholder="Enter a URL and press Enter"
placeholder={t('placeholder')}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
@ -160,19 +162,19 @@ export default function WebpageCrawler() {
setActiveTagIndex={setActiveTagIndex}
/>
<p className="text-xs text-muted-foreground mt-1">
Add multiple URLs by pressing Enter after each one
{t('hint')}
</p>
</div>
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
<div className="bg-muted/50 rounded-lg p-4 text-sm">
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
<h4 className="font-medium mb-2">{t('tips_title')}</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Enter complete URLs including http:// or https://</li>
<li>Make sure the websites allow crawling</li>
<li>Public webpages work best</li>
<li>Crawling may take some time depending on the website size</li>
<li>{t('tip_1')}</li>
<li>{t('tip_2')}</li>
<li>{t('tip_3')}</li>
<li>{t('tip_4')}</li>
</ul>
</div>
</div>
@ -182,16 +184,16 @@ export default function WebpageCrawler() {
variant="outline"
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
>
Cancel
{t('cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting || urlTags.length === 0}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
{t('submitting')}
</>
) : (
"Submit URLs for Crawling"
t('submit')
)}
</Button>
</CardFooter>

View file

@ -7,6 +7,7 @@ import { motion, type Variants } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Card,
@ -23,6 +24,7 @@ const youtubeRegex =
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
export default function YouTubeVideoAdder() {
const t = useTranslations('add_youtube');
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
@ -47,14 +49,14 @@ export default function YouTubeVideoAdder() {
const handleSubmit = async () => {
// Validate that we have at least one video URL
if (videoTags.length === 0) {
setError("Please add at least one YouTube video URL");
setError(t('error_no_video'));
return;
}
// Validate all URLs
const invalidUrls = videoTags.filter((tag) => !isValidYoutubeUrl(tag.text));
if (invalidUrls.length > 0) {
setError(`Invalid YouTube URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
setError(t('error_invalid_urls', { urls: invalidUrls.map((tag) => tag.text).join(", ") }));
return;
}
@ -62,8 +64,8 @@ export default function YouTubeVideoAdder() {
setIsSubmitting(true);
try {
toast("YouTube Video Processing", {
description: "Starting YouTube video processing...",
toast(t('processing_toast'), {
description: t('processing_toast_desc'),
});
// Extract URLs from tags
@ -92,16 +94,16 @@ export default function YouTubeVideoAdder() {
await response.json();
toast("Processing Successful", {
description: "YouTube videos have been submitted for processing",
toast(t('success_toast'), {
description: t('success_toast_desc'),
});
// Redirect to documents page
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setError(error.message || "An error occurred while processing YouTube videos");
toast("Processing Error", {
description: `Error processing YouTube videos: ${error.message}`,
setError(error.message || t('error_generic'));
toast(t('error_toast'), {
description: `${t('error_toast_desc')}: ${error.message}`,
});
} finally {
setIsSubmitting(false);
@ -112,16 +114,16 @@ export default function YouTubeVideoAdder() {
const handleAddTag = (text: string) => {
// Basic URL validation
if (!isValidYoutubeUrl(text)) {
toast("Invalid YouTube URL", {
description: "Please enter a valid YouTube video URL",
toast(t('invalid_url_toast'), {
description: t('invalid_url_toast_desc'),
});
return;
}
// Check for duplicates
if (videoTags.some((tag) => tag.text === text)) {
toast("Duplicate URL", {
description: "This YouTube video has already been added",
toast(t('duplicate_url_toast'), {
description: t('duplicate_url_toast_desc'),
});
return;
}
@ -167,10 +169,10 @@ export default function YouTubeVideoAdder() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconBrandYoutube className="h-5 w-5" />
Add YouTube Videos
{t('title')}
</CardTitle>
<CardDescription>
Enter YouTube video URLs to add to your document collection
{t('subtitle')}
</CardDescription>
</CardHeader>
</motion.div>
@ -179,12 +181,12 @@ export default function YouTubeVideoAdder() {
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="video-input">Enter YouTube Video URLs</Label>
<Label htmlFor="video-input">{t('label')}</Label>
<TagInput
id="video-input"
tags={videoTags}
setTags={setVideoTags}
placeholder="Enter a YouTube URL and press Enter"
placeholder={t('placeholder')}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
@ -200,7 +202,7 @@ export default function YouTubeVideoAdder() {
setActiveTagIndex={setActiveTagIndex}
/>
<p className="text-xs text-muted-foreground mt-1">
Add multiple YouTube URLs by pressing Enter after each one
{t('hint')}
</p>
</div>
@ -216,18 +218,18 @@ export default function YouTubeVideoAdder() {
)}
<motion.div variants={itemVariants} className="bg-muted/50 rounded-lg p-4 text-sm">
<h4 className="font-medium mb-2">Tips for adding YouTube videos:</h4>
<h4 className="font-medium mb-2">{t('tips_title')}</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)</li>
<li>Make sure videos are publicly accessible</li>
<li>Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID</li>
<li>Processing may take some time depending on video length</li>
<li>{t('tip_1')}</li>
<li>{t('tip_2')}</li>
<li>{t('tip_3')}</li>
<li>{t('tip_4')}</li>
</ul>
</motion.div>
{videoTags.length > 0 && (
<motion.div variants={itemVariants} className="mt-4 space-y-2">
<h4 className="font-medium">Preview:</h4>
<h4 className="font-medium">{t('preview')}:</h4>
<div className="grid grid-cols-1 gap-3">
{videoTags.map((tag, index) => {
const videoId = extractVideoId(tag.text);
@ -263,7 +265,7 @@ export default function YouTubeVideoAdder() {
variant="outline"
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
>
Cancel
{t('cancel')}
</Button>
<Button
onClick={handleSubmit}
@ -273,7 +275,7 @@ export default function YouTubeVideoAdder() {
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
{t('processing')}
</>
) : (
<>
@ -285,7 +287,7 @@ export default function YouTubeVideoAdder() {
>
<IconBrandYoutube className="h-4 w-4" />
</motion.span>
Submit YouTube Videos
{t('submit')}
</>
)}
<motion.div

View file

@ -45,6 +45,7 @@ import { AnimatePresence, motion, type Variants } from "motion/react";
import { useParams } from "next/navigation";
import React, { useContext, useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
import {
AlertDialog,
@ -171,7 +172,7 @@ function MessageDetails({
);
}
const columns: ColumnDef<Log>[] = [
const createColumns = (t: (key: string) => string): ColumnDef<Log>[] => [
{
id: "select",
header: ({ table }) => (
@ -195,7 +196,7 @@ const columns: ColumnDef<Log>[] = [
enableHiding: false,
},
{
header: "Level",
header: t('level'),
accessorKey: "level",
cell: ({ row }) => {
const level = row.getValue("level") as LogLevel;
@ -219,7 +220,7 @@ const columns: ColumnDef<Log>[] = [
size: 120,
},
{
header: "Status",
header: t('status'),
accessorKey: "status",
cell: ({ row }) => {
const status = row.getValue("status") as LogStatus;
@ -245,7 +246,7 @@ const columns: ColumnDef<Log>[] = [
size: 140,
},
{
header: "Source",
header: t('source'),
accessorKey: "source",
cell: ({ row }) => {
const source = row.getValue("source") as string;
@ -256,14 +257,14 @@ const columns: ColumnDef<Log>[] = [
transition={{ type: "spring", stiffness: 300 }}
>
<Terminal size={14} className="text-muted-foreground" />
<span className="text-sm font-mono">{source || "System"}</span>
<span className="text-sm font-mono">{source || t('system')}</span>
</motion.div>
);
},
size: 150,
},
{
header: "Message",
header: t('message'),
accessorKey: "message",
cell: ({ row }) => {
const message = row.getValue("message") as string;
@ -296,7 +297,7 @@ const columns: ColumnDef<Log>[] = [
size: 400,
},
{
header: "Created At",
header: t('created_at'),
accessorKey: "created_at",
cell: ({ row }) => {
const date = new Date(row.getValue("created_at"));
@ -311,13 +312,16 @@ const columns: ColumnDef<Log>[] = [
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => <LogRowActions row={row} />,
header: () => <span className="sr-only">{t('actions')}</span>,
cell: ({ row }) => <LogRowActions row={row} t={t} />,
size: 60,
enableHiding: false,
},
];
// Default columns for backward compatibility
const columns: ColumnDef<Log>[] = createColumns((key) => key);
// Create a context to share functions
const LogsContext = React.createContext<{
deleteLog: (id: number) => Promise<boolean>;
@ -325,6 +329,7 @@ const LogsContext = React.createContext<{
} | null>(null);
export default function LogsManagePage() {
const t = useTranslations('logs');
const id = useId();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
@ -358,9 +363,12 @@ export default function LogsManagePage() {
const inputRef = useRef<HTMLInputElement>(null);
// Create translated columns
const translatedColumns = useMemo(() => createColumns(t), [t]);
const table = useReactTable({
data: logs,
columns,
columns: translatedColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
@ -454,12 +462,12 @@ export default function LogsManagePage() {
transition={{ delay: 0.1 }}
>
<div>
<h2 className="text-2xl font-bold tracking-tight">Task Logs</h2>
<p className="text-muted-foreground">Monitor and analyze all task execution logs</p>
<h2 className="text-2xl font-bold tracking-tight">{t('title')}</h2>
<p className="text-muted-foreground">{t('subtitle')}</p>
</div>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
{t('refresh')}
</Button>
</motion.div>
@ -484,7 +492,7 @@ export default function LogsManagePage() {
<AlertDialogTrigger asChild>
<Button variant="outline">
<Trash className="-ms-1 me-2 opacity-60" size={16} strokeWidth={2} />
Delete Selected
{t('delete_selected')}
<span className="-me-1 ms-3 inline-flex h-5 max-h-full items-center rounded border border-border bg-background px-1 font-[inherit] text-[0.625rem] font-medium text-muted-foreground/70">
{table.getSelectedRowModel().rows.length}
</span>
@ -496,16 +504,15 @@ export default function LogsManagePage() {
<CircleAlert className="opacity-80" size={16} strokeWidth={2} />
</div>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete{" "}
{table.getSelectedRowModel().rows.length} selected log(s).
</AlertDialogDescription>
<AlertDialogTitle>{t('confirm_title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('confirm_delete_desc', { count: table.getSelectedRowModel().rows.length })}
</AlertDialogDescription>
</AlertDialogHeader>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteRows}>Delete</AlertDialogAction>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteRows}>{t('delete')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@ -520,6 +527,7 @@ export default function LogsManagePage() {
error={logsError}
onRefresh={refreshLogs}
id={id}
t={t}
/>
</motion.div>
</LogsContext.Provider>
@ -538,6 +546,7 @@ function LogsSummaryDashboard({
error: string | null;
onRefresh: () => void;
}) {
const t = useTranslations('logs');
if (loading) {
return (
<motion.div
@ -565,9 +574,9 @@ function LogsSummaryDashboard({
<CardContent className="flex items-center justify-center h-32">
<div className="flex flex-col items-center gap-2">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">Failed to load summary</p>
<p className="text-sm text-destructive">{t('failed_load_summary')}</p>
<Button variant="outline" size="sm" onClick={onRefresh}>
Retry
{t('retry')}
</Button>
</div>
</CardContent>
@ -586,12 +595,12 @@ function LogsSummaryDashboard({
<motion.div variants={fadeInScale}>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Logs</CardTitle>
<CardTitle className="text-sm font-medium">{t('total_logs')}</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{summary.total_logs}</div>
<p className="text-xs text-muted-foreground">Last {summary.time_window_hours} hours</p>
<p className="text-xs text-muted-foreground">{t('last_hours', { hours: summary.time_window_hours })}</p>
</CardContent>
</Card>
</motion.div>
@ -600,14 +609,14 @@ function LogsSummaryDashboard({
<motion.div variants={fadeInScale}>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Tasks</CardTitle>
<CardTitle className="text-sm font-medium">{t('active_tasks')}</CardTitle>
<Clock className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">
{summary.active_tasks?.length || 0}
</div>
<p className="text-xs text-muted-foreground">Currently running</p>
<p className="text-xs text-muted-foreground">{t('currently_running')}</p>
</CardContent>
</Card>
</motion.div>
@ -616,7 +625,7 @@ function LogsSummaryDashboard({
<motion.div variants={fadeInScale}>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
<CardTitle className="text-sm font-medium">{t('success_rate')}</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
@ -627,7 +636,7 @@ function LogsSummaryDashboard({
%
</div>
<p className="text-xs text-muted-foreground">
{summary.by_status?.SUCCESS || 0} successful
{summary.by_status?.SUCCESS || 0} {t('successful')}
</p>
</CardContent>
</Card>
@ -637,14 +646,14 @@ function LogsSummaryDashboard({
<motion.div variants={fadeInScale}>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Recent Failures</CardTitle>
<CardTitle className="text-sm font-medium">{t('recent_failures')}</CardTitle>
<AlertCircle className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{summary.recent_failures?.length || 0}
</div>
<p className="text-xs text-muted-foreground">Need attention</p>
<p className="text-xs text-muted-foreground">{t('need_attention')}</p>
</CardContent>
</Card>
</motion.div>
@ -666,6 +675,7 @@ function LogsFilters({
inputRef: React.RefObject<HTMLInputElement | null>;
id: string;
}) {
const t = useTranslations('logs');
return (
<motion.div
className="flex flex-wrap items-center justify-between gap-3"
@ -684,7 +694,7 @@ function LogsFilters({
)}
value={(table.getColumn("message")?.getFilterValue() ?? "") as string}
onChange={(e) => table.getColumn("message")?.setFilterValue(e.target.value)}
placeholder="Filter by message..."
placeholder={t('filter_by_message')}
type="text"
/>
<div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80">
@ -707,18 +717,20 @@ function LogsFilters({
{/* Level Filter */}
<FilterDropdown
title="Level"
title={t('level')}
column={table.getColumn("level")}
options={uniqueLevels}
id={`${id}-level`}
t={t}
/>
{/* Status Filter */}
<FilterDropdown
title="Status"
title={t('status')}
column={table.getColumn("status")}
options={uniqueStatuses}
id={`${id}-status`}
t={t}
/>
{/* Column Visibility */}
@ -726,11 +738,11 @@ function LogsFilters({
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Columns3 className="-ms-1 me-2 opacity-60" size={16} strokeWidth={2} />
View
{t('view')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuLabel>{t('toggle_columns')}</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column: any) => column.getCanHide())
@ -758,11 +770,13 @@ function FilterDropdown({
column,
options,
id,
t,
}: {
title: string;
column: any;
options: string[];
id: string;
t: (key: string) => string;
}) {
const selectedValues = useMemo(() => {
const filterValue = column?.getFilterValue() as string[];
@ -800,7 +814,7 @@ function FilterDropdown({
</PopoverTrigger>
<PopoverContent className="min-w-36 p-3" align="start">
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground">Filter by {title}</div>
<div className="text-xs font-medium text-muted-foreground">{t('filter_by')} {title}</div>
<div className="space-y-2">
{options.map((value, i) => (
<div key={value} className="flex items-center gap-2">
@ -829,6 +843,7 @@ function LogsTable({
error,
onRefresh,
id,
t,
}: {
table: any;
logs: Log[];
@ -836,6 +851,7 @@ function LogsTable({
error: string | null;
onRefresh: () => void;
id: string;
t: (key: string, params?: any) => string;
}) {
if (loading) {
return (
@ -881,12 +897,12 @@ function LogsTable({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Terminal className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No logs found</p>
</div>
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Terminal className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t('no_logs')}</p>
</div>
</div>
</motion.div>
);
}
@ -979,26 +995,26 @@ function LogsTable({
})}
</motion.tr>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No logs found.
</TableCell>
</TableRow>
)}
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{t('no_logs')}
</TableCell>
</TableRow>
)}
</AnimatePresence>
</TableBody>
</Table>
</motion.div>
{/* Pagination */}
<LogsPagination table={table} id={id} />
<LogsPagination table={table} id={id} t={t} />
</>
);
}
// Pagination Component
function LogsPagination({ table, id }: { table: any; id: string }) {
function LogsPagination({ table, id, t }: { table: any; id: string; t: (key: string) => string }) {
return (
<div className="flex items-center justify-between gap-8 mt-6">
<motion.div
@ -1007,7 +1023,7 @@ function LogsPagination({ table, id }: { table: any; id: string }) {
animate={{ opacity: 1, x: 0 }}
>
<Label htmlFor={id} className="max-sm:sr-only">
Rows per page
{t('rows_per_page')}
</Label>
<Select
value={table.getState().pagination.pageSize.toString()}
@ -1096,7 +1112,7 @@ function LogsPagination({ table, id }: { table: any; id: string }) {
}
// Row Actions Component
function LogRowActions({ row }: { row: Row<Log> }) {
function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }) {
const [isOpen, setIsOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { deleteLog, refreshLogs } = useContext(LogsContext)!;
@ -1106,11 +1122,11 @@ function LogRowActions({ row }: { row: Row<Log> }) {
setIsDeleting(true);
try {
await deleteLog(log.id);
toast.success("Log deleted successfully");
toast.success(t('log_deleted_success'));
await refreshLogs();
} catch (error) {
console.error("Error deleting log:", error);
toast.error("Failed to delete log");
toast.error(t('log_deleted_error'));
} finally {
setIsDeleting(false);
setIsOpen(false);
@ -1131,7 +1147,7 @@ function LogRowActions({ row }: { row: Row<Log> }) {
metadata={log.log_metadata}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
View Metadata
{t('view_metadata')}
</DropdownMenuItem>
}
/>
@ -1145,20 +1161,20 @@ function LogRowActions({ row }: { row: Row<Log> }) {
setIsOpen(true);
}}
>
Delete
{t('delete')}
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogTitle>{t('confirm_delete_log_title')}</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the log entry.
{t('confirm_delete_log_desc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
{isDeleting ? t('deleting') : t('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react"
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { AddProviderStep } from "@/components/onboard/add-provider-step";
import { AssignRolesStep } from "@/components/onboard/assign-roles-step";
@ -16,6 +17,7 @@ import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
const TOTAL_STEPS = 3;
const OnboardPage = () => {
const t = useTranslations('onboard');
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
@ -67,12 +69,12 @@ const OnboardPage = () => {
const progress = (currentStep / TOTAL_STEPS) * 100;
const stepTitles = ["Add LLM Provider", "Assign LLM Roles", "Setup Complete"];
const stepTitles = [t('add_llm_provider'), t('assign_llm_roles'), t('setup_complete')];
const stepDescriptions = [
"Configure your first model provider",
"Assign specific roles to your LLM configurations",
"You're all set to start using SurfSense!",
t('configure_first_provider'),
t('assign_specific_roles'),
t('all_set'),
];
const canProceedToStep2 = !configsLoading && llmConfigs.length > 0;
@ -104,7 +106,7 @@ const OnboardPage = () => {
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardContent className="flex flex-col items-center justify-center py-12">
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
<p className="text-sm text-muted-foreground">Loading your configuration...</p>
<p className="text-sm text-muted-foreground">{t('loading_config')}</p>
</CardContent>
</Card>
</div>
@ -123,10 +125,10 @@ const OnboardPage = () => {
<div className="text-center mb-8">
<div className="flex items-center justify-center mb-4">
<Logo className="w-12 h-12 mr-3 rounded-full" />
<h1 className="text-3xl font-bold">Welcome to SurfSense</h1>
<h1 className="text-3xl font-bold">{t('welcome_title')}</h1>
</div>
<p className="text-muted-foreground text-lg">
Let's configure your LLM configurations to get started
{t('welcome_subtitle')}
</p>
</div>
@ -135,9 +137,9 @@ const OnboardPage = () => {
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="text-sm font-medium">
Step {currentStep} of {TOTAL_STEPS}
{t('step_of', { current: currentStep, total: TOTAL_STEPS })}
</div>
<div className="text-sm text-muted-foreground">{Math.round(progress)}% Complete</div>
<div className="text-sm text-muted-foreground">{t('percent_complete', { percent: Math.round(progress) })}</div>
</div>
<Progress value={progress} className="mb-4" />
<div className="grid grid-cols-3 gap-4">
@ -225,7 +227,7 @@ const OnboardPage = () => {
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Previous
{t('previous')}
</Button>
<div className="flex gap-2">
@ -238,14 +240,14 @@ const OnboardPage = () => {
}
className="flex items-center gap-2"
>
Next
{t('next')}
<ArrowRight className="w-4 h-4" />
</Button>
)}
{currentStep === TOTAL_STEPS && (
<Button onClick={handleComplete} className="flex items-center gap-2">
Complete Setup
{t('complete_setup')}
<CheckCircle className="w-4 h-4" />
</Button>
)}

View file

@ -7,6 +7,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { UserDropdown } from "@/components/UserDropdown";
@ -61,6 +62,7 @@ const formatDate = (dateString: string): string => {
* Loading screen component with animation
*/
const LoadingScreen = () => {
const t = useTranslations('dashboard');
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
<motion.div
@ -70,8 +72,8 @@ const LoadingScreen = () => {
>
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Loading</CardTitle>
<CardDescription>Fetching your search spaces...</CardDescription>
<CardTitle className="text-xl font-medium">{t('loading')}</CardTitle>
<CardDescription>{t('fetching_spaces')}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<motion.div
@ -82,7 +84,7 @@ const LoadingScreen = () => {
</motion.div>
</CardContent>
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
This may take a moment
{t('may_take_moment')}
</CardFooter>
</Card>
</motion.div>
@ -94,6 +96,7 @@ const LoadingScreen = () => {
* Error screen component with animation
*/
const ErrorScreen = ({ message }: { message: string }) => {
const t = useTranslations('dashboard');
const router = useRouter();
return (
@ -107,22 +110,22 @@ const ErrorScreen = ({ message }: { message: string }) => {
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
<CardTitle className="text-xl font-medium">Error</CardTitle>
<CardTitle className="text-xl font-medium">{t('error')}</CardTitle>
</div>
<CardDescription>Something went wrong</CardDescription>
<CardDescription>{t('something_wrong')}</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error Details</AlertTitle>
<AlertTitle>{t('error_details')}</AlertTitle>
<AlertDescription className="mt-2">{message}</AlertDescription>
</Alert>
</CardContent>
<CardFooter className="flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onClick={() => router.refresh()}>
Try Again
{t('try_again')}
</Button>
<Button onClick={() => router.push("/")}>Go Home</Button>
<Button onClick={() => router.push("/")}>{t('go_home')}</Button>
</CardFooter>
</Card>
</motion.div>
@ -131,6 +134,9 @@ const ErrorScreen = ({ message }: { message: string }) => {
};
const DashboardPage = () => {
const t = useTranslations('dashboard');
const tCommon = useTranslations('common');
// Animation variants
const containerVariants: Variants = {
hidden: { opacity: 0 },
@ -239,8 +245,8 @@ const DashboardPage = () => {
<div className="flex flex-row space-x-4">
<Logo className="w-10 h-10 rounded-md" />
<div className="flex flex-col space-y-2">
<h1 className="text-4xl font-bold">SurfSense Dashboard</h1>
<p className="text-muted-foreground">Welcome to your SurfSense dashboard.</p>
<h1 className="text-4xl font-bold">{t('surfsense_dashboard')}</h1>
<p className="text-muted-foreground">{t('welcome_message')}</p>
</div>
</div>
<div className="flex items-center space-x-3">
@ -251,12 +257,12 @@ const DashboardPage = () => {
<div className="flex flex-col space-y-6 mt-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold">Your Search Spaces</h2>
<h2 className="text-2xl font-semibold">{t('your_search_spaces')}</h2>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Link href="/dashboard/searchspaces">
<Button className="h-10">
<Plus className="mr-2 h-4 w-4" />
Create Search Space
{t('create_search_space')}
</Button>
</Link>
</motion.div>
@ -312,20 +318,18 @@ const DashboardPage = () => {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Search Space</AlertDialogTitle>
<AlertDialogTitle>{t('delete_search_space')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{space.name}&quot;? This
action cannot be undone. All documents, chats, and podcasts in
this search space will be permanently deleted.
{t('delete_space_confirm', { name: space.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteSearchSpace(space.id)}
className="bg-destructive hover:bg-destructive/90"
>
Delete
{tCommon('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -343,7 +347,7 @@ const DashboardPage = () => {
</div>
<div className="mt-4 flex justify-between text-xs text-muted-foreground">
{/* <span>{space.title}</span> */}
<span>Created {formatDate(space.created_at)}</span>
<span>{t('created')} {formatDate(space.created_at)}</span>
</div>
</div>
</Link>
@ -360,14 +364,14 @@ const DashboardPage = () => {
<div className="rounded-full bg-muted/50 p-4 mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No search spaces found</h3>
<h3 className="text-lg font-medium mb-2">{t('no_spaces_found')}</h3>
<p className="text-muted-foreground mb-6">
Create your first search space to get started
{t('create_first_space')}
</p>
<Link href="/dashboard/searchspaces">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Search Space
{t('create_search_space')}
</Button>
</Link>
</motion.div>
@ -388,7 +392,7 @@ const DashboardPage = () => {
<Link href="/dashboard/searchspaces" className="flex h-full">
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
<Plus className="h-10 w-10 mb-3 text-muted-foreground" />
<span className="text-sm font-medium">Add New Search Space</span>
<span className="text-sm font-medium">{t('add_new_search_space')}</span>
</div>
</Link>
</Tilt>

View file

@ -5,6 +5,8 @@ import { Roboto } from "next/font/google";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
import { LocaleProvider } from "@/contexts/LocaleContext";
import { I18nProvider } from "@/components/providers/I18nProvider";
const roboto = Roboto({
subsets: ["latin"],
@ -77,25 +79,32 @@ export const metadata: Metadata = {
},
};
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// Using client-side i18n
// Language can be switched dynamically through LanguageSwitcher component
// Locale state is managed by LocaleContext and persisted in localStorage
return (
<html lang="en" suppressHydrationWarning>
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full")}>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme="light"
>
<RootProvider>
{children}
<Toaster />
</RootProvider>
</ThemeProvider>
<LocaleProvider>
<I18nProvider>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme="light"
>
<RootProvider>
{children}
<Toaster />
</RootProvider>
</ThemeProvider>
</I18nProvider>
</LocaleProvider>
</body>
</html>
);

View file

@ -0,0 +1,56 @@
'use client';
import {useLocaleContext} from '@/contexts/LocaleContext';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {Globe} from 'lucide-react';
/**
* Language switcher component
* Allows users to change the application language
* Persists preference in localStorage
*/
export function LanguageSwitcher() {
const {locale, setLocale} = useLocaleContext();
// Supported languages configuration
const languages = [
{code: 'en' as const, name: 'English', flag: '🇺🇸'},
{code: 'zh' as const, name: '简体中文', flag: '🇨🇳'},
];
/**
* Handle language change
* Updates locale in context and localStorage
*/
const handleLanguageChange = (newLocale: string) => {
setLocale(newLocale as 'en' | 'zh');
};
return (
<Select value={locale} onValueChange={handleLanguageChange}>
<SelectTrigger className="w-[160px]">
<Globe className="mr-2 h-4 w-4" />
<SelectValue>
{languages.find(lang => lang.code === locale)?.name || 'English'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{languages.map((language) => (
<SelectItem key={language.code} value={language.code}>
<span className="flex items-center gap-2">
<span>{language.flag}</span>
<span>{language.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View file

@ -2,6 +2,7 @@
import { usePathname } from "next/navigation";
import React from "react";
import { useTranslations } from "next-intl";
import {
Breadcrumb,
BreadcrumbItem,
@ -17,6 +18,7 @@ interface BreadcrumbItemInterface {
}
export function DashboardBreadcrumb() {
const t = useTranslations('breadcrumb');
const pathname = usePathname();
// Parse the pathname to create breadcrumb items
@ -25,11 +27,11 @@ export function DashboardBreadcrumb() {
const breadcrumbs: BreadcrumbItemInterface[] = [];
// Always start with Dashboard
breadcrumbs.push({ label: "Dashboard", href: "/dashboard" });
breadcrumbs.push({ label: t('dashboard'), href: "/dashboard" });
// Handle search space
if (segments[0] === "dashboard" && segments[1]) {
breadcrumbs.push({ label: `Search Space ${segments[1]}`, href: `/dashboard/${segments[1]}` });
breadcrumbs.push({ label: `${t('search_space')} ${segments[1]}`, href: `/dashboard/${segments[1]}` });
// Handle specific sections
if (segments[2]) {
@ -38,12 +40,13 @@ export function DashboardBreadcrumb() {
// Map section names to more readable labels
const sectionLabels: Record<string, string> = {
researcher: "Researcher",
documents: "Documents",
connectors: "Connectors",
podcasts: "Podcasts",
logs: "Logs",
chats: "Chats",
researcher: t('researcher'),
documents: t('documents'),
connectors: t('connectors'),
podcasts: t('podcasts'),
logs: t('logs'),
chats: t('chats'),
settings: t('settings'),
};
sectionLabel = sectionLabels[section] || sectionLabel;
@ -56,14 +59,14 @@ export function DashboardBreadcrumb() {
// Handle documents sub-sections
if (section === "documents") {
const documentLabels: Record<string, string> = {
upload: "Upload Documents",
youtube: "Add YouTube Videos",
webpage: "Add Webpages",
upload: t('upload_documents'),
youtube: t('add_youtube'),
webpage: t('add_webpages'),
};
const documentLabel = documentLabels[subSection] || subSectionLabel;
breadcrumbs.push({
label: "Documents",
label: t('documents'),
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({ label: documentLabel });
@ -105,13 +108,13 @@ export function DashboardBreadcrumb() {
}
const connectorLabels: Record<string, string> = {
add: "Add Connector",
manage: "Manage Connectors",
add: t('add_connector'),
manage: t('manage_connectors'),
};
const connectorLabel = connectorLabels[subSection] || subSectionLabel;
breadcrumbs.push({
label: "Connectors",
label: t('connectors'),
href: `/dashboard/${segments[1]}/connectors`,
});
breadcrumbs.push({ label: connectorLabel });
@ -120,12 +123,12 @@ export function DashboardBreadcrumb() {
// Handle other sub-sections
const subSectionLabels: Record<string, string> = {
upload: "Upload Documents",
youtube: "Add YouTube Videos",
webpage: "Add Webpages",
add: "Add Connector",
edit: "Edit Connector",
manage: "Manage",
upload: t('upload_documents'),
youtube: t('add_youtube'),
webpage: t('add_webpages'),
add: t('add_connector'),
edit: t('edit_connector'),
manage: t('manage'),
};
subSectionLabel = subSectionLabels[subSection] || subSectionLabel;

View file

@ -4,6 +4,7 @@ import { AlertCircle, Bot, Plus, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -34,6 +35,7 @@ export function AddProviderStep({
onConfigCreated,
onConfigDeleted,
}: AddProviderStepProps) {
const t = useTranslations('onboard');
const { llmConfigs, createLLMConfig, deleteLLMConfig } = useLLMConfigs(searchSpaceId);
const [isAddingNew, setIsAddingNew] = useState(false);
const [formData, setFormData] = useState<CreateLLMConfig>({
@ -94,15 +96,14 @@ export function AddProviderStep({
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Add at least one LLM provider to continue. You can configure multiple providers and choose
specific roles for each one in the next step.
{t('add_provider_instruction')}
</AlertDescription>
</Alert>
{/* Existing Configurations */}
{llmConfigs.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Your LLM Configurations</h3>
<h3 className="text-lg font-semibold">{t('your_llm_configs')}</h3>
<div className="grid gap-4">
{llmConfigs.map((config) => (
<motion.div
@ -121,9 +122,9 @@ export function AddProviderStep({
<Badge variant="secondary">{config.provider}</Badge>
</div>
<p className="text-sm text-muted-foreground">
Model: {config.model_name}
{config.language && `Language: ${config.language}`}
{config.api_base && `Base: ${config.api_base}`}
{t('model')}: {config.model_name}
{config.language && `${t('language')}: ${config.language}`}
{config.api_base && `${t('base')}: ${config.api_base}`}
</p>
</div>
<Button
@ -153,32 +154,32 @@ export function AddProviderStep({
<Card className="border-dashed border-2 hover:border-primary/50 transition-colors">
<CardContent className="flex flex-col items-center justify-center py-12">
<Plus className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Add LLM Provider</h3>
<h3 className="text-lg font-semibold mb-2">{t('add_provider_title')}</h3>
<p className="text-muted-foreground text-center mb-4">
Configure your first model provider to get started
{t('add_provider_subtitle')}
</p>
<Button onClick={() => setIsAddingNew(true)}>
<Plus className="w-4 h-4 mr-2" />
Add Provider
{t('add_provider_button')}
</Button>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Add New LLM Provider</CardTitle>
<CardTitle>{t('add_new_llm_provider')}</CardTitle>
<CardDescription>
Configure a new language model provider for your AI assistant
{t('configure_new_provider')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Configuration Name *</Label>
<Label htmlFor="name">{t('config_name_required')}</Label>
<Input
id="name"
placeholder="e.g., My OpenAI GPT-4"
placeholder={t('config_name_placeholder')}
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
required
@ -186,13 +187,13 @@ export function AddProviderStep({
</div>
<div className="space-y-2">
<Label htmlFor="provider">Provider *</Label>
<Label htmlFor="provider">{t('provider_required')}</Label>
<Select
value={formData.provider}
onValueChange={(value) => handleInputChange("provider", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
<SelectValue placeholder={t('provider_placeholder')} />
</SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map((provider) => (
@ -206,13 +207,13 @@ export function AddProviderStep({
{/* language */}
<div className="space-y-2">
<Label htmlFor="language">Language (Optional)</Label>
<Label htmlFor="language">{t('language_optional')}</Label>
<Select
value={formData.language || "English"}
onValueChange={(value) => handleInputChange("language", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select language" />
<SelectValue placeholder={t('language_placeholder')} />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((language) => (
@ -227,10 +228,10 @@ export function AddProviderStep({
{formData.provider === "CUSTOM" && (
<div className="space-y-2">
<Label htmlFor="custom_provider">Custom Provider Name *</Label>
<Label htmlFor="custom_provider">{t('custom_provider_name')}</Label>
<Input
id="custom_provider"
placeholder="e.g., my-custom-provider"
placeholder={t('custom_provider_placeholder')}
value={formData.custom_provider}
onChange={(e) => handleInputChange("custom_provider", e.target.value)}
required
@ -239,27 +240,27 @@ export function AddProviderStep({
)}
<div className="space-y-2">
<Label htmlFor="model_name">Model Name *</Label>
<Label htmlFor="model_name">{t('model_name_required')}</Label>
<Input
id="model_name"
placeholder={selectedProvider?.example || "e.g., gpt-4"}
placeholder={selectedProvider?.example || t('model_name_placeholder')}
value={formData.model_name}
onChange={(e) => handleInputChange("model_name", e.target.value)}
required
/>
{selectedProvider && (
<p className="text-xs text-muted-foreground">
Examples: {selectedProvider.example}
{t('examples')}: {selectedProvider.example}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="api_key">API Key *</Label>
<Label htmlFor="api_key">{t('api_key_required')}</Label>
<Input
id="api_key"
type="password"
placeholder="Your API key"
placeholder={t('api_key_placeholder')}
value={formData.api_key}
onChange={(e) => handleInputChange("api_key", e.target.value)}
required
@ -267,10 +268,10 @@ export function AddProviderStep({
</div>
<div className="space-y-2">
<Label htmlFor="api_base">API Base URL (Optional)</Label>
<Label htmlFor="api_base">{t('api_base_optional')}</Label>
<Input
id="api_base"
placeholder="e.g., https://api.openai.com/v1"
placeholder={t('api_base_placeholder')}
value={formData.api_base}
onChange={(e) => handleInputChange("api_base", e.target.value)}
/>
@ -286,7 +287,7 @@ export function AddProviderStep({
<div className="flex gap-2 pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Adding..." : "Add Provider"}
{isSubmitting ? t('adding') : t('add_provider')}
</Button>
<Button
type="button"
@ -294,7 +295,7 @@ export function AddProviderStep({
onClick={() => setIsAddingNew(false)}
disabled={isSubmitting}
>
Cancel
{t('cancel')}
</Button>
</div>
</form>

View file

@ -3,6 +3,7 @@
import { AlertCircle, Bot, Brain, CheckCircle, Zap } from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -16,39 +17,40 @@ import {
} from "@/components/ui/select";
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
const ROLE_DESCRIPTIONS = {
long_context: {
icon: Brain,
title: "Long Context LLM",
description: "Handles complex tasks requiring extensive context understanding and reasoning",
color: "bg-blue-100 text-blue-800 border-blue-200",
examples: "Document analysis, research synthesis, complex Q&A",
},
fast: {
icon: Zap,
title: "Fast LLM",
description: "Optimized for quick responses and real-time interactions",
color: "bg-green-100 text-green-800 border-green-200",
examples: "Quick searches, simple questions, instant responses",
},
strategic: {
icon: Bot,
title: "Strategic LLM",
description: "Advanced reasoning for planning and strategic decision making",
color: "bg-purple-100 text-purple-800 border-purple-200",
examples: "Planning workflows, strategic analysis, complex problem solving",
},
};
interface AssignRolesStepProps {
searchSpaceId: number;
onPreferencesUpdated?: () => Promise<void>;
}
export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignRolesStepProps) {
const t = useTranslations('onboard');
const { llmConfigs } = useLLMConfigs(searchSpaceId);
const { preferences, updatePreferences } = useLLMPreferences(searchSpaceId);
const ROLE_DESCRIPTIONS = {
long_context: {
icon: Brain,
title: t('long_context_llm_title'),
description: t('long_context_llm_desc'),
color: "bg-blue-100 text-blue-800 border-blue-200",
examples: t('long_context_llm_examples'),
},
fast: {
icon: Zap,
title: t('fast_llm_title'),
description: t('fast_llm_desc'),
color: "bg-green-100 text-green-800 border-green-200",
examples: t('fast_llm_examples'),
},
strategic: {
icon: Bot,
title: t('strategic_llm_title'),
description: t('strategic_llm_desc'),
color: "bg-purple-100 text-purple-800 border-purple-200",
examples: t('strategic_llm_examples'),
},
};
const [assignments, setAssignments] = useState({
long_context_llm_id: preferences.long_context_llm_id || "",
fast_llm_id: preferences.fast_llm_id || "",
@ -109,9 +111,9 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
return (
<div className="flex flex-col items-center justify-center py-12">
<AlertCircle className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No LLM Configurations Found</h3>
<h3 className="text-lg font-semibold mb-2">{t('no_llm_configs_found')}</h3>
<p className="text-muted-foreground text-center">
Please add at least one LLM provider in the previous step before assigning roles.
{t('add_provider_before_roles')}
</p>
</div>
);
@ -123,8 +125,7 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Assign your LLM configurations to specific roles. Each role serves different purposes in
your workflow.
{t('assign_roles_instruction')}
</AlertDescription>
</Alert>
@ -161,17 +162,17 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground">
<strong>Use cases:</strong> {role.examples}
<strong>{t('use_cases')}:</strong> {role.examples}
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Assign LLM Configuration:</Label>
<Label className="text-sm font-medium">{t('assign_llm_config')}:</Label>
<Select
value={currentAssignment?.toString() || ""}
onValueChange={(value) => handleRoleAssignment(`${key}_llm_id`, value)}
>
<SelectTrigger>
<SelectValue placeholder="Select an LLM configuration" />
<SelectValue placeholder={t('select_llm_config')} />
</SelectTrigger>
<SelectContent>
{llmConfigs
@ -195,12 +196,12 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
<div className="mt-3 p-3 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-sm">
<Bot className="w-4 h-4" />
<span className="font-medium">Assigned:</span>
<span className="font-medium">{t('assigned')}:</span>
<Badge variant="secondary">{assignedConfig.provider}</Badge>
<span>{assignedConfig.name}</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
Model: {assignedConfig.model_name}
{t('model')}: {assignedConfig.model_name}
</div>
</div>
)}
@ -216,7 +217,7 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
<div className="flex justify-center pt-4">
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 text-green-700 rounded-lg border border-green-200">
<CheckCircle className="w-4 h-4" />
<span className="text-sm font-medium">All roles assigned and saved!</span>
<span className="text-sm font-medium">{t('all_roles_assigned_saved')}</span>
</div>
</div>
)}
@ -224,7 +225,7 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
{/* Progress Indicator */}
<div className="flex justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Progress:</span>
<span>{t('progress')}:</span>
<div className="flex gap-1">
{Object.keys(ROLE_DESCRIPTIONS).map((key, _index) => (
<div
@ -238,8 +239,10 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
))}
</div>
<span>
{Object.values(assignments).filter(Boolean).length} of{" "}
{Object.keys(ROLE_DESCRIPTIONS).length} roles assigned
{t('roles_assigned', {
assigned: Object.values(assignments).filter(Boolean).length,
total: Object.keys(ROLE_DESCRIPTIONS).length
})}
</span>
</div>
</div>

View file

@ -0,0 +1,19 @@
'use client';
import { NextIntlClientProvider } from 'next-intl';
import { useLocaleContext } from '@/contexts/LocaleContext';
/**
* I18n Provider component
* Wraps NextIntlClientProvider with dynamic locale and messages from LocaleContext
*/
export function I18nProvider({ children }: { children: React.ReactNode }) {
const { locale, messages } = useLocaleContext();
return (
<NextIntlClientProvider messages={messages} locale={locale}>
{children}
</NextIntlClientProvider>
);
}

View file

@ -2,6 +2,7 @@
import { Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { Button } from "@/components/ui/button";
import {
@ -55,6 +56,8 @@ export function AppSidebarProvider({
navSecondary,
navMain,
}: AppSidebarProviderProps) {
const t = useTranslations('dashboard');
const tCommon = useTranslations('common');
const [recentChats, setRecentChats] = useState<
{
name: string;
@ -196,14 +199,14 @@ export function AppSidebarProvider({
if (chatError) {
return [
{
name: "Error loading chats",
name: t('error_loading_chats'),
url: "#",
icon: "AlertCircle",
id: 0,
search_space_id: Number(searchSpaceId),
actions: [
{
name: "Retry",
name: tCommon('retry'),
icon: "RefreshCw",
onClick: retryFetch,
},
@ -215,7 +218,7 @@ export function AppSidebarProvider({
if (!isLoadingChats && recentChats.length === 0) {
return [
{
name: "No recent chats",
name: t('no_recent_chats'),
url: "#",
icon: "MessageCircleMore",
id: 0,
@ -226,7 +229,7 @@ export function AppSidebarProvider({
}
return [];
}, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch]);
}, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch, t, tCommon]);
// Use fallback chats if there's an error or no chats
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
@ -240,14 +243,14 @@ export function AppSidebarProvider({
title:
searchSpace?.name ||
(isLoadingSearchSpace
? "Loading..."
? tCommon('loading')
: searchSpaceError
? "Error loading search space"
: "Unknown Search Space"),
? t('error_loading_space')
: t('unknown_search_space')),
};
}
return updated;
}, [navSecondary, isClient, searchSpace?.name, isLoadingSearchSpace, searchSpaceError]);
}, [navSecondary, isClient, searchSpace?.name, isLoadingSearchSpace, searchSpaceError, t, tCommon]);
// Show loading state if not client-side
if (!isClient) {
@ -264,12 +267,11 @@ export function AppSidebarProvider({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>Delete Chat</span>
<span>{t('delete_chat')}</span>
</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-medium">{chatToDelete?.name}</span>? This action cannot be
undone.
{t('delete_chat_confirm')}{" "}
<span className="font-medium">{chatToDelete?.name}</span>? {t('action_cannot_undone')}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
@ -278,7 +280,7 @@ export function AppSidebarProvider({
onClick={() => setShowDeleteDialog(false)}
disabled={isDeleting}
>
Cancel
{tCommon('cancel')}
</Button>
<Button
variant="destructive"
@ -289,12 +291,12 @@ export function AppSidebarProvider({
{isDeleting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Deleting...
{t('deleting')}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Delete
{tCommon('delete')}
</>
)}
</Button>

View file

@ -2,6 +2,7 @@
import { ChevronRight, type LucideIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
@ -28,57 +29,87 @@ interface NavItem {
}
export function NavMain({ items }: { items: NavItem[] }) {
const t = useTranslations('nav_menu');
// Translation function that handles both exact matches and fallback to original
const translateTitle = (title: string): string => {
const titleMap: Record<string, string> = {
'Researcher': 'researcher',
'Manage LLMs': 'manage_llms',
'Documents': 'documents',
'Upload Documents': 'upload_documents',
'Add Webpages': 'add_webpages',
'Add Youtube Videos': 'add_youtube',
'Manage Documents': 'manage_documents',
'Connectors': 'connectors',
'Add Connector': 'add_connector',
'Manage Connectors': 'manage_connectors',
'Podcasts': 'podcasts',
'Logs': 'logs',
'Platform': 'platform',
};
const key = titleMap[title];
return key ? t(key) : title;
};
// Memoize items to prevent unnecessary re-renders
const memoizedItems = useMemo(() => items, [items]);
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>{translateTitle('Platform')}</SidebarGroupLabel>
<SidebarMenu>
{memoizedItems.map((item, index) => (
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={item.title}
isActive={item.isActive}
aria-label={`${item.title}${item.items?.length ? " with submenu" : ""}`}
>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
{memoizedItems.map((item, index) => {
const translatedTitle = translateTitle(item.title);
return (
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={translatedTitle}
isActive={item.isActive}
aria-label={`${translatedTitle}${item.items?.length ? " with submenu" : ""}`}
>
<a href={item.url}>
<item.icon />
<span>{translatedTitle}</span>
</a>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction
className="data-[state=open]:rotate-90 transition-transform duration-200"
aria-label={`Toggle ${item.title} submenu`}
>
<ChevronRight />
<span className="sr-only">Toggle submenu</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 duration-200">
<SidebarMenuSub>
{item.items?.map((subItem, subIndex) => (
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
<SidebarMenuSubButton asChild aria-label={subItem.title}>
<a href={subItem.url}>
<span>{subItem.title}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
))}
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction
className="data-[state=open]:rotate-90 transition-transform duration-200"
aria-label={`Toggle ${translatedTitle} submenu`}
>
<ChevronRight />
<span className="sr-only">Toggle submenu</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 duration-200">
<SidebarMenuSub>
{item.items?.map((subItem, subIndex) => {
const translatedSubTitle = translateTitle(subItem.title);
return (
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
<SidebarMenuSubButton asChild aria-label={translatedSubTitle}>
<a href={subItem.url}>
<span>{translatedSubTitle}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu>
</SidebarGroup>
);

View file

@ -12,6 +12,7 @@ import {
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
DropdownMenuContent,
@ -56,6 +57,7 @@ interface ChatItem {
}
export function NavProjects({ chats }: { chats: ChatItem[] }) {
const t = useTranslations('sidebar');
const { isMobile } = useSidebar();
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
@ -145,13 +147,13 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) {
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarGroupLabel>{t('recent_chats')}</SidebarGroupLabel>
{/* Search Input */}
{showSearch && (
<div className="px-2 pb-2">
<SidebarInput
placeholder="Search chats..."
placeholder={t('search_chats')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
@ -168,7 +170,7 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) {
<SidebarMenuItem>
<SidebarMenuButton disabled className="text-muted-foreground">
<Search className="h-4 w-4" />
<span>{searchQuery ? "No chats found" : "No recent chats"}</span>
<span>{searchQuery ? t('no_chats_found') : t('no_recent_chats')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
@ -178,7 +180,7 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) {
<SidebarMenuItem>
<SidebarMenuButton onClick={() => router.push(`/dashboard/${searchSpaceId}/chats`)}>
<MoreHorizontal />
<span>View All Chats</span>
<span>{t('view_all_chats')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}

View file

@ -3,6 +3,7 @@
import type { LucideIcon } from "lucide-react";
import type * as React from "react";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import {
SidebarGroup,
@ -24,12 +25,14 @@ export function NavSecondary({
}: {
items: NavSecondaryItem[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const t = useTranslations('sidebar');
// Memoize items to prevent unnecessary re-renders
const memoizedItems = useMemo(() => items, [items]);
return (
<SidebarGroup {...props}>
<SidebarGroupLabel>SearchSpace</SidebarGroupLabel>
<SidebarGroupLabel>{t('search_space')}</SidebarGroupLabel>
<SidebarMenu>
{memoizedItems.map((item, index) => (
<SidebarMenuItem key={`${item.title}-${index}`}>

View file

@ -2,5 +2,5 @@
"title": "Setup",
"description": "The setup guide for Surfsense",
"root": true,
"pages": ["---Setup---", "index", "installation", "docker-installation", "manual-installation"]
"pages": ["index", "installation", "docker-installation", "manual-installation"]
}

View file

@ -0,0 +1,70 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import enMessages from '../messages/en.json';
import zhMessages from '../messages/zh.json';
type Locale = 'en' | 'zh';
interface LocaleContextType {
locale: Locale;
messages: typeof enMessages;
setLocale: (locale: Locale) => void;
}
const LocaleContext = createContext<LocaleContextType | undefined>(undefined);
const LOCALE_STORAGE_KEY = 'surfsense-locale';
export function LocaleProvider({ children }: { children: React.ReactNode }) {
// Always start with 'en' to avoid hydration mismatch
// Then sync with localStorage after mount
const [locale, setLocaleState] = useState<Locale>('en');
const [mounted, setMounted] = useState(false);
// Get messages based on current locale
const messages = locale === 'zh' ? zhMessages : enMessages;
// Load locale from localStorage after component mounts (client-side only)
useEffect(() => {
setMounted(true);
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored === 'zh') {
setLocaleState('zh');
}
}
}, []);
// Update locale and persist to localStorage
const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale);
if (typeof window !== 'undefined') {
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
// Update HTML lang attribute
document.documentElement.lang = newLocale;
}
};
// Set HTML lang attribute when locale changes
useEffect(() => {
if (typeof window !== 'undefined' && mounted) {
document.documentElement.lang = locale;
}
}, [locale, mounted]);
return (
<LocaleContext.Provider value={{ locale, messages, setLocale }}>
{children}
</LocaleContext.Provider>
);
}
export function useLocaleContext() {
const context = useContext(LocaleContext);
if (context === undefined) {
throw new Error('useLocaleContext must be used within a LocaleProvider');
}
return context;
}

View file

@ -0,0 +1,22 @@
import {getRequestConfig} from 'next-intl/server';
import {routing} from './routing';
/**
* Configuration for internationalization request handling
* This function is called for each request to determine the locale and load translations
*/
export default getRequestConfig(async ({requestLocale}) => {
// This typically corresponds to the `[locale]` segment
let locale = await requestLocale;
// Ensure that the incoming `locale` is valid
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
};
});

View file

@ -0,0 +1,24 @@
import {defineRouting} from 'next-intl/routing';
import {createNavigation} from 'next-intl/navigation';
/**
* Internationalization routing configuration
* Defines supported locales and routing behavior for the application
*/
export const routing = defineRouting({
// A list of all locales that are supported
locales: ['en', 'zh'],
// Used when no locale matches
defaultLocale: 'en',
// The `localePrefix` setting controls whether the locale is included in the pathname
// 'as-needed': Only add locale prefix when not using the default locale
localePrefix: 'as-needed'
});
// Lightweight wrappers around Next.js' navigation APIs
// that will consider the routing configuration
export const {Link, redirect, usePathname, useRouter, getPathname} =
createNavigation(routing);

View file

@ -0,0 +1,692 @@
{
"common": {
"app_name": "SurfSense",
"welcome": "Welcome",
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"update": "Update",
"search": "Search",
"close": "Close",
"confirm": "Confirm",
"back": "Back",
"next": "Next",
"submit": "Submit",
"yes": "Yes",
"no": "No",
"add": "Add",
"remove": "Remove",
"select": "Select",
"all": "All",
"none": "None",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Information",
"required": "Required",
"optional": "Optional",
"retry": "Retry"
},
"auth": {
"login": "Login",
"register": "Register",
"logout": "Logout",
"email": "Email",
"password": "Password",
"confirm_password": "Confirm Password",
"forgot_password": "Forgot Password?",
"show_password": "Show password",
"hide_password": "Hide password",
"remember_me": "Remember Me",
"sign_in": "Sign In",
"sign_up": "Sign Up",
"sign_in_with": "Sign in with {provider}",
"dont_have_account": "Don't have an account?",
"already_have_account": "Already have an account?",
"reset_password": "Reset Password",
"email_required": "Email is required",
"password_required": "Password is required",
"invalid_email": "Invalid email address",
"password_too_short": "Password must be at least 8 characters",
"welcome_back": "Welcome back",
"create_account": "Create your account",
"login_subtitle": "Enter your credentials to access your account",
"register_subtitle": "Sign up to get started with SurfSense",
"or_continue_with": "Or continue with",
"by_continuing": "By continuing, you agree to our",
"terms_of_service": "Terms of Service",
"and": "and",
"privacy_policy": "Privacy Policy",
"full_name": "Full Name",
"username": "Username",
"continue": "Continue",
"back_to_login": "Back to Login",
"login_success": "Successfully logged in",
"register_success": "Account created successfully",
"continue_with_google": "Continue with Google",
"cloud_dev_notice": "SurfSense Cloud is currently in development. Check",
"docs": "Docs",
"cloud_dev_self_hosted": "for more information on Self-Hosted version.",
"passwords_no_match": "Passwords do not match",
"password_mismatch": "Password Mismatch",
"passwords_no_match_desc": "The passwords you entered do not match",
"creating_account": "Creating your account...",
"creating_account_btn": "Creating account...",
"redirecting_login": "Redirecting to login page..."
},
"dashboard": {
"title": "Dashboard",
"search_spaces": "Search Spaces",
"documents": "Documents",
"connectors": "Connectors",
"settings": "Settings",
"researcher": "Researcher",
"api_keys": "API Keys",
"profile": "Profile",
"loading_dashboard": "Loading Dashboard",
"checking_auth": "Checking authentication...",
"loading_config": "Loading Configuration",
"checking_llm_prefs": "Checking your LLM preferences...",
"config_error": "Configuration Error",
"failed_load_llm_config": "Failed to load your LLM configuration",
"error_loading_chats": "Error loading chats",
"no_recent_chats": "No recent chats",
"error_loading_space": "Error loading search space",
"unknown_search_space": "Unknown Search Space",
"delete_chat": "Delete Chat",
"delete_chat_confirm": "Are you sure you want to delete",
"action_cannot_undone": "This action cannot be undone.",
"deleting": "Deleting...",
"surfsense_dashboard": "SurfSense Dashboard",
"welcome_message": "Welcome to your SurfSense dashboard.",
"your_search_spaces": "Your Search Spaces",
"create_search_space": "Create Search Space",
"add_new_search_space": "Add New Search Space",
"loading": "Loading",
"fetching_spaces": "Fetching your search spaces...",
"may_take_moment": "This may take a moment",
"error": "Error",
"something_wrong": "Something went wrong",
"error_details": "Error Details",
"try_again": "Try Again",
"go_home": "Go Home",
"delete_search_space": "Delete Search Space",
"delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents, chats, and podcasts in this search space will be permanently deleted.",
"no_spaces_found": "No search spaces found",
"create_first_space": "Create your first search space to get started",
"created": "Created"
},
"navigation": {
"home": "Home",
"docs": "Docs",
"pricing": "Pricing",
"contact": "Contact",
"login": "Login",
"register": "Register",
"dashboard": "Dashboard",
"sign_in": "Sign In",
"book_a_call": "Book a call"
},
"nav_menu": {
"platform": "Platform",
"researcher": "Researcher",
"manage_llms": "Manage LLMs",
"documents": "Documents",
"upload_documents": "Upload Documents",
"add_webpages": "Add Webpages",
"add_youtube": "Add Youtube Videos",
"add_youtube_videos": "Add Youtube Videos",
"manage_documents": "Manage Documents",
"connectors": "Connectors",
"add_connector": "Add Connector",
"manage_connectors": "Manage Connectors",
"podcasts": "Podcasts",
"logs": "Logs",
"all_search_spaces": "All Search Spaces"
},
"pricing": {
"title": "SurfSense Pricing",
"subtitle": "Choose that works for you",
"community_name": "COMMUNITY",
"enterprise_name": "ENTERPRISE",
"forever": "forever",
"contact_us": "Contact Us",
"feature_llms": "Supports 100+ LLMs",
"feature_ollama": "Supports local Ollama or vLLM setups",
"feature_embeddings": "6000+ Embedding Models",
"feature_files": "50+ File extensions supported.",
"feature_podcasts": "Podcasts support with local TTS providers.",
"feature_sources": "Connects with 15+ external sources.",
"feature_extension": "Cross-Browser Extension for dynamic webpages including authenticated content",
"upcoming_mindmaps": "Upcoming: Mergeable MindMaps",
"upcoming_notes": "Upcoming: Note Management",
"community_desc": "Open source version with powerful features",
"get_started": "Get Started",
"everything_community": "Everything in Community",
"priority_support": "Priority Support",
"access_controls": "Access Controls",
"collaboration": "Collaboration and multiplayer features",
"video_gen": "Video generation",
"advanced_security": "Advanced security features",
"enterprise_desc": "For large organizations with specific needs",
"contact_sales": "Contact Sales"
},
"contact": {
"title": "Contact",
"subtitle": "We'd love to Hear From You.",
"we_are_here": "We are here",
"full_name": "Full name",
"email_address": "Email Address",
"company": "Company",
"message": "Message",
"optional": "optional",
"name_placeholder": "John Doe",
"email_placeholder": "john.doe@example.com",
"company_placeholder": "Example Inc.",
"message_placeholder": "Type your message here",
"submit": "Submit",
"submitting": "Submitting...",
"name_required": "Name is required",
"name_too_long": "Name is too long",
"invalid_email": "Invalid email address",
"email_too_long": "Email is too long",
"company_required": "Company is required",
"company_too_long": "Company name is too long",
"message_sent": "Message sent successfully!",
"we_will_contact": "We will get back to you as soon as possible.",
"send_failed": "Failed to send message",
"try_again_later": "Please try again later.",
"something_wrong": "Something went wrong"
},
"researcher": {
"loading": "Loading...",
"select_documents": "Select Documents",
"select_documents_desc": "Choose documents to include in your research context",
"loading_documents": "Loading documents...",
"select_connectors": "Select Connectors",
"select_connectors_desc": "Choose which data sources to include in your research",
"clear_all": "Clear All",
"select_all": "Select All",
"scope": "Scope",
"documents": "Documents",
"docs": "Docs",
"chunks": "Chunks",
"mode": "Mode",
"research_mode": "Research Mode",
"mode_qna": "Q&A",
"mode_general": "General Report",
"mode_general_short": "General",
"mode_deep": "Deep Report",
"mode_deep_short": "Deep",
"mode_deeper": "Deeper Report",
"mode_deeper_short": "Deeper",
"fast_llm": "Fast LLM",
"select_llm": "Select LLM",
"fast_llm_selection": "Fast LLM Selection",
"no_llm_configs": "No LLM configurations",
"configure_llm_to_start": "Configure AI models to get started",
"open_settings": "Open Settings",
"start_surfing": "Let's Start Surfing",
"through_knowledge_base": "through your knowledge base.",
"all_connectors": "All Connectors",
"connectors_selected": "{count} Connectors",
"placeholder": "Ask me anything..."
},
"connectors": {
"title": "Connectors",
"subtitle": "Manage your connected services and data sources.",
"add_connector": "Add Connector",
"your_connectors": "Your Connectors",
"view_manage": "View and manage all your connected services.",
"no_connectors": "No connectors found",
"no_connectors_desc": "You haven't added any connectors yet. Add one to enhance your search capabilities.",
"add_first": "Add Your First Connector",
"name": "Name",
"type": "Type",
"last_indexed": "Last Indexed",
"periodic": "Periodic",
"actions": "Actions",
"never": "Never",
"not_indexable": "Not indexable",
"index_date_range": "Index with Date Range",
"quick_index": "Quick Index",
"quick_index_auto": "Quick Index (Auto Date Range)",
"delete_connector": "Delete Connector",
"delete_confirm": "Are you sure you want to delete this connector? This action cannot be undone.",
"select_date_range": "Select Date Range for Indexing",
"select_date_range_desc": "Choose the start and end dates for indexing content. Leave empty to use default range.",
"start_date": "Start Date",
"end_date": "End Date",
"pick_date": "Pick a date",
"clear_dates": "Clear Dates",
"last_30_days": "Last 30 Days",
"last_year": "Last Year",
"start_indexing": "Start Indexing",
"failed_load": "Failed to load connectors",
"delete_success": "Connector deleted successfully",
"delete_failed": "Failed to delete connector",
"indexing_started": "Connector content indexing started",
"indexing_failed": "Failed to index connector content"
},
"documents": {
"title": "Documents",
"subtitle": "Manage your documents and files.",
"no_rows_selected": "No rows selected",
"delete_success_count": "Successfully deleted {count} document(s)",
"delete_partial_failed": "Some documents could not be deleted",
"delete_error": "Error deleting documents",
"filter_by_title": "Filter by title...",
"bulk_delete": "Delete Selected",
"filter_types": "Filter Types",
"columns": "Columns",
"confirm_delete": "Confirm Delete",
"confirm_delete_desc": "Are you sure you want to delete {count} document(s)? This action cannot be undone.",
"uploading": "Uploading...",
"upload_success": "Document uploaded successfully",
"upload_failed": "Failed to upload document",
"loading": "Loading documents...",
"error_loading": "Error loading documents",
"retry": "Retry",
"no_documents": "No documents found",
"type": "Type",
"content_summary": "Content Summary",
"view_full": "View Full Content",
"filter_placeholder": "Filter by title...",
"rows_per_page": "Rows per page"
},
"add_connector": {
"title": "Connect Your Tools",
"subtitle": "Integrate with your favorite services to enhance your research capabilities.",
"search_engines": "Search Engines",
"team_chats": "Team Chats",
"project_management": "Project Management",
"knowledge_bases": "Knowledge Bases",
"communication": "Communication",
"connect": "Connect",
"coming_soon": "Coming Soon",
"connected": "Connected",
"manage": "Manage",
"tavily_desc": "Search the web using the Tavily API",
"searxng_desc": "Use your own SearxNG meta-search instance for web results.",
"linkup_desc": "Search the web using the Linkup API",
"elasticsearch_desc": "Connect to Elasticsearch to index and search documents, logs and metrics.",
"baidu_desc": "Search the Chinese web using Baidu AI Search API",
"slack_desc": "Connect to your Slack workspace to access messages and channels.",
"teams_desc": "Connect to Microsoft Teams to access your team's conversations.",
"discord_desc": "Connect to Discord servers to access messages and channels.",
"linear_desc": "Connect to Linear to search issues, comments and project data.",
"jira_desc": "Connect to Jira to search issues, tickets and project data.",
"clickup_desc": "Connect to ClickUp to search tasks, comments and project data.",
"notion_desc": "Connect to your Notion workspace to access pages and databases.",
"github_desc": "Connect a GitHub PAT to index code and docs from accessible repositories.",
"confluence_desc": "Connect to Confluence to search pages, comments and documentation.",
"airtable_desc": "Connect to Airtable to search records, tables and database content.",
"luma_desc": "Connect to Luma to search events",
"calendar_desc": "Connect to Google Calendar to search events, meetings and schedules.",
"gmail_desc": "Connect to your Gmail account to search through your emails.",
"zoom_desc": "Connect to Zoom to access meeting recordings and transcripts."
},
"upload_documents": {
"title": "Upload Documents",
"subtitle": "Upload your files to make them searchable and accessible through AI-powered conversations.",
"file_size_limit": "Maximum file size: 50MB per file. Supported formats vary based on your ETL service configuration.",
"drop_files": "Drop files here",
"drag_drop": "Drag & drop files here",
"or_browse": "or click to browse",
"browse_files": "Browse Files",
"selected_files": "Selected Files ({count})",
"total_size": "Total size",
"clear_all": "Clear all",
"uploading_files": "Uploading files...",
"uploading": "Uploading...",
"upload_button": "Upload {count} {count, plural, one {file} other {files}}",
"upload_initiated": "Upload Task Initiated",
"upload_initiated_desc": "Files Uploading Initiated",
"upload_error": "Upload Error",
"upload_error_desc": "Error uploading files",
"supported_file_types": "Supported File Types",
"file_types_desc": "These file types are supported based on your current ETL service configuration."
},
"add_webpage": {
"title": "Add Webpages for Crawling",
"subtitle": "Enter URLs to crawl and add to your document collection",
"label": "Enter URLs to crawl",
"placeholder": "Enter a URL and press Enter",
"hint": "Add multiple URLs by pressing Enter after each one",
"tips_title": "Tips for URL crawling:",
"tip_1": "Enter complete URLs including http:// or https://",
"tip_2": "Make sure the websites allow crawling",
"tip_3": "Public webpages work best",
"tip_4": "Crawling may take some time depending on the website size",
"cancel": "Cancel",
"submit": "Submit URLs for Crawling",
"submitting": "Submitting...",
"error_no_url": "Please add at least one URL",
"error_invalid_urls": "Invalid URLs detected: {urls}",
"crawling_toast": "URL Crawling",
"crawling_toast_desc": "Starting URL crawling process...",
"success_toast": "Crawling Successful",
"success_toast_desc": "URLs have been submitted for crawling",
"error_toast": "Crawling Error",
"error_toast_desc": "Error crawling URLs",
"error_generic": "An error occurred while crawling URLs",
"invalid_url_toast": "Invalid URL",
"invalid_url_toast_desc": "Please enter a valid URL",
"duplicate_url_toast": "Duplicate URL",
"duplicate_url_toast_desc": "This URL has already been added"
},
"add_youtube": {
"title": "Add YouTube Videos",
"subtitle": "Enter YouTube video URLs to add to your document collection",
"label": "Enter YouTube Video URLs",
"placeholder": "Enter a YouTube URL and press Enter",
"hint": "Add multiple YouTube URLs by pressing Enter after each one",
"tips_title": "Tips for adding YouTube videos:",
"tip_1": "Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)",
"tip_2": "Make sure videos are publicly accessible",
"tip_3": "Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID",
"tip_4": "Processing may take some time depending on video length",
"preview": "Preview",
"cancel": "Cancel",
"submit": "Submit YouTube Videos",
"processing": "Processing...",
"error_no_video": "Please add at least one YouTube video URL",
"error_invalid_urls": "Invalid YouTube URLs detected: {urls}",
"processing_toast": "YouTube Video Processing",
"processing_toast_desc": "Starting YouTube video processing...",
"success_toast": "Processing Successful",
"success_toast_desc": "YouTube videos have been submitted for processing",
"error_toast": "Processing Error",
"error_toast_desc": "Error processing YouTube videos",
"error_generic": "An error occurred while processing YouTube videos",
"invalid_url_toast": "Invalid YouTube URL",
"invalid_url_toast_desc": "Please enter a valid YouTube video URL",
"duplicate_url_toast": "Duplicate URL",
"duplicate_url_toast_desc": "This YouTube video has already been added"
},
"settings": {
"title": "Settings",
"subtitle": "Manage your LLM configurations and role assignments for this search space.",
"back_to_dashboard": "Back to Dashboard",
"model_configs": "Model Configs",
"models": "Models",
"llm_roles": "LLM Roles",
"roles": "Roles",
"llm_role_management": "LLM Role Management",
"llm_role_desc": "Assign your LLM configurations to specific roles for different purposes.",
"no_llm_configs_found": "No LLM configurations found. Please add at least one LLM provider in the Model Configs tab before assigning roles.",
"select_llm_config": "Select an LLM configuration",
"long_context_llm": "Long Context LLM",
"fast_llm": "Fast LLM",
"strategic_llm": "Strategic LLM",
"long_context_desc": "Handles complex tasks requiring extensive context understanding and reasoning",
"long_context_examples": "Document analysis, research synthesis, complex Q&A",
"large_context_window": "Large context window",
"deep_reasoning": "Deep reasoning",
"complex_analysis": "Complex analysis",
"fast_llm_desc": "Optimized for quick responses and real-time interactions",
"fast_llm_examples": "Quick searches, simple questions, instant responses",
"low_latency": "Low latency",
"quick_responses": "Quick responses",
"real_time_chat": "Real-time chat",
"strategic_llm_desc": "Advanced reasoning for planning and strategic decision making",
"strategic_llm_examples": "Planning workflows, strategic analysis, complex problem solving",
"strategic_thinking": "Strategic thinking",
"long_term_planning": "Long-term planning",
"complex_reasoning": "Complex reasoning",
"use_cases": "Use cases",
"assign_llm_config": "Assign LLM Configuration",
"unassigned": "Unassigned",
"assigned": "Assigned",
"model": "Model",
"base": "Base",
"all_roles_assigned": "All roles are assigned and ready to use! Your LLM configuration is complete.",
"save_changes": "Save Changes",
"saving": "Saving...",
"reset": "Reset",
"status": "Status",
"status_ready": "Ready",
"status_setup": "Setup",
"complete_role_assignments": "Complete all role assignments to enable full functionality. Each role serves different purposes in your workflow.",
"all_roles_saved": "All roles assigned and saved!",
"progress": "Progress",
"roles_assigned_count": "{assigned} of {total} roles assigned"
},
"podcasts": {
"title": "Podcasts",
"subtitle": "Listen to generated podcasts.",
"search_placeholder": "Search podcasts...",
"sort_order": "Sort order",
"newest_first": "Newest First",
"oldest_first": "Oldest First",
"loading": "Loading podcasts...",
"error_loading": "Error loading podcasts",
"no_podcasts": "No podcasts found",
"adjust_filters": "Try adjusting your search filters",
"generate_hint": "Generate podcasts from your chats to get started",
"loading_podcast": "Loading podcast...",
"now_playing": "Now Playing",
"delete_podcast": "Delete Podcast",
"delete_confirm_1": "Are you sure you want to delete",
"delete_confirm_2": "This action cannot be undone.",
"cancel": "Cancel",
"delete": "Delete",
"deleting": "Deleting..."
},
"logs": {
"title": "Task Logs",
"subtitle": "Monitor and analyze all task execution logs",
"refresh": "Refresh",
"delete_selected": "Delete Selected",
"confirm_title": "Are you absolutely sure?",
"confirm_delete_desc": "This action cannot be undone. This will permanently delete {count} selected log(s).",
"cancel": "Cancel",
"delete": "Delete",
"level": "Level",
"status": "Status",
"source": "Source",
"message": "Message",
"created_at": "Created At",
"actions": "Actions",
"system": "System",
"filter_by_message": "Filter by message...",
"filter_by": "Filter by",
"total_logs": "Total Logs",
"active_tasks": "Active Tasks",
"success_rate": "Success Rate",
"recent_failures": "Recent Failures",
"last_hours": "Last {hours} hours",
"currently_running": "Currently running",
"successful": "successful",
"need_attention": "Need attention",
"no_logs": "No logs found",
"loading": "Loading logs...",
"error_loading": "Error loading logs",
"columns": "Columns",
"failed_load_summary": "Failed to load summary",
"retry": "Retry",
"view": "View",
"toggle_columns": "Toggle columns",
"rows_per_page": "Rows per page",
"view_metadata": "View Metadata",
"log_deleted_success": "Log deleted successfully",
"log_deleted_error": "Failed to delete log",
"confirm_delete_log_title": "Are you sure?",
"confirm_delete_log_desc": "This action cannot be undone. This will permanently delete the log entry.",
"deleting": "Deleting..."
},
"onboard": {
"welcome_title": "Welcome to SurfSense",
"welcome_subtitle": "Let's configure your LLM configurations to get started",
"step_of": "Step {current} of {total}",
"percent_complete": "{percent}% Complete",
"add_llm_provider": "Add LLM Provider",
"assign_llm_roles": "Assign LLM Roles",
"setup_complete": "Setup Complete",
"configure_first_provider": "Configure your first model provider",
"assign_specific_roles": "Assign specific roles to your LLM configurations",
"all_set": "You're all set to start using SurfSense!",
"loading_config": "Loading your configuration...",
"previous": "Previous",
"next": "Next",
"complete_setup": "Complete Setup",
"add_provider_instruction": "Add at least one LLM provider to continue. You can configure multiple providers and choose specific roles for each one in the next step.",
"your_llm_configs": "Your LLM Configurations",
"model": "Model",
"language": "Language",
"base": "Base",
"add_provider_title": "Add LLM Provider",
"add_provider_subtitle": "Configure your first model provider to get started",
"add_provider_button": "Add Provider",
"add_new_llm_provider": "Add New LLM Provider",
"configure_new_provider": "Configure a new language model provider for your AI assistant",
"config_name": "Configuration Name",
"config_name_required": "Configuration Name *",
"config_name_placeholder": "e.g., My OpenAI GPT-4",
"provider": "Provider",
"provider_required": "Provider *",
"provider_placeholder": "Select a provider",
"language_optional": "Language (Optional)",
"language_placeholder": "Select language",
"custom_provider_name": "Custom Provider Name *",
"custom_provider_placeholder": "e.g., my-custom-provider",
"model_name_required": "Model Name *",
"model_name_placeholder": "e.g., gpt-4",
"examples": "Examples",
"api_key_required": "API Key *",
"api_key_placeholder": "Your API key",
"api_base_optional": "API Base URL (Optional)",
"api_base_placeholder": "e.g., https://api.openai.com/v1",
"adding": "Adding...",
"add_provider": "Add Provider",
"cancel": "Cancel",
"assign_roles_instruction": "Assign your LLM configurations to specific roles. Each role serves different purposes in your workflow.",
"no_llm_configs_found": "No LLM Configurations Found",
"add_provider_before_roles": "Please add at least one LLM provider in the previous step before assigning roles.",
"long_context_llm_title": "Long Context LLM",
"long_context_llm_desc": "Handles complex tasks requiring extensive context understanding and reasoning",
"long_context_llm_examples": "Document analysis, research synthesis, complex Q&A",
"fast_llm_title": "Fast LLM",
"fast_llm_desc": "Optimized for quick responses and real-time interactions",
"fast_llm_examples": "Quick searches, simple questions, instant responses",
"strategic_llm_title": "Strategic LLM",
"strategic_llm_desc": "Advanced reasoning for planning and strategic decision making",
"strategic_llm_examples": "Planning workflows, strategic analysis, complex problem solving",
"use_cases": "Use cases",
"assign_llm_config": "Assign LLM Configuration",
"select_llm_config": "Select an LLM configuration",
"assigned": "Assigned",
"all_roles_assigned_saved": "All roles assigned and saved!",
"progress": "Progress",
"roles_assigned": "{assigned} of {total} roles assigned"
},
"model_config": {
"title": "Model Configurations",
"subtitle": "Manage your LLM provider configurations and API settings.",
"refresh": "Refresh",
"loading": "Loading configurations...",
"total_configs": "Total Configurations",
"unique_providers": "Unique Providers",
"system_status": "System Status",
"active": "Active",
"your_configs": "Your Configurations",
"manage_configs": "Manage and configure your LLM providers",
"add_config": "Add Configuration",
"no_configs": "No Configurations Yet",
"no_configs_desc": "Get started by adding your first LLM provider configuration to begin using the system.",
"add_first_config": "Add First Configuration",
"created": "Created"
},
"breadcrumb": {
"dashboard": "Dashboard",
"search_space": "Search Space",
"researcher": "Researcher",
"documents": "Documents",
"connectors": "Connectors",
"podcasts": "Podcasts",
"logs": "Logs",
"chats": "Chats",
"settings": "Settings",
"upload_documents": "Upload Documents",
"add_youtube": "Add YouTube Videos",
"add_webpages": "Add Webpages",
"add_connector": "Add Connector",
"manage_connectors": "Manage Connectors",
"edit_connector": "Edit Connector",
"manage": "Manage"
},
"sidebar": {
"recent_chats": "Recent Chats",
"search_chats": "Search chats...",
"no_chats_found": "No chats found",
"no_recent_chats": "No recent chats",
"view_all_chats": "View All Chats",
"search_space": "Search Space"
},
"errors": {
"something_went_wrong": "Something went wrong",
"try_again": "Please try again",
"not_found": "Not found",
"unauthorized": "Unauthorized",
"forbidden": "Forbidden",
"server_error": "Server error",
"network_error": "Network error"
},
"homepage": {
"hero_title_part1": "The AI Workspace",
"hero_title_part2": "Built for Teams",
"hero_description": "Connect any LLM to your internal knowledge sources and chat with it in real time alongside your team.",
"cta_start_trial": "Start Free Trial",
"cta_explore": "Explore",
"integrations_title": "Integrations",
"integrations_subtitle": "Integrate with your team's most important tools",
"features_title": "Your Team's AI-Powered Knowledge Hub",
"features_subtitle": "Powerful features designed to enhance collaboration, boost productivity, and streamline your workflow.",
"feature_workflow_title": "Streamlined Workflow",
"feature_workflow_desc": "Centralize all your knowledge and resources in one intelligent workspace. Find what you need instantly and accelerate decision-making.",
"feature_collaboration_title": "Seamless Collaboration",
"feature_collaboration_desc": "Work together effortlessly with real-time collaboration tools that keep your entire team aligned.",
"feature_customizable_title": "Fully Customizable",
"feature_customizable_desc": "Choose from 100+ leading LLMs and seamlessly call any model on demand.",
"cta_transform": "Transform how your team",
"cta_transform_bold": "discovers and collaborates",
"cta_unite_start": "Unite your",
"cta_unite_knowledge": "team's knowledge",
"cta_unite_middle": "in one collaborative space with",
"cta_unite_search": "intelligent search",
"cta_talk_to_us": "Talk to us",
"features": {
"find_ask_act": {
"title": "Find, Ask, Act",
"description": "Get instant information, detailed updates, and cited answers across company and personal knowledge."
},
"real_time_collab": {
"title": "Work Together in Real Time",
"description": "Transform your company docs into multiplayer spaces with live edits, synced content, and presence."
},
"beyond_text": {
"title": "Collaborate Beyond Text",
"description": "Create podcasts and multimedia your team can comment on, share, and refine together."
},
"context_counts": {
"title": "Context Where It Counts",
"description": "Add comments directly to your chats and docs for clear, in-the-moment feedback."
},
"citation_illustration_title": "Citation feature illustration showing clickable source reference",
"referenced_chunk": "Referenced Chunk",
"collab_illustration_label": "Illustration of a realtime collaboration in a text editor.",
"real_time": "Real-time",
"collab_part1": "collabo",
"collab_part2": "orat",
"collab_part3": "ion",
"annotation_illustration_label": "Illustration of a text editor with annotation comments.",
"add_context_with": "Add context with",
"comments": "comments",
"example_comment": "Let's discuss this tomorrow!"
}
}
}

View file

@ -0,0 +1,692 @@
{
"common": {
"app_name": "SurfSense",
"welcome": "欢迎",
"loading": "加载中...",
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"create": "创建",
"update": "更新",
"search": "搜索",
"close": "关闭",
"confirm": "确认",
"back": "返回",
"next": "下一步",
"submit": "提交",
"yes": "是",
"no": "否",
"add": "添加",
"remove": "移除",
"select": "选择",
"all": "全部",
"none": "无",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "信息",
"required": "必填",
"optional": "可选",
"retry": "重试"
},
"auth": {
"login": "登录",
"register": "注册",
"logout": "登出",
"email": "电子邮箱",
"password": "密码",
"confirm_password": "确认密码",
"forgot_password": "忘记密码?",
"show_password": "显示密码",
"hide_password": "隐藏密码",
"remember_me": "记住我",
"sign_in": "登录",
"sign_up": "注册",
"sign_in_with": "使用 {provider} 登录",
"dont_have_account": "还没有账户?",
"already_have_account": "已有账户?",
"reset_password": "重置密码",
"email_required": "请输入电子邮箱",
"password_required": "请输入密码",
"invalid_email": "电子邮箱格式不正确",
"password_too_short": "密码至少需要 8 个字符",
"welcome_back": "欢迎回来",
"create_account": "创建您的账户",
"login_subtitle": "输入您的凭据以访问您的账户",
"register_subtitle": "注册以开始使用 SurfSense",
"or_continue_with": "或继续使用",
"by_continuing": "继续即表示您同意我们的",
"terms_of_service": "服务条款",
"and": "和",
"privacy_policy": "隐私政策",
"full_name": "全名",
"username": "用户名",
"continue": "继续",
"back_to_login": "返回登录",
"login_success": "登录成功",
"register_success": "账户创建成功",
"continue_with_google": "使用 Google 继续",
"cloud_dev_notice": "SurfSense 云版本正在开发中。查看",
"docs": "文档",
"cloud_dev_self_hosted": "以获取有关自托管版本的更多信息。",
"passwords_no_match": "密码不匹配",
"password_mismatch": "密码不匹配",
"passwords_no_match_desc": "您输入的密码不一致",
"creating_account": "正在创建您的账户...",
"creating_account_btn": "创建中...",
"redirecting_login": "正在跳转到登录页面..."
},
"dashboard": {
"title": "仪表盘",
"search_spaces": "搜索空间",
"documents": "文档",
"connectors": "连接器",
"settings": "设置",
"researcher": "AI 研究",
"api_keys": "API 密钥",
"profile": "个人资料",
"loading_dashboard": "正在加载仪表盘",
"checking_auth": "正在检查身份验证...",
"loading_config": "正在加载配置",
"checking_llm_prefs": "正在检查您的 LLM 偏好设置...",
"config_error": "配置错误",
"failed_load_llm_config": "无法加载您的 LLM 配置",
"error_loading_chats": "加载对话失败",
"no_recent_chats": "暂无最近对话",
"error_loading_space": "加载搜索空间失败",
"unknown_search_space": "未知搜索空间",
"delete_chat": "删除对话",
"delete_chat_confirm": "您确定要删除",
"action_cannot_undone": "此操作无法撤销。",
"deleting": "删除中...",
"surfsense_dashboard": "SurfSense 仪表盘",
"welcome_message": "欢迎来到您的 SurfSense 仪表盘。",
"your_search_spaces": "您的搜索空间",
"create_search_space": "创建搜索空间",
"add_new_search_space": "添加新的搜索空间",
"loading": "加载中",
"fetching_spaces": "正在获取您的搜索空间...",
"may_take_moment": "这可能需要一些时间",
"error": "错误",
"something_wrong": "出现错误",
"error_details": "错误详情",
"try_again": "重试",
"go_home": "返回首页",
"delete_search_space": "删除搜索空间",
"delete_space_confirm": "您确定要删除\"{name}\"吗?此操作无法撤销。此搜索空间中的所有文档、对话和播客将被永久删除。",
"no_spaces_found": "未找到搜索空间",
"create_first_space": "创建您的第一个搜索空间以开始使用",
"created": "创建于"
},
"navigation": {
"home": "首页",
"docs": "文档",
"pricing": "定价",
"contact": "联系我们",
"login": "登录",
"register": "注册",
"dashboard": "仪表盘",
"sign_in": "登录",
"book_a_call": "预约咨询"
},
"nav_menu": {
"platform": "平台",
"researcher": "AI 研究",
"manage_llms": "管理 LLM",
"documents": "文档",
"upload_documents": "上传文档",
"add_webpages": "添加网页",
"add_youtube": "添加 YouTube 视频",
"add_youtube_videos": "添加 YouTube 视频",
"manage_documents": "管理文档",
"connectors": "连接器",
"add_connector": "添加连接器",
"manage_connectors": "管理连接器",
"podcasts": "播客",
"logs": "日志",
"all_search_spaces": "所有搜索空间"
},
"pricing": {
"title": "SurfSense 定价",
"subtitle": "选择适合您的方案",
"community_name": "社区版",
"enterprise_name": "企业版",
"forever": "永久",
"contact_us": "联系我们",
"feature_llms": "支持 100+ 种 LLM",
"feature_ollama": "支持本地 Ollama 或 vLLM 部署",
"feature_embeddings": "6000+ 种嵌入模型",
"feature_files": "支持 50+ 种文件扩展名",
"feature_podcasts": "支持播客与本地 TTS 提供商",
"feature_sources": "连接 15+ 种外部数据源",
"feature_extension": "跨浏览器扩展支持动态网页,包括需要身份验证的内容",
"upcoming_mindmaps": "即将推出:可合并思维导图",
"upcoming_notes": "即将推出:笔记管理",
"community_desc": "开源版本,功能强大",
"get_started": "开始使用",
"everything_community": "包含社区版所有功能",
"priority_support": "优先支持",
"access_controls": "访问控制",
"collaboration": "协作和多人功能",
"video_gen": "视频生成",
"advanced_security": "高级安全功能",
"enterprise_desc": "为有特定需求的大型组织提供",
"contact_sales": "联系销售"
},
"contact": {
"title": "联系我们",
"subtitle": "我们很乐意听到您的声音",
"we_are_here": "我们在这里",
"full_name": "全名",
"email_address": "电子邮箱地址",
"company": "公司",
"message": "留言",
"optional": "可选",
"name_placeholder": "张三",
"email_placeholder": "zhangsan@example.com",
"company_placeholder": "示例公司",
"message_placeholder": "在此输入您的留言",
"submit": "提交",
"submitting": "提交中...",
"name_required": "请输入姓名",
"name_too_long": "姓名过长",
"invalid_email": "电子邮箱格式不正确",
"email_too_long": "电子邮箱过长",
"company_required": "请输入公司名称",
"company_too_long": "公司名称过长",
"message_sent": "消息已成功发送!",
"we_will_contact": "我们会尽快与您联系。",
"send_failed": "发送消息失败",
"try_again_later": "请稍后重试。",
"something_wrong": "出错了"
},
"researcher": {
"loading": "加载中...",
"select_documents": "选择文档",
"select_documents_desc": "选择要包含在研究上下文中的文档",
"loading_documents": "正在加载文档...",
"select_connectors": "选择连接器",
"select_connectors_desc": "选择要包含在研究中的数据源",
"clear_all": "全部清除",
"select_all": "全部选择",
"scope": "范围",
"documents": "文档",
"docs": "文档",
"chunks": "块",
"mode": "模式",
"research_mode": "研究模式",
"mode_qna": "问答",
"mode_general": "通用报告",
"mode_general_short": "通用",
"mode_deep": "深度报告",
"mode_deep_short": "深度",
"mode_deeper": "更深度报告",
"mode_deeper_short": "更深",
"fast_llm": "快速 LLM",
"select_llm": "选择 LLM",
"fast_llm_selection": "快速 LLM 选择",
"no_llm_configs": "未配置 LLM",
"configure_llm_to_start": "配置 AI 模型以开始使用",
"open_settings": "打开设置",
"start_surfing": "开始探索",
"through_knowledge_base": "您的知识库。",
"all_connectors": "所有连接器",
"connectors_selected": "{count} 个连接器",
"placeholder": "问我任何问题..."
},
"connectors": {
"title": "连接器",
"subtitle": "管理您的已连接服务和数据源。",
"add_connector": "添加连接器",
"your_connectors": "您的连接器",
"view_manage": "查看和管理您的所有已连接服务。",
"no_connectors": "未找到连接器",
"no_connectors_desc": "您还没有添加任何连接器。添加一个来增强您的搜索能力。",
"add_first": "添加您的第一个连接器",
"name": "名称",
"type": "类型",
"last_indexed": "最后索引",
"periodic": "定期",
"actions": "操作",
"never": "从未",
"not_indexable": "不可索引",
"index_date_range": "按日期范围索引",
"quick_index": "快速索引",
"quick_index_auto": "快速索引(自动日期范围)",
"delete_connector": "删除连接器",
"delete_confirm": "您确定要删除此连接器吗?此操作无法撤销。",
"select_date_range": "选择索引日期范围",
"select_date_range_desc": "选择索引内容的开始和结束日期。留空以使用默认范围。",
"start_date": "开始日期",
"end_date": "结束日期",
"pick_date": "选择日期",
"clear_dates": "清除日期",
"last_30_days": "最近 30 天",
"last_year": "去年",
"start_indexing": "开始索引",
"failed_load": "加载连接器失败",
"delete_success": "连接器删除成功",
"delete_failed": "删除连接器失败",
"indexing_started": "连接器内容索引已开始",
"indexing_failed": "索引连接器内容失败"
},
"documents": {
"title": "文档",
"subtitle": "管理您的文档和文件。",
"no_rows_selected": "未选择任何行",
"delete_success_count": "成功删除 {count} 个文档",
"delete_partial_failed": "部分文档无法删除",
"delete_error": "删除文档时出错",
"filter_by_title": "按标题筛选...",
"bulk_delete": "删除所选",
"filter_types": "筛选类型",
"columns": "列",
"confirm_delete": "确认删除",
"confirm_delete_desc": "您确定要删除 {count} 个文档吗?此操作无法撤销。",
"uploading": "上传中...",
"upload_success": "文档上传成功",
"upload_failed": "上传文档失败",
"loading": "正在加载文档...",
"error_loading": "加载文档时出错",
"retry": "重试",
"no_documents": "未找到文档",
"type": "类型",
"content_summary": "内容摘要",
"view_full": "查看完整内容",
"filter_placeholder": "按标题筛选...",
"rows_per_page": "每页行数"
},
"add_connector": {
"title": "连接您的工具",
"subtitle": "集成您喜欢的服务以增强研究能力。",
"search_engines": "搜索引擎",
"team_chats": "团队聊天",
"project_management": "项目管理",
"knowledge_bases": "知识库",
"communication": "通讯",
"connect": "连接",
"coming_soon": "即将推出",
"connected": "已连接",
"manage": "管理",
"tavily_desc": "使用 Tavily API 搜索网络",
"searxng_desc": "使用您自己的 SearxNG 元搜索实例获取网络结果。",
"linkup_desc": "使用 Linkup API 搜索网络",
"elasticsearch_desc": "连接到 Elasticsearch 以索引和搜索文档、日志和指标。",
"baidu_desc": "使用百度 AI 搜索 API 搜索中文网络",
"slack_desc": "连接到您的 Slack 工作区以访问消息和频道。",
"teams_desc": "连接到 Microsoft Teams 以访问团队对话。",
"discord_desc": "连接到 Discord 服务器以访问消息和频道。",
"linear_desc": "连接到 Linear 以搜索问题、评论和项目数据。",
"jira_desc": "连接到 Jira 以搜索问题、工单和项目数据。",
"clickup_desc": "连接到 ClickUp 以搜索任务、评论和项目数据。",
"notion_desc": "连接到您的 Notion 工作区以访问页面和数据库。",
"github_desc": "连接 GitHub PAT 以索引可访问存储库的代码和文档。",
"confluence_desc": "连接到 Confluence 以搜索页面、评论和文档。",
"airtable_desc": "连接到 Airtable 以搜索记录、表格和数据库内容。",
"luma_desc": "连接到 Luma 以搜索活动",
"calendar_desc": "连接到 Google 日历以搜索活动、会议和日程。",
"gmail_desc": "连接到您的 Gmail 账户以搜索您的电子邮件。",
"zoom_desc": "连接到 Zoom 以访问会议录制和转录。"
},
"upload_documents": {
"title": "上传文档",
"subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。",
"file_size_limit": "最大文件大小:每个文件 50MB。支持的格式因您的 ETL 服务配置而异。",
"drop_files": "放下文件到这里",
"drag_drop": "拖放文件到这里",
"or_browse": "或点击浏览",
"browse_files": "浏览文件",
"selected_files": "已选择的文件 ({count})",
"total_size": "总大小",
"clear_all": "全部清除",
"uploading_files": "正在上传文件...",
"uploading": "上传中...",
"upload_button": "上传 {count} 个文件",
"upload_initiated": "上传任务已启动",
"upload_initiated_desc": "文件上传已开始",
"upload_error": "上传错误",
"upload_error_desc": "上传文件时出错",
"supported_file_types": "支持的文件类型",
"file_types_desc": "根据您当前的 ETL 服务配置支持这些文件类型。"
},
"add_webpage": {
"title": "添加网页爬取",
"subtitle": "输入要爬取的 URL 并添加到您的文档集合",
"label": "输入要爬取的 URL",
"placeholder": "输入 URL 并按 Enter",
"hint": "按 Enter 键添加多个 URL",
"tips_title": "URL 爬取提示:",
"tip_1": "输入完整的 URL包括 http:// 或 https://",
"tip_2": "确保网站允许爬取",
"tip_3": "公开网页效果最佳",
"tip_4": "爬取时间可能会根据网站大小而有所不同",
"cancel": "取消",
"submit": "提交 URL 进行爬取",
"submitting": "提交中...",
"error_no_url": "请至少添加一个 URL",
"error_invalid_urls": "检测到无效的 URL{urls}",
"crawling_toast": "URL 爬取",
"crawling_toast_desc": "开始 URL 爬取过程...",
"success_toast": "爬取成功",
"success_toast_desc": "URL 已提交爬取",
"error_toast": "爬取错误",
"error_toast_desc": "爬取 URL 时出错",
"error_generic": "爬取 URL 时发生错误",
"invalid_url_toast": "无效的 URL",
"invalid_url_toast_desc": "请输入有效的 URL",
"duplicate_url_toast": "重复的 URL",
"duplicate_url_toast_desc": "此 URL 已添加"
},
"add_youtube": {
"title": "添加 YouTube 视频",
"subtitle": "输入 YouTube 视频 URL 以添加到您的文档集合",
"label": "输入 YouTube 视频 URL",
"placeholder": "输入 YouTube URL 并按 Enter",
"hint": "按 Enter 键添加多个 YouTube URL",
"tips_title": "添加 YouTube 视频的提示:",
"tip_1": "使用标准 YouTube URLyoutube.com/watch?v= 或 youtu.be/",
"tip_2": "确保视频可公开访问",
"tip_3": "支持的格式youtube.com/watch?v=VIDEO_ID 或 youtu.be/VIDEO_ID",
"tip_4": "处理时间可能会根据视频长度而有所不同",
"preview": "预览",
"cancel": "取消",
"submit": "提交 YouTube 视频",
"processing": "处理中...",
"error_no_video": "请至少添加一个 YouTube 视频 URL",
"error_invalid_urls": "检测到无效的 YouTube URL{urls}",
"processing_toast": "YouTube 视频处理",
"processing_toast_desc": "开始 YouTube 视频处理...",
"success_toast": "处理成功",
"success_toast_desc": "YouTube 视频已提交处理",
"error_toast": "处理错误",
"error_toast_desc": "处理 YouTube 视频时出错",
"error_generic": "处理 YouTube 视频时发生错误",
"invalid_url_toast": "无效的 YouTube URL",
"invalid_url_toast_desc": "请输入有效的 YouTube 视频 URL",
"duplicate_url_toast": "重复的 URL",
"duplicate_url_toast_desc": "此 YouTube 视频已添加"
},
"settings": {
"title": "设置",
"subtitle": "管理此搜索空间的 LLM 配置和角色分配。",
"back_to_dashboard": "返回仪表盘",
"model_configs": "模型配置",
"models": "模型",
"llm_roles": "LLM 角色",
"roles": "角色",
"llm_role_management": "LLM 角色管理",
"llm_role_desc": "为不同用途分配您的 LLM 配置到特定角色。",
"no_llm_configs_found": "未找到 LLM 配置。在分配角色之前,请在模型配置选项卡中至少添加一个 LLM 提供商。",
"select_llm_config": "选择 LLM 配置",
"long_context_llm": "长上下文 LLM",
"fast_llm": "快速 LLM",
"strategic_llm": "战略 LLM",
"long_context_desc": "处理需要广泛上下文理解和推理的复杂任务",
"long_context_examples": "文档分析、研究综合、复杂问答",
"large_context_window": "大型上下文窗口",
"deep_reasoning": "深度推理",
"complex_analysis": "复杂分析",
"fast_llm_desc": "针对快速响应和实时交互进行优化",
"fast_llm_examples": "快速搜索、简单问题、即时响应",
"low_latency": "低延迟",
"quick_responses": "快速响应",
"real_time_chat": "实时对话",
"strategic_llm_desc": "用于规划和战略决策的高级推理",
"strategic_llm_examples": "规划工作流、战略分析、复杂问题解决",
"strategic_thinking": "战略思维",
"long_term_planning": "长期规划",
"complex_reasoning": "复杂推理",
"use_cases": "使用场景",
"assign_llm_config": "分配 LLM 配置",
"unassigned": "未分配",
"assigned": "已分配",
"model": "模型",
"base": "基础地址",
"all_roles_assigned": "所有角色已分配并准备使用!您的 LLM 配置已完成。",
"save_changes": "保存更改",
"saving": "保存中...",
"reset": "重置",
"status": "状态",
"status_ready": "就绪",
"status_setup": "设置中",
"complete_role_assignments": "完成所有角色分配以启用完整功能。每个角色在您的工作流中都有不同的用途。",
"all_roles_saved": "所有角色已分配并保存!",
"progress": "进度",
"roles_assigned_count": "{assigned} / {total} 个角色已分配"
},
"podcasts": {
"title": "播客",
"subtitle": "收听生成的播客。",
"search_placeholder": "搜索播客...",
"sort_order": "排序方式",
"newest_first": "最新优先",
"oldest_first": "最旧优先",
"loading": "正在加载播客...",
"error_loading": "加载播客时出错",
"no_podcasts": "未找到播客",
"adjust_filters": "尝试调整搜索条件",
"generate_hint": "从您的聊天中生成播客以开始使用",
"loading_podcast": "正在加载播客...",
"now_playing": "正在播放",
"delete_podcast": "删除播客",
"delete_confirm_1": "您确定要删除",
"delete_confirm_2": "此操作无法撤销。",
"cancel": "取消",
"delete": "删除",
"deleting": "删除中..."
},
"logs": {
"title": "任务日志",
"subtitle": "监控和分析所有任务执行日志",
"refresh": "刷新",
"delete_selected": "删除所选",
"confirm_title": "您确定要这样做吗?",
"confirm_delete_desc": "此操作无法撤销。这将永久删除 {count} 个所选日志。",
"cancel": "取消",
"delete": "删除",
"level": "级别",
"status": "状态",
"source": "来源",
"message": "消息",
"created_at": "创建时间",
"actions": "操作",
"system": "系统",
"filter_by_message": "按消息筛选...",
"filter_by": "筛选",
"total_logs": "总日志数",
"active_tasks": "活动任务",
"success_rate": "成功率",
"recent_failures": "最近失败",
"last_hours": "最近 {hours} 小时",
"currently_running": "当前运行中",
"successful": "成功",
"need_attention": "需要注意",
"no_logs": "未找到日志",
"loading": "正在加载日志...",
"error_loading": "加载日志时出错",
"columns": "列",
"failed_load_summary": "加载摘要失败",
"retry": "重试",
"view": "查看",
"toggle_columns": "切换列",
"rows_per_page": "每页行数",
"view_metadata": "查看元数据",
"log_deleted_success": "日志已成功删除",
"log_deleted_error": "删除日志失败",
"confirm_delete_log_title": "确定要删除吗?",
"confirm_delete_log_desc": "此操作无法撤销。这将永久删除该日志条目。",
"deleting": "删除中..."
},
"onboard": {
"welcome_title": "欢迎来到 SurfSense",
"welcome_subtitle": "让我们配置您的 LLM 以开始使用",
"step_of": "第 {current} 步,共 {total} 步",
"percent_complete": "已完成 {percent}%",
"add_llm_provider": "添加 LLM 提供商",
"assign_llm_roles": "分配 LLM 角色",
"setup_complete": "设置完成",
"configure_first_provider": "配置您的第一个模型提供商",
"assign_specific_roles": "为您的 LLM 配置分配特定角色",
"all_set": "您已准备好开始使用 SurfSense",
"loading_config": "正在加载您的配置...",
"previous": "上一步",
"next": "下一步",
"complete_setup": "完成设置",
"add_provider_instruction": "至少添加一个 LLM 提供商才能继续。您可以配置多个提供商,并在下一步为每个提供商选择特定角色。",
"your_llm_configs": "您的 LLM 配置",
"model": "模型",
"language": "语言",
"base": "基础地址",
"add_provider_title": "添加 LLM 提供商",
"add_provider_subtitle": "配置您的第一个模型提供商以开始使用",
"add_provider_button": "添加提供商",
"add_new_llm_provider": "添加新的 LLM 提供商",
"configure_new_provider": "为您的 AI 助手配置新的语言模型提供商",
"config_name": "配置名称",
"config_name_required": "配置名称 *",
"config_name_placeholder": "例如:我的 OpenAI GPT-4",
"provider": "提供商",
"provider_required": "提供商 *",
"provider_placeholder": "选择提供商",
"language_optional": "语言(可选)",
"language_placeholder": "选择语言",
"custom_provider_name": "自定义提供商名称 *",
"custom_provider_placeholder": "例如my-custom-provider",
"model_name_required": "模型名称 *",
"model_name_placeholder": "例如gpt-4",
"examples": "示例",
"api_key_required": "API 密钥 *",
"api_key_placeholder": "您的 API 密钥",
"api_base_optional": "API 基础 URL可选",
"api_base_placeholder": "例如https://api.openai.com/v1",
"adding": "添加中...",
"add_provider": "添加提供商",
"cancel": "取消",
"assign_roles_instruction": "为您的 LLM 配置分配特定角色。每个角色在您的工作流程中有不同的用途。",
"no_llm_configs_found": "未找到 LLM 配置",
"add_provider_before_roles": "在分配角色之前,请先在上一步中添加至少一个 LLM 提供商。",
"long_context_llm_title": "长上下文 LLM",
"long_context_llm_desc": "处理需要广泛上下文理解和推理的复杂任务",
"long_context_llm_examples": "文档分析、研究综合、复杂问答",
"fast_llm_title": "快速 LLM",
"fast_llm_desc": "针对快速响应和实时交互进行优化",
"fast_llm_examples": "快速搜索、简单问题、即时响应",
"strategic_llm_title": "战略 LLM",
"strategic_llm_desc": "用于规划和战略决策的高级推理",
"strategic_llm_examples": "规划工作流、战略分析、复杂问题解决",
"use_cases": "使用场景",
"assign_llm_config": "分配 LLM 配置",
"select_llm_config": "选择 LLM 配置",
"assigned": "已分配",
"all_roles_assigned_saved": "所有角色已分配并保存!",
"progress": "进度",
"roles_assigned": "{assigned}/{total} 个角色已分配"
},
"model_config": {
"title": "模型配置",
"subtitle": "管理您的 LLM 提供商配置和 API 设置。",
"refresh": "刷新",
"loading": "正在加载配置...",
"total_configs": "配置总数",
"unique_providers": "独立提供商",
"system_status": "系统状态",
"active": "活跃",
"your_configs": "您的配置",
"manage_configs": "管理和配置您的 LLM 提供商",
"add_config": "添加配置",
"no_configs": "暂无配置",
"no_configs_desc": "开始添加您的第一个 LLM 提供商配置以开始使用系统。",
"add_first_config": "添加首个配置",
"created": "创建于"
},
"breadcrumb": {
"dashboard": "仪表盘",
"search_space": "搜索空间",
"researcher": "AI 研究",
"documents": "文档",
"connectors": "连接器",
"podcasts": "播客",
"logs": "日志",
"chats": "聊天",
"settings": "设置",
"upload_documents": "上传文档",
"add_youtube": "添加 YouTube 视频",
"add_webpages": "添加网页",
"add_connector": "添加连接器",
"manage_connectors": "管理连接器",
"edit_connector": "编辑连接器",
"manage": "管理"
},
"sidebar": {
"recent_chats": "最近对话",
"search_chats": "搜索对话...",
"no_chats_found": "未找到对话",
"no_recent_chats": "暂无最近对话",
"view_all_chats": "查看所有对话",
"search_space": "搜索空间"
},
"errors": {
"something_went_wrong": "出错了",
"try_again": "请重试",
"not_found": "未找到",
"unauthorized": "未授权",
"forbidden": "禁止访问",
"server_error": "服务器错误",
"network_error": "网络错误"
},
"homepage": {
"hero_title_part1": "AI 工作空间",
"hero_title_part2": "为团队而生",
"hero_description": "将任何 LLM 连接到您的内部知识库,与团队实时协作对话。",
"cta_start_trial": "开始免费试用",
"cta_explore": "探索更多",
"integrations_title": "集成",
"integrations_subtitle": "与您团队最重要的工具集成",
"features_title": "团队的 AI 驱动知识中心",
"features_subtitle": "强大的功能,旨在增强协作、提升生产力并简化您的工作流程。",
"feature_workflow_title": "简化工作流程",
"feature_workflow_desc": "在一个智能工作空间中集中管理所有知识和资源。即时找到所需内容,加速决策制定。",
"feature_collaboration_title": "无缝协作",
"feature_collaboration_desc": "通过实时协作工具轻松协同工作,保持整个团队同步一致。",
"feature_customizable_title": "完全可定制",
"feature_customizable_desc": "从 100 多个领先的 LLM 中选择,按需无缝调用任何模型。",
"cta_transform": "转变团队的",
"cta_transform_bold": "发现和协作方式",
"cta_unite_start": "将您的",
"cta_unite_knowledge": "团队知识",
"cta_unite_middle": "集中在一个协作空间,配备",
"cta_unite_search": "智能搜索",
"cta_talk_to_us": "联系我们",
"features": {
"find_ask_act": {
"title": "查找、提问、行动",
"description": "跨公司和个人知识库获取即时信息、详细更新和引用答案。"
},
"real_time_collab": {
"title": "实时协作",
"description": "将您的公司文档转变为多人协作空间,支持实时编辑、同步内容和在线状态。"
},
"beyond_text": {
"title": "超越文本的协作",
"description": "创建播客和多媒体内容,您的团队可以一起评论、分享和完善。"
},
"context_counts": {
"title": "关键时刻的上下文",
"description": "直接在聊天和文档中添加评论,获得清晰、即时的反馈。"
},
"citation_illustration_title": "引用功能图示,显示可点击的来源参考",
"referenced_chunk": "引用片段",
"collab_illustration_label": "文本编辑器中实时协作的图示。",
"real_time": "实时",
"collab_part1": "协",
"collab_part2": "作",
"collab_part3": "",
"annotation_illustration_label": "带注释评论的文本编辑器图示。",
"add_context_with": "添加上下文",
"comments": "评论",
"example_comment": "我们明天讨论这个!"
}
}
}

View file

@ -0,0 +1,12 @@
// Middleware temporarily disabled for client-side i18n implementation
// Server-side i18n routing would require restructuring entire app directory to app/[locale]/...
// which is too invasive for this project
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Empty middleware - just pass through all requests
export function middleware(request: NextRequest) {
return NextResponse.next();
}

View file

@ -1,5 +1,9 @@
import { createMDX } from "fumadocs-mdx/next";
import type { NextConfig } from "next";
import createNextIntlPlugin from 'next-intl/plugin';
// Create the next-intl plugin
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
const nextConfig: NextConfig = {
output: "standalone",
@ -19,7 +23,7 @@ const nextConfig: NextConfig = {
},
};
// Wrap the config with createMDX
// Wrap the config with MDX and next-intl plugins
const withMDX = createMDX({});
export default withMDX(nextConfig);
export default withNextIntl(withMDX(nextConfig));

View file

@ -62,6 +62,7 @@
"lucide-react": "^0.477.0",
"motion": "^12.23.22",
"next": "^15.4.4",
"next-intl": "^3.26.5",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"postgres": "^3.4.7",