feat(ui): add i18n support with English, Chinese, and Portuguese

This commit is contained in:
Manus AI
2026-02-03 15:49:48 -05:00
committed by Peter Steinberger
parent a03098ca49
commit 4b17ce7f48
14 changed files with 631 additions and 172 deletions

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

@@ -0,0 +1,3 @@
export * from "./lib/types";
export * from "./lib/translate";
export * from "./lib/lit-controller";

View File

@@ -0,0 +1,22 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { i18n } from "./translate";
export class I18nController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
this.host.addController(this);
}
hostConnected() {
this.unsubscribe = i18n.subscribe(() => {
this.host.requestUpdate();
});
}
hostDisconnected() {
this.unsubscribe?.();
}
}

View File

@@ -0,0 +1,106 @@
import type { Locale, TranslationMap } from "./types";
import { en } from "../locales/en";
type Subscriber = (locale: Locale) => void;
class I18nManager {
private locale: Locale = "en";
private translations: Record<Locale, TranslationMap> = { en } as Record<Locale, TranslationMap>;
private subscribers: Set<Subscriber> = new Set();
constructor() {
this.loadLocale();
}
private loadLocale() {
const saved = localStorage.getItem("openclaw.i18n.locale") as Locale;
if (saved && ["en", "zh-CN", "zh-TW", "pt-BR"].includes(saved)) {
this.locale = saved;
} else {
const navLang = navigator.language;
if (navLang.startsWith("zh")) {
this.locale = navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN";
} else if (navLang.startsWith("pt")) {
this.locale = "pt-BR";
} else {
this.locale = "en";
}
}
}
public getLocale(): Locale {
return this.locale;
}
public async setLocale(locale: Locale) {
if (this.locale === locale) return;
// Lazy load translations if needed
if (!this.translations[locale]) {
try {
const module = await import(`../locales/${locale}.ts`);
this.translations[locale] = module[locale.replace("-", "_")];
} catch (e) {
console.error(`Failed to load locale: ${locale}`, e);
return;
}
}
this.locale = locale;
localStorage.setItem("openclaw.i18n.locale", locale);
this.notify();
}
public registerTranslation(locale: Locale, map: TranslationMap) {
this.translations[locale] = map;
}
public subscribe(sub: Subscriber) {
this.subscribers.add(sub);
return () => this.subscribers.delete(sub);
}
private notify() {
this.subscribers.forEach((sub) => sub(this.locale));
}
public t(key: string, params?: Record<string, string>): string {
const keys = key.split(".");
let value: any = this.translations[this.locale] || this.translations["en"];
for (const k of keys) {
if (value && typeof value === "object") {
value = value[k];
} else {
value = undefined;
break;
}
}
// Fallback to English
if (value === undefined && this.locale !== "en") {
value = this.translations["en"];
for (const k of keys) {
if (value && typeof value === "object") {
value = value[k];
} else {
value = undefined;
break;
}
}
}
if (typeof value !== "string") {
return key;
}
if (params) {
return value.replace(/\{(\w+)\}/g, (_, k) => params[k] || `{${k}}`);
}
return value;
}
}
export const i18n = new I18nManager();
export const t = (key: string, params?: Record<string, string>) => i18n.t(key, params);

9
ui/src/i18n/lib/types.ts Normal file
View File

@@ -0,0 +1,9 @@
export type TranslationMap = { [key: string]: string | TranslationMap };
export type Locale = "en" | "zh-CN" | "zh-TW" | "pt-BR";
export interface I18nConfig {
locale: Locale;
fallbackLocale: Locale;
translations: Record<Locale, TranslationMap>;
}

107
ui/src/i18n/locales/en.ts Normal file
View File

