feat(i18n): add Chinese and English language support for CLI and TUI

- Add CLI i18n module (astrbot/cli/i18n.py) with zh/en translations
- Add TUI i18n module (astrbot/tui/i18n.py) with zh/en translations
- Update CLI commands to use translated strings
- Update TUI app and screen to use translated strings
- Add ASTRBOT_CLI_LANG and ASTRBOT_TUI_LANG to .env.example
- Update cmd_run.py env var documentation
- Add backward compatibility for moved modules with DeprecationWarning
This commit is contained in:
LIghtJUNction
2026-03-23 12:43:37 +08:00
parent 8d26c38b32
commit da62b57c56
12 changed files with 693 additions and 59 deletions

View File

@@ -80,6 +80,22 @@ ASTRBOT_SYSTEMD=1
# 默认 Default: True
ASTRBOT_DASHBOARD_ENABLE=True
# ------------------------------------------
# 国际化配置 / Internationalization Configuration
# ------------------------------------------
# CLI 界面语言
# CLI interface language
# 可选值 Values: zh (中文), en (英文)
# 默认 Default: zh (跟随系统 locale / follows system locale)
# ASTRBOT_CLI_LANG=zh
# TUI 界面语言
# TUI interface language
# 可选值 Values: zh (中文), en (英文)
# 默认 Default: zh
# ASTRBOT_TUI_LANG=zh
# ------------------------------------------
# 网络配置 / Network Configuration
# ------------------------------------------

View File

@@ -8,6 +8,7 @@ from click.shell_completion import get_completion_class
from . import __version__
from .commands import bk, conf, init, plug, run, tui, uninstall
from .i18n import t
logo_tmpl = r"""
___ _______.___________..______ .______ ______ .___________.
@@ -26,8 +27,8 @@ def cli() -> None:
Agentic IM Chatbot infrastructure that integrates lots of IM platforms, LLMs, plugins and AI feature, and can be your openclaw alternative. ✨
"""
click.echo(logo_tmpl)
click.echo("Welcome to AstrBot CLI!")
click.echo(f"AstrBot CLI version: {__version__}")
click.echo(t("cli_welcome"))
click.echo(t("cli_version", version=__version__))
@click.command()
@@ -70,7 +71,7 @@ def help(command_name: str | None, all: bool) -> None:
cmd_ctx = click.Context(command, info_name=command.name, parent=parent)
click.echo(command.get_help(cmd_ctx))
else:
click.echo(f"Unknown command: {command_name}")
click.echo(t("cli_unknown_command", command=command_name))
sys.exit(1)
else:
# Display general help information

View File

@@ -3,6 +3,7 @@ import shutil
import click
from astrbot.cli.i18n import t
from astrbot.cli.utils import (
PluginStatus,
build_plug_list,
@@ -43,7 +44,7 @@ def new(name: str) -> None:
plug_path = base_path / "plugins" / name
if plug_path.exists():
raise click.ClickException(f"Plugin {name} already exists")
raise click.ClickException(t("plugin_already_exists", name=name))
author = click.prompt("Enter plugin author", type=str)
desc = click.prompt("Enter plugin description", type=str)
@@ -155,7 +156,7 @@ def install(name: str, proxy: str | None) -> None:
)
if not plugin:
raise click.ClickException(f"Plugin {name} not found or already installed")
raise click.ClickException(t("plugin_not_found_or_installed", name=name))
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
@@ -171,19 +172,19 @@ def remove(name: str) -> None:
plugin = next((p for p in plugins if p["name"] == name), None)
if not plugin or not plugin.get("local_path"):
raise click.ClickException(f"Plugin {name} does not exist or is not installed")
raise click.ClickException(t("plugin_not_found_or_installed", name=name))
plugin_path = plugin["local_path"]
click.confirm(
f"Are you sure you want to uninstall plugin {name}?", default=False, abort=True
t("plugin_uninstall_confirm", name=name), default=False, abort=True
)
try:
shutil.rmtree(plugin_path)
click.echo(f"Plugin {name} has been uninstalled")
click.echo(t("plugin_uninstall_success", name=name))
except Exception as e:
raise click.ClickException(f"Failed to uninstall plugin {name}: {e}")
raise click.ClickException(t("plugin_uninstall_failed_ex", name=name, error=str(e)))
@plug.command()
@@ -219,13 +220,13 @@ def update(name: str, proxy: str | None) -> None:
]
if not need_update_plugins:
click.echo("No plugins need updating")
click.echo(t("plugin_no_update_needed"))
return
click.echo(f"Found {len(need_update_plugins)} plugin(s) needing update")
click.echo(t("plugin_found_update", count=str(len(need_update_plugins))))
for plugin in need_update_plugins:
plugin_name = plugin["name"]
click.echo(f"Updating plugin {plugin_name}...")
click.echo(t("plugin_updating", name=plugin_name))
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
@@ -247,7 +248,7 @@ def search(query: str) -> None:
]
if not matched_plugins:
click.echo(f"No plugins matching '{query}' found")
click.echo(t("plugin_search_no_result", query=query))
return
display_plugins(matched_plugins, f"Search results: '{query}'", "cyan")
display_plugins(matched_plugins, t("plugin_search_results", query=query), "cyan")

