mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
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:
parent
8aeaf419d0
commit
f58c7e4602
37 changed files with 2267 additions and 542 deletions
3
surfsense_web/.gitignore
vendored
3
surfsense_web/.gitignore
vendored
|
|
@ -47,3 +47,6 @@ next-env.d.ts
|
|||
|
||||
# source
|
||||
/.source/
|
||||
|
||||
.pnpm-store/
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 "{space.name}"? 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
56
surfsense_web/components/LanguageSwitcher.tsx
Normal file
56
surfsense_web/components/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
19
surfsense_web/components/providers/I18nProvider.tsx
Normal file
19
surfsense_web/components/providers/I18nProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}`}>
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
70
surfsense_web/contexts/LocaleContext.tsx
Normal file
70
surfsense_web/contexts/LocaleContext.tsx
Normal 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;
|
||||
}
|
||||
|
||||
22
surfsense_web/i18n/request.ts
Normal file
22
surfsense_web/i18n/request.ts
Normal 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
|
||||
};
|
||||
});
|
||||
|
||||
24
surfsense_web/i18n/routing.ts
Normal file
24
surfsense_web/i18n/routing.ts
Normal 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);
|
||||
|
||||
692
surfsense_web/messages/en.json
Normal file
692
surfsense_web/messages/en.json
Normal 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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
692
surfsense_web/messages/zh.json
Normal file
692
surfsense_web/messages/zh.json
Normal 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 URL(youtube.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": "我们明天讨论这个!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
surfsense_web/middleware.ts
Normal file
12
surfsense_web/middleware.ts
Normal 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();
|
||||
}
|
||||
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue