feat(ui): add i18n internationalization support with Chinese translation

- Add i18n core module with locale management and translation helpers
- Add English (en.json) and Chinese (zh.json) translation files
- Add language-switcher component for the top bar
- Update navigation.ts to use localized strings
- Update app-render.ts to include language switcher and i18n
- Update app.ts to subscribe to locale changes for reactivity

Features:
- Auto-detect browser language preference
- Persist language choice in localStorage
- Support for nested translation keys (e.g., nav.tabs.chat)
- Fallback to English for missing translations

Translations include:
- Navigation menu items and descriptions
- Status indicators (connected, offline, etc.)
- Action buttons (save, cancel, apply, etc.)
- Configuration categories
- Common UI elements
This commit is contained in:
Mike 2026-01-28 19:18:32 +08:00
parent 9688454a30
commit a4dee5cba8
7 changed files with 705 additions and 57 deletions

134
ui/src/i18n/index.ts Normal file
View File

@ -0,0 +1,134 @@
/**
* Clawdbot Control UI - Internationalization (i18n) Module
*
* A lightweight i18n system for Lit web components.
* Supports dynamic locale switching and nested translation keys.
*/
import en from "./locales/en.json" with { type: "json" };
import zh from "./locales/zh.json" with { type: "json" };
export type Locale = "en" | "zh";
export type TranslationDict = Record<string, string | TranslationDict>;
const locales: Record<Locale, TranslationDict> = { en, zh };
// Current locale state
let currentLocale: Locale = "en";
// Subscribers for locale changes
const subscribers = new Set<() => void>();
/**
* Get the current locale
*/
export function getLocale(): Locale {
return currentLocale;
}
/**
* Set the current locale and notify subscribers
*/
export function setLocale(locale: Locale): void {
if (locale === currentLocale) return;
if (!locales[locale]) {
console.warn(`[i18n] Unknown locale: ${locale}, falling back to 'en'`);
locale = "en";
}
currentLocale = locale;
localStorage.setItem("clawdbot-locale", locale);
subscribers.forEach((cb) => cb());
}
/**
* Initialize locale from localStorage or browser preference
*/
export function initLocale(): void {
const saved = localStorage.getItem("clawdbot-locale") as Locale | null;
if (saved && locales[saved]) {
currentLocale = saved;
return;
}
// Auto-detect from browser
const browserLang = navigator.language.toLowerCase();
if (browserLang.startsWith("zh")) {
currentLocale = "zh";
} else {
currentLocale = "en";
}
}
/**
* Subscribe to locale changes (for Lit component updates)
*/
export function subscribeLocale(callback: () => void): () => void {
subscribers.add(callback);
return () => subscribers.delete(callback);
}
/**
* Get a translation by key (supports nested keys with dot notation)
* Example: t("nav.chat") or t("common.save")
*/
export function t(key: string, fallback?: string): string {
const dict = locales[currentLocale] ?? locales.en;
const keys = key.split(".");
let value: string | TranslationDict | undefined = dict;
for (const k of keys) {
if (typeof value === "object" && value !== null) {
value = value[k];
} else {
value = undefined;
break;
}
}
if (typeof value === "string") return value;
// Fallback to English if not found in current locale
if (currentLocale !== "en") {
let enValue: string | TranslationDict | undefined = locales.en;
for (const k of keys) {
if (typeof enValue === "object" && enValue !== null) {
enValue = enValue[k];
} else {
enValue = undefined;
break;
}
}
if (typeof enValue === "string") return enValue;
}
return fallback ?? key;
}
/**
* Template literal tag for translations
* Usage: i18n`nav.chat` or i18n`common.save`
*/
export function i18n(
strings: TemplateStringsArray,
...values: unknown[]
): string {
const key = strings.reduce(
(acc, str, i) => acc + str + (values[i] ?? ""),
"",
);
return t(key);
}
/**
* Get all available locales with display names
*/
export function getAvailableLocales(): Array<{ code: Locale; name: string }> {
return [
{ code: "en", name: "English" },
{ code: "zh", name: "中文" },
];
}
// Initialize on load
initLocale();