View File

@@ -14,23 +14,24 @@ Core:
- `PYTHON`: Python executable path override (for local code execution).
Dashboard / Backend:
- `ASTRBOT_DASHBOARD_ENABLE` / `DASHBOARD_ENABLE`: Enable/Disable Dashboard.
- `ASTRBOT_HOST` / `DASHBOARD_HOST`: Dashboard bind host.
- `ASTRBOT_PORT` / `DASHBOARD_PORT`: Dashboard bind port.
- `ASTRBOT_DASHBOARD_ENABLE`: Enable/Disable Dashboard.
- `ASTRBOT_HOST`: Dashboard bind host.
- `ASTRBOT_PORT`: Dashboard bind port.
Backend-standard SSL names (preferred for server):
- `ASTRBOT_SSL_ENABLE` / `DASHBOARD_SSL_ENABLE`: Enable SSL for API.
- `ASTRBOT_SSL_CERT` / `DASHBOARD_SSL_CERT`: SSL Certificate path for backend.
- `ASTRBOT_SSL_KEY` / `DASHBOARD_SSL_KEY`: SSL Key path for backend.
- `ASTRBOT_SSL_CA_CERTS` / `DASHBOARD_SSL_CA_CERTS`: SSL CA Certs path for backend.
Legacy compatibility:
- The CLI will set both `ASTRBOT_SSL_*` and the legacy `DASHBOARD_SSL_*` names to remain compatible.
SSL (AstrBot-standard names):
- `ASTRBOT_SSL_ENABLE`: Enable SSL for API.
- `ASTRBOT_SSL_CERT`: SSL Certificate path for backend.
- `ASTRBOT_SSL_KEY`: SSL Key path for backend.
- `ASTRBOT_SSL_CA_CERTS`: SSL CA Certs path for backend.
Network:
- `http_proxy` / `https_proxy`: Proxy URL.
- `no_proxy`: No proxy list.
Internationalization:
- `ASTRBOT_CLI_LANG`: CLI interface language (zh/en).
- `ASTRBOT_TUI_LANG`: TUI interface language (zh/en).
Integrations:
- `DASHSCOPE_API_KEY`: Alibaba DashScope API Key (for Rerank).
- `COZE_API_KEY` / `COZE_BOT_ID`: Coze integration.

301
astrbot/cli/i18n.py Normal file
View File