@@ -0,0 +1,107 @@
import type { TranslationMap } from "../lib/types";
export const en: TranslationMap = {
common: {
health: "Health",
ok: "OK",
offline: "Offline",
connect: "Connect",
refresh: "Refresh",
enabled: "Enabled",
disabled: "Disabled",
na: "n/a",
docs: "Docs",
resources: "Resources",
},
nav: {
chat: "Chat",
control: "Control",
agent: "Agent",
settings: "Settings",
expand: "Expand sidebar",
collapse: "Collapse sidebar",
},
tabs: {
agents: "Agents",
overview: "Overview",
channels: "Channels",
instances: "Instances",
sessions: "Sessions",
usage: "Usage",
cron: "Cron Jobs",
skills: "Skills",
nodes: "Nodes",
chat: "Chat",
config: "Config",
debug: "Debug",
logs: "Logs",
},
subtitles: {
agents: "Manage agent workspaces, tools, and identities.",
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.",
usage: "Monitor API usage and costs.",
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 ~/.openclaw/openclaw.json safely.",
debug: "Gateway snapshots, events, and manual RPC calls.",
logs: "Live tail of the gateway file logs.",
},
overview: {
access: {
title: "Gateway Access",
subtitle: "Where the dashboard connects and how it authenticates.",
wsUrl: "WebSocket URL",
token: "Gateway Token",
password: "Password (not stored)",
sessionKey: "Default Session Key",
connectHint: "Click Connect to apply connection changes.",
},
snapshot: {
title: "Snapshot",
subtitle: "Latest gateway handshake information.",
status: "Status",
uptime: "Uptime",
tickInterval: "Tick Interval",
lastChannelsRefresh: "Last Channels Refresh",
channelsHint: "Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.",
},
stats: {
instances: "Instances",
instancesHint: "Presence beacons in the last 5 minutes.",
sessions: "Sessions",
sessionsHint: "Recent session keys tracked by the gateway.",
cron: "Cron",
cronNext: "Next wake {time}",
},
notes: {
title: "Notes",
subtitle: "Quick reminders for remote control setups.",
tailscaleTitle: "Tailscale serve",
tailscaleText: "Prefer serve mode to keep the gateway on loopback with tailnet auth.",
sessionTitle: "Session hygiene",
sessionText: "Use /new or sessions.patch to reset context.",
cronTitle: "Cron reminders",
cronText: "Use isolated sessions for recurring runs.",
},
auth: {
required: "This gateway requires auth. Add a token or password, then click Connect.",
failed: "Auth failed. Re-copy a tokenized URL with {command}, or update the token, then click Connect.",
},
insecure: {
hint: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open {url} on the gateway host.",
stayHttp: "If you must stay on HTTP, set {config} (token-only).",
},
},
chat: {
disconnected: "Disconnected from gateway.",
refreshTitle: "Refresh chat data",
thinkingToggle: "Toggle assistant thinking/working output",
focusToggle: "Toggle focus mode (hide sidebar + page header)",
onboardingDisabled: "Disabled during onboarding",
},
};

View File

