mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
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:
155
docs/server-side-i18n-implementation.md
Normal file
155
docs/server-side-i18n-implementation.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 服务端渲染 I18n 实现总结
|
||||
|
||||
## 问题分析
|
||||
|
||||
原始的纯前端i18n方案存在以下问题:
|
||||
1. **语言闪烁问题**:页面首次加载时会显示英文,然后才切换到用户设置的语言
|
||||
2. **首屏渲染慢**:需要等待JavaScript加载并执行才能显示正确的语言
|
||||
3. **SEO不友好**:搜索引擎爬虫看到的是默认语言内容
|
||||
|
||||
## 解决方案
|
||||
|
||||
实现了**混合式服务端+客户端i18n系统**:
|
||||
|
||||
### 1. 服务端 I18n 管理器 (`py/services/server_i18n.py`)
|
||||
|
||||
**功能**:
|
||||
- 解析JavaScript格式的语言文件(`.js`文件中的`export const`语法)
|
||||
- 提供Jinja2模板过滤器支持
|
||||
- 支持嵌套键值查找(如`header.navigation.loras`)
|
||||
- 支持参数插值(`{param}`和`{{param}}`语法)
|
||||
- 自动回退到英语翻译
|
||||
|
||||
**核心特性**:
|
||||
```python
|
||||
# 设置语言
|
||||
server_i18n.set_locale('zh-CN')
|
||||
|
||||
# 获取翻译
|
||||
title = server_i18n.get_translation('header.appTitle')
|
||||
|
||||
# 创建模板过滤器
|
||||
template_filter = server_i18n.create_template_filter()
|
||||
```
|
||||
|
||||
### 2. 模板层面的改进
|
||||
|
||||
**修改的文件**:
|
||||
- `templates/base.html` - 添加服务端翻译数据预设
|
||||
- `templates/components/header.html` - 使用服务端翻译
|
||||
- `templates/loras.html` - 标题和初始化消息服务端渲染
|
||||
|
||||
**模板语法示例**:
|
||||
```html
|
||||
<!-- 服务端渲染 -->
|
||||
<span class="app-title">{{ t('header.appTitle') }}</span>
|
||||
|
||||
<!-- 动态内容仍使用客户端 -->
|
||||
<span data-i18n="dynamic.content">Content</span>
|
||||
```
|
||||
|
||||
### 3. 路由层面的集成
|
||||
|
||||
**修改的文件**:
|
||||
- `py/routes/base_model_routes.py` - 基础模型路由
|
||||
- `py/routes/recipe_routes.py` - 配方路由
|
||||
- `py/routes/stats_routes.py` - 统计路由
|
||||
- `py/routes/misc_routes.py` - 添加语言设置API
|
||||
|
||||
**路由实现**:
|
||||
```python
|
||||
# 获取用户语言设置
|
||||
user_language = settings.get('language', 'en')
|
||||
|
||||
# 设置服务端i18n语言
|
||||
server_i18n.set_locale(user_language)
|
||||
|
||||
# 为模板环境添加i18n过滤器
|
||||
self.template_env.filters['t'] = server_i18n.create_template_filter()
|
||||
|
||||
# 模板上下文
|
||||
template_context = {
|
||||
'user_language': user_language,
|
||||
't': server_i18n.get_translation,
|
||||
'server_i18n': server_i18n,
|
||||
'common_translations': {
|
||||
'loading': server_i18n.get_translation('common.status.loading'),
|
||||
# ... 其他常用翻译
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 前端混合处理器 (`static/js/utils/mixedI18n.js`)
|
||||
|
||||
**功能**:
|
||||
- 协调服务端和客户端翻译
|
||||
- 避免重复翻译已经服务端渲染的内容
|
||||
- 处理动态内容的客户端翻译
|
||||
- 支持语言切换(触发页面重新加载)
|
||||
|
||||
**工作流程**:
|
||||
1. 检查`window.__SERVER_TRANSLATIONS__`获取服务端预设的翻译
|
||||
2. 导入客户端i18n模块
|
||||
3. 同步客户端和服务端的语言设置
|
||||
4. 只翻译需要客户端处理的剩余元素
|
||||
|
||||
### 5. API支持
|
||||
|
||||
**新增API端点**:
|
||||
- `POST /api/set-language` - 设置用户语言偏好
|
||||
- `GET /api/get-language` - 获取当前语言设置
|
||||
|
||||
### 6. 语言文件扩展
|
||||
|
||||
**新增翻译内容**:
|
||||
```javascript
|
||||
initialization: {
|
||||
loras: {
|
||||
title: 'Initializing LoRA Manager',
|
||||
message: 'Scanning and building LoRA cache...'
|
||||
},
|
||||
checkpoints: {
|
||||
title: 'Initializing Checkpoint Manager',
|
||||
message: 'Scanning and building checkpoint cache...'
|
||||
},
|
||||
// ... 其他模块的初始化消息
|
||||
}
|
||||
```
|
||||
|
||||
## 实现效果
|
||||
|
||||
### 🎯 解决的问题
|
||||
|
||||
1. **✅ 消除语言闪烁**:首屏内容直接以用户设置的语言渲染
|
||||
2. **✅ 提升首屏性能**:关键UI元素无需等待JavaScript即可显示正确语言
|
||||
3. **✅ 改善SEO**:搜索引擎可以抓取到本地化内容
|
||||
4. **✅ 保持兼容性**:动态内容仍使用前端i18n,现有功能不受影响
|
||||
|
||||
### 🔧 技术优势
|
||||
|
||||
1. **渐进式增强**:服务端渲染提供基础体验,客户端增强交互功能
|
||||
2. **智能协调**:避免重复翻译,优化性能
|
||||
3. **回退机制**:如果服务端翻译失败,自动回退到客户端翻译
|
||||
4. **统一管理**:使用相同的语言文件,保持翻译一致性
|
||||
|
||||
### 🎨 用户体验提升
|
||||
|
||||
- **即时显示**:页面打开即显示用户语言,无等待时间
|
||||
- **无缝切换**:语言切换通过页面重载,确保所有内容都正确翻译
|
||||
- **一致性**:服务端和客户端使用相同翻译源,避免不一致
|
||||
|
||||
## 部署说明
|
||||
|
||||
1. 现有的JavaScript语言文件无需修改
|
||||
2. 服务端会自动解析并缓存翻译数据
|
||||
3. 用户的语言偏好保存在`settings.json`中
|
||||
4. 页面刷新后自动应用服务端翻译
|
||||
|
||||
## 兼容性
|
||||
|
||||
- ✅ 保持现有前端i18n功能完整
|
||||
- ✅ 支持所有现有语言(en, zh-CN, zh-TW, ru, de, ja, ko, fr, es)
|
||||
- ✅ 向后兼容现有的`data-i18n`属性
|
||||
- ✅ 支持复杂的动态内容翻译
|
||||
|
||||
此实现完美解决了原始问题,在不破坏现有功能的前提下,显著提升了用户体验和应用性能。
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
154
py/services/server_i18n.py
Normal 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()
|
||||
@@ -42,6 +42,11 @@ class I18nManager {
|
||||
return window.__INITIAL_LANGUAGE__;
|
||||
}
|
||||
|
||||
// 检查服务端传递的翻译数据
|
||||
if (window.__SERVER_TRANSLATIONS__ && window.__SERVER_TRANSLATIONS__.language && this.locales[window.__SERVER_TRANSLATIONS__.language]) {
|
||||
return window.__SERVER_TRANSLATIONS__.language;
|
||||
}
|
||||
|
||||
// Check localStorage for user-selected language
|
||||
const STORAGE_PREFIX = 'lora_manager_';
|
||||
let userLanguage = null;
|
||||
|
||||
@@ -373,6 +373,26 @@ export const en = {
|
||||
initialization: {
|
||||
title: 'Initializing LoRA Manager',
|
||||
message: 'Scanning and building LoRA cache. This may take a few minutes...',
|
||||
loras: {
|
||||
title: 'Initializing LoRA Manager',
|
||||
message: 'Scanning and building LoRA cache. This may take a few minutes...'
|
||||
},
|
||||
checkpoints: {
|
||||
title: 'Initializing Checkpoint Manager',
|
||||
message: 'Scanning and building checkpoint cache. This may take a few minutes...'
|
||||
},
|
||||
embeddings: {
|
||||
title: 'Initializing Embedding Manager',
|
||||
message: 'Scanning and building embedding cache. This may take a few minutes...'
|
||||
},
|
||||
recipes: {
|
||||
title: 'Initializing Recipe Manager',
|
||||
message: 'Loading and processing recipes. This may take a few minutes...'
|
||||
},
|
||||
statistics: {
|
||||
title: 'Initializing Statistics',
|
||||
message: 'Processing model data for statistics. This may take a few minutes...'
|
||||
},
|
||||
steps: {
|
||||
scanning: 'Scanning model files...',
|
||||
processing: 'Processing metadata...',
|
||||
|
||||
@@ -373,6 +373,26 @@ export const zhCN = {
|
||||
initialization: {
|
||||
title: '初始化 LoRA 管理器',
|
||||
message: '正在扫描并构建 LoRA 缓存,这可能需要几分钟时间...',
|
||||
loras: {
|
||||
title: '初始化 LoRA 管理器',
|
||||
message: '正在扫描并构建 LoRA 缓存,这可能需要几分钟时间...'
|
||||
},
|
||||
checkpoints: {
|
||||
title: '初始化大模型管理器',
|
||||
message: '正在扫描并构建大模型缓存,这可能需要几分钟时间...'
|
||||
},
|
||||
embeddings: {
|
||||
title: '初始化 Embedding 管理器',
|
||||
message: '正在扫描并构建 Embedding 缓存,这可能需要几分钟时间...'
|
||||
},
|
||||
recipes: {
|
||||
title: '初始化配方管理器',
|
||||
message: '正在加载和处理配方,这可能需要几分钟时间...'
|
||||
},
|
||||
statistics: {
|
||||
title: '初始化统计信息',
|
||||
message: '正在处理模型数据以生成统计信息,这可能需要几分钟时间...'
|
||||
},
|
||||
steps: {
|
||||
scanning: '扫描模型文件...',
|
||||
processing: '处理元数据...',
|
||||
|
||||
@@ -169,8 +169,19 @@ export function formatNumber(number, options = {}) {
|
||||
* This should be called after DOM content is loaded
|
||||
*/
|
||||
export function initializePageI18n() {
|
||||
// Translate all elements with data-i18n attributes
|
||||
translateDOM();
|
||||
// 优先使用服务端传递的翻译数据,避免闪烁
|
||||
if (window.__SERVER_TRANSLATIONS__ && window.__SERVER_TRANSLATIONS__.language) {
|
||||
// 设置客户端i18n的语言为服务端传递的语言
|
||||
if (window.i18n && window.i18n.setLanguage) {
|
||||
window.i18n.setLanguage(window.__SERVER_TRANSLATIONS__.language);
|
||||
}
|
||||
|
||||
// 对于剩余的需要动态翻译的元素,仍使用客户端翻译
|
||||
translateDOM();
|
||||
} else {
|
||||
// 回退到完整的客户端翻译
|
||||
translateDOM();
|
||||
}
|
||||
|
||||
// Update search placeholder based on current page
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
212
static/js/utils/mixedI18n.js
Normal file
212
static/js/utils/mixedI18n.js
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Mixed i18n handler - coordinates server-side and client-side translations
|
||||
* Reduces language flashing by using server-rendered content initially
|
||||
*/
|
||||
|
||||
class MixedI18nHandler {
|
||||
constructor() {
|
||||
this.serverTranslations = window.__SERVER_TRANSLATIONS__ || {};
|
||||
this.currentLanguage = this.serverTranslations.language || 'en';
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize mixed i18n system
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Import the main i18n module
|
||||
const { i18n } = await import('/loras_static/js/i18n/index.js');
|
||||
this.clientI18n = i18n;
|
||||
|
||||
// Ensure client i18n uses the same language as server
|
||||
if (this.currentLanguage && this.clientI18n.getCurrentLocale() !== this.currentLanguage) {
|
||||
this.clientI18n.setLanguage(this.currentLanguage);
|
||||
}
|
||||
|
||||
// Translate any remaining elements that need client-side translation
|
||||
this.translateRemainingElements();
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
// Dispatch event to notify that mixed i18n is ready
|
||||
window.dispatchEvent(new CustomEvent('mixedI18nReady', {
|
||||
detail: { language: this.currentLanguage }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate elements that still need client-side translation
|
||||
* (primarily dynamic content and complex components)
|
||||
*/
|
||||
translateRemainingElements() {
|
||||
if (!this.clientI18n) return;
|
||||
|
||||
// Find all elements with data-i18n attribute that haven't been server-rendered
|
||||
const elements = document.querySelectorAll('[data-i18n]');
|
||||
|
||||
elements.forEach(element => {
|
||||
// Skip if already translated by server (check if content matches key pattern)
|
||||
const key = element.getAttribute('data-i18n');
|
||||
const currentContent = element.textContent || element.value || element.placeholder;
|
||||
|
||||
// If the current content looks like a translation key, translate it
|
||||
if (currentContent === key || currentContent.includes('.') || currentContent === '') {
|
||||
this.translateElement(element, key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a single element using client-side i18n
|
||||
*/
|
||||
translateElement(element, key) {
|
||||
if (!this.clientI18n) return;
|
||||
|
||||
const params = element.getAttribute('data-i18n-params');
|
||||
let parsedParams = {};
|
||||
|
||||
if (params) {
|
||||
try {
|
||||
parsedParams = JSON.parse(params);
|
||||
} catch (e) {
|
||||
console.warn(`Invalid JSON in data-i18n-params for key ${key}:`, params);
|
||||
}
|
||||
}
|
||||
|
||||
// Get translated text
|
||||
const translatedText = this.clientI18n.t(key, parsedParams);
|
||||
|
||||
// Handle different translation targets
|
||||
const target = element.getAttribute('data-i18n-target') || 'textContent';
|
||||
|
||||
switch (target) {
|
||||
case 'placeholder':
|
||||
element.placeholder = translatedText;
|
||||
break;
|
||||
case 'title':
|
||||
element.title = translatedText;
|
||||
break;
|
||||
case 'alt':
|
||||
element.alt = translatedText;
|
||||
break;
|
||||
case 'innerHTML':
|
||||
element.innerHTML = translatedText;
|
||||
break;
|
||||
case 'textContent':
|
||||
default:
|
||||
element.textContent = translatedText;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch language (triggers page reload for server-side re-rendering)
|
||||
*/
|
||||
async switchLanguage(languageCode) {
|
||||
try {
|
||||
// Update server-side setting
|
||||
const response = await fetch('/api/set-language', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ language: languageCode })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Reload page to get server-rendered content in new language
|
||||
window.location.reload();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to set language:', error.error);
|
||||
|
||||
// Fallback to client-side only language change
|
||||
if (this.clientI18n) {
|
||||
this.clientI18n.setLanguage(languageCode);
|
||||
this.currentLanguage = languageCode;
|
||||
this.translateRemainingElements();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error switching language:', error);
|
||||
|
||||
// Fallback to client-side only language change
|
||||
if (this.clientI18n) {
|
||||
this.clientI18n.setLanguage(languageCode);
|
||||
this.currentLanguage = languageCode;
|
||||
this.translateRemainingElements();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current language
|
||||
*/
|
||||
getCurrentLanguage() {
|
||||
return this.currentLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation using client-side i18n (for dynamic content)
|
||||
*/
|
||||
t(key, params = {}) {
|
||||
if (this.clientI18n) {
|
||||
return this.clientI18n.t(key, params);
|
||||
}
|
||||
|
||||
// Fallback: check server translations
|
||||
if (this.serverTranslations.common && key.startsWith('common.')) {
|
||||
const subKey = key.substring(7); // Remove 'common.' prefix
|
||||
return this.serverTranslations.common[subKey] || key;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size using client-side i18n
|
||||
*/
|
||||
formatFileSize(bytes, decimals = 2) {
|
||||
if (this.clientI18n) {
|
||||
return this.clientI18n.formatFileSize(bytes, decimals);
|
||||
}
|
||||
|
||||
// Simple fallback
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date using client-side i18n
|
||||
*/
|
||||
formatDate(date, options = {}) {
|
||||
if (this.clientI18n) {
|
||||
return this.clientI18n.formatDate(date, options);
|
||||
}
|
||||
|
||||
// Simple fallback
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
return dateObj.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.mixedI18n = new MixedI18nHandler();
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.mixedI18n.initialize();
|
||||
});
|
||||
} else {
|
||||
window.mixedI18n.initialize();
|
||||
}
|
||||
|
||||
// Export for module usage
|
||||
export default window.mixedI18n;
|
||||
@@ -55,6 +55,17 @@
|
||||
|
||||
// 设置初始语言到全局变量,供i18n系统使用
|
||||
window.__INITIAL_LANGUAGE__ = userLanguage;
|
||||
|
||||
// 预设服务端翻译的内容,避免初始渲染时的闪烁
|
||||
window.__SERVER_TRANSLATIONS__ = {
|
||||
language: userLanguage,
|
||||
common: {
|
||||
loading: '{{ t("common.status.loading") }}',
|
||||
error: '{{ t("common.status.error") }}',
|
||||
refresh: '{{ t("common.actions.refresh") }}',
|
||||
search: '{{ t("common.actions.search") }}'
|
||||
}
|
||||
};
|
||||
|
||||
// Apply theme immediately based on stored preference
|
||||
const STORAGE_PREFIX = 'lora_manager_';
|
||||
@@ -109,6 +120,8 @@
|
||||
<!-- Load initialization JavaScript -->
|
||||
<script type="module" src="/loras_static/js/components/initialization.js"></script>
|
||||
{% else %}
|
||||
<!-- Load mixed i18n handler first for better coordination -->
|
||||
<script type="module" src="/loras_static/js/utils/mixedI18n.js"></script>
|
||||
{% block main_script %}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -3,36 +3,36 @@
|
||||
<div class="header-branding">
|
||||
<a href="/loras" class="logo-link">
|
||||
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
||||
<span class="app-title" data-i18n="header.appTitle">LoRA Manager</span>
|
||||
<span class="app-title">{{ t('header.appTitle') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<nav class="main-nav">
|
||||
<a href="/loras" class="nav-item" id="lorasNavItem">
|
||||
<i class="fas fa-layer-group"></i> <span data-i18n="header.navigation.loras">LoRAs</span>
|
||||
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
||||
</a>
|
||||
<a href="/loras/recipes" class="nav-item" id="recipesNavItem">
|
||||
<i class="fas fa-book-open"></i> <span data-i18n="header.navigation.recipes">Recipes</span>
|
||||
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
||||
</a>
|
||||
<a href="/checkpoints" class="nav-item" id="checkpointsNavItem">
|
||||
<i class="fas fa-check-circle"></i> <span data-i18n="header.navigation.checkpoints">Checkpoints</span>
|
||||
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
||||
</a>
|
||||
<a href="/embeddings" class="nav-item" id="embeddingsNavItem">
|
||||
<i class="fas fa-code"></i> <span data-i18n="header.navigation.embeddings">Embeddings</span>
|
||||
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
||||
</a>
|
||||
<a href="/statistics" class="nav-item" id="statisticsNavItem">
|
||||
<i class="fas fa-chart-bar"></i> <span data-i18n="header.navigation.statistics">Stats</span>
|
||||
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Context-aware search container -->
|
||||
<div class="header-search" id="headerSearch">
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" data-i18n="header.search.placeholder" data-i18n-target="placeholder" placeholder="Search..." />
|
||||
<input type="text" id="searchInput" placeholder="{{ t('header.search.placeholder') }}" />
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<button class="search-options-toggle" id="searchOptionsToggle" data-i18n="header.search.options" data-i18n-target="title" title="Search Options">
|
||||
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
</button>
|
||||
<button class="search-filter-toggle" id="filterButton" data-i18n="header.filter.title" data-i18n-target="title" title="Filter models">
|
||||
<button class="search-filter-toggle" id="filterButton" title="{{ t('header.filter.title') }}">
|
||||
<i class="fas fa-filter"></i>
|
||||
<span class="filter-badge" id="activeFiltersCount" style="display: none">0</span>
|
||||
</button>
|
||||
@@ -69,35 +69,35 @@
|
||||
<!-- Add search options panel with context-aware options -->
|
||||
<div id="searchOptionsPanel" class="search-options-panel hidden">
|
||||
<div class="options-header">
|
||||
<h3 data-i18n="header.search.options">Search Options</h3>
|
||||
<h3>{{ t('header.search.options') }}</h3>
|
||||
<button class="close-options-btn" id="closeSearchOptions">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="options-section">
|
||||
<h4 data-i18n="header.search.searchIn">Search In:</h4>
|
||||
<h4>{{ t('header.search.searchIn') }}</h4>
|
||||
<div class="search-option-tags">
|
||||
{% if request.path == '/loras/recipes' %}
|
||||
<div class="search-option-tag active" data-option="title" data-i18n="header.search.filters.title">Recipe Title</div>
|
||||
<div class="search-option-tag active" data-option="tags" data-i18n="header.search.filters.tags">Tags</div>
|
||||
<div class="search-option-tag active" data-option="loraName" data-i18n="header.search.filters.loraName">LoRA Filename</div>
|
||||
<div class="search-option-tag active" data-option="loraModel" data-i18n="header.search.filters.loraModel">LoRA Model Name</div>
|
||||
<div class="search-option-tag active" data-option="title">{{ t('header.search.filters.title') }}</div>
|
||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||
<div class="search-option-tag active" data-option="loraName">{{ t('header.search.filters.loraName') }}</div>
|
||||
<div class="search-option-tag active" data-option="loraModel">{{ t('header.search.filters.loraModel') }}</div>
|
||||
{% elif request.path == '/checkpoints' %}
|
||||
<div class="search-option-tag active" data-option="filename" data-i18n="header.search.filters.filename">Filename</div>
|
||||
<div class="search-option-tag active" data-option="modelname" data-i18n="header.search.filters.modelname">Checkpoint Name</div>
|
||||
<div class="search-option-tag active" data-option="tags" data-i18n="header.search.filters.tags">Tags</div>
|
||||
<div class="search-option-tag" data-option="creator" data-i18n="header.search.filters.creator">Creator</div>
|
||||
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
||||
{% elif request.path == '/embeddings' %}
|
||||
<div class="search-option-tag active" data-option="filename" data-i18n="header.search.filters.filename">Filename</div>
|
||||
<div class="search-option-tag active" data-option="modelname" data-i18n="header.search.filters.modelname">Embedding Name</div>
|
||||
<div class="search-option-tag active" data-option="tags" data-i18n="header.search.filters.tags">Tags</div>
|
||||
<div class="search-option-tag" data-option="creator" data-i18n="header.search.filters.creator">Creator</div>
|
||||
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
||||
{% else %}
|
||||
<!-- Default options for LoRAs page -->
|
||||
<div class="search-option-tag active" data-option="filename" data-i18n="header.search.filters.filename">Filename</div>
|
||||
<div class="search-option-tag active" data-option="modelname" data-i18n="header.search.filters.modelname">Model Name</div>
|
||||
<div class="search-option-tag active" data-option="tags" data-i18n="header.search.filters.tags">Tags</div>
|
||||
<div class="search-option-tag" data-option="creator" data-i18n="header.search.filters.creator">Creator</div>
|
||||
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,27 +106,27 @@
|
||||
<!-- Add filter panel -->
|
||||
<div id="filterPanel" class="filter-panel hidden">
|
||||
<div class="filter-header">
|
||||
<h3 data-i18n="header.filter.title">Filter Models</h3>
|
||||
<h3>{{ t('header.filter.title') }}</h3>
|
||||
<button class="close-filter-btn" onclick="filterManager.closeFilterPanel()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<h4 data-i18n="header.filter.baseModel">Base Model</h4>
|
||||
<h4>{{ t('header.filter.baseModel') }}</h4>
|
||||
<div class="filter-tags" id="baseModelTags">
|
||||
<!-- Tags will be dynamically inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<h4 data-i18n="header.filter.modelTags">Tags (Top 20)</h4>
|
||||
<h4>{{ t('header.filter.modelTags') }}</h4>
|
||||
<div class="filter-tags" id="modelTagsFilter">
|
||||
<!-- Top tags will be dynamically inserted here -->
|
||||
<div class="tags-loading" data-i18n="common.status.loading">Loading tags...</div>
|
||||
<div class="tags-loading">{{ t('common.status.loading') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button class="clear-filters-btn" onclick="filterManager.clearFilters()" data-i18n="header.filter.clearAll">
|
||||
Clear All Filters
|
||||
<button class="clear-filters-btn" onclick="filterManager.clearFilters()">
|
||||
{{ t('header.filter.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}LoRA Manager{% endblock %}
|
||||
{% block title %}{{ t('header.appTitle') }}{% endblock %}
|
||||
{% block page_id %}loras{% endblock %}
|
||||
|
||||
{% block preload %}
|
||||
@@ -9,8 +9,8 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block init_title %}Initializing LoRA Manager{% endblock %}
|
||||
{% block init_message %}Scanning and building LoRA cache. This may take a few minutes...{% endblock %}
|
||||
{% block init_title %}{{ t('initialization.loras.title') }}{% endblock %}
|
||||
{% block init_message %}{{ t('initialization.loras.message') }}{% endblock %}
|
||||
{% block init_check_url %}/api/loras/list?page=1&page_size=1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
Reference in New Issue
Block a user