@@ -0,0 +1,301 @@
"""Internationalization support for AstrBot CLI.
This module provides i18n support with Chinese and English languages.
Language is auto-detected from environment or can be set manually.
"""
from __future__ import annotations
import os
from enum import Enum
from functools import lru_cache
class Language(Enum):
"""Supported languages."""
ZH = "zh"
EN = "en"
# Translation dictionaries
_TRANSLATIONS: dict[Language, dict[str, str]] = {
Language.ZH: {
# CLI welcome and general
"cli_welcome": "欢迎使用 AstrBot CLI!",
"cli_version": "AstrBot CLI 版本: {version}",
"cli_unknown_command": "未知命令: {command}",
"cli_help_available": "使用 astrbot help --all 查看所有命令",
# Dashboard commands
"dashboard_bundled": "Dashboard 已打包在安装包中 - 跳过下载",
"dashboard_not_installed": "Dashboard 未安装",
"dashboard_install_confirm": "是否安装 Dashboard?",
"dashboard_installing": "正在安装 Dashboard...",
"dashboard_install_success": "Dashboard 安装成功",
"dashboard_install_failed": "Dashboard 安装失败: {error}",
"dashboard_not_needed": "Dashboard 不需要安装",
"dashboard_declined": "Dashboard 安装已取消",
"dashboard_already_up_to_date": "Dashboard 已是最新版本",
"dashboard_version": "Dashboard 版本: {version}",
"dashboard_download_failed": "Dashboard 下载失败: {error}",
"dashboard_init_dir": "正在初始化 Dashboard 目录...",
"dashboard_init_success": "Dashboard 初始化成功",
# Plugin commands
"plugin_installing": "正在安装插件: {name}",
"plugin_install_success": "插件安装成功: {name}",
"plugin_install_failed": "插件安装失败: {name}",
"plugin_uninstall_confirm": "确定要卸载插件 {name} 吗?",
"plugin_uninstall_success": "插件卸载成功: {name}",
"plugin_uninstall_failed": "插件卸载失败: {name}",
"plugin_list_empty": "未安装任何插件",
"plugin_already_installed": "插件已安装: {name}",
"plugin_not_found": "插件未找到: {name}",
"plugin_already_exists": "插件已存在: {name}",
"plugin_not_found_or_installed": "插件未找到或已安装: {name}",
"plugin_uninstall_failed_ex": "插件卸载失败 {name}: {error}",
"plugin_no_update_needed": "没有需要更新的插件",
"plugin_found_update": "发现 {count} 个插件需要更新",
"plugin_updating": "正在更新插件 {name}...",
"plugin_search_no_result": "未找到匹配 '{query}' 的插件",
"plugin_search_results": "搜索结果: '{query}'",
# Config commands
"config_show": "显示配置",
"config_set_success": "配置项已更新: {key} = {value}",
"config_set_failed": "配置项更新失败: {key}",
"config_set_failed_ex": "设置配置失败: {error}",
"config_get_success": "{key} = {value}",
"config_get_not_found": "配置项未找到: {key}",
"config_reset_confirm": "确定要重置所有配置吗?",
"config_reset_success": "配置已重置",
# Config validators
"config_log_level_invalid": "日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
"config_port_must_be_number": "端口必须是数字",
"config_port_range_invalid": "端口必须在 1-65535 范围内",
"config_username_empty": "用户名不能为空",
"config_password_empty": "密码不能为空",
"config_timezone_invalid": "无效的时区: {value}。请使用有效的 IANA 时区名称",
"config_callback_invalid": "回调 API 基础路径必须以 http:// 或 https:// 开头",
"config_key_unsupported": "不支持的配置项: {key}",
"config_key_unknown": "未知的配置项: {key}",
"config_updated": "配置已更新: {key}",
# Init command
"init_creating": "正在创建配置目录...",
"init_created": "配置目录已创建: {path}",
"init_copying": "正在复制配置文件...",
"init_copied": "配置文件已复制",
"init_success": "AstrBot 初始化完成!",
"init_failed": "初始化失败: {error}",
# Run command
"run_starting": "正在启动 AstrBot...",
"run_started": "AstrBot 已启动!",
"run_backend_only": "以无界面模式启动",
"run_failed": "启动失败: {error}",
"run_stopped": "AstrBot 已停止",
# TUI command
"tui_starting": "正在启动 TUI...",
"tui_started": "TUI 已启动",
"tui_failed": "TUI 启动失败: {error}",
# Common
"yes": "",
"no": "",
"cancel": "取消",
"confirm": "确认",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "信息",
"loading": "加载中...",
"done": "完成",
"failed": "失败",
"retry": "重试",
"exit": "退出",
"continue": "继续",
},
Language.EN: {
# CLI welcome and general
"cli_welcome": "Welcome to AstrBot CLI!",
"cli_version": "AstrBot CLI version: {version}",
"cli_unknown_command": "Unknown command: {command}",
"cli_help_available": "Use astrbot help --all to see all commands",
# Dashboard commands
"dashboard_bundled": "Dashboard is bundled with the package - skipping download",
"dashboard_not_installed": "Dashboard is not installed",
"dashboard_install_confirm": "Install Dashboard?",
"dashboard_installing": "Installing Dashboard...",
"dashboard_install_success": "Dashboard installed successfully",
"dashboard_install_failed": "Failed to install dashboard: {error}",
"dashboard_not_needed": "Dashboard not needed",
"dashboard_declined": "Dashboard installation declined.",
"dashboard_already_up_to_date": "Dashboard is already up to date",
"dashboard_version": "Dashboard version: {version}",
"dashboard_download_failed": "Failed to download dashboard: {error}",
"dashboard_init_dir": "Initializing dashboard directory...",
"dashboard_init_success": "Dashboard initialized successfully",
# Plugin commands
"plugin_installing": "Installing plugin: {name}",
"plugin_install_success": "Plugin installed successfully: {name}",
"plugin_install_failed": "Failed to install plugin: {name}",
"plugin_uninstall_confirm": "Uninstall plugin {name}?",
"plugin_uninstall_success": "Plugin uninstalled successfully: {name}",
"plugin_uninstall_failed": "Failed to uninstall plugin: {name}",
"plugin_list_empty": "No plugins installed",
"plugin_already_installed": "Plugin already installed: {name}",
"plugin_not_found": "Plugin not found: {name}",
"plugin_already_exists": "Plugin {name} already exists",
"plugin_not_found_or_installed": "Plugin {name} not found or already installed",
"plugin_uninstall_failed_ex": "Failed to uninstall plugin {name}: {error}",
"plugin_no_update_needed": "No plugins need updating",
"plugin_found_update": "Found {count} plugin(s) needing update",
"plugin_updating": "Updating plugin {name}...",
"plugin_search_no_result": "No plugins matching '{query}' found",
"plugin_search_results": "Search results: '{query}'",
# Config commands
"config_show": "Show configuration",
"config_set_success": "Configuration updated: {key} = {value}",
"config_set_failed": "Failed to update configuration: {key}",
"config_set_failed_ex": "Failed to set config: {error}",
"config_get_success": "{key} = {value}",
"config_get_not_found": "Configuration key not found: {key}",
"config_reset_confirm": "Reset all configuration?",
"config_reset_success": "Configuration reset",
# Config validators
"config_log_level_invalid": "Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
"config_port_must_be_number": "Port must be a number",
"config_port_range_invalid": "Port must be in range 1-65535",
"config_username_empty": "Username cannot be empty",
"config_password_empty": "Password cannot be empty",
"config_timezone_invalid": "Invalid timezone: {value}. Please use a valid IANA timezone name",
"config_callback_invalid": "Callback API base must start with http:// or https://",
"config_key_unsupported": "Unsupported config key: {key}",
"config_key_unknown": "Unknown config key: {key}",
"config_updated": "Config updated: {key}",
# Init command
"init_creating": "Creating config directory...",
"init_created": "Config directory created: {path}",
"init_copying": "Copying config files...",
"init_copied": "Config files copied",
"init_success": "AstrBot initialized successfully!",
"init_failed": "Initialization failed: {error}",
# Run command
"run_starting": "Starting AstrBot...",
"run_started": "AstrBot started!",
"run_backend_only": "Starting in backend-only mode",
"run_failed": "Failed to start: {error}",
"run_stopped": "AstrBot stopped",
# TUI command
"tui_starting": "Starting TUI...",
"tui_started": "TUI started",
"tui_failed": "Failed to start TUI: {error}",
# Common
"yes": "Yes",
"no": "No",
"cancel": "Cancel",
"confirm": "Confirm",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"loading": "Loading...",
"done": "Done",
"failed": "Failed",
"retry": "Retry",
"exit": "Exit",
"continue": "Continue",
},
}
@lru_cache(maxsize=1)
def get_current_language() -> Language:
"""Get the current language based on environment or default.
Detection order:
1. ASTRBOT_CLI_LANG environment variable (zh/en)
2. LANG environment variable (if contains zh/cn)
3. LC_ALL environment variable (if contains zh/cn)
4. Default to Chinese (most users are Chinese)
"""
# Check explicit override first
explicit = os.environ.get("ASTRBOT_CLI_LANG", "").lower()
if explicit in ("zh", "en"):
return Language.ZH if explicit == "zh" else Language.EN
# Check LANG/LC_ALL for Chinese
for env_var in ("LANG", "LC_ALL"):
lang = os.environ.get(env_var, "").lower()
if "zh" in lang or "cn" in lang:
return Language.ZH
# Default to Chinese for broader appeal
return Language.ZH
def set_language(lang: Language) -> None:
"""Set the current language (clears all translation caches)."""
get_current_language.cache_clear()
_t_cached.cache_clear()
# Set environment variable for persistence
os.environ["ASTRBOT_CLI_LANG"] = lang.value
@lru_cache(maxsize=128)
def _t_cached(key: str, lang: Language) -> str:
"""Cached translation lookup."""
return _TRANSLATIONS.get(lang, {}).get(key, key)
def t(translation_key: str, **kwargs: str) -> str:
"""Get translation for the given key in the current language.
Args:
translation_key: Translation key (e.g., "cli_welcome", "plugin_installing")
**kwargs: Format arguments for the translation string
Returns:
Translated string, or the key itself if not found
"""
result = _t_cached(translation_key, get_current_language())
if kwargs:
result = result.format(**kwargs)
return result
def tr(key: str, **kwargs: str) -> str:
"""Get translation (alias for t())."""
return t(key, **kwargs)
class CLITranslations:
"""Translation accessor class for CLI contexts.
Usage:
translations = CLITranslations()
print(translations.cli_welcome)
print(translations.plugin_installing(name="my_plugin"))
"""
def __getattr__(self, key: str) -> str:
return t(key)
def __call__(self, key: str, **kwargs: str) -> str:
return t(key, **kwargs)
# Convenience instance
translations = CLITranslations()