@@ -0,0 +1,107 @@
import type { TranslationMap } from "../lib/types";
export const pt_BR: TranslationMap = {
common: {
health: "Saúde",
ok: "OK",
offline: "Offline",
connect: "Conectar",
refresh: "Atualizar",
enabled: "Ativado",
disabled: "Desativado",
na: "n/a",
docs: "Docs",
resources: "Recursos",
},
nav: {
chat: "Chat",
control: "Controle",
agent: "Agente",
settings: "Configurações",
expand: "Expandir barra lateral",
collapse: "Recolher barra lateral",
},
tabs: {
agents: "Agentes",
overview: "Visão Geral",
channels: "Canais",
instances: "Instâncias",
sessions: "Sessões",
usage: "Uso",
cron: "Tarefas Cron",
skills: "Habilidades",
nodes: "Nós",
chat: "Chat",
config: "Config",
debug: "Debug",
logs: "Logs",
},
subtitles: {
agents: "Gerenciar espaços de trabalho, ferramentas e identidades de agentes.",
overview: "Status do gateway, pontos de entrada e leitura rápida de saúde.",
channels: "Gerenciar canais e configurações.",
instances: "Beacons de presença de clientes e nós conectados.",
sessions: "Inspecionar sessões ativas e ajustar padrões por sessão.",
usage: "Monitorar uso e custos da API.",
cron: "Agendar despertares e execuções recorrentes de agentes.",
skills: "Gerenciar disponibilidade de habilidades e injeção de chaves de API.",
nodes: "Dispositivos pareados, capacidades e exposição de comandos.",
chat: "Sessão de chat direta com o gateway para intervenções rápidas.",
config: "Editar ~/.openclaw/openclaw.json com segurança.",
debug: "Snapshots do gateway, eventos e chamadas RPC manuais.",
logs: "Acompanhamento ao vivo dos logs de arquivo do gateway.",
},
overview: {
access: {
title: "Acesso ao Gateway",
subtitle: "Onde o dashboard se conecta e como ele se autentica.",
wsUrl: "URL WebSocket",
token: "Token do Gateway",
password: "Senha (não armazenada)",
sessionKey: "Chave de Sessão Padrão",
connectHint: "Clique em Conectar para aplicar as alterações de conexão.",
},
snapshot: {
title: "Snapshot",
subtitle: "Informações mais recentes do handshake do gateway.",
status: "Status",
uptime: "Tempo de Atividade",
tickInterval: "Intervalo de Tick",
lastChannelsRefresh: "Última Atualização de Canais",
channelsHint: "Use Canais para vincular WhatsApp, Telegram, Discord, Signal ou iMessage.",
},
stats: {
instances: "Instâncias",
instancesHint: "Beacons de presença nos últimos 5 minutos.",
sessions: "Sessões",
sessionsHint: "Chaves de sessão recentes rastreadas pelo gateway.",
cron: "Cron",
cronNext: "Próximo despertar {time}",
},
notes: {
title: "Notas",
subtitle: "Lembretes rápidos para configurações de controle remoto.",
tailscaleTitle: "Tailscale serve",
tailscaleText: "Prefira o modo serve para manter o gateway em loopback com autenticação tailnet.",
sessionTitle: "Higiene de sessão",
sessionText: "Use /new ou sessions.patch para redefinir o contexto.",
cronTitle: "Lembretes de Cron",
cronText: "Use sessões isoladas para execuções recorrentes.",
},
auth: {
required: "Este gateway requer autenticação. Adicione um token ou senha e clique em Conectar.",
failed: "Falha na autenticação. Recopie uma URL com token usando {command}, ou atualize o token e clique em Conectar.",
},
insecure: {
hint: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra {url} no host do gateway.",
stayHttp: "Se você precisar permanecer em HTTP, defina {config} (apenas token).",
},
},
chat: {
disconnected: "Desconectado do gateway.",
refreshTitle: "Atualizar dados do chat",
thinkingToggle: "Alternar saída de pensamento/trabalho do assistente",
focusToggle: "Alternar modo de foco (ocultar barra lateral + cabeçalho da página)",
onboardingDisabled: "Desativado durante a integração",
},
};

View File

