Compare commits

...

1 Commits

Author SHA1 Message Date
Soulter
88ab472f07 feat(i18n): add internationalization support for runtime
- Implemented translation functionality in help.py, setunset.py, and sid.py using a new i18n module.
- Created locale files for English (en-US) and Simplified Chinese (zh-CN) with appropriate translations.
- Updated default configuration to include language settings.
- Enhanced content safety check strategies to support localized error messages.
- Added language selection feature in the dashboard with prompts for language change confirmation and restart.
2026-04-13 23:14:00 +08:00
31 changed files with 493 additions and 45 deletions

View File

@@ -3,6 +3,8 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.core.config.default import VERSION
from astrbot.core.utils.io import download_dashboard
from ..i18n import t
class AdminCommands:
def __init__(self, context: star.Context) -> None:
@@ -10,6 +12,6 @@ class AdminCommands:
async def update_dashboard(self, event: AstrMessageEvent) -> None:
"""更新管理面板"""
await event.send(MessageChain().message("⏳ Updating dashboard..."))
await event.send(MessageChain().message(t(self.context, "dashboard.updating")))
await download_dashboard(version=f"v{VERSION}", latest=False)
await event.send(MessageChain().message("✅ Dashboard updated successfully."))
await event.send(MessageChain().message(t(self.context, "dashboard.updated")))

View File

@@ -9,6 +9,7 @@ from astrbot.core.agent.runners.deerflow.constants import (
from astrbot.core.agent.runners.deerflow.deerflow_api_client import DeerFlowAPIClient
from astrbot.core.utils.active_event_registry import active_event_registry
from ..i18n import t
from .utils.rst_scene import RstScene
THIRD_PARTY_AGENT_RUNNER_KEY = {
@@ -138,8 +139,12 @@ class ConversationCommands:
if required_perm == "admin" and message.role != "admin":
message.set_result(
MessageEventResult().message(
f"Reset command requires admin permission in {scene.name} scenario, "
f"you (ID {message.get_sender_id()}) are not admin, cannot perform this action.",
t(
self.context,
"conversation.reset_admin_required",
scene_name=t(self.context, f"scene.{scene.key}"),
sender_id=message.get_sender_id(),
),
),
)
return
@@ -153,14 +158,16 @@ class ConversationCommands:
agent_runner_type,
)
message.set_result(
MessageEventResult().message("✅ Conversation reset successfully.")
MessageEventResult().message(
t(self.context, "conversation.reset_success"),
)
)
return
if not self.context.get_using_provider(umo):
message.set_result(
MessageEventResult().message(
"😕 Cannot find any LLM provider. Configure one first."
t(self.context, "conversation.no_provider"),
),
)
return
@@ -170,7 +177,7 @@ class ConversationCommands:
if not cid:
message.set_result(
MessageEventResult().message(
"😕 You are not in a conversation. Use /new to create one.",
t(self.context, "conversation.no_conversation"),
),
)
return
@@ -183,7 +190,7 @@ class ConversationCommands:
[],
)
ret = "✅ Conversation reset successfully."
ret = t(self.context, "conversation.reset_success")
message.set_extra("_clean_ltm_session", True)
@@ -206,13 +213,19 @@ class ConversationCommands:
if stopped_count > 0:
message.set_result(
MessageEventResult().message(
f"✅ Requested to stop {stopped_count} running tasks."
t(
self.context,
"conversation.stop_requested",
count=stopped_count,
),
)
)
return
message.set_result(
MessageEventResult().message("✅ No running tasks in the current session.")
MessageEventResult().message(
t(self.context, "conversation.no_running_tasks"),
)
)
async def new_conv(self, message: AstrMessageEvent) -> None:
@@ -227,7 +240,9 @@ class ConversationCommands:
agent_runner_type,
)
message.set_result(
MessageEventResult().message("✅ New conversation created.")
MessageEventResult().message(
t(self.context, "conversation.new_created")
)
)
return
@@ -243,6 +258,10 @@ class ConversationCommands:
message.set_result(
MessageEventResult().message(
f"✅ Switched to new conversation: {cid[:4]}"
t(
self.context,
"conversation.switched_new",
conversation_id=cid[:4],
),
),
)

View File