View File

@@ -1,10 +1,11 @@
import os
import sys
from importlib import resources
from pathlib import Path
import click
from astrbot.cli.i18n import t
from .version_comparator import VersionComparator
@@ -17,25 +18,20 @@ class DashboardManager:
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
if self._bundled_dist.is_dir():
click.echo("Dashboard is bundled with the package - skipping download.")
click.echo(t("dashboard_bundled"))
return
try:
dashboard_version = await get_dashboard_version()
match dashboard_version:
case None:
click.echo("Dashboard is not installed")
click.echo(t("dashboard_not_installed"))
# Skip interactive prompt in non-interactive environments
if not sys.stdin.isatty():
click.echo(
"Skipping interactive dashboard installation in non-interactive mode."
)
click.echo(t("dashboard_not_needed"))
return
if click.confirm(
"Install dashboard?",
default=True,
):
click.echo("Installing dashboard...")
if click.confirm(t("dashboard_install_confirm"), default=True):
click.echo(t("dashboard_installing"))
try:
await download_dashboard(
path="data/dashboard.zip",
@@ -43,22 +39,22 @@ class DashboardManager:
version=f"v{VERSION}",
latest=False,
)
click.echo("Dashboard installed successfully")
click.echo(t("dashboard_install_success"))
except Exception as e:
click.echo(f"Failed to install dashboard: {e}")
click.echo(t("dashboard_install_failed", error=str(e)))
else:
click.echo("Dashboard installation declined.")
click.echo(t("dashboard_declined"))
case str():
if (
VersionComparator.compare_version(VERSION, dashboard_version)
<= 0
):
click.echo("Dashboard is already up to date")
click.echo(t("dashboard_already_up_to_date"))
return
try:
version = dashboard_version.split("v")[1]
click.echo(f"Dashboard version: {version}")
click.echo(t("dashboard_version", version=version))
await download_dashboard(
path="data/dashboard.zip",
extract_path=str(astrbot_root / "data"),
@@ -66,10 +62,10 @@ class DashboardManager:
latest=False,
)
except Exception as e:
click.echo(f"Failed to download dashboard: {e}")
click.echo(t("dashboard_download_failed", error=str(e)))
return
except FileNotFoundError:
click.echo("Initializing dashboard directory...")
click.echo(t("dashboard_init_dir"))
try:
await download_dashboard(
path=str(astrbot_root / "data" / "dashboard.zip"),
@@ -77,7 +73,7 @@ class DashboardManager:
version=f"v{VERSION}",
latest=False,
)
click.echo("Dashboard initialized successfully")
click.echo(t("dashboard_init_success"))
except Exception as e:
click.echo(f"Failed to download dashboard: {e}")
click.echo(t("dashboard_download_failed", error=str(e)))
return