@@ -0,0 +1,107 @@
import type { TranslationMap } from "../lib/types";
export const zh_CN: TranslationMap = {
common: {
health: "健康状况",
ok: "正常",
offline: "离线",
connect: "连接",
refresh: "刷新",
enabled: "已启用",
disabled: "已禁用",
na: "不适用",
docs: "文档",
resources: "资源",
},
nav: {
chat: "聊天",
control: "控制",
agent: "代理",
settings: "设置",
expand: "展开侧边栏",
collapse: "折叠侧边栏",
},
tabs: {
agents: "代理",
overview: "概览",
channels: "频道",
instances: "实例",
sessions: "会话",
usage: "使用情况",
cron: "定时任务",
skills: "技能",
nodes: "节点",
chat: "聊天",
config: "配置",
debug: "调试",
logs: "日志",
},
subtitles: {
agents: "管理代理工作区、工具和身份。",
overview: "网关状态、入口点和快速健康读取。",
channels: "管理频道和设置。",
instances: "来自已连接客户端和节点的在线信号。",
sessions: "检查活动会话并调整每个会话的默认设置。",
usage: "监控 API 使用情况和成本。",
cron: "安排唤醒和重复的代理运行。",
skills: "管理技能可用性和 API 密钥注入。",
nodes: "配对设备、功能和命令公开。",
chat: "用于快速干预的直接网关聊天会话。",
config: "安全地编辑 ~/.openclaw/openclaw.json。",
debug: "网关快照、事件和手动 RPC 调用。",
logs: "网关文件日志的实时追踪。",
},
overview: {
access: {
title: "网关访问",
subtitle: "仪表板连接的位置及其身份验证方式。",
wsUrl: "WebSocket URL",
token: "网关令牌",
password: "密码 (不存储)",
sessionKey: "默认会话密钥",
connectHint: "点击连接以应用连接更改。",
},
snapshot: {
title: "快照",
subtitle: "最新的网关握手信息。",
status: "状态",
uptime: "运行时间",
tickInterval: "刻度间隔",
lastChannelsRefresh: "最后频道刷新",
channelsHint: "使用频道链接 WhatsApp、Telegram、Discord、Signal 或 iMessage。",
},
stats: {
instances: "实例",
instancesHint: "过去 5 分钟内的在线信号。",
sessions: "会话",
sessionsHint: "网关跟踪的最近会话密钥。",
cron: "定时任务",
cronNext: "下次唤醒 {time}",
},
notes: {
title: "备注",
subtitle: "远程控制设置的快速提醒。",
tailscaleTitle: "Tailscale serve",
tailscaleText: "首选 serve 模式以通过 tailnet 身份验证将网关保持在回环地址。",
sessionTitle: "会话清理",
sessionText: "使用 /new 或 sessions.patch 重置上下文。",
cronTitle: "定时任务提醒",
cronText: "为重复运行使用隔离的会话。",
},
auth: {
required: "此网关需要身份验证。添加令牌或密码,然后点击连接。",
failed: "身份验证失败。请使用 {command} 重新复制令牌化 URL或更新令牌然后点击连接。",
},
insecure: {
hint: "此页面为 HTTP因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。",
stayHttp: "如果您必须保持 HTTP请设置 {config} (仅限令牌)。",
},
},
chat: {
disconnected: "已断开与网关的连接。",
refreshTitle: "刷新聊天数据",
thinkingToggle: "切换助手思考/工作输出",
focusToggle: "切换专注模式 (隐藏侧边栏 + 页面页眉)",
onboardingDisabled: "引导期间禁用",
},
};

View File

@@ -0,0 +1,31 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { i18n, t } from "../lib/translate";
describe("i18n", () => {
beforeEach(() => {
localStorage.clear();
// Reset to English
void i18n.setLocale("en");
});
it("should return the key if translation is missing", () => {
expect(t("non.existent.key")).toBe("non.existent.key");
});
it("should return the correct English translation", () => {
expect(t("common.health")).toBe("Health");
});
it("should replace parameters correctly", () => {
expect(t("overview.stats.cronNext", { time: "10:00" })).toBe("Next wake 10:00");
});
it("should fallback to English if key is missing in another locale", async () => {
// We haven't registered other locales in the test environment yet,
// but the logic should fallback to 'en' map which is always there.
await i18n.setLocale("zh-CN");
// Since we don't mock the import, it might fail to load zh-CN,
// but let's assume it falls back to English for now.
expect(t("common.health")).toBeDefined();
});
});

View File