205
ui/src/i18n/locales/en.json Normal file
View File

@ -0,0 +1,205 @@
{
"app": {
"title": "Clawdbot Control",
"loading": "Loading…",
"error": "Error",
"version": "Version"
},
"nav": {
"groups": {
"chat": "Chat",
"control": "Control",
"agent": "Agent",
"settings": "Settings"
},
"tabs": {
"overview": "Overview",
"channels": "Channels",
"instances": "Instances",
"sessions": "Sessions",
"cron": "Cron Jobs",
"skills": "Skills",
"nodes": "Nodes",
"chat": "Chat",
"config": "Config",
"debug": "Debug",
"logs": "Logs"
},
"subtitles": {
"overview": "Gateway status, entry points, and a fast health read.",
"channels": "Manage channels and settings.",
"instances": "Presence beacons from connected clients and nodes.",
"sessions": "Inspect active sessions and adjust per-session defaults.",
"cron": "Schedule wakeups and recurring agent runs.",
"skills": "Manage skill availability and API key injection.",
"nodes": "Paired devices, capabilities, and command exposure.",
"chat": "Direct gateway chat session for quick interventions.",
"config": "Edit ~/.clawdbot/clawdbot.json safely.",
"debug": "Gateway snapshots, events, and manual RPC calls.",
"logs": "Live tail of the gateway file logs."
}
},
"sidebar": {
"collapse": "Collapse sidebar",
"expand": "Expand sidebar",
"close": "Close sidebar",
"exitFocus": "Exit focus mode"
},
"chat": {
"placeholder": "Message (↩ to send, Shift+↩ for line breaks)",
"connect": "Connect to the gateway to start chatting…",
"agentMessage": "Agent message",
"agentMessageRequired": "Agent message required.",
"assistant": "Assistant",
"agent": "Agent",
"copyMarkdown": "Copy as markdown",
"copied": "Copied",
"copyFailed": "Copy failed",
"attach": "Attach",
"send": "Send"
},
"status": {
"health": "Health",
"ok": "OK",
"offline": "Offline",
"connected": "Connected",
"disconnected": "Disconnected",
"disconnectedGateway": "Disconnected from gateway.",
"active": "Active",
"enabled": "Enabled",
"disabled": "Disabled",
"installed": "Installed",
"loading": "Loading…",
"applying": "Applying…",
"installing": "Installing…",
"importing": "Importing..."
},
"actions": {
"apply": "Apply",
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"add": "Add",
"addJob": "Add job",
"enable": "Enable",
"disable": "Disable",
"copy": "Copy",
"retry": "Retry",
"refresh": "Refresh",
"loadConfig": "Load config",
"loadApprovals": "Load approvals",
"importRelays": "Import from Relays",
"editProfile": "Edit Profile"
},
"config": {
"categories": {
"gateway": "Gateway",
"agents": "Agents",
"authentication": "Authentication",
"models": "Models",
"tools": "Tools",
"channels": "Channels",
"messages": "Messages",
"commands": "Commands",
"browser": "Browser",
"audio": "Audio",
"broadcast": "Broadcast",
"hooks": "Hooks",
"logging": "Logging",
"skills": "Skills",
"plugins": "Plugins",
"discovery": "Discovery",
"bindings": "Bindings",
"environment": "Environment",
"canvasHost": "Canvas Host",
"cron": "Cron"
},
"descriptions": {
"gateway": "Gateway server settings (port, auth, binding)",
"agents": "Agent configurations, models, and identities",
"authentication": "API keys and authentication profiles",
"models": "AI model configurations and providers",
"audio": "Audio input/output settings",
"browser": "Browser automation settings",
"broadcast": "Broadcast and notification settings",
"update": "Auto-update settings and release channel",
"messages": "Message handling and routing settings",
"logging": "Log levels and output configuration",
"canvasHost": "Canvas rendering and display",
"environment": "Environment variables passed to the gateway process"
},
"noSettings": "No settings in this section",
"hideAdvanced": "Hide Advanced",
"showAdvanced": "Show Advanced",
"hashMissing": "Config hash missing; reload and retry.",
"approvalHashMissing": "Exec approvals hash missing; reload and retry."
},
"channels": {
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"discord": "Discord",
"slack": "Slack",
"signal": "Signal",
"imessage": "iMessage",
"bluebubbles": "BlueBubbles",
"msteams": "MS Teams",
"mattermost": "Mattermost"
},
"security": {
"host": "Host",
"full": "Full",
"allowlist": "Allowlist",
"deny": "Deny",
"ask": "Ask",
"always": "Always",
"defaultSecurityMode": "Default security mode.",
"defaultPromptPolicy": "Default prompt policy.",
"allowSkillExecutables": "Allow skill executables listed by the Gateway."
},
"cron": {
"cronRequired": "Cron expression required.",
"invalidInterval": "Invalid interval amount.",
"invalidRunTime": "Invalid run time."
},
"profile": {
"displayName": "Display Name",
"avatarUrl": "Avatar URL",
"bannerUrl": "Banner URL",
"bio": "Bio",
"bioPlaceholder": "A brief bio or description",
"avatarPlaceholder": "HTTPS URL to your profile picture",
"bannerPlaceholder": "HTTPS URL to a banner image",
"lightningAddress": "Lightning Address",
"lightningPlaceholder": "Lightning address for tips (LUD-16)",
"picture": "Profile picture",
"picturePreview": "Profile picture preview",
"importedRelays": "Profile imported from relays. Review and publish.",
"imported": "Profile imported. Review and publish.",
"publishFailed": "Profile publish failed on all relays."
},
"theme": {
"dark": "Dark",
"light": "Light",
"darkTheme": "Dark theme",
"lightTheme": "Light theme"
},
"common": {
"key": "Key",
"value": "JSON value",
"enter": "Enter",
"apiKeySaved": "API key saved",
"loggedOut": "Logged out.",
"disabledOnboarding": "Disabled during onboarding",
"newDeviceToken": "New device token (copy and store securely):",
"environmentVariables": "Environment Variables",
"docsNewTab": "Docs (opens in new tab)",
"authDocsNewTab": "Control UI auth docs (opens in new tab)",
"insecureDocsNewTab": "Insecure HTTP docs (opens in new tab)"
},
"language": {
"select": "Language",
"en": "English",
"zh": "中文"
}
}