View File

@@ -1,3 +1,28 @@
"""
MCP client - DEPRECATED
.. deprecated::
This module has been moved to :mod:`astrbot._internal.mcp`.
Please update your imports accordingly.
Old import (deprecated):
from astrbot.core.agent.mcp_client import MCPClient, MCPTool
New import:
from astrbot._internal.mcp import MCPClient, MCPTool
This file exists solely for backward compatibility and will be removed in a future version.
"""
import warnings
warnings.warn(
"astrbot.core.agent.mcp_client has been moved to astrbot._internal.mcp. "
"Please update your imports.",
DeprecationWarning,
stacklevel=2,
)
import asyncio
import logging
import os

View File

@@ -1,3 +1,39 @@
from .skill_manager import SkillInfo, SkillManager, build_skills_prompt
"""
AstrBot skills module - DEPRECATED
__all__ = ["SkillInfo", "SkillManager", "build_skills_prompt"]
.. deprecated::
This module has been moved to :mod:`astrbot._internal.skills`.
Please update your imports accordingly.
Old import (deprecated):
from astrbot.core.skills import SkillManager, SkillInfo
New import:
from astrbot._internal.skills import SkillManager, SkillInfo
This file exists solely for backward compatibility and will be removed in a future version.
"""
import warnings
warnings.warn(
"astrbot.core.skills has been moved to astrbot._internal.skills. "
"Please update your imports.",
DeprecationWarning,
stacklevel=2,
)
# Re-export from new location for backward compatibility
from astrbot._internal.skills import (
SkillInfo,
SkillManager,
build_skills_prompt,
SkillToToolConverter,
)
__all__ = [
"SkillInfo",
"SkillManager",
"build_skills_prompt",
"SkillToToolConverter",
]

