feat(i18n): Implement server-side internationalization support

- Added ServerI18nManager to handle translations and locale settings on the server.
- Integrated server-side translations into templates, reducing language flashing on initial load.
- Created API endpoints for setting and getting user language preferences.
- Enhanced client-side i18n handling to work seamlessly with server-rendered content.
- Updated various templates to utilize the new translation system.
- Added mixed i18n handler to coordinate server and client translations, improving user experience.
- Expanded translation files to include initialization messages for various components.
This commit is contained in:
Will Miao
2025-08-30 16:56:56 +08:00
parent 3c9e402bc0
commit 29160bd6e5
14 changed files with 775 additions and 42 deletions

View File

@@ -11,6 +11,7 @@ import jinja2
from ..utils.routes_common import ModelRouteUtils
from ..services.websocket_manager import ws_manager
from ..services.settings_manager import settings
from ..services.server_i18n import server_i18n
from ..utils.utils import calculate_relative_path_for_model
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ..config import config
@@ -116,13 +117,31 @@ class BaseModelRoutes(ABC):
# 获取用户语言设置
user_language = settings.get('language', 'en')
# 设置服务端i18n语言
server_i18n.set_locale(user_language)
# 为模板环境添加i18n过滤器
if not hasattr(self.template_env, '_i18n_filter_added'):
self.template_env.filters['t'] = server_i18n.create_template_filter()
self.template_env._i18n_filter_added = True
# 准备模板上下文
template_context = {
'is_initializing': is_initializing,
'settings': settings,
'request': request,
'user_language': user_language, # 传递语言设置到模板
'folders': []
'folders': [],
# 添加服务端翻译函数
't': server_i18n.get_translation,
'server_i18n': server_i18n,
# 添加一些常用的翻译到上下文,避免在模板中频繁调用
'common_translations': {
'loading': server_i18n.get_translation('common.status.loading'),
'error': server_i18n.get_translation('common.status.error'),
'refresh': server_i18n.get_translation('common.actions.refresh'),
'search': server_i18n.get_translation('common.actions.search'),
}
}
if not is_initializing:

View File