205
ui/src/i18n/locales/zh.json Normal file
View File

@ -0,0 +1,205 @@
{
"app": {
"title": "Clawdbot 控制台",
"loading": "加载中…",
"error": "错误",
"version": "版本"
},
"nav": {
"groups": {
"chat": "聊天",
"control": "控制",
"agent": "智能体",
"settings": "设置"
},
"tabs": {
"overview": "概览",
"channels": "渠道",
"instances": "实例",
"sessions": "会话",
"cron": "定时任务",
"skills": "技能",
"nodes": "节点",
"chat": "聊天",
"config": "配置",
"debug": "调试",
"logs": "日志"
},
"subtitles": {
"overview": "网关状态、入口点和快速健康检查。",
"channels": "管理渠道和设置。",
"instances": "来自已连接客户端和节点的在线状态。",
"sessions": "检查活动会话并调整每个会话的默认设置。",
"cron": "安排唤醒和定期运行智能体任务。",
"skills": "管理技能可用性和 API 密钥注入。",
"nodes": "配对设备、功能和命令暴露。",
"chat": "用于快速操作的直接网关聊天会话。",
"config": "安全编辑 ~/.clawdbot/clawdbot.json",
"debug": "网关快照、事件和手动 RPC 调用。",
"logs": "实时查看网关日志文件。"
}
},
"sidebar": {
"collapse": "折叠侧边栏",
"expand": "展开侧边栏",
"close": "关闭侧边栏",
"exitFocus": "退出专注模式"
},
"chat": {
"placeholder": "消息(↩ 发送Shift+↩ 换行)",
"connect": "连接到网关开始聊天…",
"agentMessage": "智能体消息",
"agentMessageRequired": "需要智能体消息。",
"assistant": "助手",
"agent": "智能体",
"copyMarkdown": "复制为 Markdown",
"copied": "已复制",
"copyFailed": "复制失败",
"attach": "附加文件",
"send": "发送"
},
"status": {
"health": "状态",
"ok": "正常",
"offline": "离线",
"connected": "已连接",
"disconnected": "已断开",
"disconnectedGateway": "已从网关断开。",
"active": "活跃",
"enabled": "已启用",
"disabled": "已禁用",
"installed": "已安装",
"loading": "加载中…",
"applying": "应用中…",
"installing": "安装中…",
"importing": "导入中..."
},
"actions": {
"apply": "应用",
"save": "保存",
"cancel": "取消",
"edit": "编辑",
"delete": "删除",
"add": "添加",
"addJob": "添加任务",
"enable": "启用",
"disable": "禁用",
"copy": "复制",
"retry": "重试",
"refresh": "刷新",
"loadConfig": "加载配置",
"loadApprovals": "加载审批",
"importRelays": "从中继导入",
"editProfile": "编辑资料"
},
"config": {
"categories": {
"gateway": "网关",
"agents": "智能体",
"authentication": "认证",
"models": "模型",
"tools": "工具",
"channels": "渠道",
"messages": "消息",
"commands": "命令",
"browser": "浏览器",
"audio": "音频",
"broadcast": "广播",
"hooks": "钩子",
"logging": "日志记录",
"skills": "技能",
"plugins": "插件",
"discovery": "发现",
"bindings": "绑定",
"environment": "环境变量",
"canvasHost": "画布主机",
"cron": "定时任务"
},
"descriptions": {
"gateway": "网关服务器设置(端口、认证、绑定)",
"agents": "智能体配置、模型和身份",
"authentication": "API 密钥和认证配置",
"models": "AI 模型配置和提供商",
"audio": "音频输入/输出设置",
"browser": "浏览器自动化设置",
"broadcast": "广播和通知设置",
"update": "自动更新设置和发布渠道",
"messages": "消息处理和路由设置",
"logging": "日志级别和输出配置",
"canvasHost": "画布渲染和显示",
"environment": "传递给网关进程的环境变量"
},
"noSettings": "此部分没有设置",
"hideAdvanced": "隐藏高级选项",
"showAdvanced": "显示高级选项",
"hashMissing": "配置哈希缺失;请重新加载并重试。",
"approvalHashMissing": "执行审批哈希缺失;请重新加载并重试。"
},
"channels": {
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"discord": "Discord",
"slack": "Slack",
"signal": "Signal",
"imessage": "iMessage",
"bluebubbles": "BlueBubbles",
"msteams": "微软 Teams",
"mattermost": "Mattermost"
},
"security": {
"host": "主机",
"full": "完全",
"allowlist": "白名单",
"deny": "拒绝",
"ask": "询问",
"always": "始终",
"defaultSecurityMode": "默认安全模式。",
"defaultPromptPolicy": "默认提示策略。",
"allowSkillExecutables": "允许网关列出的技能可执行文件。"
},
"cron": {
"cronRequired": "需要 Cron 表达式。",
"invalidInterval": "无效的间隔数量。",
"invalidRunTime": "无效的运行时间。"
},
"profile": {
"displayName": "显示名称",
"avatarUrl": "头像 URL",
"bannerUrl": "横幅 URL",
"bio": "简介",
"bioPlaceholder": "简短的个人介绍或描述",
"avatarPlaceholder": "您头像的 HTTPS URL",
"bannerPlaceholder": "横幅图片的 HTTPS URL",
"lightningAddress": "闪电网络地址",
"lightningPlaceholder": "用于打赏的闪电网络地址 (LUD-16)",
"picture": "头像",
"picturePreview": "头像预览",
"importedRelays": "已从中继导入资料。请检查并发布。",
"imported": "资料已导入。请检查并发布。",
"publishFailed": "资料在所有中继上发布失败。"
},
"theme": {
"dark": "深色",
"light": "浅色",
"darkTheme": "深色主题",
"lightTheme": "浅色主题"
},
"common": {
"key": "键",
"value": "JSON 值",
"enter": "输入",
"apiKeySaved": "API 密钥已保存",
"loggedOut": "已登出。",
"disabledOnboarding": "引导期间禁用",
"newDeviceToken": "新设备令牌(请复制并安全存储):",
"environmentVariables": "环境变量",
"docsNewTab": "文档(在新标签页打开)",
"authDocsNewTab": "控制台认证文档(在新标签页打开)",
"insecureDocsNewTab": "不安全 HTTP 文档(在新标签页打开)"
},
"language": {
"select": "语言",
"en": "English",
"zh": "中文"
}
}