View File

@@ -0,0 +1,47 @@
"""
AstrBot core tools - DEPRECATED
.. deprecated::
This module has been moved to :mod:`astrbot._internal.tools.builtin`.
Please update your imports accordingly.
Old import (deprecated):
from astrbot.core.tools import cron_tools, send_message, kb_query
New import:
from astrbot._internal.tools.builtin import (
CreateActiveCronTool,
DeleteCronJobTool,
ListCronJobsTool,
SendMessageToUserTool,
KnowledgeBaseQueryTool,
)
This file exists solely for backward compatibility and will be removed in a future version.
"""
import warnings
warnings.warn(
"astrbot.core.tools has been moved to astrbot._internal.tools.builtin. "
"Please update your imports.",
DeprecationWarning,
stacklevel=2,
)
# Re-export from new location for backward compatibility
from astrbot._internal.tools.builtin import (
CREATE_CRON_JOB_TOOL,
DELETE_CRON_JOB_TOOL,
KNOWLEDGE_BASE_QUERY_TOOL,
LIST_CRON_JOBS_TOOL,
SEND_MESSAGE_TO_USER_TOOL,
)
__all__ = [
"CREATE_CRON_JOB_TOOL",
"DELETE_CRON_JOB_TOOL",
"KNOWLEDGE_BASE_QUERY_TOOL",
"LIST_CRON_JOBS_TOOL",
"SEND_MESSAGE_TO_USER_TOOL",
]

183
astrbot/tui/i18n.py Normal file
View File