@@ -10,6 +10,7 @@ import { OpenClawApp } from "./app.ts";
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
import { icons } from "./icons.ts";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
import { t } from "../i18n/index.ts";
type SessionDefaultsSnapshot = {
mainSessionKey?: string;
@@ -186,7 +187,7 @@ export function renderChatControls(state: AppViewState) {
});
}
}}
title="Refresh chat data"
title=${t("chat.refreshTitle")}
>
${refreshIcon}
</button>
@@ -206,8 +207,8 @@ export function renderChatControls(state: AppViewState) {
aria-pressed=${showThinking}
title=${
disableThinkingToggle
? "Disabled during onboarding"
: "Toggle assistant thinking/working output"
? t("chat.onboardingDisabled")
: t("chat.thinkingToggle")
}
>
${icons.brain}
@@ -227,8 +228,8 @@ export function renderChatControls(state: AppViewState) {
aria-pressed=${focusActive}
title=${
disableFocusToggle
? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"
? t("chat.onboardingDisabled")
: t("chat.focusToggle")
}
>
${focusIcon}

View File

@@ -50,9 +50,18 @@ import {
saveSkillApiKey,
updateSkillEdit,
updateSkillEnabled,
type SkillMessage,
} from "./controllers/skills.ts";
import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import {
normalizeBasePath,
TAB_GROUPS,
iconForTab,
pathForTab,
subtitleForTab,
titleForTab,
type Tab,
} from "./navigation.ts";
import { renderAgents } from "./views/agents.ts";
import { renderChannels } from "./views/channels.ts";
import { renderChat } from "./views/chat.ts";
@@ -67,6 +76,7 @@ import { renderNodes } from "./views/nodes.ts";
import { renderOverview } from "./views/overview.ts";
import { renderSessions } from "./views/sessions.ts";
import { renderSkills } from "./views/skills.ts";
import { t, i18n, type Locale } from "../i18n/index.ts";
const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i;
@@ -91,7 +101,7 @@ export function renderApp(state: AppViewState) {
const presenceCount = state.presenceEntries.length;
const sessionsCount = state.sessionsResult?.count ?? null;
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
const chatDisabledReason = state.connected ? null : t("chat.disconnected");
const isChat = state.tab === "chat";
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
@@ -117,8 +127,8 @@ export function renderApp(state: AppViewState) {
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
>
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>
@@ -135,8 +145,8 @@ 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("common.health")}</span>
<span class="mono">${state.connected ? t("common.ok") : t("common.offline")}</span>
</div>
${renderThemeToggle(state)}
</div>
@@ -159,7 +169,7 @@ export function renderApp(state: AppViewState) {
}}
aria-expanded=${!isGroupCollapsed}
>
<span class="nav-label__text">${group.label}</span>
<span class="nav-label__text">${t(`nav.${group.label}`)}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
</button>
<div class="nav-group__items">
@@ -170,7 +180,7 @@ export function renderApp(state: AppViewState) {
})}
<div class="nav-group nav-group--links">
<div class="nav-label nav-label--static">
<span class="nav-label__text">Resources</span>
<span class="nav-label__text">${t("common.resources")}</span>
</div>
<div class="nav-group__items">
<a
@@ -178,10 +188,10 @@ export function renderApp(state: AppViewState) {
href="https://docs.openclaw.ai"
target="_blank"
rel="noreferrer"
title="Docs (opens in new tab)"
title="${t("common.docs")} (opens in new tab)"
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">Docs</span>
<span class="nav-item__text">${t("common.docs")}</span>
</a>
</div>
</div>

View File

@@ -80,6 +80,7 @@ import { normalizeAssistantIdentity } from "./assistant-identity.ts";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
import { loadSettings, type UiSettings } from "./storage.ts";
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts";
import { i18n, I18nController, type Locale } from "../i18n/index.ts";
declare global {
interface Window {
@@ -104,7 +105,17 @@ function resolveOnboardingMode(): boolean {
@customElement("openclaw-app")
export class OpenClawApp extends LitElement {
private i18nController = new I18nController(this);
@state() settings: UiSettings = loadSettings();
constructor() {
super();
if (this.settings.locale) {
const supportedLocales: Locale[] = ["en", "zh-CN", "zh-TW", "pt-BR"];
if (supportedLocales.includes(this.settings.locale as Locale)) {
void i18n.setLocale(this.settings.locale as Locale);
}
}
}
@state() password = "";
@state() tab: Tab = "chat";
@state() onboarding = resolveOnboardingMode();

View File

@@ -1,13 +1,14 @@
import type { IconName } from "./icons.js";
import { t } from "../i18n/index.js";
export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] },
{ label: "chat", tabs: ["chat"] },
{
label: "Control",
label: "control",
tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"],
},
{ label: "Agent", tabs: ["agents", "skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] },
{ label: "agent", tabs: ["agents", "skills", "nodes"] },
{ label: "settings", tabs: ["config", "debug", "logs"] },
] as const;
export type Tab =
@@ -156,67 +157,9 @@ export function iconForTab(tab: Tab): IconName {
}
export function titleForTab(tab: Tab) {
switch (tab) {
case "agents":
return "Agents";
case "overview":
return "Overview";
case "channels":
return "Channels";
case "instances":
return "Instances";
case "sessions":
return "Sessions";
case "usage":
return "Usage";
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";
}
return t(`tabs.${tab}`);
}
export function subtitleForTab(tab: Tab) {
switch (tab) {
case "agents":
return "Manage agent workspaces, tools, and identities.";
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 "usage":
return "";
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 ~/.openclaw/openclaw.json safely.";
case "debug":
return "Gateway snapshots, events, and manual RPC calls.";
case "logs":
return "Live tail of the gateway file logs.";
default:
return "";
}
return t(`subtitles.${tab}`);
}

View File

@@ -13,6 +13,7 @@ export type UiSettings = {
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
navCollapsed: boolean; // Collapsible sidebar state
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
locale?: string;
};
export function loadSettings(): UiSettings {
@@ -32,6 +33,7 @@ export function loadSettings(): UiSettings {
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
locale: "en",
};
try {
@@ -77,6 +79,7 @@ export function loadSettings(): UiSettings {
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed
: defaults.navGroupsCollapsed,
locale: typeof parsed.locale === "string" ? parsed.locale : defaults.locale,
};
} catch {
return defaults;

View File

@@ -1,8 +1,9 @@
import { html } from "lit";
import type { GatewayHelloOk } from "../gateway.ts";
import type { UiSettings } from "../storage.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import { formatNextRun } from "../presenter.ts";
import type { GatewayHelloOk } from "../gateway";
import type { UiSettings } from "../storage";
import { formatAgo, formatDurationMs } from "../format";
import { formatNextRun } from "../presenter";
import { t, i18n, type Locale } from "../../i18n";
export type OverviewProps = {
connected: boolean;
@@ -24,33 +25,24 @@ export type OverviewProps = {
export function renderOverview(props: OverviewProps) {
const snapshot = props.hello?.snapshot as
| {
uptimeMs?: number;
policy?: { tickIntervalMs?: number };
authMode?: "none" | "token" | "password" | "trusted-proxy";
}
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
| undefined;
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : "n/a";
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
const authMode = snapshot?.authMode;
const isTrustedProxy = authMode === "trusted-proxy";
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : t("common.na");
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : t("common.na");
const authHint = (() => {
if (props.connected || !props.lastError) {
return null;
}
if (props.connected || !props.lastError) return null;
const lower = props.lastError.toLowerCase();
const authFailed = lower.includes("unauthorized") || lower.includes("connect failed");
if (!authFailed) {
return null;
}
if (!authFailed) return null;
const hasToken = Boolean(props.settings.token.trim());
const hasPassword = Boolean(props.password.trim());
if (!hasToken && !hasPassword) {
return html`
<div class="muted" style="margin-top: 8px">
This gateway requires auth. Add a token or password, then click Connect.
${t("overview.auth.required")}
<div style="margin-top: 6px">
<span class="mono">openclaw dashboard --no-open</span> → open the Control UI<br />
<span class="mono">openclaw dashboard --no-open</span> → tokenized URL<br />
<span class="mono">openclaw doctor --generate-gateway-token</span> → set token
</div>
<div style="margin-top: 6px">
@@ -68,7 +60,7 @@ export function renderOverview(props: OverviewProps) {
}
return html`
<div class="muted" style="margin-top: 8px">
Auth failed. Update the token or password in Control UI settings, then click Connect.
${t("overview.auth.failed", { command: "openclaw dashboard --no-open" })}
<div style="margin-top: 6px">
<a
class="session-link"
@@ -82,25 +74,20 @@ export function renderOverview(props: OverviewProps) {
</div>
`;
})();
const insecureContextHint = (() => {
if (props.connected || !props.lastError) {
return null;
}
if (props.connected || !props.lastError) return null;
const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true;
if (isSecureContext) {
return null;
}
if (isSecureContext !== false) return null;
const lower = props.lastError.toLowerCase();
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
return null;
}
return html`
<div class="muted" style="margin-top: 8px">
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open
<span class="mono">http://127.0.0.1:18789</span> on the gateway host.
${t("overview.insecure.hint", { url: "http://127.0.0.1:18789" })}
<div style="margin-top: 6px">
If you must stay on HTTP, set
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
${t("overview.insecure.stayHttp", { config: "gateway.controlUi.allowInsecureAuth: true" })}
</div>
<div style="margin-top: 6px">
<a
@@ -125,14 +112,16 @@ export function renderOverview(props: OverviewProps) {
`;
})();
const currentLocale = i18n.getLocale();
return html`
<section class="grid grid-cols-2">
<div class="card">
<div class="card-title">Gateway Access</div>
<div class="card-sub">Where the dashboard connects and how it authenticates.</div>
<div class="card-title">${t("overview.access.title")}</div>
<div class="card-sub">${t("overview.access.subtitle")}</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>WebSocket URL</span>
<span>${t("overview.access.wsUrl")}</span>
<input
.value=${props.settings.gatewayUrl}
@input=${(e: Event) => {
@@ -142,37 +131,31 @@ export function renderOverview(props: OverviewProps) {
placeholder="ws://100.x.y.z:18789"
/>
</label>
${
isTrustedProxy
? ""
: html`
<label class="field">
<span>Gateway Token</span>
<input
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN"
/>
</label>
<label class="field">
<span>Password (not stored)</span>
<input
type="password"
.value=${props.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
/>
</label>
`
}
<label class="field">
<span>Default Session Key</span>
<span>${t("overview.access.token")}</span>
<input
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN"
/>
</label>
<label class="field">
<span>${t("overview.access.password")}</span>
<input
type="password"
.value=${props.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
/>
</label>
<label class="field">
<span>${t("overview.access.sessionKey")}</span>
<input
.value=${props.settings.sessionKey}
@input=${(e: Event) => {
@@ -181,36 +164,52 @@ export function renderOverview(props: OverviewProps) {
}}
/>
</label>
<label class="field">
<span>Language</span>
<select
.value=${currentLocale}
@change=${(e: Event) => {
const v = (e.target as HTMLSelectElement).value as Locale;
void i18n.setLocale(v);
props.onSettingsChange({ ...props.settings, locale: v });
}}
>
<option value="en">English</option>
<option value="zh-CN">简体中文 (Simplified Chinese)</option>
<option value="zh-TW">繁體中文 (Traditional Chinese)</option>
<option value="pt-BR">Português (Brazilian Portuguese)</option>
</select>
</label>
</div>
<div class="row" style="margin-top: 14px;">
<button class="btn" @click=${() => props.onConnect()}>Connect</button>
<button class="btn" @click=${() => props.onRefresh()}>Refresh</button>
<span class="muted">${isTrustedProxy ? "Authenticated via trusted proxy." : "Click Connect to apply connection changes."}</span>
<button class="btn" @click=${() => props.onConnect()}>${t("common.connect")}</button>
<button class="btn" @click=${() => props.onRefresh()}>${t("common.refresh")}</button>
<span class="muted">${t("overview.access.connectHint")}</span>
</div>
</div>
<div class="card">
<div class="card-title">Snapshot</div>
<div class="card-sub">Latest gateway handshake information.</div>
<div class="card-title">${t("overview.snapshot.title")}</div>
<div class="card-sub">${t("overview.snapshot.subtitle")}</div>
<div class="stat-grid" style="margin-top: 16px;">
<div class="stat">
<div class="stat-label">Status</div>
<div class="stat-label">${t("overview.snapshot.status")}</div>
<div class="stat-value ${props.connected ? "ok" : "warn"}">
${props.connected ? "Connected" : "Disconnected"}
${props.connected ? t("common.ok") : t("common.offline")}
</div>
</div>
<div class="stat">
<div class="stat-label">Uptime</div>
<div class="stat-label">${t("overview.snapshot.uptime")}</div>
<div class="stat-value">${uptime}</div>
</div>
<div class="stat">
<div class="stat-label">Tick Interval</div>
<div class="stat-label">${t("overview.snapshot.tickInterval")}</div>
<div class="stat-value">${tick}</div>
</div>
<div class="stat">
<div class="stat-label">Last Channels Refresh</div>
<div class="stat-label">${t("overview.snapshot.lastChannelsRefresh")}</div>
<div class="stat-value">
${props.lastChannelsRefresh ? formatRelativeTimestamp(props.lastChannelsRefresh) : "n/a"}
${props.lastChannelsRefresh ? formatAgo(props.lastChannelsRefresh) : t("common.na")}
</div>
</div>
</div>
@@ -223,7 +222,7 @@ export function renderOverview(props: OverviewProps) {
</div>`
: html`
<div class="callout" style="margin-top: 14px">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
${t("overview.snapshot.channelsHint")}
</div>
`
}
@@ -232,41 +231,41 @@ export function renderOverview(props: OverviewProps) {
<section class="grid grid-cols-3" style="margin-top: 18px;">
<div class="card stat-card">
<div class="stat-label">Instances</div>
<div class="stat-label">${t("overview.stats.instances")}</div>
<div class="stat-value">${props.presenceCount}</div>
<div class="muted">Presence beacons in the last 5 minutes.</div>
<div class="muted">${t("overview.stats.instancesHint")}</div>
</div>
<div class="card stat-card">
<div class="stat-label">Sessions</div>
<div class="stat-value">${props.sessionsCount ?? "n/a"}</div>
<div class="muted">Recent session keys tracked by the gateway.</div>
<div class="stat-label">${t("overview.stats.sessions")}</div>
<div class="stat-value">${props.sessionsCount ?? t("common.na")}</div>
<div class="muted">${t("overview.stats.sessionsHint")}</div>
</div>
<div class="card stat-card">
<div class="stat-label">Cron</div>
<div class="stat-label">${t("overview.stats.cron")}</div>
<div class="stat-value">
${props.cronEnabled == null ? "n/a" : props.cronEnabled ? "Enabled" : "Disabled"}
${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")}
</div>
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
<div class="muted">${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}</div>
</div>
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Notes</div>
<div class="card-sub">Quick reminders for remote control setups.</div>
<div class="card-title">${t("overview.notes.title")}</div>
<div class="card-sub">${t("overview.notes.subtitle")}</div>
<div class="note-grid" style="margin-top: 14px;">
<div>
<div class="note-title">Tailscale serve</div>
<div class="note-title">${t("overview.notes.tailscaleTitle")}</div>
<div class="muted">
Prefer serve mode to keep the gateway on loopback with tailnet auth.
${t("overview.notes.tailscaleText")}
</div>
</div>
<div>
<div class="note-title">Session hygiene</div>
<div class="muted">Use /new or sessions.patch to reset context.</div>
<div class="note-title">${t("overview.notes.sessionTitle")}</div>
<div class="muted">${t("overview.notes.sessionText")}</div>
</div>
<div>
<div class="note-title">Cron reminders</div>
<div class="muted">Use isolated sessions for recurring runs.</div>
<div class="note-title">${t("overview.notes.cronTitle")}</div>
<div class="muted">${t("overview.notes.cronText")}</div>
</div>
</div>
</section>