View File

@ -9,8 +9,11 @@ import {
pathForTab,
subtitleForTab,
titleForTab,
localizedGroupLabel,
type Tab,
} from "./navigation";
import { t } from "../i18n/index.js";
import "./components/language-switcher.js";
import { icons } from "./icons";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
@ -140,10 +143,11 @@ export function renderApp(state: AppViewState) {
<div class="topbar-status">
<div class="pill">
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
<span>${t("status.health", "Health")}</span>
<span class="mono">${state.connected ? t("status.ok", "OK") : t("status.offline", "Offline")}</span>
</div>
${renderThemeToggle(state)}
<language-switcher></language-switcher>
</div>
</header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
@ -164,7 +168,7 @@ export function renderApp(state: AppViewState) {
}}
aria-expanded=${!isGroupCollapsed}
>
<span class="nav-label__text">${group.label}</span>
<span class="nav-label__text">${localizedGroupLabel(group.label)}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
</button>
<div class="nav-group__items">

View File

@ -1,6 +1,7 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { subscribeLocale, type Locale, getLocale } from "../i18n/index.js";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import { resolveInjectedAssistantIdentity } from "./assistant-identity";
import { loadSettings, type UiSettings } from "./storage";
@ -108,9 +109,11 @@ export class MoltbotApp extends LitElement {
@state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null;
@state() eventLog: EventLogEntry[] = [];
@state() locale: Locale = getLocale();
private eventLogBuffer: EventLogEntry[] = [];
private toolStreamSyncTimer: number | null = null;
private sidebarCloseTimer: number | null = null;
private unsubscribeLocale?: () => void;
@state() assistantName = injectedAssistantIdentity.name;
@state() assistantAvatar = injectedAssistantIdentity.avatar;
@ -273,6 +276,10 @@ export class MoltbotApp extends LitElement {
connectedCallback() {
super.connectedCallback();
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
// Subscribe to locale changes for i18n
this.unsubscribeLocale = subscribeLocale(() => {
this.locale = getLocale();
});
}
protected firstUpdated() {
@ -281,6 +288,7 @@ export class MoltbotApp extends LitElement {
disconnectedCallback() {
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
this.unsubscribeLocale?.();
super.disconnectedCallback();
}

View File

@ -0,0 +1,127 @@
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
getLocale,
setLocale,
subscribeLocale,
getAvailableLocales,
type Locale,
} from "../../i18n/index.js";
/**
* Language switcher component for Clawdbot Control UI
*
* Usage: <language-switcher></language-switcher>
*/
@customElement("language-switcher")
export class LanguageSwitcher extends LitElement {
static override styles = css`
:host {
display: inline-flex;
align-items: center;
}
.switcher {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 8px;
background: var(--md-sys-color-surface-container, #1e1e1e);
border: 1px solid var(--md-sys-color-outline-variant, #444);
cursor: pointer;
transition: all 0.2s ease;
}
.switcher:hover {
background: var(--md-sys-color-surface-container-high, #2a2a2a);
border-color: var(--md-sys-color-outline, #666);
}
.globe {
width: 16px;
height: 16px;
opacity: 0.8;
}
select {
background: transparent;
border: none;
color: var(--md-sys-color-on-surface, #e0e0e0);
font-size: 14px;
font-family: inherit;
cursor: pointer;
outline: none;
padding: 0;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
select option {
background: var(--md-sys-color-surface-container, #1e1e1e);
color: var(--md-sys-color-on-surface, #e0e0e0);
}
.arrow {
width: 12px;
height: 12px;
opacity: 0.6;
margin-left: 2px;
}
`;
@state()
private locale: Locale = getLocale();
private unsubscribe?: () => void;
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = subscribeLocale(() => {
this.locale = getLocale();
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
}
private handleChange(e: Event): void {
const select = e.target as HTMLSelectElement;
setLocale(select.value as Locale);
}
override render() {
const locales = getAvailableLocales();
return html`
<div class="switcher">
<svg class="globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<select .value=${this.locale} @change=${this.handleChange}>
${locales.map(
(l) => html`
<option value=${l.code} ?selected=${l.code === this.locale}>
${l.name}
</option>
`,
)}
</select>
<svg class="arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"language-switcher": LanguageSwitcher;
}
}

View File

@ -1,4 +1,13 @@
import type { IconName } from "./icons.js";
import { t } from "../i18n/index.js";
// Tab group labels (used as keys for i18n)
const TAB_GROUP_LABELS = {
Chat: "nav.groups.chat",
Control: "nav.groups.control",
Agent: "nav.groups.agent",
Settings: "nav.groups.settings",
} as const;
export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] },
@ -10,6 +19,12 @@ export const TAB_GROUPS = [
{ label: "Settings", tabs: ["config", "debug", "logs"] },
] as const;
/** Get localized label for a tab group */
export function localizedGroupLabel(label: string): string {
const key = TAB_GROUP_LABELS[label as keyof typeof TAB_GROUP_LABELS];
return key ? t(key) : label;
}
export type Tab =
| "overview"
| "channels"
@ -129,60 +144,10 @@ export function iconForTab(tab: Tab): IconName {
}
}
export function titleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Overview";
case "channels":
return "Channels";
case "instances":
return "Instances";
case "sessions":
return "Sessions";
case "cron":
return "Cron Jobs";
case "skills":
return "Skills";
case "nodes":
return "Nodes";
case "chat":
return "Chat";
case "config":
return "Config";
case "debug":
return "Debug";
case "logs":
return "Logs";
default:
return "Control";
}
export function titleForTab(tab: Tab): string {
return t(`nav.tabs.${tab}`);
}
export function subtitleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Gateway status, entry points, and a fast health read.";
case "channels":
return "Manage channels and settings.";
case "instances":
return "Presence beacons from connected clients and nodes.";
case "sessions":
return "Inspect active sessions and adjust per-session defaults.";
case "cron":
return "Schedule wakeups and recurring agent runs.";
case "skills":
return "Manage skill availability and API key injection.";
case "nodes":
return "Paired devices, capabilities, and command exposure.";
case "chat":
return "Direct gateway chat session for quick interventions.";
case "config":
return "Edit ~/.clawdbot/moltbot.json safely.";
case "debug":
return "Gateway snapshots, events, and manual RPC calls.";
case "logs":
return "Live tail of the gateway file logs.";
default:
return "";
}
export function subtitleForTab(tab: Tab): string {
return t(`nav.subtitles.${tab}`);
}