@@ -112,6 +112,10 @@ class MiscRoutes:
# Add new route for checking if a model exists in the library
app.router.add_get('/api/check-model-exists', MiscRoutes.check_model_exists)
# Language settings endpoints
app.router.add_post('/api/set-language', MiscRoutes.set_language)
app.router.add_get('/api/get-language', MiscRoutes.get_language)
@staticmethod
async def clear_cache(request):
@@ -697,3 +701,69 @@ class MiscRoutes:
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def set_language(request):
"""
Set user language preference
Expects a JSON body with:
{
"language": "en" | "zh-CN" | "zh-TW" | "ru" | "de" | "ja" | "ko" | "fr" | "es"
}
"""
try:
data = await request.json()
language = data.get('language')
if not language:
return web.json_response({
'success': False,
'error': 'Missing language parameter'
}, status=400)
# Validate language code
supported_languages = ['en', 'zh-CN', 'zh-TW', 'ru', 'de', 'ja', 'ko', 'fr', 'es']
if language not in supported_languages:
return web.json_response({
'success': False,
'error': f'Unsupported language: {language}. Supported languages: {", ".join(supported_languages)}'
}, status=400)
# Save language setting
settings.set('language', language)
return web.json_response({
'success': True,
'message': f'Language set to {language}',
'language': language
})
except Exception as e:
logger.error(f"Failed to set language: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def get_language(request):
"""
Get current user language preference
Returns the current language setting from settings
"""
try:
current_language = settings.get('language', 'en')
return web.json_response({
'success': True,
'language': current_language
})
except Exception as e:
logger.error(f"Failed to get language: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -17,6 +17,7 @@ from ..recipes import RecipeParserFactory
from ..utils.constants import CARD_PREVIEW_WIDTH
from ..services.settings_manager import settings
from ..services.server_i18n import server_i18n
from ..config import config
# Check if running in standalone mode
@@ -127,6 +128,17 @@ class RecipeRoutes:
# Ensure services are initialized
await self.init_services()
# 获取用户语言设置
user_language = settings.get('language', 'en')
# 设置服务端i18n语言
server_i18n.set_locale(user_language)
# 为模板环境添加i18n过滤器
if not hasattr(self.template_env, '_i18n_filter_added'):
self.template_env.filters['t'] = server_i18n.create_template_filter()
self.template_env._i18n_filter_added = True
# Skip initialization check and directly try to get cached data
try:
# Recipe scanner will initialize cache if needed
@@ -136,7 +148,18 @@ class RecipeRoutes:
recipes=[], # Frontend will load recipes via API
is_initializing=False,
settings=settings,
request=request
request=request,
user_language=user_language,
# 添加服务端翻译函数
t=server_i18n.get_translation,
server_i18n=server_i18n,
# 添加一些常用的翻译到上下文
common_translations={
'loading': server_i18n.get_translation('common.status.loading'),
'error': server_i18n.get_translation('common.status.error'),
'refresh': server_i18n.get_translation('common.actions.refresh'),
'search': server_i18n.get_translation('common.actions.search'),
}
)
except Exception as cache_error:
logger.error(f"Error loading recipe cache data: {cache_error}")
@@ -145,7 +168,16 @@ class RecipeRoutes:
rendered = template.render(
is_initializing=True,
settings=settings,
request=request
request=request,
user_language=user_language,
# 添加服务端翻译函数
t=server_i18n.get_translation,
server_i18n=server_i18n,
# 添加一些常用的翻译到上下文
common_translations={
'loading': server_i18n.get_translation('common.status.loading'),
'error': server_i18n.get_translation('common.status.error'),
}
)
logger.info("Recipe cache error, returning initialization page")

View File

@@ -9,6 +9,7 @@ from typing import Dict, List, Any
from ..config import config
from ..services.settings_manager import settings
from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry
from ..utils.usage_stats import UsageStats
@@ -58,11 +59,32 @@ class StatsRoutes:
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
# 获取用户语言设置
user_language = settings.get('language', 'en')
# 设置服务端i18n语言
server_i18n.set_locale(user_language)
# 为模板环境添加i18n过滤器
if not hasattr(self.template_env, '_i18n_filter_added'):
self.template_env.filters['t'] = server_i18n.create_template_filter()
self.template_env._i18n_filter_added = True
template = self.template_env.get_template('statistics.html')
rendered = template.render(
is_initializing=is_initializing,
settings=settings,
request=request
request=request,
user_language=user_language,
# 添加服务端翻译函数
t=server_i18n.get_translation,
server_i18n=server_i18n,
# 添加一些常用的翻译到上下文
common_translations={
'loading': server_i18n.get_translation('common.status.loading'),
'error': server_i18n.get_translation('common.status.error'),
'refresh': server_i18n.get_translation('common.actions.refresh'),
}
)
return web.Response(

154
py/services/server_i18n.py Normal file
View File

@@ -0,0 +1,154 @@
import os
import json
import logging
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__)
class ServerI18nManager:
"""Server-side internationalization manager for template rendering"""
def __init__(self):
self.translations = {}
self.current_locale = 'en'
self._load_translations()
def _load_translations(self):
"""Load all translation files from the static/js/i18n directory"""
i18n_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'static', 'js', 'i18n', 'locales'
)
if not os.path.exists(i18n_path):
logger.warning(f"I18n directory not found: {i18n_path}")
return
# Load all available locale files
for filename in os.listdir(i18n_path):
if filename.endswith('.js'):
locale_code = filename[:-3] # Remove .js extension
try:
self._load_locale_file(i18n_path, filename, locale_code)
except Exception as e:
logger.error(f"Error loading locale file {filename}: {e}")
def _load_locale_file(self, path: str, filename: str, locale_code: str):
"""Load a single locale file and extract translation data"""
file_path = os.path.join(path, filename)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Look for export const pattern like: export const en = { ... }
import re
# Extract the variable name and object
export_pattern = r'export\s+const\s+(\w+)\s*=\s*(\{.*\});?\s*$'
match = re.search(export_pattern, content, re.DOTALL | re.MULTILINE)
if not match:
logger.warning(f"No export const found in {filename}")
return
var_name = match.group(1)
js_object = match.group(2)
# Convert JS object to JSON
json_str = self._js_object_to_json(js_object)
# Parse as JSON
translations = json.loads(json_str)
self.translations[locale_code] = translations
logger.debug(f"Loaded translations for {locale_code} (variable: {var_name})")
except Exception as e:
logger.error(f"Error parsing locale file {filename}: {e}")
def _js_object_to_json(self, js_obj: str) -> str:
"""Convert JavaScript object to JSON string"""
import re
# Remove comments (single line and multi-line)
js_obj = re.sub(r'//.*?$', '', js_obj, flags=re.MULTILINE)
js_obj = re.sub(r'/\*.*?\*/', '', js_obj, flags=re.DOTALL)
# Replace unquoted object keys with quoted keys
js_obj = re.sub(r'(\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:', r'\1"\2":', js_obj)
# Handle strings more robustly using regex
# First, find all single-quoted strings and replace them with double-quoted ones
def replace_single_quotes(match):
content = match.group(1)
# Escape any double quotes in the content
content = content.replace('"', '\\"')
# Handle escaped single quotes
content = content.replace("\\'", "'")
return f'"{content}"'
# Replace single-quoted strings with double-quoted strings
js_obj = re.sub(r"'([^'\\]*(?:\\.[^'\\]*)*)'", replace_single_quotes, js_obj)
return js_obj
def set_locale(self, locale: str):
"""Set the current locale"""
if locale in self.translations:
self.current_locale = locale
else:
logger.warning(f"Locale {locale} not found, using 'en'")
self.current_locale = 'en'
def get_translation(self, key: str, params: Dict[str, Any] = None) -> str:
"""Get translation for a key with optional parameters"""
if self.current_locale not in self.translations:
return key
# Navigate through nested object using dot notation
keys = key.split('.')
value = self.translations[self.current_locale]
for k in keys:
if isinstance(value, dict) and k in value:
value = value[k]
else:
# Fallback to English if current locale doesn't have the key
if self.current_locale != 'en' and 'en' in self.translations:
en_value = self.translations['en']
for k in keys:
if isinstance(en_value, dict) and k in en_value:
en_value = en_value[k]
else:
return key
value = en_value
else:
return key
break
if not isinstance(value, str):
return key
# Replace parameters if provided
if params:
for param_key, param_value in params.items():
placeholder = f"{{{param_key}}}"
double_placeholder = f"{{{{{param_key}}}}}"
value = value.replace(placeholder, str(param_value))
value = value.replace(double_placeholder, str(param_value))
return value
def get_available_locales(self) -> list:
"""Get list of available locales"""
return list(self.translations.keys())
def create_template_filter(self):
"""Create a Jinja2 filter function for templates"""
def t_filter(key: str, **params) -> str:
return self.get_translation(key, params)
return t_filter
# Create global instance
server_i18n = ServerI18nManager()