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

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

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

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

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

View file

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

View file

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