@@ -0,0 +1,183 @@
"""Internationalization support for AstrBot TUI.
This module provides i18n support with Chinese and English languages.
Language is auto-detected from environment or can be set manually.
"""
from __future__ import annotations
import os
from enum import Enum
from functools import lru_cache
class Language(Enum):
"""Supported languages."""
ZH = "zh"
EN = "en"
# Translation dictionaries
_TRANSLATIONS: dict[Language, dict[str, str]] = {
Language.ZH: {
# Welcome messages
"welcome_title": "欢迎使用 AstrBot TUI",
"welcome_local_mode": "本地测试模式",
"welcome_instructions": "输入消息后按 Enter 发送, ESC 或 Ctrl+C 退出",
"welcome_language": "语言已自动检测为中文",
# Status messages
"status_ready": "就绪",
"status_connected": "已连接",
"status_disconnected": "未连接",
"status_processing": "处理中...",
"status_sending": "发送中...",
# Message indicators
"indicator_user": "",
"indicator_bot": "AI",
"indicator_system": "系统",
"indicator_tool": "工具",
"indicator_reasoning": "推理",
# Input hints
"input_prompt": "> ",
"input_placeholder": "输入消息...",
# Error messages
"error_empty_message": "消息不能为空",
"error_send_failed": "发送失败",
"error_connection_lost": "连接已断开",
"error_unknown": "未知错误",
# Tool messages
"tool_using": "使用工具中",
"tool_completed": "工具执行完成",
"tool_failed": "工具执行失败",
# Reasoning messages
"reasoning_thinking": "思考中...",
"reasoning_reasoning": "推理中...",
},
Language.EN: {
# Welcome messages
"welcome_title": "Welcome to AstrBot TUI",
"welcome_local_mode": "Local Testing Mode",
"welcome_instructions": "Type your message and press Enter to send. ESC or Ctrl+C to exit.",
"welcome_language": "Language auto-detected as English",
# Status messages
"status_ready": "Ready",
"status_connected": "Connected",
"status_disconnected": "Disconnected",
"status_processing": "Processing...",
"status_sending": "Sending...",
# Message indicators
"indicator_user": "Me",
"indicator_bot": "AI",
"indicator_system": "Sys",
"indicator_tool": "Tool",
"indicator_reasoning": "Reason",
# Input hints
"input_prompt": "> ",
"input_placeholder": "Type a message...",
# Error messages
"error_empty_message": "Message cannot be empty",
"error_send_failed": "Failed to send",
"error_connection_lost": "Connection lost",
"error_unknown": "Unknown error",
# Tool messages
"tool_using": "Using tool",
"tool_completed": "Tool completed",
"tool_failed": "Tool failed",
# Reasoning messages
"reasoning_thinking": "Thinking...",
"reasoning_reasoning": "Reasoning...",
},
}
@lru_cache(maxsize=1)
def get_current_language() -> Language:
"""Get the current language based on environment or default.
Detection order:
1. ASTRBOT_TUI_LANG environment variable (zh/en)
2. LANG environment variable (if contains zh/cn)
3. LC_ALL environment variable (if contains zh/cn)
4. Default to Chinese (most users are Chinese)
"""
# Check explicit override first
explicit = os.environ.get("ASTRBOT_TUI_LANG", "").lower()
if explicit in ("zh", "en"):
return Language.ZH if explicit == "zh" else Language.EN
# Check LANG/LC_ALL for Chinese
for env_var in ("LANG", "LC_ALL"):
lang = os.environ.get(env_var, "").lower()
if "zh" in lang or "cn" in lang:
return Language.ZH
# Default to Chinese for broader appeal
return Language.ZH
def set_language(lang: Language) -> None:
"""Set the current language (clears all translation caches)."""
get_current_language.cache_clear()
_t_cached.cache_clear()
# Set environment variable for persistence
os.environ["ASTRBOT_TUI_LANG"] = lang.value
@lru_cache(maxsize=128)
def _t_cached(translation_key: str, lang: Language) -> str:
"""Cached translation lookup."""
return _TRANSLATIONS.get(lang, {}).get(translation_key, translation_key)
def t(translation_key: str) -> str:
"""Get translation for the given key in the current language.
Args:
translation_key: Translation key (e.g., "welcome_title", "status_ready")
Returns:
Translated string, or the key itself if not found
"""
return _t_cached(translation_key, get_current_language())
def tr(translation_key: str) -> str:
"""Get translation (alias for t())."""
return t(translation_key)
class TUITranslations:
"""Translation accessor class for non-function contexts.
Usage:
translations = TUITranslations()
print(translations.WELCOME_TITLE)
"""
def __getattr__(self, key: str) -> str:
return t(key)
def __getitem__(self, key: str) -> str:
return t(key)
def get(self, key: str, default: str | None = None) -> str:
"""Get translation with default."""
result = t(key)
return default if result == key and default else result
# Convenience instance
translations = TUITranslations()

