diff --git a/.env.example b/.env.example index 903526f14..ac209b29c 100644 --- a/.env.example +++ b/.env.example @@ -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 # ------------------------------------------ diff --git a/astrbot/cli/__main__.py b/astrbot/cli/__main__.py index 31345fdc0..e150729da 100644 --- a/astrbot/cli/__main__.py +++ b/astrbot/cli/__main__.py @@ -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 diff --git a/astrbot/cli/commands/cmd_plug.py b/astrbot/cli/commands/cmd_plug.py index 734427569..c50686427 100644 --- a/astrbot/cli/commands/cmd_plug.py +++ b/astrbot/cli/commands/cmd_plug.py @@ -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") diff --git a/astrbot/cli/commands/cmd_run.py b/astrbot/cli/commands/cmd_run.py index 7dec21678..1d32c903e 100644 --- a/astrbot/cli/commands/cmd_run.py +++ b/astrbot/cli/commands/cmd_run.py @@ -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. diff --git a/astrbot/cli/i18n.py b/astrbot/cli/i18n.py new file mode 100644 index 000000000..05e5b01a4 --- /dev/null +++ b/astrbot/cli/i18n.py @@ -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() diff --git a/astrbot/cli/utils/dashboard.py b/astrbot/cli/utils/dashboard.py index e6a435194..7cbbf2f17 100644 --- a/astrbot/cli/utils/dashboard.py +++ b/astrbot/cli/utils/dashboard.py @@ -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 diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index 3bf8f7243..a055722d4 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -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 diff --git a/astrbot/core/skills/__init__.py b/astrbot/core/skills/__init__.py index d214db0d7..d8530b347 100644 --- a/astrbot/core/skills/__init__.py +++ b/astrbot/core/skills/__init__.py @@ -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", +] diff --git a/astrbot/core/tools/__init__.py b/astrbot/core/tools/__init__.py new file mode 100644 index 000000000..8f7fcb977 --- /dev/null +++ b/astrbot/core/tools/__init__.py @@ -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", +] diff --git a/astrbot/tui/i18n.py b/astrbot/tui/i18n.py new file mode 100644 index 000000000..f3356168d --- /dev/null +++ b/astrbot/tui/i18n.py @@ -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() diff --git a/astrbot/tui/screen.py b/astrbot/tui/screen.py index 3e298bc1b..7676283a6 100644 --- a/astrbot/tui/screen.py +++ b/astrbot/tui/screen.py @@ -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: diff --git a/astrbot/tui/tui_app.py b/astrbot/tui/tui_app.py index f37c819cf..1d66f8eb0 100644 --- a/astrbot/tui/tui_app.py +++ b/astrbot/tui/tui_app.py @@ -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()