@@ -6,6 +6,8 @@ from astrbot.core.config.default import VERSION
from astrbot.core.star import command_management
from astrbot.core.utils.io import get_dashboard_version
from ..i18n import t
class HelpCommand:
def __init__(self, context: star.Context) -> None:
@@ -77,7 +79,7 @@ class HelpCommand:
commands_section = (
"\n".join(command_lines)
if command_lines
else "No enabled built-in commands."
else t(self.context, "help.no_enabled_builtin_commands")
)
msg_parts = [

View File

@@ -1,6 +1,8 @@
from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from ..i18n import t
class SetUnsetCommands:
def __init__(self, context: star.Context) -> None:
@@ -15,7 +17,12 @@ class SetUnsetCommands:
event.set_result(
MessageEventResult().message(
f"会话 {uid} 变量 {key} 存储成功。使用 /unset 移除。",
t(
self.context,
"setunset.set_success",
session_id=uid,
key=key,
),
),
)
@@ -26,11 +33,20 @@ class SetUnsetCommands:
if key not in session_var:
event.set_result(
MessageEventResult().message("没有那个变量名。格式 /unset 变量名。"),
MessageEventResult().message(
t(self.context, "setunset.unset_not_found")
),
)
else:
del session_var[key]
await sp.session_put(uid, "session_variables", session_var)
event.set_result(
MessageEventResult().message(f"会话 {uid} 变量 {key} 移除成功。"),
MessageEventResult().message(
t(
self.context,
"setunset.unset_success",
session_id=uid,
key=key,
),
),
)

View File

@@ -3,6 +3,8 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from ..i18n import t
class SIDCommand:
"""会话ID命令类"""
@@ -17,20 +19,24 @@ class SIDCommand:
umo_platform = event.session.platform_id
umo_msg_type = event.session.message_type.value
umo_session_id = event.session.session_id
ret = (
f"UMO: 「{sid}\n"
f"UID: 「{user_id}\n"
"*Use UMO to set whitelist and configure routing, use UID to set admin list(UMO 可用于设置白名单和配置文件路由UID 可用于设置管理员列表)\n\n"
f"Your session information:\n"
f"Bot ID: 「{umo_platform}\n"
f"Message Type: 「{umo_msg_type}\n"
f"Session ID: 「{umo_session_id}\n\n"
ret = t(
self.context,
"sid.info",
sid=sid,
user_id=user_id,
platform=umo_platform,
message_type=umo_msg_type,
session_id=umo_session_id,
)
if (
self.context.get_config()["platform_settings"]["unique_session"]
and event.get_group_id()
):
ret += f"\n\nThe group's ID: 「{event.get_group_id()}」. Set this ID to whitelist to allow the entire group."
ret += t(
self.context,
"sid.group_whitelist",
group_id=event.get_group_id(),
)
event.set_result(MessageEventResult().message(ret).use_t2i(False))

View File

@@ -0,0 +1,30 @@
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
LOCALE_DIR = Path(__file__).resolve().parent / "locales"
@lru_cache(maxsize=2)
def _load_locale(language: str) -> dict[str, Any]:
with (LOCALE_DIR / f"{language}.json").open(encoding="utf-8") as f:
return json.load(f)
def _resolve_key(data: dict[str, Any], translation_key: str) -> Any:
value: Any = data
for part in translation_key.split("."):
if not isinstance(value, dict) or part not in value:
return None
value = value[part]
return value
def t(context: Any, translation_key: str, **kwargs: Any) -> str:
text = _resolve_key(_load_locale(context.get_current_language()), translation_key)
if not isinstance(text, str):
return translation_key
if not kwargs:
return text
return text.format(**kwargs)

View File

@@ -0,0 +1,33 @@
{
"help": {
"no_enabled_builtin_commands": "No enabled built-in commands."
},
"sid": {
"info": "UMO: 「{sid}」\nUID: 「{user_id}」\n*Use UMO to set whitelist and configure routing, use UID to set admin list.\n\nYour session information:\nBot ID: 「{platform}」\nMessage Type: 「{message_type}」\nSession ID: 「{session_id}」\n\n",
"group_whitelist": "\n\nThe group's ID: 「{group_id}」. Set this ID to whitelist to allow the entire group."
},
"dashboard": {
"updating": "⏳ Updating dashboard...",
"updated": "✅ Dashboard updated successfully."
},
"scene": {
"group_unique_on": "group chat with unique session enabled",
"group_unique_off": "group chat with unique session disabled",
"private": "private chat"
},
"conversation": {
"reset_admin_required": "Reset command requires admin permission in {scene_name} scenario, you (ID {sender_id}) are not admin, cannot perform this action.",
"reset_success": "✅ Conversation reset successfully.",
"no_provider": "😕 Cannot find any LLM provider. Configure one first.",
"no_conversation": "😕 You are not in a conversation. Use /new to create one.",
"stop_requested": "✅ Requested to stop {count} running tasks.",
"no_running_tasks": "✅ No running tasks in the current session.",
"new_created": "✅ New conversation created.",
"switched_new": "✅ Switched to new conversation: {conversation_id}."
},
"setunset": {
"set_success": "Session {session_id} variable {key} saved. Use /unset to remove it.",
"unset_not_found": "No variable with that name. Format: /unset variable_name.",
"unset_success": "Session {session_id} variable {key} removed."
}
}

View File

@@ -0,0 +1,33 @@
{
"help": {
"no_enabled_builtin_commands": "没有已启用的内置指令。"
},
"sid": {
"info": "UMO: 「{sid}」\nUID: 「{user_id}」\n*使用 UMO 设置白名单和配置文件路由,使用 UID 设置管理员列表。\n\n当前会话信息\n机器人 ID: 「{platform}」\n消息类型: 「{message_type}」\n会话 ID: 「{session_id}」\n\n",
"group_whitelist": "\n\n当前群聊 ID: 「{group_id}」。将此 ID 加入白名单可允许整个群聊。"
},
"dashboard": {
"updating": "⏳ 正在更新管理面板...",
"updated": "✅ 管理面板更新成功。"
},
"scene": {
"group_unique_on": "群聊+会话隔离开启",
"group_unique_off": "群聊+会话隔离关闭",
"private": "私聊"
},
"conversation": {
"reset_admin_required": "在 {scene_name} 场景下reset 指令需要管理员权限。你(ID {sender_id})不是管理员,无法执行此操作。",
"reset_success": "✅ 会话已重置。",
"no_provider": "😕 未找到可用的 LLM 提供商,请先配置。",
"no_conversation": "😕 当前未处于对话状态。请使用 /new 创建一个对话。",
"stop_requested": "✅ 已请求停止 {count} 个正在运行的任务。",
"no_running_tasks": "✅ 当前会话没有正在运行的任务。",
"new_created": "✅ 已创建新对话。",
"switched_new": "✅ 已切换到新对话:{conversation_id}。"
},
"setunset": {
"set_success": "会话 {session_id} 变量 {key} 存储成功。使用 /unset 移除。",
"unset_not_found": "没有那个变量名。格式 /unset 变量名。",
"unset_success": "会话 {session_id} 变量 {key} 移除成功。"
}
}

View File

@@ -3,6 +3,7 @@
import os
from typing import Any, TypedDict
from astrbot.core.i18n import Language
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.23.0"
@@ -53,6 +54,7 @@ WEBHOOK_SUPPORTED_PLATFORMS = [
# 默认配置
DEFAULT_CONFIG = {
"config_version": 2,
"language": Language.ZH_CN.value,
"platform_settings": {
"unique_session": False,
"rate_limit": {
@@ -4037,6 +4039,13 @@ CONFIG_METADATA_3_SYSTEM = {
"description": "系统配置",
"type": "object",
"items": {
"language": {
"description": "系统语言",
"type": "string",
"hint": "用于 AstrBot 运行时回复的语言。目前支持简体中文和英文。",
"options": [Language.ZH_CN.value, Language.EN_US.value],
"labels": ["简体中文", "English"],
},
"t2i_strategy": {
"description": "文本转图像策略",
"type": "string",

View File

@@ -0,0 +1,62 @@
import json
from enum import Enum
from functools import lru_cache
from pathlib import Path
from typing import Any
CORE_LOCALE_DIR = Path(__file__).resolve().parent / "locales"
class Language(str, Enum):
ZH_CN = "zh-CN"
EN_US = "en-US"
DEFAULT_LANGUAGE = Language.ZH_CN.value
def normalize_language(language: str | Language | None) -> str:
if isinstance(language, Language):
return language.value
if language == Language.EN_US.value:
return Language.EN_US.value
return Language.ZH_CN.value
@lru_cache(maxsize=64)
def _load_locale(locale_dir: str, language: str) -> dict[str, Any]:
locale_path = Path(locale_dir) / f"{language}.json"
with locale_path.open(encoding="utf-8") as f:
return json.load(f)
def _resolve_key(data: dict[str, Any], key: str) -> Any:
value: Any = data
for part in key.split("."):
if not isinstance(value, dict) or part not in value:
return None
value = value[part]
return value
def t(
translation_key: str,
*,
locale: str | None = None,
locale_dir: str | Path | None = None,
**kwargs: Any,
) -> str:
language = normalize_language(locale)
resolved_locale_dir = str(locale_dir or CORE_LOCALE_DIR)
text = _resolve_key(_load_locale(resolved_locale_dir, language), translation_key)
if text is None and language != DEFAULT_LANGUAGE:
text = _resolve_key(
_load_locale(resolved_locale_dir, DEFAULT_LANGUAGE),
translation_key,
)
if not isinstance(text, str):
return translation_key
if not kwargs:
return text
return text.format(**kwargs)

View File

@@ -0,0 +1,12 @@
{
"pipeline": {
"filter_error": "Plugin {plugin_name}: {error}",
"no_permission": "You (ID: {sender_id}) do not have permission to use this command. Use /sid to get your ID and ask an administrator to add it.",
"content_blocked": "Your message or the model response contains inappropriate content and has been blocked.",
"keyword_blocked_reason": "Content safety check failed because a sensitive keyword was matched.",
"baidu_aip_violation_header": "Baidu content moderation found {count} violations:\n",
"baidu_aip_conclusion": "\nConclusion: {conclusion}",
"plugin_handler_error": ":(\n\nAn exception occurred while calling plugin {plugin_name}'s handler {handler_name}: {error}",
"reasoning_prefix": "🤔 Thinking: {reasoning_content}\n"
}
}

View File

@@ -0,0 +1,12 @@
{
"pipeline": {
"filter_error": "插件 {plugin_name}: {error}",
"no_permission": "您(ID: {sender_id})的权限不足以使用此指令。通过 /sid 获取 ID 并请管理员添加。",
"content_blocked": "你的消息或者大模型的响应中包含不适当的内容,已被屏蔽。",
"keyword_blocked_reason": "内容安全检查不通过,匹配到敏感词。",
"baidu_aip_violation_header": "百度审核服务发现 {count} 处违规:\n",
"baidu_aip_conclusion": "\n判断结果{conclusion}",
"plugin_handler_error": ":(\n\n在调用插件 {plugin_name} 的处理函数 {handler_name} 时出现异常:{error}",
"reasoning_prefix": "🤔 思考: {reasoning_content}\n"
}
}

View File

@@ -1,6 +1,7 @@
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.i18n import t
from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -17,6 +18,7 @@ class ContentSafetyCheckStage(Stage):
"""
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
config = ctx.astrbot_config["content_safety"]
self.strategy_selector = StrategySelector(config)
@@ -27,12 +29,13 @@ class ContentSafetyCheckStage(Stage):
) -> AsyncGenerator[None, None]:
"""检查内容安全"""
text = check_text if check_text else event.get_message_str()
ok, info = self.strategy_selector.check(text)
locale = self.ctx.get_current_language()
ok, info = self.strategy_selector.check(text, locale=locale)
if not ok:
if event.is_at_or_wake_command:
event.set_result(
MessageEventResult().message(
"你的消息或者大模型的响应中包含不适当的内容,已被屏蔽。",
t("pipeline.content_blocked", locale=locale),
),
)
yield

View File

@@ -3,5 +3,5 @@ import abc
class ContentSafetyStrategy(abc.ABC):
@abc.abstractmethod
def check(self, content: str) -> tuple[bool, str]:
def check(self, content: str, locale: str | None = None) -> tuple[bool, str]:
raise NotImplementedError

View File

@@ -4,6 +4,8 @@ from typing import Any, cast
from aip import AipContentCensor
from astrbot.core.i18n import t
from . import ContentSafetyStrategy
@@ -14,7 +16,7 @@ class BaiduAipStrategy(ContentSafetyStrategy):
self.secret_key = sk
self.client = AipContentCensor(self.app_id, self.api_key, self.secret_key)
def check(self, content: str) -> tuple[bool, str]:
def check(self, content: str, locale: str | None = None) -> tuple[bool, str]:
res = self.client.textCensorUserDefined(content)
if "conclusionType" not in res:
return False, ""
@@ -23,10 +25,18 @@ class BaiduAipStrategy(ContentSafetyStrategy):
if "data" not in res:
return False, ""
count = len(res["data"])
parts = [f"百度审核服务发现 {count} 处违规:\n"]
parts = [
t("pipeline.baidu_aip_violation_header", locale=locale, count=count),
]
for i in res["data"]:
# 百度 AIP 返回结构是动态 dict类型检查时 i 可能被推断为序列,转成 dict 后用 get 取字段
parts.append(f"{cast(dict[str, Any], i).get('msg', '')}\n")
parts.append("\n判断结果:" + res["conclusion"])
parts.append(
t(
"pipeline.baidu_aip_conclusion",
locale=locale,
conclusion=res["conclusion"],
),
)
info = "".join(parts)
return False, info

View File

@@ -1,5 +1,7 @@
import re
from astrbot.core.i18n import t
from . import ContentSafetyStrategy
@@ -17,8 +19,8 @@ class KeywordsStrategy(ContentSafetyStrategy):
# json.loads(base64.b64decode(f.read()).decode("utf-8"))["keywords"]
# )
def check(self, content: str) -> tuple[bool, str]:
def check(self, content: str, locale: str | None = None) -> tuple[bool, str]:
for keyword in self.keywords:
if re.search(keyword, content):
return False, "内容安全检查不通过,匹配到敏感词。"
return False, t("pipeline.keyword_blocked_reason", locale=locale)
return True, ""

View File

@@ -26,9 +26,9 @@ class StrategySelector:
),
)
def check(self, content: str) -> tuple[bool, str]:
def check(self, content: str, locale: str | None = None) -> tuple[bool, str]:
for strategy in self.enabled_strategies:
ok, info = strategy.check(content)
ok, info = strategy.check(content, locale=locale)
if not ok:
return False, info
return True, ""

View File

@@ -20,3 +20,6 @@ class PipelineContext:
astrbot_config_id: str
call_handler = call_handler
call_event_hook = call_event_hook
def get_current_language(self) -> str:
return self.plugin_manager.context.get_current_language()

View File

@@ -5,6 +5,7 @@ from collections.abc import AsyncGenerator
from typing import Any
from astrbot.core import logger
from astrbot.core.i18n import t
from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.star import star_map
@@ -62,7 +63,13 @@ class StarRequestSubStage(Stage):
)
if not event.is_stopped() and event.is_at_or_wake_command:
ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
ret = t(
"pipeline.plugin_handler_error",
locale=self.ctx.get_current_language(),
plugin_name=md.name,
handler_name=handler.handler_name,
error=e,
)
event.set_result(MessageEventResult().message(ret))
yield
event.clear_result()

View File

@@ -5,6 +5,7 @@ import traceback
from collections.abc import AsyncGenerator
from astrbot.core import file_token_service, html_renderer, logger
from astrbot.core.i18n import t
from astrbot.core.message.components import At, Image, Json, Node, Plain, Record, Reply
from astrbot.core.message.message_event_result import ResultContentType
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
@@ -289,7 +290,16 @@ class ResultDecorateStage(Stage):
),
)
else:
result.chain.insert(0, Plain(f"🤔 思考: {reasoning_content}\n"))
result.chain.insert(
0,
Plain(
t(
"pipeline.reasoning_prefix",
locale=self.ctx.get_current_language(),
reasoning_content=reasoning_content,
),
),
)
if should_tts and tts_provider:
new_chain = []

View File

@@ -1,6 +1,7 @@
from collections.abc import AsyncGenerator, Callable
from astrbot import logger
from astrbot.core.i18n import t
from astrbot.core.message.components import At, AtAll, Reply
from astrbot.core.message.message_event_result import MessageChain, MessageEventResult
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -187,7 +188,12 @@ class WakingCheckStage(Stage):
except Exception as e:
await event.send(
MessageEventResult().message(
f"插件 {star_map[handler.handler_module_path].name}: {e}",
t(
"pipeline.filter_error",
locale=self.ctx.get_current_language(),
plugin_name=star_map[handler.handler_module_path].name,
error=e,
),
),
)
event.stop_event()
@@ -201,7 +207,11 @@ class WakingCheckStage(Stage):
if self.no_permission_reply:
await event.send(
MessageChain().message(
f"您(ID: {event.get_sender_id()})的权限不足以使用此指令。通过 /sid 获取 ID 并请管理员添加。",
t(
"pipeline.no_permission",
locale=self.ctx.get_current_language(),
sender_id=event.get_sender_id(),
),
),
)
logger.info(

View File

@@ -15,6 +15,7 @@ from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.db import BaseDatabase
from astrbot.core.i18n import normalize_language
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.persona_mgr import PersonaManager
@@ -438,6 +439,10 @@ class Context:
return self._config
return self.astrbot_config_mgr.get_conf(umo)
def get_current_language(self) -> str:
"""Get the current runtime reply language."""
return normalize_language(self._config.get("language"))
async def send_message(
self,
session: str | MessageSesion,

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 247 icons */
/* Auto-generated MDI subset 248 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -680,6 +680,10 @@
content: "\F0B3C";
}
.mdi-numeric-4::before {
content: "\F0B3D";
}
.mdi-open-in-new::before {
content: "\F03CC";
}

View File

@@ -987,6 +987,14 @@
"name": "System",
"system": {
"description": "System Settings",
"language": {
"description": "System Language",
"hint": "Language used for AstrBot runtime replies. Currently supports Simplified Chinese and English.",
"labels": [
"Simplified Chinese",
"English"
]
},
"t2i_strategy": {
"description": "Text-to-Image Strategy",
"hint": "Text-to-image strategy. `remote` uses a remote HTML-based rendering service, `local` uses PIL for local rendering. When using local, place a TTF font named 'font.ttf' in the data/ directory to customize the font."

View File

@@ -25,6 +25,11 @@
"step3SelectLabel": "Computer Access",
"step3Allow": "Allow",
"step3Deny": "Disallow",
"step4Title": "Choose Language",
"step4Desc": "Set AstrBot's preferred language.",
"step4SelectLabel": "Language",
"step4Chinese": "简体中文",
"step4English": "English",
"configure": "Configure",
"save": "Save",
"skip": "Skip",
@@ -38,7 +43,10 @@
"computerAccessUpdateFailed": "Failed to update Agent computer access",
"computerAccessAllowed": "Agent is now allowed to access and use the computer",
"computerAccessDenied": "Agent is no longer allowed to access and use the computer",
"step3HelpItem3": "For more detailed control, configure the Computer Access options under Settings -> General -> Computer Access."
"step3HelpItem3": "For more detailed control, configure the Computer Access options under Settings -> General -> Computer Access.",
"languageUpdateFailed": "Failed to update language settings",
"languageUpdated": "Language settings updated",
"languageRestartConfirm": "AstrBot needs to restart to apply the language change. Restart now?"
},
"resources": {
"title": "Resources",

View File

@@ -25,6 +25,11 @@
"step3SelectLabel": "Доступ к компьютеру",
"step3Allow": "Разрешить",
"step3Deny": "Запретить",
"step4Title": "Выберите язык",
"step4Desc": "Задайте предпочитаемый язык AstrBot.",
"step4SelectLabel": "Язык",
"step4Chinese": "简体中文",
"step4English": "English",
"configure": "Настроить",
"save": "Сохранить",
"skip": "Пропустить",
@@ -38,7 +43,10 @@
"computerAccessUpdateFailed": "Не удалось обновить доступ Agent к компьютеру",
"computerAccessAllowed": "Agent теперь может получать доступ к компьютеру и использовать его",
"computerAccessDenied": "Agent больше не может получать доступ к компьютеру и использовать его",
"step3HelpItem3": "Для более тонкой настройки перейдите в «Конфигурация → Основные настройки → Доступ к компьютеру»."
"step3HelpItem3": "Для более тонкой настройки перейдите в «Конфигурация → Основные настройки → Доступ к компьютеру».",
"languageUpdateFailed": "Не удалось обновить настройки языка",
"languageUpdated": "Настройки языка обновлены",
"languageRestartConfirm": "Чтобы применить смену языка, AstrBot нужно перезапустить. Перезапустить сейчас?"
},
"resources": {
"title": "Ресурсы",

View File

@@ -989,6 +989,14 @@
"name": "系统配置",
"system": {
"description": "系统配置",
"language": {
"description": "系统语言",
"hint": "用于 AstrBot 运行时回复的语言。目前支持简体中文和英文。",
"labels": [
"简体中文",
"English"
]
},
"t2i_strategy": {
"description": "文本转图像策略",
"hint": "文本转图像策略。`remote` 为使用远程基于 HTML 的渲染服务,`local` 为使用 PIL 本地渲染。当使用 local 时,将 ttf 字体命名为 'font.ttf' 放在 data/ 目录下可自定义字体。"
@@ -1643,4 +1651,4 @@
"helpMiddle": "或",
"helpSuffix": "。"
}
}
}

View File

@@ -25,6 +25,11 @@
"step3SelectLabel": "电脑访问权限",
"step3Allow": "允许",
"step3Deny": "不允许",
"step4Title": "选择语言",
"step4Desc": "设置 AstrBot 首选语言。",
"step4SelectLabel": "语言",
"step4Chinese": "简体中文",
"step4English": "English",
"configure": "去配置",
"save": "保存",
"skip": "跳过",
@@ -38,7 +43,10 @@
"computerAccessUpdateFailed": "更新 Agent 电脑访问权限失败",
"computerAccessAllowed": "已允许 Agent 访问和使用电脑",
"computerAccessDenied": "已禁止 Agent 访问和使用电脑",
"step3HelpItem3": "如需更多细化权限与能力配置,可前往 配置文件 -> 普通配置 -> 使用电脑能力 继续设置。"
"step3HelpItem3": "如需更多细化权限与能力配置,可前往 配置文件 -> 普通配置 -> 使用电脑能力 继续设置。",
"languageUpdateFailed": "更新语言设置失败",
"languageUpdated": "语言设置已更新",
"languageRestartConfirm": "应用语言切换之后需要重启 AstrBot。是否立即重启"
},
"resources": {
"title": "相关资源",

View File

@@ -89,6 +89,29 @@
</div>
</div>
</v-timeline-item>
<v-timeline-item :dot-color="languageStepState === 'completed' ? 'success' : 'primary'"
icon="mdi-numeric-4" fill-dot size="small">
<div class="pl-2">
<div class="text-h6 font-weight-bold mb-1">{{ tm('onboard.step4Title') }}</div>
<p class="text-body-2 text-medium-emphasis mb-3">{{ tm('onboard.step4Desc') }}</p>
<div class="d-flex flex-wrap align-center ga-3">
<v-select
v-model="selectedLanguage"
:items="languageOptions"
item-title="title"
item-value="value"
:label="tm('onboard.step4SelectLabel')"
:loading="savingLanguage"
:disabled="savingLanguage"
hide-details
density="comfortable"
variant="outlined"
class="language-select"
/>
</div>
</div>
</v-timeline-item>
</v-timeline>
</v-card>
@@ -188,6 +211,7 @@
</v-card-actions>
</v-card>
</v-dialog>
<WaitingForRestart ref="wfr" />
</div>
</template>
@@ -195,23 +219,34 @@
import { computed, ref, watch, onMounted } from 'vue';
import axios from 'axios';
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Locale } from '@/i18n/types';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
import { useToast } from '@/utils/toast';
import { MarkdownRender } from 'markstream-vue';
import 'markstream-vue/index.css';
type StepState = 'pending' | 'completed' | 'skipped';
type ComputerAccessRuntime = 'local' | 'none';
type CoreLanguage = 'zh-CN' | 'en-US';
type WaitingForRestartRef = {
check: (initialStartTime?: number | null) => void | Promise<void>;
stop?: () => void;
};
const { tm } = useModuleI18n('features/welcome');
const { locale } = useI18n();
const { locale, setLocale } = useI18n();
const { success: showSuccess, error: showError } = useToast();
const confirmDialog = useConfirmDialog();
const showAddPlatformDialog = ref(false);
const showProviderDialog = ref(false);
const showComputerAccessHelpDialog = ref(false);
const loadingPlatformDialog = ref(false);
const wfr = ref<WaitingForRestartRef | null>(null);
const platformMetadata = ref<Record<string, any>>({});
const platformConfigData = ref<Record<string, any>>({});
@@ -224,6 +259,10 @@ const computerAccessStepState = ref<StepState>('pending');
const computerAccessRuntime = ref<ComputerAccessRuntime>('none');
const savedComputerAccessRuntime = ref<ComputerAccessRuntime>('none');
const savingComputerAccess = ref(false);
const languageStepState = ref<StepState>('pending');
const selectedLanguage = ref<CoreLanguage>('zh-CN');
const savedLanguage = ref<CoreLanguage>('zh-CN');
const savingLanguage = ref(false);
const welcomeAnnouncementRaw = ref<unknown>(null);
function resolveWelcomeAnnouncement(raw: unknown, currentLocale: string) {
@@ -419,6 +458,11 @@ const computerAccessOptions = computed(() => [
{ title: tm('onboard.step3Deny'), value: 'none' }
]);
const languageOptions = computed(() => [
{ title: tm('onboard.step4Chinese'), value: 'zh-CN' },
{ title: tm('onboard.step4English'), value: 'en-US' }
]);
async function saveComputerAccessRuntime() {
savingComputerAccess.value = true;
try {
@@ -453,6 +497,62 @@ async function saveComputerAccessRuntime() {
}
}
function normalizeCoreLanguage(value: unknown): CoreLanguage {
return value === 'en-US' ? 'en-US' : 'zh-CN';
}
async function syncLanguage(configData: any) {
const normalizedLanguage = normalizeCoreLanguage(configData?.language);
selectedLanguage.value = normalizedLanguage;
savedLanguage.value = normalizedLanguage;
languageStepState.value = 'completed';
if (locale.value !== normalizedLanguage) {
await setLocale(normalizedLanguage as Locale);
}
}
async function promptRestartAfterLanguageChange() {
const shouldRestart = await askForConfirmation(
tm('onboard.languageRestartConfirm'),
confirmDialog
);
if (!shouldRestart) return;
try {
await restartAstrBotRuntime(wfr.value);
} catch (err) {
console.error(err);
}
}
async function saveLanguage() {
savingLanguage.value = true;
try {
const configData = await fetchDefaultConfig();
configData.language = selectedLanguage.value;
const updateRes = await axios.post('/api/config/astrbot/update', {
conf_id: 'default',
config: configData
});
if (updateRes.data.status !== 'ok') {
throw new Error(updateRes.data.message || tm('onboard.languageUpdateFailed'));
}
savedLanguage.value = selectedLanguage.value;
languageStepState.value = 'completed';
await setLocale(selectedLanguage.value as Locale);
showSuccess(tm('onboard.languageUpdated'));
await promptRestartAfterLanguageChange();
} catch (err: any) {
selectedLanguage.value = savedLanguage.value;
showError(err?.response?.data?.message || err?.message || tm('onboard.languageUpdateFailed'));
} finally {
savingLanguage.value = false;
}
}
async function loadWelcomeAnnouncement() {
try {
const res = await axios.get('https://cloud.astrbot.app/api/v1/announcement');
@@ -487,6 +587,7 @@ onMounted(async () => {
try {
const defaultConfig = await fetchDefaultConfig();
syncComputerAccessRuntime(defaultConfig);
await syncLanguage(defaultConfig);
} catch (e) {
console.error(e);
}
@@ -552,6 +653,18 @@ watch(computerAccessRuntime, async (value, oldValue) => {
computerAccessRuntime.value = savedComputerAccessRuntime.value;
}
});
watch(selectedLanguage, async (value, oldValue) => {
if (value === oldValue) return;
if (value === savedLanguage.value) return;
if (savingLanguage.value) return;
try {
await saveLanguage();
} catch {
selectedLanguage.value = savedLanguage.value;
}
});
</script>
<style scoped>
@@ -572,6 +685,11 @@ watch(computerAccessRuntime, async (value, oldValue) => {
min-width: 220px;
}
.language-select {
max-width: 240px;
min-width: 220px;
}
.computer-access-help-list {
margin: 0;
padding-left: 1.25rem;