mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-25 03:04:29 -04:00
feat(ui): add i18n support with English, Chinese, and Portuguese
This commit is contained in:
committed by
Peter Steinberger
parent
a03098ca49
commit
4b17ce7f48
3
ui/src/i18n/index.ts
Normal file
3
ui/src/i18n/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./lib/types";
|
||||
export * from "./lib/translate";
|
||||
export * from "./lib/lit-controller";
|
||||
22
ui/src/i18n/lib/lit-controller.ts
Normal file
22
ui/src/i18n/lib/lit-controller.ts
Normal 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?.();
|
||||
}
|
||||
}
|
||||
106
ui/src/i18n/lib/translate.ts
Normal file
106
ui/src/i18n/lib/translate.ts
Normal 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
9
ui/src/i18n/lib/types.ts
Normal 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
107
ui/src/i18n/locales/en.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
107
ui/src/i18n/locales/pt-BR.ts
Normal file
107
ui/src/i18n/locales/pt-BR.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
107
ui/src/i18n/locales/zh-CN.ts
Normal file
107
ui/src/i18n/locales/zh-CN.ts
Normal 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: "引导期间禁用",
|
||||
},
|
||||
};
|
||||
31
ui/src/i18n/test/translate.test.ts
Normal file
31
ui/src/i18n/test/translate.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user