View File

@@ -6,6 +6,8 @@ import curses
from collections.abc import Callable
from enum import Enum
from astrbot.tui.i18n import t
class ColorPair(Enum):
WHITE = 1
@@ -156,7 +158,7 @@ class Screen:
if not self._header_win:
return
self._header_win.clear()
title = " AstrBot TUI "
title = f" {t('welcome_title')} "
try:
self._header_win.bkgdset(curses.color_pair(ColorPair.HEADER_FG.value))
@@ -201,20 +203,25 @@ class Screen:
if y >= max_y:
break
# Get localized indicator
indicator_map = {
"user": t("indicator_user"),
"bot": t("indicator_bot"),
"tool": t("indicator_tool"),
"reasoning": t("indicator_reasoning"),
"system": t("indicator_system"),
}
indicator = indicator_map.get(sender, t("indicator_system"))
if sender == "user":
indicator = ">"
color = self.get_color(ColorPair.USER_MSG)
elif sender == "bot":
indicator = ""
color = self.get_color(ColorPair.BOT_MSG)
elif sender == "tool":
indicator = ""
color = self.get_color(ColorPair.TOOL_MSG)
elif sender == "reasoning":
indicator = ""
color = self.get_color(ColorPair.REASONING_MSG)
else:
indicator = ""
color = self.get_color(ColorPair.SYSTEM_MSG)
max_text_width = self.width - 4
@@ -260,8 +267,8 @@ class Screen:
return
self._input_win.clear()
prompt = "> "
prompt_len = 2
prompt = t("input_prompt")
prompt_len = len(prompt)
max_input_width = self.width - 2
try:

View File

@@ -10,6 +10,7 @@ import curses
from dataclasses import dataclass, field
from enum import Enum
from astrbot.tui.i18n import TUITranslations, t
from astrbot.tui.screen import Screen
@@ -21,6 +22,25 @@ class MessageSender(Enum):
REASONING = "reasoning"
# Translation accessor for templates
tr = TUITranslations()
# Mapping from sender to translation key
_SENDER_TO_KEY = {
MessageSender.USER: "indicator_user",
MessageSender.BOT: "indicator_bot",
MessageSender.SYSTEM: "indicator_system",
MessageSender.TOOL: "indicator_tool",
MessageSender.REASONING: "indicator_reasoning",
}
def get_indicator(sender: MessageSender) -> str:
"""Get the localized indicator string for a message sender."""
return t(_SENDER_TO_KEY.get(sender, "indicator_system"))
@dataclass
class Message:
sender: MessageSender
@@ -33,7 +53,7 @@ class TUIState:
messages: list[Message] = field(default_factory=list)
input_buffer: str = ""
cursor_x: int = 0
status: str = "Ready"
status: str = field(default_factory=lambda: t("status_ready"))
running: bool = True
connected: bool = False
@@ -182,9 +202,9 @@ class AstrBotTUI:
self.screen.layout_windows()
# Welcome message
self.add_system_message("Welcome to AstrBot TUI (local mode)!")
self.add_system_message("Type your message and press Enter to send.")
self.add_system_message("Press ESC or Ctrl+C to exit.")
self.add_system_message(t("welcome_title"))
self.add_system_message(t("welcome_local_mode"))
self.add_system_message(t("welcome_instructions"))
# Initial render
self.render()