feat: add zh-CN localization and i18n support

This commit is contained in:
Apple User 2026-01-28 09:53:03 +08:00
parent 9daa846457
commit ae17ea9c1b
48 changed files with 2995 additions and 797 deletions

View File

@ -1 +1 @@
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
9d9765de99758bf1005206ba11ab8f0e5ea80a1d46c974ae579779abc199e1b1

View File

@ -191,6 +191,13 @@
transition: border-color var(--duration-fast) ease;
}
button.pill {
appearance: none;
font: inherit;
color: inherit;
cursor: pointer;
}
.pill:hover {
border-color: var(--border-strong);
}

View File

@ -26,6 +26,7 @@ import {
import type { ClawdbotApp } from "./app";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import { loadAssistantIdentity } from "./controllers/assistant-identity";
import { t } from "./i18n";
type GatewayHost = {
settings: UiSettings;
@ -108,6 +109,24 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps
}
}
function localizeCloseReason(reason: string | undefined | null) {
const raw = typeof reason === "string" ? reason.trim() : "";
if (!raw) return t("gateway.noReason");
const lower = raw.toLowerCase();
if (lower.includes("gateway token missing")) return t("gateway.reason.tokenMissing");
if (lower.includes("gateway token mismatch")) return t("gateway.reason.tokenMismatch");
if (lower.includes("gateway token not configured")) return t("gateway.reason.tokenNotConfigured");
if (lower.includes("gateway password missing")) return t("gateway.reason.passwordMissing");
if (lower.includes("gateway password mismatch")) return t("gateway.reason.passwordMismatch");
if (lower.includes("gateway password not configured")) return t("gateway.reason.passwordNotConfigured");
if (lower.includes("tailscale identity missing")) return t("gateway.reason.tailscaleIdentityMissing");
if (lower.includes("tailscale proxy headers missing")) return t("gateway.reason.tailscaleProxyHeadersMissing");
if (lower.includes("tailscale identity check failed")) return t("gateway.reason.tailscaleIdentityCheckFailed");
if (lower.includes("tailscale identity mismatch")) return t("gateway.reason.tailscaleIdentityMismatch");
if (lower === "unauthorized") return t("gateway.reason.unauthorized");
return raw;
}
export function connectGateway(host: GatewayHost) {
host.lastError = null;
host.hello = null;
@ -143,12 +162,15 @@ export function connectGateway(host: GatewayHost) {
host.connected = false;
// Code 1012 = Service Restart (expected during config saves, don't show as error)
if (code !== 1012) {
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
host.lastError = t("gateway.disconnected", {
code,
reason: localizeCloseReason(reason),
});
}
},
onEvent: (evt) => handleGatewayEvent(host, evt),
onGap: ({ expected, received }) => {
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
host.lastError = t("gateway.eventGap", { expected, received });
},
});
host.client.start();

View File

@ -9,6 +9,7 @@ import { syncUrlWithSessionKey } from "./app-settings";
import type { SessionsListResult } from "./types";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import { t } from "./i18n";
export function renderTab(state: AppViewState, tab: Tab) {
const href = pathForTab(tab, state.basePath);
@ -89,7 +90,7 @@ export function renderChatControls(state: AppViewState) {
state.resetToolStream();
void loadChatHistory(state);
}}
title="Refresh chat history"
title=${t("chatControls.refreshHistory")}
>
${refreshIcon}
</button>
@ -105,9 +106,7 @@ export function renderChatControls(state: AppViewState) {
});
}}
aria-pressed=${showThinking}
title=${disableThinkingToggle
? "Disabled during onboarding"
: "Toggle assistant thinking/working output"}
title=${disableThinkingToggle ? t("chatControls.disabledOnboarding") : t("chatControls.toggleThinking")}
>
${icons.brain}
</button>
@ -122,9 +121,7 @@ export function renderChatControls(state: AppViewState) {
});
}}
aria-pressed=${focusActive}
title=${disableFocusToggle
? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"}
title=${disableFocusToggle ? t("chatControls.disabledOnboarding") : t("chatControls.toggleFocus")}
>
${focusIcon}
</button>
@ -171,14 +168,14 @@ export function renderThemeToggle(state: AppViewState) {
return html`
<div class="theme-toggle" style="--theme-index: ${index};">
<div class="theme-toggle__track" role="group" aria-label="Theme">
<div class="theme-toggle__track" role="group" aria-label=${t("theme.label")}>
<span class="theme-toggle__indicator"></span>
<button
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
@click=${applyTheme("system")}
aria-pressed=${state.theme === "system"}
aria-label="System theme"
title="System"
aria-label=${t("theme.system")}
title=${t("theme.system")}
>
${renderMonitorIcon()}
</button>
@ -186,8 +183,8 @@ export function renderThemeToggle(state: AppViewState) {
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
@click=${applyTheme("light")}
aria-pressed=${state.theme === "light"}
aria-label="Light theme"
title="Light"
aria-label=${t("theme.light")}
title=${t("theme.light")}
>
${renderSunIcon()}
</button>
@ -195,8 +192,8 @@ export function renderThemeToggle(state: AppViewState) {
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
@click=${applyTheme("dark")}
aria-pressed=${state.theme === "dark"}
aria-label="Dark theme"
title="Dark"
aria-label=${t("theme.dark")}
title=${t("theme.dark")}
>
${renderMoonIcon()}
</button>

View File

@ -15,6 +15,7 @@ import { icons } from "./icons";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import { t } from "./i18n";
import type {
ConfigSnapshot,
CronJob,
@ -104,7 +105,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.disconnectedReason");
const isChat = state.tab === "chat";
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
@ -122,8 +123,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("topbar.expandSidebar") : t("topbar.collapseSidebar")}
aria-label=${state.settings.navCollapsed ? t("topbar.expandSidebar") : t("topbar.collapseSidebar")}
>
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>
@ -133,22 +134,37 @@ export function renderApp(state: AppViewState) {
</div>
<div class="brand-text">
<div class="brand-title">CLAWDBOT</div>
<div class="brand-sub">Gateway Dashboard</div>
<div class="brand-sub">${t("topbar.gatewayDashboard")}</div>
</div>
</div>
</div>
<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("topbar.health")}</span>
<span class="mono">${state.connected ? t("topbar.healthOk") : t("topbar.healthOffline")}</span>
</div>
<button
class="btn btn--sm"
type="button"
title=${t("settings.language")}
aria-label=${t("settings.language")}
@click=${() =>
state.applySettings({
...state.settings,
locale: state.settings.locale === "zh-CN" ? "en" : "zh-CN",
})}
>
${state.settings.locale === "zh-CN"
? t("settings.lang.zhShort")
: t("settings.lang.enShort")}
</button>
${renderThemeToggle(state)}
</div>
</header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.id] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
@ -156,7 +172,7 @@ export function renderApp(state: AppViewState) {
class="nav-label"
@click=${() => {
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
next[group.id] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
@ -164,7 +180,7 @@ export function renderApp(state: AppViewState) {
}}
aria-expanded=${!isGroupCollapsed}
>
<span class="nav-label__text">${group.label}</span>
<span class="nav-label__text">${t(group.labelKey)}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
</button>
<div class="nav-group__items">
@ -175,7 +191,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("nav.resources")}</span>
</div>
<div class="nav-group__items">
<a
@ -183,10 +199,10 @@ export function renderApp(state: AppViewState) {
href="https://docs.clawd.bot"
target="_blank"
rel="noreferrer"
title="Docs (opens in new tab)"
title=${t("nav.docs")}
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">Docs</span>
<span class="nav-item__text">${t("nav.docs")}</span>
</a>
</div>
</div>
@ -199,7 +215,13 @@ export function renderApp(state: AppViewState) {
</div>
<div class="page-meta">
${state.lastError
? html`<div class="pill danger">${state.lastError}</div>`
? html`<button
type="button"
class="pill danger"
@click=${() => state.setTab("overview")}
>
${state.lastError}
</button>`
: nothing}
${isChat ? renderChatControls(state) : nothing}
</div>

View File

@ -17,6 +17,7 @@ import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
import { refreshChat } from "./app-chat";
import type { ClawdbotApp } from "./app";
import { normalizeLocale } from "./i18n";
type SettingsHost = {
settings: UiSettings;
@ -63,6 +64,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
const passwordRaw = params.get("password");
const sessionRaw = params.get("session");
const gatewayUrlRaw = params.get("gatewayUrl");
const langRaw = params.get("lang");
let shouldCleanUrl = false;
if (tokenRaw != null) {
@ -104,6 +106,15 @@ export function applySettingsFromUrl(host: SettingsHost) {
shouldCleanUrl = true;
}
if (langRaw != null) {
const next = normalizeLocale(langRaw);
if (next && next !== host.settings.locale) {
applySettings(host, { ...host.settings, locale: next });
}
params.delete("lang");
shouldCleanUrl = true;
}
if (!shouldCleanUrl) return;
const url = new URL(window.location.href);
url.search = params.toString();

View File

@ -12,6 +12,7 @@ import {
formatReasoningMarkdown,
} from "./message-extract";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
import { t } from "../i18n";
type ImageBlock = {
url: string;
@ -80,7 +81,7 @@ export function renderStreamingGroup(
hour: "numeric",
minute: "2-digit",
});
const name = assistant?.name ?? "Assistant";
const name = assistant?.name ?? t("chat.role.assistant");
return html`
<div class="chat-group assistant">
@ -114,13 +115,13 @@ export function renderMessageGroup(
},
) {
const normalizedRole = normalizeRoleForGrouping(group.role);
const assistantName = opts.assistantName ?? "Assistant";
const assistantName = opts.assistantName ?? t("chat.role.assistant");
const who =
normalizedRole === "user"
? "You"
? t("chat.role.you")
: normalizedRole === "assistant"
? assistantName
: normalizedRole;
: t(`chat.role.${normalizedRole}`);
const roleClass =
normalizedRole === "user"
? "user"
@ -164,7 +165,7 @@ function renderAvatar(
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
) {
const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant";
const assistantName = assistant?.name?.trim() || t("chat.role.assistant");
const assistantAvatar = assistant?.avatar?.trim() || "";
const initial =
normalized === "user"
@ -214,7 +215,7 @@ function renderMessageImages(images: ImageBlock[]) {
(img) => html`
<img
src=${img.url}
alt=${img.alt ?? "Attached image"}
alt=${img.alt ?? t("chat.attachedImage")}
class="chat-message-image"
@click=${() => window.open(img.url, "_blank")}
/>

View File

@ -3,6 +3,7 @@ import { html, nothing } from "lit";
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
import { icons } from "../icons";
import type { ToolCard } from "../types/chat-types";
import { t } from "../i18n";
import { TOOL_INLINE_THRESHOLD } from "./constants";
import {
formatToolOutputForSidebar,
@ -69,8 +70,8 @@ export function renderToolCardSidebar(
return;
}
const info = `## ${display.label}\n\n${
detail ? `**Command:** \`${detail}\`\n\n` : ""
}*No output tool completed successfully.*`;
detail ? `**${t("chat.tool.commandLabel")}** \`${detail}\`\n\n` : ""
}*${t("chat.tool.noOutput")}*`;
onOpenSidebar!(info);
}
: undefined;
@ -100,7 +101,7 @@ export function renderToolCardSidebar(
<span>${display.label}</span>
</div>
${canClick
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>`
? html`<span class="chat-tool-card__action">${hasText ? t("common.view") : ""} ${icons.check}</span>`
: nothing}
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${icons.check}</span>` : nothing}
</div>
@ -108,7 +109,7 @@ export function renderToolCardSidebar(
? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing}
${isEmpty
? html`<div class="chat-tool-card__status-text muted">Completed</div>`
? html`<div class="chat-tool-card__status-text muted">${t("chat.tool.completed")}</div>`
: nothing}
${showCollapsed
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`

View File

@ -1,3 +1,4 @@
import { t } from "../i18n";
import type { ChannelsStatusSnapshot } from "../types";
import type { ChannelsState } from "./channels.types";
@ -65,7 +66,7 @@ export async function logoutWhatsApp(state: ChannelsState) {
state.whatsappBusy = true;
try {
await state.client.request("channels.logout", { channel: "whatsapp" });
state.whatsappLoginMessage = "Logged out.";
state.whatsappLoginMessage = t("channels.whatsapp.loggedOut");
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} catch (err) {

View File

@ -1,4 +1,5 @@
import { toNumber } from "../format";
import { t } from "../i18n";
import type { GatewayBrowserClient } from "../gateway";
import type { CronJob, CronRunLogEntry, CronStatus } from "../types";
import type { CronFormState } from "../ui-types";
@ -46,29 +47,29 @@ export async function loadCronJobs(state: CronState) {
export function buildCronSchedule(form: CronFormState) {
if (form.scheduleKind === "at") {
const ms = Date.parse(form.scheduleAt);
if (!Number.isFinite(ms)) throw new Error("Invalid run time.");
if (!Number.isFinite(ms)) throw new Error(t("cron.error.invalidRunTime"));
return { kind: "at" as const, atMs: ms };
}
if (form.scheduleKind === "every") {
const amount = toNumber(form.everyAmount, 0);
if (amount <= 0) throw new Error("Invalid interval amount.");
if (amount <= 0) throw new Error(t("cron.error.invalidInterval"));
const unit = form.everyUnit;
const mult = unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000;
return { kind: "every" as const, everyMs: amount * mult };
}
const expr = form.cronExpr.trim();
if (!expr) throw new Error("Cron expression required.");
if (!expr) throw new Error(t("cron.error.exprRequired"));
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined };
}
export function buildCronPayload(form: CronFormState) {
if (form.payloadKind === "systemEvent") {
const text = form.payloadText.trim();
if (!text) throw new Error("System event text required.");
if (!text) throw new Error(t("cron.error.systemTextRequired"));
return { kind: "systemEvent" as const, text };
}
const message = form.payloadText.trim();
if (!message) throw new Error("Agent message required.");
if (!message) throw new Error(t("cron.error.agentMessageRequired"));
const payload: {
kind: "agentTurn";
message: string;
@ -108,7 +109,7 @@ export async function addCronJob(state: CronState) {
? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() }
: undefined,
};
if (!job.name) throw new Error("Name required.");
if (!job.name) throw new Error(t("cron.error.nameRequired"));
await state.client.request("cron.add", job);
state.cronForm = {
...state.cronForm,

View File

@ -1,3 +1,4 @@
import { t } from "../i18n";
import type { GatewayBrowserClient } from "../gateway";
import type { PresenceEntry } from "../types";
@ -21,12 +22,12 @@ export async function loadPresence(state: PresenceState) {
| PresenceEntry[]
| undefined;
if (Array.isArray(res)) {
state.presenceEntries = res;
state.presenceStatus = res.length === 0 ? "No instances yet." : null;
} else {
state.presenceEntries = [];
state.presenceStatus = "No presence payload.";
}
state.presenceEntries = res;
state.presenceStatus = res.length === 0 ? t("instances.noInstances") : null;
} else {
state.presenceEntries = [];
state.presenceStatus = t("instances.noPayload");
}
} catch (err) {
state.presenceError = String(err);
} finally {

View File

@ -1,5 +1,6 @@
import type { GatewayBrowserClient } from "../gateway";
import { toNumber } from "../format";
import { t } from "../i18n";
import type { SessionsListResult } from "../types";
export type SessionsState = {
@ -66,9 +67,7 @@ export async function patchSession(
export async function deleteSession(state: SessionsState, key: string) {
if (!state.client || !state.connected) return;
if (state.sessionsLoading) return;
const confirmed = window.confirm(
`Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`,
);
const confirmed = window.confirm(t("sessions.deleteConfirm", { key }));
if (!confirmed) return;
state.sessionsLoading = true;
state.sessionsError = null;

View File

@ -1,39 +1,40 @@
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
import { t } from "./i18n";
export function formatMs(ms?: number | null): string {
if (!ms && ms !== 0) return "n/a";
if (!ms && ms !== 0) return t("common.na");
return new Date(ms).toLocaleString();
}
export function formatAgo(ms?: number | null): string {
if (!ms && ms !== 0) return "n/a";
if (!ms && ms !== 0) return t("common.na");
const diff = Date.now() - ms;
if (diff < 0) return "just now";
if (diff < 0) return t("format.ago.justNow");
const sec = Math.round(diff / 1000);
if (sec < 60) return `${sec}s ago`;
if (sec < 60) return t("format.ago.seconds", { seconds: sec });
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
if (min < 60) return t("format.ago.minutes", { minutes: min });
const hr = Math.round(min / 60);
if (hr < 48) return `${hr}h ago`;
if (hr < 48) return t("format.ago.hours", { hours: hr });
const day = Math.round(hr / 24);
return `${day}d ago`;
return t("format.ago.days", { days: day });
}
export function formatDurationMs(ms?: number | null): string {
if (!ms && ms !== 0) return "n/a";
if (!ms && ms !== 0) return t("common.na");
if (ms < 1000) return `${ms}ms`;
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
if (sec < 60) return t("format.duration.seconds", { seconds: sec });
const min = Math.round(sec / 60);
if (min < 60) return `${min}m`;
if (min < 60) return t("format.duration.minutes", { minutes: min });
const hr = Math.round(min / 60);
if (hr < 48) return `${hr}h`;
if (hr < 48) return t("format.duration.hours", { hours: hr });
const day = Math.round(hr / 24);
return `${day}d`;
return t("format.duration.days", { days: day });
}
export function formatList(values?: Array<string | null | undefined>): string {
if (!values || values.length === 0) return "none";
if (!values || values.length === 0) return t("common.none");
return values.filter((v): v is string => Boolean(v && v.trim())).join(", ");
}

84
ui/src/ui/i18n.ts Normal file
View File

@ -0,0 +1,84 @@
export type Locale = "en" | "zh-CN";
export type MessageParams = Record<string, string | number | boolean | null | undefined>;
export type Messages = Record<string, string>;
import { en } from "./locales/en";
import { zhCN } from "./locales/zh-CN";
const BUNDLES: Record<Locale, Messages> = {
en,
"zh-CN": zhCN,
};
let currentLocale: Locale = "en";
const missingKeys = new Set<string>();
export function normalizeLocale(input: string | null | undefined): Locale | null {
if (!input) return null;
const raw = input.trim();
if (!raw) return null;
const lower = raw.toLowerCase();
if (lower === "zh" || lower === "zh-cn" || lower.startsWith("zh-")) return "zh-CN";
if (lower === "en" || lower === "en-us" || lower.startsWith("en-")) return "en";
return null;
}
export function resolveInitialLocale(saved: string | null | undefined): Locale {
try {
const params = new URLSearchParams(window.location.search || "");
const fromUrl = normalizeLocale(params.get("lang"));
if (fromUrl) return fromUrl;
} catch {
}
const fromSaved = normalizeLocale(saved);
if (fromSaved) return fromSaved;
const fromNavigator =
typeof navigator !== "undefined" && typeof navigator.language === "string"
? normalizeLocale(navigator.language)
: null;
if (fromNavigator) return fromNavigator;
return "en";
}
export function getLocale(): Locale {
return currentLocale;
}
export function setLocale(next: Locale) {
currentLocale = next;
}
export function getMissingTranslationKeys(): string[] {
return Array.from(missingKeys).sort((a, b) => a.localeCompare(b));
}
export function resetMissingTranslationKeys() {
missingKeys.clear();
}
function format(template: string, params?: MessageParams) {
if (!params) return template;
return template.replace(/\$\{(\w+)\}/g, (_, key: string) => {
const value = params[key];
if (value === null || value === undefined) return "";
return String(value);
});
}
export function t(key: string, params?: MessageParams): string {
const bundle = BUNDLES[currentLocale] ?? BUNDLES.en;
const hasLocal = Object.prototype.hasOwnProperty.call(bundle, key);
const hasFallback = Object.prototype.hasOwnProperty.call(BUNDLES.en, key);
if (!hasLocal && !hasFallback) {
missingKeys.add(key);
(globalThis as unknown as { __clawdbot_i18n_missing?: Set<string> }).__clawdbot_i18n_missing =
missingKeys;
}
const fallback = hasFallback ? BUNDLES.en[key] : undefined;
const raw = (hasLocal ? bundle[key] : undefined) ?? fallback ?? key;
return format(raw, params);
}

View File

@ -0,0 +1,45 @@
# Clawdbot UI Localization (i18n) Guide
This project uses a custom lightweight i18n system for the UI.
## Structure
- `i18n.ts`: The i18n engine and `t()` function.
- `locales/en.ts`: English translations (source of truth).
- `locales/zh-CN.ts`: Chinese (Simplified) translations.
## Adding New Strings
1. **Add the key to `en.ts`**: Always add new strings to `en.ts` first. Use a hierarchical key structure (e.g., `feature.component.label`).
2. **Add the key to `zh-CN.ts`**: Provide the Chinese translation for the same key.
3. **Use `t()` in your code**:
```typescript
import { t } from "./i18n";
// ...
html`<span>${t("my.key")}</span>`
```
## Best Practices
- **Avoid hardcoded strings**: All user-facing text should be in the i18n files.
- **Interpolation**: Use `${variable}` syntax in i18n strings for dynamic content.
- Locale: `"Hello, ${name}!"`
- Code: `t("greeting", { name: "User" })`
- **Config Form Auto-Translation**: The configuration form automatically attempts to translate field labels and help text using the following keys:
- Label: `config.label.<propertyName>`
- Help/Description: `config.help.<propertyName>`
- If these keys are not found, it falls back to the schema title or a "humanized" version of the property name.
- **Context**: If a string's meaning is ambiguous, add a comment in the i18n file for translators.
- **Consistency**: Use consistent terms for core concepts (Agent -> 智能体, Session -> 会话, etc.).
- **Formatting**: Use `formatMs`, `formatAgo`, etc., from `format.ts` which are already integrated with the i18n system.
## Common Terms (Chinese)
- **Agent**: 智能体
- **Session**: 会话
- **Channel**: 渠道
- **Cron Job**: 定时任务
- **Node**: 节点
- **Skill**: 技能
- **Gateway**: 网关
- **Dashboard**: 仪表板

801
ui/src/ui/locales/en.ts Normal file
View File

@ -0,0 +1,801 @@
import type { Messages } from "../i18n";
export const en: Messages = {
"common.na": "n/a",
"common.loading": "Loading…",
"common.refresh": "Refresh",
"common.save": "Save",
"common.apply": "Apply",
"common.cancel": "Cancel",
"common.close": "Close",
"common.search": "Search",
"common.filter": "Filter",
"common.export": "Export",
"common.copy": "Copy",
"common.openInNewTab": "Open in new tab",
"common.enabled": "Enabled",
"common.disabled": "Disabled",
"common.on": "On",
"common.off": "Off",
"common.start": "Start",
"common.stop": "Stop",
"common.add": "Add",
"common.remove": "Remove",
"common.delete": "Delete",
"common.edit": "Edit",
"common.view": "View",
"common.useDefault": "Use default",
"common.useDefaultWith": "Use default (${value})",
"common.ok": "OK",
"common.yes": "Yes",
"common.no": "No",
"common.agent": "Agent",
"common.inherit": "inherit",
"common.file": "File",
"common.none": "none",
"common.never": "never",
"common.unknown": "unknown",
"common.enable": "Enable",
"common.disable": "Disable",
"common.run": "Run",
"common.export.visible": "visible",
"common.export.filtered": "filtered",
"nav.chat": "Chat",
"nav.control": "Control",
"nav.agent": "Agent",
"nav.settings": "Settings",
"nav.resources": "Resources",
"nav.docs": "Docs",
"tab.overview.title": "Overview",
"tab.channels.title": "Channels",
"tab.instances.title": "Instances",
"tab.sessions.title": "Sessions",
"tab.cron.title": "Cron Jobs",
"tab.skills.title": "Skills",
"tab.nodes.title": "Nodes",
"tab.chat.title": "Chat",
"tab.config.title": "Config",
"tab.debug.title": "Debug",
"tab.logs.title": "Logs",
"tab.control.title": "Control",
"tab.overview.subtitle": "Gateway status, entry points, and a fast health read.",
"tab.channels.subtitle": "Manage channels and settings.",
"tab.instances.subtitle": "Presence beacons from connected clients and nodes.",
"tab.sessions.subtitle": "Inspect active sessions and adjust per-session defaults.",
"tab.cron.subtitle": "Schedule wakeups and recurring agent runs.",
"tab.skills.subtitle": "Manage skill availability and API key injection.",
"tab.nodes.subtitle": "Paired devices, capabilities, and command exposure.",
"tab.chat.subtitle": "Direct gateway chat session for quick interventions.",
"tab.config.subtitle": "Edit ~/.clawdbot/clawdbot.json safely.",
"tab.debug.subtitle": "Gateway snapshots, events, and manual RPC calls.",
"tab.logs.subtitle": "Live tail of the gateway file logs.",
"topbar.expandSidebar": "Expand sidebar",
"topbar.collapseSidebar": "Collapse sidebar",
"topbar.gatewayDashboard": "Gateway Dashboard",
"topbar.health": "Health",
"topbar.healthOk": "OK",
"topbar.healthOffline": "Offline",
"gateway.disconnected": "disconnected (${code}): ${reason}",
"gateway.noReason": "no reason",
"gateway.eventGap": "event gap detected (expected seq ${expected}, got ${received}); refresh recommended",
"gateway.reason.tokenMissing": "unauthorized: gateway token missing (open a tokenized dashboard URL or paste token in settings)",
"gateway.reason.tokenMismatch": "unauthorized: gateway token mismatch (update the token in settings to match the gateway)",
"gateway.reason.tokenNotConfigured": "unauthorized: gateway token not configured on gateway (set gateway.auth.token)",
"gateway.reason.passwordMissing": "unauthorized: gateway password missing (paste password in settings or use token)",
"gateway.reason.passwordMismatch": "unauthorized: gateway password mismatch (check the password in settings)",
"gateway.reason.passwordNotConfigured": "unauthorized: gateway password not configured on gateway (set gateway.auth.password)",
"gateway.reason.tailscaleIdentityMissing": "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)",
"gateway.reason.tailscaleProxyHeadersMissing": "unauthorized: tailscale proxy headers missing (use Tailscale Serve or gateway token/password)",
"gateway.reason.tailscaleIdentityCheckFailed": "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)",
"gateway.reason.tailscaleIdentityMismatch": "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)",
"gateway.reason.unauthorized": "unauthorized",
"format.ago.justNow": "just now",
"format.ago.seconds": "${seconds}s ago",
"format.ago.minutes": "${minutes}m ago",
"format.ago.hours": "${hours}h ago",
"format.ago.days": "${days}d ago",
"format.duration.seconds": "${seconds}s",
"format.duration.minutes": "${minutes}m",
"format.duration.hours": "${hours}h",
"format.duration.days": "${days}d",
"chat.loading": "Loading chat…",
"chat.disconnectedReason": "Disconnected from gateway.",
"chat.compacting": "Compacting context...",
"chat.compacted": "Context compacted",
"chat.attachmentPreview": "Attachment preview",
"chat.attachedImage": "Attached image",
"chat.removeAttachment": "Remove attachment",
"chat.exitFocusMode": "Exit focus mode",
"chat.queued": "Queued (${count})",
"chat.removeQueuedMessage": "Remove queued message",
"chat.imageCount": "Image (${count})",
"chat.messageLabel": "Message",
"chat.placeholder.connected": "Message (↩ to send, Shift+↩ for line breaks, paste images)",
"chat.placeholder.connectedWithAttachments": "Add a message or paste more images...",
"chat.placeholder.disconnected": "Connect to the gateway to start chatting…",
"chat.stop": "Stop",
"chat.newSession": "New session",
"chat.queue": "Queue",
"chat.send": "Send",
"chat.showingLast": "Showing last ${limit} messages (${hidden} hidden).",
"chat.tool.commandLabel": "Command:",
"chat.tool.noOutput": "No output — tool completed successfully.",
"chat.tool.completed": "Completed",
"chat.role.you": "You",
"chat.role.assistant": "Assistant",
"chat.role.user": "User",
"chat.role.system": "System",
"chat.role.tool": "Tool",
"sidebar.toolOutput": "Tool Output",
"sidebar.viewRawText": "View Raw Text",
"sidebar.noContent": "No content available",
"execApproval.title": "Exec approval needed",
"execApproval.expiresIn": "expires in ${remaining}",
"execApproval.expired": "expired",
"execApproval.pendingCount": "${count} pending",
"execApproval.meta.host": "Host",
"execApproval.meta.agent": "Agent",
"execApproval.meta.session": "Session",
"execApproval.meta.cwd": "CWD",
"execApproval.meta.resolved": "Resolved",
"execApproval.meta.security": "Security",
"execApproval.meta.ask": "Ask",
"execApproval.allowOnce": "Allow once",
"execApproval.allowAlways": "Always allow",
"execApproval.deny": "Deny",
"settings.language": "Language",
"settings.lang.zhCN": "中文",
"settings.lang.en": "English",
"settings.lang.zhShort": "中",
"settings.lang.enShort": "EN",
"overview.gatewayAccess.title": "Gateway Access",
"overview.gatewayAccess.subtitle": "Where the dashboard connects and how it authenticates.",
"overview.websocketUrl": "WebSocket URL",
"overview.gatewayToken": "Gateway Token",
"overview.passwordNotStored": "Password (not stored)",
"overview.passwordPlaceholder": "system or shared password",
"overview.defaultSessionKey": "Default Session Key",
"overview.connect": "Connect",
"overview.refresh": "Refresh",
"overview.connectHint": "Click Connect to apply connection changes.",
"overview.snapshot.title": "Snapshot",
"overview.snapshot.subtitle": "Latest gateway handshake information.",
"overview.status": "Status",
"overview.connected": "Connected",
"overview.disconnected": "Disconnected",
"overview.uptime": "Uptime",
"overview.tickInterval": "Tick Interval",
"overview.lastChannelsRefresh": "Last Channels Refresh",
"overview.channelsHint": "Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.",
"overview.instances": "Instances",
"overview.instancesHint": "Presence beacons in the last 5 minutes.",
"overview.sessions": "Sessions",
"overview.sessionsHint": "Recent session keys tracked by the gateway.",
"overview.cron": "Cron",
"overview.enabled": "Enabled",
"overview.disabled": "Disabled",
"overview.nextWake": "Next wake ${when}",
"overview.notes.title": "Notes",
"overview.notes.subtitle": "Quick reminders for remote control setups.",
"overview.notes.tailscaleServe.title": "Tailscale Serve",
"overview.notes.tailscaleServe.body": "Prefer serve mode to keep the gateway on loopback with tailnet auth.",
"overview.notes.sessionHygiene.title": "Session hygiene",
"overview.notes.sessionHygiene.body": "Use /new or sessions.patch to reset context.",
"overview.notes.cronReminders.title": "Cron reminders",
"overview.notes.cronReminders.body": "Use isolated sessions for recurring runs.",
"overview.auth.required": "This gateway requires auth. Add a token or password, then click Connect.",
"overview.auth.tokenizedUrl": "tokenized URL",
"overview.auth.setToken": "set token",
"overview.auth.docsTitle": "Control UI auth docs (opens in new tab)",
"overview.auth.docsLabel": "Docs: Control UI auth",
"overview.auth.failed": "Auth failed. Re-copy a tokenized URL with ",
"overview.auth.failedAfterCommand": ", or update the token, then click Connect.",
"overview.insecureHttp": "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open ",
"overview.insecureHttpAfterUrl": " on the gateway host.",
"overview.insecureHttpOption": "If you must stay on HTTP, set ",
"overview.insecureHttpOptionAfter": " (token-only).",
"overview.docs.opensNewTab": "Docs (opens in new tab)",
"overview.docs.tailscaleServe": "Docs: Tailscale Serve",
"overview.docs.insecureHttp": "Docs: Insecure HTTP",
"chatControls.refreshHistory": "Refresh chat history",
"chatControls.disabledOnboarding": "Disabled during onboarding",
"chatControls.toggleThinking": "Toggle assistant thinking/working output",
"chatControls.toggleFocus": "Toggle focus mode (hide sidebar + page header)",
"theme.label": "Theme",
"theme.system": "System",
"theme.light": "Light",
"theme.dark": "Dark",
"logs.subtitle": "Gateway file logs (JSONL).",
"logs.searchPlaceholder": "Search logs",
"logs.autoFollow": "Auto-follow",
"logs.truncated": "Log output truncated; showing latest chunk.",
"logs.empty": "No log entries.",
"sessions.subtitle": "Active session keys and per-session overrides.",
"sessions.filters.activeWithin": "Active within (minutes)",
"sessions.filters.limit": "Limit",
"sessions.filters.includeGlobal": "Include global",
"sessions.filters.includeUnknown": "Include unknown",
"sessions.store": "Store: ${path}",
"sessions.table.key": "Key",
"sessions.table.label": "Label",
"sessions.table.kind": "Kind",
"sessions.table.updated": "Updated",
"sessions.table.tokens": "Tokens",
"sessions.table.thinking": "Thinking",
"sessions.table.verbose": "Verbose",
"sessions.table.reasoning": "Reasoning",
"sessions.table.actions": "Actions",
"sessions.empty": "No sessions found.",
"sessions.optional": "(optional)",
"sessions.deleteConfirm": "Delete session \"${key}\"?\n\nDeletes the session entry and archives its transcript.",
"sessions.verbose.offExplicit": "off (explicit)",
"sessions.verbose.on": "on",
"sessions.level.off": "off",
"sessions.level.minimal": "minimal",
"sessions.level.low": "low",
"sessions.level.medium": "medium",
"sessions.level.high": "high",
"sessions.level.on": "on",
"sessions.level.stream": "stream",
"sessions.kind.global": "global",
"sessions.kind.agent": "agent",
"sessions.kind.isolated": "isolated",
"skills.subtitle": "Bundled, managed, and workspace skills.",
"skills.searchPlaceholder": "Search skills",
"skills.shownCount": "${count} shown",
"skills.empty": "No skills found.",
"skills.missing": "Missing",
"skills.reason": "Reason",
"skills.eligible": "eligible",
"skills.blocked": "blocked",
"skills.disabled": "disabled",
"skills.enable": "Enable",
"skills.disable": "Disable",
"skills.installing": "Installing…",
"skills.apiKey": "API key",
"skills.saveKey": "Save key",
"skills.reason.disabled": "disabled",
"skills.reason.blockedByAllowlist": "blocked by allowlist",
"instances.title": "Connected Instances",
"instances.subtitle": "Presence beacons from the gateway and clients.",
"instances.empty": "No instances reported yet.",
"instances.secondsAgo": "${seconds}s ago",
"instances.scopesCount": "${count} scopes",
"instances.scopesList": "scopes: ${list}",
"instances.unknownHost": "unknown host",
"instances.lastInput": "Last input",
"instances.reason": "Reason",
"instances.noInstances": "No instances yet.",
"instances.noPayload": "No presence payload.",
"cron.scheduler.title": "Scheduler",
"cron.scheduler.subtitle": "Gateway-owned cron scheduler status.",
"cron.scheduler.enabled": "Enabled",
"cron.scheduler.jobs": "Jobs",
"cron.scheduler.nextWake": "Next wake",
"cron.status.next": "next",
"cron.status.last": "last",
"cron.refreshing": "Refreshing…",
"cron.newJob.title": "New Job",
"cron.newJob.subtitle": "Create a scheduled wakeup or agent run.",
"cron.newJob.name": "Name",
"cron.newJob.description": "Description",
"cron.newJob.agentId": "Agent ID",
"cron.newJob.enabled": "Enabled",
"cron.newJob.schedule": "Schedule",
"cron.schedule.every": "Every",
"cron.schedule.at": "At",
"cron.schedule.cron": "Cron",
"cron.newJob.session": "Session",
"cron.session.main": "Main",
"cron.session.isolated": "Isolated",
"cron.newJob.wakeMode": "Wake mode",
"cron.wakeMode.nextHeartbeat": "Next heartbeat",
"cron.wakeMode.now": "Now",
"cron.newJob.payload": "Payload",
"cron.payload.systemEvent": "System event",
"cron.payload.agentTurn": "Agent turn",
"cron.newJob.systemText": "System text",
"cron.newJob.agentMessage": "Agent message",
"cron.newJob.deliver": "Deliver",
"cron.newJob.channel": "Channel",
"cron.newJob.to": "To",
"cron.newJob.toPlaceholder": "+1555… or chat id",
"cron.newJob.timeoutSeconds": "Timeout (seconds)",
"cron.newJob.postToMainPrefix": "Post to main prefix",
"cron.newJob.addJob": "Add job",
"cron.error.invalidRunTime": "Invalid run time.",
"cron.error.invalidInterval": "Invalid interval amount.",
"cron.error.exprRequired": "Cron expression required.",
"cron.error.systemTextRequired": "System event text required.",
"cron.error.agentMessageRequired": "Agent message required.",
"cron.error.nameRequired": "Name required.",
"cron.jobs.title": "Jobs",
"cron.jobs.subtitle": "All scheduled jobs stored in the gateway.",
"cron.jobs.empty": "No jobs yet.",
"cron.runs.title": "Run history",
"cron.runs.subtitle": "Latest runs for selected job.",
"cron.runs.subtitleFor": "Latest runs for ${jobId}.",
"cron.runs.selectJob": "Select a job to inspect run history.",
"cron.runs.empty": "No runs yet.",
"cron.schedule.runAt": "Run at",
"cron.schedule.everyLabel": "Every",
"cron.schedule.unit": "Unit",
"cron.schedule.unit.minutes": "Minutes",
"cron.schedule.unit.hours": "Hours",
"cron.schedule.unit.days": "Days",
"cron.schedule.expression": "Expression",
"cron.schedule.timezoneOptional": "Timezone (optional)",
"cron.schedule.atValue": "At ${time}",
"cron.schedule.everyValue": "Every ${duration}",
"cron.schedule.cronValue": "Cron ${expr}",
"cron.payload.system": "System: ${text}",
"cron.payload.agent": "Agent: ${message}",
"nodes.subtitle": "Paired devices and live links.",
"nodes.status.paired": "paired",
"nodes.status.unpaired": "unpaired",
"nodes.status.connected": "connected",
"nodes.status.offline": "offline",
"nodes.empty": "No nodes found.",
"nodes.devices.title": "Devices",
"nodes.devices.subtitle": "Pairing requests + role tokens.",
"nodes.devices.pending": "Pending",
"nodes.devices.paired": "Paired",
"nodes.devices.empty": "No paired devices.",
"nodes.devices.roleWithValue": "role: ${role}",
"nodes.devices.roleEmpty": "role: -",
"nodes.devices.repair": "repair",
"nodes.devices.requestedAt": "requested ${age}",
"nodes.devices.approve": "Approve",
"nodes.devices.reject": "Reject",
"nodes.devices.roles": "roles: ${list}",
"nodes.devices.scopes": "scopes: ${list}",
"nodes.devices.tokensNone": "Tokens: none",
"nodes.devices.tokens": "Tokens",
"nodes.devices.tokenRevoked": "revoked",
"nodes.devices.tokenActive": "active",
"nodes.devices.rotate": "Rotate",
"nodes.devices.revoke": "Revoke",
"nodes.binding.title": "Exec node binding",
"nodes.binding.subtitle": "Pin agents to a specific node when using ${example}.",
"nodes.binding.loadPrompt": "Load config to edit bindings.",
"nodes.binding.loadConfig": "Load config",
"nodes.binding.defaultTitle": "Default binding",
"nodes.binding.defaultSubtitle": "Used when agents do not override a node binding.",
"nodes.binding.node": "Node",
"nodes.binding.anyNode": "Any node",
"nodes.binding.noNodes": "No nodes with system.run available.",
"nodes.binding.noAgents": "No agents found.",
"nodes.binding.defaultAgent": "default agent",
"nodes.binding.agent": "agent",
"nodes.binding.any": "any",
"nodes.binding.usesDefault": "uses default (${value})",
"nodes.binding.override": "override: ${value}",
"nodes.binding.switchMode": "Switch the Config tab to <strong>Form</strong> mode to edit bindings here.",
"nodes.allowlist.title": "Allowlist",
"nodes.allowlist.subtitle": "Case-insensitive glob patterns.",
"nodes.allowlist.addPattern": "Add pattern",
"nodes.allowlist.empty": "No allowlist entries yet.",
"nodes.allowlist.newPattern": "New pattern",
"nodes.allowlist.lastUsed": "Last used: ${when}",
"nodes.allowlist.pattern": "Pattern",
"nodes.execApprovals.title": "Exec approvals",
"nodes.execApprovals.subtitle": "Allowlist and approval policy for ${example}.",
"nodes.execApprovals.loadPrompt": "Load exec approvals to edit allowlists.",
"nodes.execApprovals.load": "Load approvals",
"nodes.execApprovals.target.title": "Target",
"nodes.execApprovals.target.subtitle": "Gateway edits local approvals; node edits the selected node.",
"nodes.execApprovals.target.host": "Host",
"nodes.execApprovals.target.gateway": "Gateway",
"nodes.execApprovals.target.node": "Node",
"nodes.execApprovals.target.nodeLabel": "Node",
"nodes.execApprovals.target.selectNode": "Select node",
"nodes.execApprovals.target.noNodes": "No nodes advertise exec approvals yet.",
"nodes.execApprovals.scope": "Scope",
"nodes.execApprovals.defaults": "Defaults",
"nodes.execApprovals.policy.security": "Security",
"nodes.execApprovals.policy.security.deny": "Deny",
"nodes.execApprovals.policy.security.allowlist": "Allowlist",
"nodes.execApprovals.policy.security.full": "Full",
"nodes.execApprovals.policy.securityDefault": "Default security mode.",
"nodes.execApprovals.policy.defaultValue": "Default: ${value}.",
"nodes.execApprovals.policy.mode": "Mode",
"nodes.execApprovals.policy.ask": "Ask",
"nodes.execApprovals.policy.ask.off": "Off",
"nodes.execApprovals.policy.ask.on-miss": "On miss",
"nodes.execApprovals.policy.ask.always": "Always",
"nodes.execApprovals.policy.askDefault": "Default prompt policy.",
"nodes.execApprovals.policy.askFallback": "Ask fallback",
"nodes.execApprovals.policy.askFallbackDefault": "Applied when the UI prompt is unavailable.",
"nodes.execApprovals.policy.fallback": "Fallback",
"nodes.execApprovals.policy.autoAllowSkills": "Auto-allow skill CLIs",
"nodes.execApprovals.policy.autoAllowSkillsDefault": "Allow skill executables listed by the Gateway.",
"nodes.execApprovals.policy.usingDefault": "Using default (${value}).",
"nodes.execApprovals.policy.override": "Override (${value}).",
"debug.snapshots.title": "Snapshots",
"debug.snapshots.subtitle": "Status, health, and heartbeat data.",
"debug.snapshots.status": "Status",
"debug.snapshots.health": "Health",
"debug.snapshots.lastHeartbeat": "Last heartbeat",
"debug.snapshots.securityAudit": "Audit: ${label} (and ${info} info).",
"debug.snapshots.securityAudit.critical": "${count} critical",
"debug.snapshots.securityAudit.warnings": "${count} warnings",
"debug.snapshots.securityAudit.none": "No critical issues",
"debug.snapshots.securityAuditSuffix": "to fix.",
"debug.rpc.title": "Manual RPC",
"debug.rpc.subtitle": "Send a raw gateway method with JSON params.",
"debug.rpc.method": "Method",
"debug.rpc.params": "Params (JSON)",
"debug.rpc.call": "Call",
"debug.models.title": "Models",
"debug.models.subtitle": "Catalog from models.list.",
"debug.events.title": "Event Log",
"debug.events.subtitle": "Latest gateway events.",
"debug.events.empty": "No events yet.",
"channels.accountsCount": "Accounts (${count})",
"channels.health.title": "Channel health",
"channels.health.subtitle": "Channel status snapshots from the gateway.",
"channels.health.noSnapshot": "No snapshot yet.",
"channels.generic.subtitle": "Channel status and configuration.",
"channels.status.configured": "Configured",
"channels.status.running": "Running",
"channels.status.connected": "Connected",
"channels.status.lastInbound": "Last inbound",
"channels.status.active": "Active",
"channels.config.schemaUnavailable": "Schema unavailable. Use Raw.",
"channels.config.channelSchemaUnavailable": "Channel config schema unavailable.",
"channels.config.loadingSchema": "Loading config schema…",
"channels.config.reload": "Reload",
"channels.whatsapp.title": "WhatsApp",
"channels.whatsapp.subtitle": "Link WhatsApp Web and monitor connection health.",
"channels.whatsapp.linked": "Linked",
"channels.whatsapp.lastConnect": "Last connect",
"channels.whatsapp.lastMessage": "Last message",
"channels.whatsapp.authAge": "Auth age",
"channels.whatsapp.qrAlt": "WhatsApp QR",
"channels.whatsapp.showQr": "Show QR",
"channels.whatsapp.relink": "Relink",
"channels.whatsapp.waitForScan": "Wait for scan",
"channels.whatsapp.logout": "Logout",
"channels.whatsapp.loggedOut": "Logged out.",
"channels.telegram.title": "Telegram",
"channels.telegram.subtitle": "Bot status and channel configuration.",
"channels.telegram.mode": "Mode",
"channels.telegram.lastStart": "Last start",
"channels.telegram.lastProbe": "Last probe",
"channels.telegram.probe": "Probe",
"channels.telegram.probeOk": "ok",
"channels.telegram.probeFailed": "failed",
"channels.probe": "Probe",
"channels.probeOk": "ok",
"channels.probeFailed": "failed",
"channels.discord.title": "Discord",
"channels.discord.subtitle": "Bot status and channel configuration.",
"channels.discord.lastStart": "Last start",
"channels.discord.lastProbe": "Last probe",
"channels.googlechat.title": "Google Chat",
"channels.googlechat.subtitle": "Chat API webhook status and channel configuration.",
"channels.googlechat.credential": "Credential",
"channels.googlechat.audience": "Audience",
"channels.slack.title": "Slack",
"channels.slack.subtitle": "Socket mode status and channel configuration.",
"channels.signal.title": "Signal",
"channels.signal.subtitle": "signal-cli status and channel configuration.",
"channels.signal.baseUrl": "Base URL",
"channels.imessage.title": "iMessage",
"channels.imessage.subtitle": "macOS bridge status and channel configuration.",
"channels.nostr.title": "Nostr",
"channels.nostr.subtitle": "Decentralized DMs via Nostr relays (NIP-04).",
"channels.nostr.publicKey": "Public Key",
"channels.nostr.profile": "Profile",
"channels.nostr.editProfile": "Edit Profile",
"channels.nostr.profilePictureAlt": "Profile picture",
"channels.nostr.name": "Name",
"channels.nostr.displayName": "Display Name",
"channels.nostr.about": "About",
"channels.nostr.noProfile": "No profile set. Click \"Edit Profile\" to add your name, bio, and avatar.",
"channels.nostrProfile.account": "Account: ${accountId}",
"channels.nostrProfile.picturePreviewAlt": "Profile picture preview",
"channels.nostrProfile.username": "Username",
"channels.nostrProfile.usernameHelp": "Short username (e.g., satoshi)",
"channels.nostrProfile.displayName": "Display Name",
"channels.nostrProfile.displayNameHelp": "Your full display name",
"channels.nostrProfile.bio": "Bio",
"channels.nostrProfile.bioPlaceholder": "Tell people about yourself...",
"channels.nostrProfile.bioHelp": "A brief bio or description",
"channels.nostrProfile.avatarUrl": "Avatar URL",
"channels.nostrProfile.avatarUrlHelp": "HTTPS URL to your profile picture",
"channels.nostrProfile.advanced": "Advanced",
"channels.nostrProfile.bannerUrl": "Banner URL",
"channels.nostrProfile.bannerUrlHelp": "HTTPS URL to a banner image",
"channels.nostrProfile.website": "Website",
"channels.nostrProfile.websiteHelp": "Your personal website",
"channels.nostrProfile.nip05": "NIP-05 Identifier",
"channels.nostrProfile.nip05Help": "Verifiable identifier (e.g., you@domain.com)",
"channels.nostrProfile.lud16": "Lightning Address",
"channels.nostrProfile.lud16Help": "Lightning address for tips (LUD-16)",
"channels.nostrProfile.savePublish": "Save & Publish",
"channels.nostrProfile.import": "Import from Relays",
"channels.nostrProfile.showAdvanced": "Show Advanced",
"channels.nostrProfile.hideAdvanced": "Hide Advanced",
"channels.nostrProfile.unsaved": "You have unsaved changes",
"config.sidebar.title": "Settings",
"config.validity.unknown": "unknown",
"config.validity.valid": "valid",
"config.validity.invalid": "invalid",
"config.search.placeholder": "Search settings...",
"config.section.all": "All Settings",
"config.section.env": "Environment",
"config.section.update": "Updates",
"config.section.agents": "Agents",
"config.section.auth": "Authentication",
"config.section.channels": "Channels",
"config.section.messages": "Messages",
"config.section.commands": "Commands",
"config.section.hooks": "Hooks",
"config.section.skills": "Skills",
"config.section.tools": "Tools",
"config.section.gateway": "Gateway",
"config.section.wizard": "Setup Wizard",
"config.section.web": "Web",
"config.section.discovery": "Discovery",
"config.section.canvasHost": "Canvas Host",
"config.section.talk": "Talk",
"config.section.plugins": "Plugins",
"config.section.nodeHost": "Node Host",
"config.section.approvals": "Approvals",
"config.mode.form": "Form",
"config.mode.raw": "Raw",
"config.changes.raw": "Unsaved changes",
"config.changes.form.one": "1 unsaved change",
"config.changes.form.many": "${count} unsaved changes",
"config.changes.none": "No changes",
"config.reload": "Reload",
"config.update": "Update",
"config.diff.view.one": "View 1 pending change",
"config.diff.view.many": "View ${count} pending changes",
"config.subsection.all": "All",
"config.loadingSchema": "Loading schema…",
"config.formUnsafe": "Form view can't safely edit some fields. Use Raw to avoid losing config entries.",
"config.rawJson5": "Raw JSON5",
"config.schemaUnavailable": "Schema unavailable.",
"config.unsupportedSchema": "Unsupported schema. Use Raw.",
"config.empty.search": "No settings match \"${query}\"",
"config.empty.section": "No settings in this section",
"config.meta.env.label": "Environment Variables",
"config.meta.env.desc": "Environment variables passed to the gateway process",
"config.meta.update.label": "Updates",
"config.meta.update.desc": "Auto-update settings and release channel",
"config.meta.agents.label": "Agents",
"config.meta.agents.desc": "Agent configurations, models, and identities",
"config.meta.auth.label": "Authentication",
"config.meta.auth.desc": "API keys and authentication profiles",
"config.meta.channels.label": "Channels",
"config.meta.channels.desc": "Messaging channels (Telegram, Discord, Slack, etc.)",
"config.meta.messages.label": "Messages",
"config.meta.messages.desc": "Message handling and routing settings",
"config.meta.commands.label": "Commands",
"config.meta.commands.desc": "Custom slash commands",
"config.meta.hooks.label": "Hooks",
"config.meta.hooks.desc": "Webhooks and event hooks",
"config.meta.skills.label": "Skills",
"config.meta.skills.desc": "Skill packs and capabilities",
"config.meta.tools.label": "Tools",
"config.meta.tools.desc": "Tool configurations (browser, search, etc.)",
"config.meta.gateway.label": "Gateway",
"config.meta.gateway.desc": "Gateway server settings (port, auth, binding)",
"config.meta.wizard.label": "Setup Wizard",
"config.meta.wizard.desc": "Setup wizard state and history",
"config.meta.meta.label": "Metadata",
"config.meta.meta.desc": "Gateway metadata and version information",
"config.meta.logging.label": "Logging",
"config.meta.logging.desc": "Log levels and output configuration",
"config.meta.browser.label": "Browser",
"config.meta.browser.desc": "Browser automation settings",
"config.meta.ui.label": "UI",
"config.meta.ui.desc": "User interface preferences",
"config.meta.models.label": "Models",
"config.meta.models.desc": "AI model configurations and providers",
"config.meta.diagnostics.label": "Diagnostics",
"config.meta.diagnostics.desc": "Diagnostics logs, tracing, and OpenTelemetry",
"config.meta.bindings.label": "Bindings",
"config.meta.bindings.desc": "Key bindings and shortcuts",
"config.meta.broadcast.label": "Broadcast",
"config.meta.broadcast.desc": "Broadcast and notification settings",
"config.meta.audio.label": "Audio",
"config.meta.audio.desc": "Audio input/output settings",
"config.meta.media.label": "Media",
"config.meta.media.desc": "Media file handling",
"config.meta.session.label": "Session",
"config.meta.session.desc": "Session management and persistence",
"config.meta.approvals.label": "Approvals",
"config.meta.approvals.desc": "Approval forwarding for exec and other actions",
"config.meta.cron.label": "Cron",
"config.meta.cron.desc": "Scheduled tasks and automation",
"config.meta.web.label": "Web",
"config.meta.web.desc": "Web server and API settings",
"config.meta.discovery.label": "Discovery",
"config.meta.discovery.desc": "Service discovery and networking",
"config.meta.canvasHost.label": "Canvas Host",
"config.meta.canvasHost.desc": "Canvas rendering and display",
"config.meta.talk.label": "Talk",
"config.meta.talk.desc": "Voice and speech settings",
"config.meta.plugins.label": "Plugins",
"config.meta.plugins.desc": "Plugin management and extensions",
"config.meta.nodeHost.label": "Node Host",
"config.meta.nodeHost.desc": "Manage node connections, proxying, and permissions",
"config.label.auth": "Auth",
"config.label.bind": "Bind",
"config.label.controlUi": "Control UI",
"config.label.http": "HTTP",
"config.label.mode": "Mode",
"config.label.nodes": "Nodes",
"config.label.port": "Port",
"config.label.reload": "Reload",
"config.label.remote": "Remote",
"config.label.tailscale": "Tailscale",
"config.label.tls": "TLS",
"config.label.trustedProxies": "Trusted Proxies",
"config.label.web": "Web",
"config.label.discovery": "Discovery",
"config.label.canvasHost": "Canvas Host",
"config.label.talk": "Talk",
"config.label.plugins": "Plugins",
"config.label.nodeHost": "Node Host",
"config.label.approvals": "Approvals",
"config.label.wizard": "Setup Wizard",
"config.label.env": "Env",
"config.label.update": "Update",
"config.label.agents": "Agents",
"config.label.channels": "Channels",
"config.label.messages": "Messages",
"config.label.commands": "Commands",
"config.label.hooks": "Hooks",
"config.label.skills": "Skills",
"config.label.tools": "Tools",
"config.label.logging": "Logging",
"config.label.browser": "Browser",
"config.label.ui": "UI",
"config.label.models": "Models",
"config.label.diagnostics": "Diagnostics",
"config.label.bindings": "Bindings",
"config.label.broadcast": "Broadcast",
"config.label.audio": "Audio",
"config.label.media": "Media",
"config.label.session": "Session",
"config.label.cron": "Cron",
"config.label.allowTailscale": "Allow Tailscale",
"config.label.gatewayPassword": "Gateway Password",
"config.label.gatewayToken": "Gateway Token",
"config.label.account": "Account",
"config.label.accounts": "Accounts",
"config.label.token": "Token",
"config.label.password": "Password",
"config.label.url": "URL",
"config.label.host": "Host",
"config.label.path": "Path",
"config.label.enabled": "Enabled",
"config.label.disabled": "Disabled",
"config.label.name": "Name",
"config.label.description": "Description",
"config.label.id": "ID",
"config.label.version": "Version",
"config.label.uptime": "Uptime",
"config.label.status": "Status",
"config.label.health": "Health",
"config.label.lastRun": "Last Run",
"config.label.lastRunAt": "Last Run At",
"config.label.lastRunCommand": "Last Run Command",
"config.label.lastRunCommit": "Last Run Commit",
"config.label.lastRunMode": "Last Run Mode",
"config.label.lastRunVersion": "Last Run Version",
"config.label.browserProxy": "Browser Proxy",
"config.label.nodeBrowserProxyAllowedProfiles": "Allowed Profiles",
"config.label.nodeBrowserProxyEnabled": "Enabled",
"config.label.allowedProfiles": "Allowed Profiles",
"config.help.nodeBrowserProxyAllowedProfiles": "Optional allowlist of browser profile names exposed via the node proxy.",
"config.help.nodeBrowserProxyEnabled": "Expose the local browser control server via node proxy.",
"config.help.gatewayPassword": "Required for Tailscale funnel.",
"config.help.gatewayToken": "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.",
"config.option.token": "Token",
"config.option.password": "Password",
"config.option.local": "Local",
"config.option.remote": "Remote",
"config.field.unsupportedNode": "Unsupported schema node. Use Raw mode.",
"config.field.unsupportedType": "Unsupported type: ${type}. Use Raw mode.",
"config.field.defaultPlaceholder": "Default: ${value}",
"config.field.resetDefault": "Reset to default",
"config.field.selectPlaceholder": "Select...",
"config.field.unsupportedArray": "Unsupported array schema. Use Raw mode.",
"config.array.count.one": "1 item",
"config.array.count.many": "${count} items",
"config.array.add": "Add",
"config.array.empty": "No items yet. Click \"Add\" to create one.",
"config.array.removeItem": "Remove item",
"config.map.title": "Custom entries",
"config.map.addEntry": "Add Entry",
"config.map.empty": "No custom entries.",
"config.map.keyPlaceholder": "Key",
"config.map.jsonPlaceholder": "JSON value",
"config.map.removeEntry": "Remove entry",
"config.hints.diagnostics.cacheTrace.label": "Cache Trace",
"config.hints.diagnostics.enabled.label": "Diagnostics Enabled",
"config.hints.diagnostics.enabled.help": "Enable diagnostics logging.",
"config.hints.diagnostics.flags.label": "Diagnostics Flags",
"config.hints.diagnostics.flags.help": "Enable targeted diagnostics logs by flag (e.g. [\"telegram.http\"]). Supports wildcards like \"telegram.*\" or \"*\".",
"config.hints.diagnostics.otel.label": "OpenTelemetry",
"config.hints.diagnostics.cacheTrace.enabled.label": "Cache Trace Enabled",
"config.hints.diagnostics.cacheTrace.enabled.help": "Log cache trace snapshots for embedded agent runs (default: false).",
"config.hints.diagnostics.cacheTrace.filePath.label": "Cache Trace File Path",
"config.hints.diagnostics.cacheTrace.filePath.help": "JSONL output path for cache trace logs (default: $CLAWDBOT_STATE_DIR/logs/cache-trace.jsonl).",
"config.hints.diagnostics.cacheTrace.includeMessages.label": "Cache Trace Include Messages",
"config.hints.diagnostics.cacheTrace.includeMessages.help": "Include full message payloads in trace output (default: true).",
"config.hints.diagnostics.cacheTrace.includePrompt.label": "Cache Trace Include Prompt",
"config.hints.diagnostics.cacheTrace.includePrompt.help": "Include prompt text in trace output (default: true).",
"config.hints.diagnostics.cacheTrace.includeSystem.label": "Cache Trace Include System",
"config.hints.diagnostics.cacheTrace.includeSystem.help": "Include system prompt in trace output (default: true).",
"config.hints.diagnostics.otel.enabled": "OpenTelemetry Enabled",
"config.hints.diagnostics.otel.endpoint": "OpenTelemetry Endpoint",
"config.hints.diagnostics.otel.protocol": "OpenTelemetry Protocol",
"config.hints.diagnostics.otel.headers": "OpenTelemetry Headers",
"config.hints.diagnostics.otel.serviceName": "OpenTelemetry Service Name",
"config.hints.diagnostics.otel.traces": "OpenTelemetry Traces Enabled",
"config.hints.diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled",
"config.hints.diagnostics.otel.logs": "OpenTelemetry Logs Enabled",
"config.hints.diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate",
"config.hints.diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)",
"config.hints.approvals.exec.label": "Exec",
"config.hints.approvals.exec.agentFilter": "Agent Filter",
"config.hints.approvals.exec.enabled": "Enabled",
"config.hints.approvals.exec.mode": "Mode",
"config.hints.approvals.exec.sessionFilter": "Session Filter",
"config.hints.approvals.exec.targets": "Targets",
"config.hints.approvals.exec.targets.channel": "Channel",
"config.hints.approvals.exec.targets.to": "To",
"config.hints.approvals.exec.targets.accountId": "Account ID",
"config.hints.approvals.exec.targets.threadId": "Thread ID",
"config.options.approvals.exec.mode.session": "session",
"config.options.approvals.exec.mode.targets": "targets",
"config.options.approvals.exec.mode.both": "both",
"config.hints.media.preserveFilenames.label": "Preserve Filenames",
"config.hints.media.preserveFilenames.help": "Keep original filenames when saving media attachments.",
};

801
ui/src/ui/locales/zh-CN.ts Normal file
View File

@ -0,0 +1,801 @@
import type { Messages } from "../i18n";
export const zhCN: Messages = {
"common.na": "无",
"common.loading": "加载中…",
"common.refresh": "刷新",
"common.save": "保存",
"common.apply": "应用",
"common.cancel": "取消",
"common.close": "关闭",
"common.search": "搜索",
"common.filter": "筛选",
"common.export": "导出",
"common.copy": "复制",
"common.openInNewTab": "在新标签页打开",
"common.enabled": "已启用",
"common.disabled": "已禁用",
"common.on": "开",
"common.off": "关",
"common.start": "启动",
"common.stop": "停止",
"common.add": "添加",
"common.remove": "移除",
"common.delete": "删除",
"common.edit": "编辑",
"common.view": "查看",
"common.useDefault": "使用默认值",
"common.useDefaultWith": "使用默认值(${value}",
"common.ok": "确定",
"common.yes": "是",
"common.no": "否",
"common.agent": "智能体",
"common.inherit": "继承",
"common.file": "文件",
"common.none": "无",
"common.never": "从未",
"common.unknown": "未知",
"common.enable": "启用",
"common.disable": "禁用",
"common.run": "运行",
"common.export.visible": "可见",
"common.export.filtered": "已筛选",
"nav.chat": "聊天",
"nav.control": "控制",
"nav.agent": "智能体",
"nav.settings": "设置",
"nav.resources": "资源",
"nav.docs": "文档",
"tab.overview.title": "概览",
"tab.channels.title": "渠道",
"tab.instances.title": "实例",
"tab.sessions.title": "会话",
"tab.cron.title": "定时任务",
"tab.skills.title": "技能",
"tab.nodes.title": "节点",
"tab.chat.title": "聊天",
"tab.config.title": "配置",
"tab.debug.title": "调试",
"tab.logs.title": "日志",
"tab.control.title": "控制台",
"tab.overview.subtitle": "网关状态、入口点和健康状态概览。",
"tab.channels.subtitle": "管理渠道及其设置。",
"tab.instances.subtitle": "来自已连接客户端和节点的在线信号。",
"tab.sessions.subtitle": "检查活动会话并调整会话默认设置。",
"tab.cron.subtitle": "调度唤醒任务和周期性智能体运行。",
"tab.skills.subtitle": "管理技能可用性及 API 密钥注入。",
"tab.nodes.subtitle": "已配对设备、功能和命令展示。",
"tab.chat.subtitle": "直接与网关进行聊天,以便快速干预。",
"tab.config.subtitle": "安全地编辑 ~/.clawdbot/clawdbot.json。",
"tab.debug.subtitle": "网关快照、事件和手动 RPC 调用。",
"tab.logs.subtitle": "实时查看网关文件日志。",
"topbar.expandSidebar": "展开侧边栏",
"topbar.collapseSidebar": "收起侧边栏",
"topbar.gatewayDashboard": "Gateway 控制台",
"topbar.health": "健康",
"topbar.healthOk": "正常",
"topbar.healthOffline": "离线",
"gateway.disconnected": "已断开(${code}${reason}",
"gateway.noReason": "无原因",
"gateway.eventGap": "检测到事件缺口(期望序号 ${expected},实际 ${received}),建议刷新页面",
"gateway.reason.tokenMissing": "未授权:缺少网关令牌(请打开带 Token 的仪表板 URL或在设置中粘贴令牌",
"gateway.reason.tokenMismatch": "未授权:网关令牌不匹配(请在设置中更新令牌以匹配网关)",
"gateway.reason.tokenNotConfigured": "未授权:网关未配置令牌(请设置 gateway.auth.token",
"gateway.reason.passwordMissing": "未授权:缺少网关密码(请在设置中粘贴密码,或使用令牌)",
"gateway.reason.passwordMismatch": "未授权:网关密码不匹配(请检查设置中的密码)",
"gateway.reason.passwordNotConfigured": "未授权:网关未配置密码(请设置 gateway.auth.password",
"gateway.reason.tailscaleIdentityMissing": "未授权:缺少 Tailscale 身份(请使用 Tailscale Serve 认证,或使用网关令牌/密码)",
"gateway.reason.tailscaleProxyHeadersMissing": "未授权:缺少 Tailscale 代理请求头(请使用 Tailscale Serve或使用网关令牌/密码)",
"gateway.reason.tailscaleIdentityCheckFailed": "未授权Tailscale 身份检查失败(请使用 Tailscale Serve 认证,或使用网关令牌/密码)",
"gateway.reason.tailscaleIdentityMismatch": "未授权Tailscale 身份不匹配(请使用 Tailscale Serve 认证,或使用网关令牌/密码)",
"gateway.reason.unauthorized": "未授权",
"format.ago.justNow": "刚刚",
"format.ago.seconds": "${seconds}秒前",
"format.ago.minutes": "${minutes}分钟前",
"format.ago.hours": "${hours}小时前",
"format.ago.days": "${days}天前",
"format.duration.seconds": "${seconds}秒",
"format.duration.minutes": "${minutes}分钟",
"format.duration.hours": "${hours}小时",
"format.duration.days": "${days}天",
"chat.loading": "正在加载聊天记录…",
"chat.disconnectedReason": "未连接到 Gateway。",
"chat.compacting": "正在压缩上下文…",
"chat.compacted": "上下文已压缩",
"chat.attachmentPreview": "附件预览",
"chat.attachedImage": "已添加图片",
"chat.removeAttachment": "移除附件",
"chat.exitFocusMode": "退出专注模式",
"chat.queued": "队列(${count}",
"chat.removeQueuedMessage": "移除队列消息",
"chat.imageCount": "图片(${count}",
"chat.messageLabel": "消息",
"chat.placeholder.connected": "输入消息(↩ 发送Shift+↩ 换行,可粘贴图片)",
"chat.placeholder.connectedWithAttachments": "添加消息或继续粘贴图片…",
"chat.placeholder.disconnected": "请先连接到 Gateway 再开始聊天…",
"chat.stop": "停止",
"chat.newSession": "新会话",
"chat.queue": "加入队列",
"chat.send": "发送",
"chat.showingLast": "仅显示最近 ${limit} 条消息(已隐藏 ${hidden} 条)。",
"chat.tool.commandLabel": "命令:",
"chat.tool.noOutput": "无输出 — 工具已成功执行。",
"chat.tool.completed": "已完成",
"chat.role.you": "你",
"chat.role.assistant": "智能体",
"chat.role.user": "用户",
"chat.role.system": "系统",
"chat.role.tool": "工具",
"sidebar.toolOutput": "工具输出",
"sidebar.viewRawText": "查看原始文本",
"sidebar.noContent": "暂无内容",
"execApproval.title": "需要 Exec 审批",
"execApproval.expiresIn": "${remaining} 后过期",
"execApproval.expired": "已过期",
"execApproval.pendingCount": "还有 ${count} 条待处理",
"execApproval.meta.host": "Host",
"execApproval.meta.agent": "Agent",
"execApproval.meta.session": "会话",
"execApproval.meta.cwd": "CWD",
"execApproval.meta.resolved": "解析结果",
"execApproval.meta.security": "安全策略",
"execApproval.meta.ask": "询问策略",
"execApproval.allowOnce": "仅本次允许",
"execApproval.allowAlways": "始终允许",
"execApproval.deny": "拒绝",
"settings.language": "语言",
"settings.lang.zhCN": "中文",
"settings.lang.en": "English",
"settings.lang.zhShort": "中",
"settings.lang.enShort": "EN",
"overview.gatewayAccess.title": "Gateway 连接",
"overview.gatewayAccess.subtitle": "仪表板的连接位置及认证方式。",
"overview.websocketUrl": "WebSocket 地址",
"overview.gatewayToken": "网关令牌",
"overview.passwordNotStored": "密码(不存储)",
"overview.passwordPlaceholder": "系统密码或共享密码",
"overview.defaultSessionKey": "默认会话 Key",
"overview.connect": "连接",
"overview.refresh": "刷新",
"overview.connectHint": "点击“连接”以应用连接变更。",
"overview.snapshot.title": "快照",
"overview.snapshot.subtitle": "最新的网关握手信息。",
"overview.status": "状态",
"overview.connected": "已连接",
"overview.disconnected": "已断开",
"overview.uptime": "运行时间",
"overview.tickInterval": "滴答间隔",
"overview.lastChannelsRefresh": "最近渠道刷新",
"overview.channelsHint": "使用“渠道”页面来关联 WhatsApp, Telegram, Discord, Signal 或 iMessage。",
"overview.instances": "实例",
"overview.instancesHint": "过去 5 分钟内的在线信号。",
"overview.sessions": "会话",
"overview.sessionsHint": "网关追踪的最近会话 Key。",
"overview.cron": "定时任务",
"overview.enabled": "已启用",
"overview.disabled": "已禁用",
"overview.nextWake": "${when} 下次唤醒",
"overview.notes.title": "备注",
"overview.notes.subtitle": "远程控制场景的快速提醒。",
"overview.notes.tailscaleServe.title": "Tailscale Serve",
"overview.notes.tailscaleServe.body": "优先使用 serve 让 Gateway 保持 loopback并使用 tailnet 身份认证。",
"overview.notes.sessionHygiene.title": "会话卫生",
"overview.notes.sessionHygiene.body": "用 /new 或 sessions.patch 重置上下文。",
"overview.notes.cronReminders.title": "定时提醒",
"overview.notes.cronReminders.body": "周期性任务建议使用隔离会话运行。",
"overview.auth.required": "当前 Gateway 需要认证。请填写 token 或密码,然后点击“连接”。",
"overview.auth.tokenizedUrl": "带 token 的链接",
"overview.auth.setToken": "设置 token",
"overview.auth.docsTitle": "Control UI 认证文档(在新标签页打开)",
"overview.auth.docsLabel": "文档Control UI 认证",
"overview.auth.failed": "认证失败。请重新复制带 token 的链接:",
"overview.auth.failedAfterCommand": ",或更新 token 后再点击“连接”。",
"overview.insecureHttp": "当前页面是 HTTP浏览器会阻止设备身份能力。请使用 HTTPSTailscale Serve或在 Gateway 主机上打开 ",
"overview.insecureHttpAfterUrl": "。",
"overview.insecureHttpOption": "如果必须使用 HTTP可设置 ",
"overview.insecureHttpOptionAfter": "(仅 token 认证)。",
"overview.docs.opensNewTab": "文档(在新标签页打开)",
"overview.docs.tailscaleServe": "文档Tailscale Serve",
"overview.docs.insecureHttp": "文档Insecure HTTP",
"chatControls.refreshHistory": "刷新聊天记录",
"chatControls.disabledOnboarding": "在向导流程中不可用",
"chatControls.toggleThinking": "切换思考/工作输出显示",
"chatControls.toggleFocus": "切换专注模式(隐藏侧边栏与页头)",
"theme.label": "主题",
"theme.system": "跟随系统",
"theme.light": "浅色",
"theme.dark": "深色",
"logs.subtitle": "Gateway 文件日志JSONL。",
"logs.searchPlaceholder": "搜索日志",
"logs.autoFollow": "自动跟随",
"logs.truncated": "日志输出已截断,仅显示最新的一段内容。",
"logs.empty": "暂无日志。",
"sessions.subtitle": "活跃会话 key 与每会话覆盖配置。",
"sessions.filters.activeWithin": "活跃时间(分钟)",
"sessions.filters.limit": "数量上限",
"sessions.filters.includeGlobal": "包含全局",
"sessions.filters.includeUnknown": "包含未知",
"sessions.store": "存储:${path}",
"sessions.table.key": "Key",
"sessions.table.label": "标签",
"sessions.table.kind": "类型",
"sessions.table.updated": "更新时间",
"sessions.table.tokens": "Token",
"sessions.table.thinking": "思考",
"sessions.table.verbose": "详细输出",
"sessions.table.reasoning": "推理",
"sessions.table.actions": "操作",
"sessions.empty": "未找到会话。",
"sessions.optional": "(可选)",
"sessions.deleteConfirm": "确认删除会话 \"${key}\"\n\n这将删除会话条目并归档其聊天记录。",
"sessions.verbose.offExplicit": "关闭(显式)",
"sessions.verbose.on": "开启",
"sessions.level.off": "关闭",
"sessions.level.minimal": "最小",
"sessions.level.low": "低",
"sessions.level.medium": "中",
"sessions.level.high": "高",
"sessions.level.on": "开启",
"sessions.level.stream": "流式",
"sessions.kind.global": "全局",
"sessions.kind.agent": "智能体",
"sessions.kind.isolated": "隔离",
"skills.subtitle": "内置、托管与工作区技能。",
"skills.searchPlaceholder": "搜索技能",
"skills.shownCount": "显示 ${count} 个",
"skills.empty": "未找到技能。",
"skills.missing": "缺少",
"skills.reason": "原因",
"skills.eligible": "可用",
"skills.blocked": "被阻止",
"skills.disabled": "已禁用",
"skills.enable": "启用",
"skills.disable": "禁用",
"skills.installing": "正在安装…",
"skills.apiKey": "API Key",
"skills.saveKey": "保存 Key",
"skills.reason.disabled": "已禁用",
"skills.reason.blockedByAllowlist": "被 allowlist 阻止",
"instances.title": "已连接实例",
"instances.subtitle": "来自 Gateway 与客户端的在线信标。",
"instances.empty": "尚无实例上报。",
"instances.secondsAgo": "${seconds} 秒前",
"instances.scopesCount": "${count} 个范围",
"instances.scopesList": "范围:${list}",
"instances.unknownHost": "未知主机",
"instances.lastInput": "最近输入",
"instances.reason": "原因",
"instances.noInstances": "尚无实例。",
"instances.noPayload": "无在线信息。",
"cron.scheduler.title": "调度器",
"cron.scheduler.subtitle": "Gateway 内置的 Cron 调度状态。",
"cron.scheduler.enabled": "已启用",
"cron.scheduler.jobs": "任务数",
"cron.scheduler.nextWake": "下次唤醒",
"cron.status.next": "下次",
"cron.status.last": "上次",
"cron.refreshing": "正在刷新…",
"cron.newJob.title": "新建任务",
"cron.newJob.subtitle": "创建定时唤醒或智能体运行。",
"cron.newJob.name": "名称",
"cron.newJob.description": "描述",
"cron.newJob.agentId": "智能体 ID",
"cron.newJob.enabled": "启用",
"cron.newJob.schedule": "调度方式",
"cron.schedule.every": "周期",
"cron.schedule.at": "指定时间",
"cron.schedule.cron": "Cron 表达式",
"cron.newJob.session": "会话",
"cron.session.main": "主会话",
"cron.session.isolated": "隔离会话",
"cron.newJob.wakeMode": "唤醒模式",
"cron.wakeMode.nextHeartbeat": "下次心跳",
"cron.wakeMode.now": "立即",
"cron.newJob.payload": "负载",
"cron.payload.systemEvent": "系统事件",
"cron.payload.agentTurn": "智能体轮次",
"cron.newJob.systemText": "系统文本",
"cron.newJob.agentMessage": "智能体消息",
"cron.newJob.deliver": "发送消息",
"cron.newJob.channel": "渠道",
"cron.newJob.to": "目标",
"cron.newJob.toPlaceholder": "+1555… 或聊天 ID",
"cron.newJob.timeoutSeconds": "超时时间(秒)",
"cron.newJob.postToMainPrefix": "回写主会话前缀",
"cron.newJob.addJob": "添加任务",
"cron.error.invalidRunTime": "无效的运行时间。",
"cron.error.invalidInterval": "无效的间隔数值。",
"cron.error.exprRequired": "必须填写 Cron 表达式。",
"cron.error.systemTextRequired": "必须填写系统事件文本。",
"cron.error.agentMessageRequired": "必须填写智能体消息。",
"cron.error.nameRequired": "必须填写名称。",
"cron.jobs.title": "任务列表",
"cron.jobs.subtitle": "所有存储在 Gateway 中的定时任务。",
"cron.jobs.empty": "暂无任务。",
"cron.runs.title": "运行历史",
"cron.runs.subtitle": "最近一次选中任务的运行记录。",
"cron.runs.subtitleFor": "${jobId} 的最近运行记录。",
"cron.runs.selectJob": "请选择一个任务以查看运行历史。",
"cron.runs.empty": "尚无运行记录。",
"cron.schedule.runAt": "运行时间",
"cron.schedule.everyLabel": "每隔",
"cron.schedule.unit": "单位",
"cron.schedule.unit.minutes": "分钟",
"cron.schedule.unit.hours": "小时",
"cron.schedule.unit.days": "天",
"cron.schedule.expression": "表达式",
"cron.schedule.timezoneOptional": "时区(可选)",
"cron.schedule.atValue": "在 ${time}",
"cron.schedule.everyValue": "每隔 ${duration}",
"cron.schedule.cronValue": "Cron 表达式 ${expr}",
"cron.payload.system": "系统:${text}",
"cron.payload.agent": "智能体:${message}",
"nodes.subtitle": "已配对设备与实时连接。",
"nodes.status.paired": "已配对",
"nodes.status.unpaired": "未配对",
"nodes.status.connected": "已连接",
"nodes.status.offline": "离线",
"nodes.empty": "未找到节点。",
"nodes.devices.title": "设备",
"nodes.devices.subtitle": "配对请求与角色 token。",
"nodes.devices.pending": "待处理",
"nodes.devices.paired": "已配对",
"nodes.devices.empty": "暂无已配对设备。",
"nodes.devices.roleWithValue": "角色:${role}",
"nodes.devices.roleEmpty": "角色:-",
"nodes.devices.repair": "修复",
"nodes.devices.requestedAt": "请求于 ${age}",
"nodes.devices.approve": "批准",
"nodes.devices.reject": "拒绝",
"nodes.devices.roles": "角色:${list}",
"nodes.devices.scopes": "范围:${list}",
"nodes.devices.tokensNone": "Token无",
"nodes.devices.tokens": "Token",
"nodes.devices.tokenRevoked": "已撤销",
"nodes.devices.tokenActive": "生效中",
"nodes.devices.rotate": "轮换",
"nodes.devices.revoke": "撤销",
"nodes.binding.title": "Exec 节点绑定",
"nodes.binding.subtitle": "在使用 ${example} 时将智能体固定到指定节点。",
"nodes.binding.loadPrompt": "加载配置后才能编辑绑定。",
"nodes.binding.loadConfig": "加载配置",
"nodes.binding.defaultTitle": "默认绑定",
"nodes.binding.defaultSubtitle": "当智能体没有单独绑定节点时使用此节点。",
"nodes.binding.node": "节点",
"nodes.binding.anyNode": "任意节点",
"nodes.binding.noNodes": "没有支持 system.run 的节点。",
"nodes.binding.noAgents": "未找到智能体。",
"nodes.binding.defaultAgent": "默认智能体",
"nodes.binding.agent": "智能体",
"nodes.binding.any": "任意",
"nodes.binding.usesDefault": "使用默认值(${value}",
"nodes.binding.override": "覆盖:${value}",
"nodes.binding.switchMode": "请将“配置”标签页切换到<strong>表单</strong>模式以在此处编辑绑定。",
"nodes.allowlist.title": "白名单",
"nodes.allowlist.subtitle": "大小写不敏感的 glob 模式。",
"nodes.allowlist.addPattern": "添加模式",
"nodes.allowlist.empty": "尚无白名单条目。",
"nodes.allowlist.newPattern": "新模式",
"nodes.allowlist.lastUsed": "最近使用:${when}",
"nodes.allowlist.pattern": "模式",
"nodes.execApprovals.title": "Exec 审批",
"nodes.execApprovals.subtitle": "为 ${example} 设置白名单与审批策略。",
"nodes.execApprovals.loadPrompt": "加载 exec 审批后才能编辑白名单。",
"nodes.execApprovals.load": "加载审批信息",
"nodes.execApprovals.target.title": "目标",
"nodes.execApprovals.target.subtitle": "网关编辑本地审批;节点编辑所选节点的审批。",
"nodes.execApprovals.target.host": "宿主",
"nodes.execApprovals.target.gateway": "网关",
"nodes.execApprovals.target.node": "节点",
"nodes.execApprovals.target.nodeLabel": "节点",
"nodes.execApprovals.target.selectNode": "选择节点",
"nodes.execApprovals.target.noNodes": "尚无节点声明支持 exec 审批。",
"nodes.execApprovals.scope": "范围",
"nodes.execApprovals.defaults": "默认值",
"nodes.execApprovals.policy.security": "安全性",
"nodes.execApprovals.policy.security.deny": "拒绝",
"nodes.execApprovals.policy.security.allowlist": "白名单",
"nodes.execApprovals.policy.security.full": "完全允许",
"nodes.execApprovals.policy.securityDefault": "默认安全模式。",
"nodes.execApprovals.policy.defaultValue": "默认值:${value}。",
"nodes.execApprovals.policy.mode": "模式",
"nodes.execApprovals.policy.ask": "询问",
"nodes.execApprovals.policy.ask.off": "关闭",
"nodes.execApprovals.policy.ask.on-miss": "未命中时",
"nodes.execApprovals.policy.ask.always": "总是",
"nodes.execApprovals.policy.askDefault": "默认提示策略。",
"nodes.execApprovals.policy.askFallback": "询问回退",
"nodes.execApprovals.policy.askFallbackDefault": "当 UI 提示不可用时使用。",
"nodes.execApprovals.policy.fallback": "回退",
"nodes.execApprovals.policy.autoAllowSkills": "自动允许技能可执行文件",
"nodes.execApprovals.policy.autoAllowSkillsDefault": "允许网关列出的技能可执行文件。",
"nodes.execApprovals.policy.usingDefault": "使用默认值(${value})。",
"nodes.execApprovals.policy.override": "覆盖(${value})。",
"debug.snapshots.title": "快照",
"debug.snapshots.subtitle": "状态、健康与心跳数据。",
"debug.snapshots.status": "状态",
"debug.snapshots.health": "健康",
"debug.snapshots.lastHeartbeat": "最近心跳",
"debug.snapshots.securityAudit": "审计:${label}(以及 ${info} 条信息)。",
"debug.snapshots.securityAudit.critical": "${count} 条严重问题",
"debug.snapshots.securityAudit.warnings": "${count} 条警告",
"debug.snapshots.securityAudit.none": "未发现严重问题",
"debug.snapshots.securityAuditSuffix": "以修复。",
"debug.rpc.title": "手动 RPC",
"debug.rpc.subtitle": "发送带 JSON 参数的原始 gateway 方法。",
"debug.rpc.method": "方法",
"debug.rpc.params": "参数JSON",
"debug.rpc.call": "调用",
"debug.models.title": "模型",
"debug.models.subtitle": "来自 models.list 的目录。",
"debug.events.title": "事件日志",
"debug.events.subtitle": "最新 Gateway 事件。",
"debug.events.empty": "暂时没有事件。",
"channels.accountsCount": "账号(${count}",
"channels.health.title": "渠道健康",
"channels.health.subtitle": "来自 Gateway 的渠道状态快照。",
"channels.health.noSnapshot": "暂无快照。",
"channels.generic.subtitle": "渠道状态与配置。",
"channels.status.configured": "已配置",
"channels.status.running": "运行中",
"channels.status.connected": "已连接",
"channels.status.lastInbound": "最近入站",
"channels.status.active": "活跃",
"channels.config.schemaUnavailable": "配置 schema 不可用,请使用“原始”模式。",
"channels.config.channelSchemaUnavailable": "该渠道的配置 schema 不可用。",
"channels.config.loadingSchema": "正在加载配置 schema…",
"channels.config.reload": "重新加载",
"channels.whatsapp.title": "WhatsApp",
"channels.whatsapp.subtitle": "连接 WhatsApp Web 并监控连接健康状态。",
"channels.whatsapp.linked": "已绑定",
"channels.whatsapp.lastConnect": "最近连接",
"channels.whatsapp.lastMessage": "最近消息",
"channels.whatsapp.authAge": "认证时长",
"channels.whatsapp.qrAlt": "WhatsApp 二维码",
"channels.whatsapp.showQr": "显示二维码",
"channels.whatsapp.relink": "重新绑定",
"channels.whatsapp.waitForScan": "等待扫码",
"channels.whatsapp.logout": "退出登录",
"channels.whatsapp.loggedOut": "已退出登录。",
"channels.telegram.title": "Telegram",
"channels.telegram.subtitle": "机器人状态与渠道配置。",
"channels.telegram.mode": "模式",
"channels.telegram.lastStart": "最近启动",
"channels.telegram.lastProbe": "最近探测",
"channels.telegram.probe": "探测",
"channels.telegram.probeOk": "成功",
"channels.telegram.probeFailed": "失败",
"channels.probe": "探测",
"channels.probeOk": "成功",
"channels.probeFailed": "失败",
"channels.discord.title": "Discord",
"channels.discord.subtitle": "机器人状态与渠道配置。",
"channels.discord.lastStart": "最近启动",
"channels.discord.lastProbe": "最近探测",
"channels.googlechat.title": "Google Chat",
"channels.googlechat.subtitle": "Chat API Webhook 状态与渠道配置。",
"channels.googlechat.credential": "凭据",
"channels.googlechat.audience": "受众",
"channels.slack.title": "Slack",
"channels.slack.subtitle": "Socket Mode 状态与渠道配置。",
"channels.signal.title": "Signal",
"channels.signal.subtitle": "signal-cli 状态与渠道配置。",
"channels.signal.baseUrl": "Base URL",
"channels.imessage.title": "iMessage",
"channels.imessage.subtitle": "macOS 桥接状态与渠道配置。",
"channels.nostr.title": "Nostr",
"channels.nostr.subtitle": "通过 Nostr Relay 的去中心化私信NIP-04。",
"channels.nostr.publicKey": "公钥",
"channels.nostr.profile": "资料",
"channels.nostr.editProfile": "编辑资料",
"channels.nostr.profilePictureAlt": "头像",
"channels.nostr.name": "名称",
"channels.nostr.displayName": "显示名称",
"channels.nostr.about": "简介",
"channels.nostr.noProfile": "尚未设置资料。点击“编辑资料”添加名称、简介与头像。",
"channels.nostrProfile.account": "账号:${accountId}",
"channels.nostrProfile.picturePreviewAlt": "头像预览",
"channels.nostrProfile.username": "用户名",
"channels.nostrProfile.usernameHelp": "简短用户名(例如 satoshi",
"channels.nostrProfile.displayName": "显示名称",
"channels.nostrProfile.displayNameHelp": "你的完整显示名称",
"channels.nostrProfile.bio": "简介",
"channels.nostrProfile.bioPlaceholder": "介绍一下你自己…",
"channels.nostrProfile.bioHelp": "一段简短的介绍或说明",
"channels.nostrProfile.avatarUrl": "头像 URL",
"channels.nostrProfile.avatarUrlHelp": "指向头像的 HTTPS URL",
"channels.nostrProfile.advanced": "高级",
"channels.nostrProfile.bannerUrl": "横幅 URL",
"channels.nostrProfile.bannerUrlHelp": "指向横幅图片的 HTTPS URL",
"channels.nostrProfile.website": "网站",
"channels.nostrProfile.websiteHelp": "你的个人网站",
"channels.nostrProfile.nip05": "NIP-05 标识",
"channels.nostrProfile.nip05Help": "可验证标识(例如 you@domain.com",
"channels.nostrProfile.lud16": "闪电地址",
"channels.nostrProfile.lud16Help": "用于打赏的闪电地址LUD-16",
"channels.nostrProfile.savePublish": "保存并发布",
"channels.nostrProfile.import": "从 Relay 导入",
"channels.nostrProfile.showAdvanced": "显示高级项",
"channels.nostrProfile.hideAdvanced": "隐藏高级项",
"channels.nostrProfile.unsaved": "你有未保存的更改",
"config.sidebar.title": "设置",
"config.validity.unknown": "未知",
"config.validity.valid": "有效",
"config.validity.invalid": "无效",
"config.search.placeholder": "搜索设置…",
"config.section.all": "全部设置",
"config.section.env": "环境",
"config.section.update": "更新",
"config.section.agents": "智能体",
"config.section.auth": "认证",
"config.section.channels": "渠道",
"config.section.messages": "消息",
"config.section.commands": "命令",
"config.section.hooks": "钩子",
"config.section.skills": "技能",
"config.section.tools": "工具",
"config.section.gateway": "网关",
"config.section.wizard": "安装向导",
"config.section.web": "Web 服务",
"config.section.discovery": "服务发现",
"config.section.canvasHost": "Canvas 宿主",
"config.section.talk": "对话",
"config.section.plugins": "插件",
"config.section.nodeHost": "节点宿主",
"config.section.approvals": "审批",
"config.mode.form": "表单",
"config.mode.raw": "原始",
"config.changes.raw": "有未保存的更改",
"config.changes.form.one": "1 项未保存更改",
"config.changes.form.many": "${count} 项未保存更改",
"config.changes.none": "无更改",
"config.reload": "重新加载",
"config.update": "更新",
"config.diff.view.one": "查看 1 项待应用更改",
"config.diff.view.many": "查看 ${count} 项待应用更改",
"config.subsection.all": "全部",
"config.loadingSchema": "正在加载 schema…",
"config.formUnsafe": "表单视图无法安全编辑部分字段。请使用“原始”避免丢失配置项。",
"config.rawJson5": "原始 JSON5",
"config.schemaUnavailable": "Schema 不可用。",
"config.unsupportedSchema": "不支持的 schema请使用“原始”。",
"config.empty.search": "没有设置项匹配“${query}”",
"config.empty.section": "该分区下没有设置项",
"config.meta.env.label": "环境变量",
"config.meta.env.desc": "传递给 Gateway 进程的环境变量",
"config.meta.update.label": "更新",
"config.meta.update.desc": "自动更新设置与发布通道",
"config.meta.agents.label": "智能体",
"config.meta.agents.desc": "智能体配置、模型与身份信息",
"config.meta.auth.label": "认证",
"config.meta.auth.desc": "API Key 与认证配置",
"config.meta.channels.label": "渠道",
"config.meta.channels.desc": "消息渠道Telegram、Discord、Slack 等)",
"config.meta.messages.label": "消息",
"config.meta.messages.desc": "消息处理与路由设置",
"config.meta.commands.label": "命令",
"config.meta.commands.desc": "自定义斜杠命令",
"config.meta.hooks.label": "钩子",
"config.meta.hooks.desc": "Webhook 与事件钩子",
"config.meta.skills.label": "技能",
"config.meta.skills.desc": "技能包与能力",
"config.meta.tools.label": "工具",
"config.meta.tools.desc": "工具配置(浏览器、搜索等)",
"config.meta.gateway.label": "网关",
"config.meta.gateway.desc": "网关服务器设置(端口、认证、绑定)",
"config.meta.wizard.label": "安装向导",
"config.meta.wizard.desc": "安装向导状态与历史",
"config.meta.meta.label": "元数据",
"config.meta.meta.desc": "Gateway 元数据与版本信息",
"config.meta.logging.label": "日志",
"config.meta.logging.desc": "日志级别与输出配置",
"config.meta.browser.label": "浏览器",
"config.meta.browser.desc": "浏览器自动化设置",
"config.meta.ui.label": "界面",
"config.meta.ui.desc": "用户界面偏好",
"config.meta.models.label": "模型",
"config.meta.models.desc": "AI 模型配置与提供商",
"config.meta.diagnostics.label": "诊断",
"config.meta.diagnostics.desc": "诊断日志、追踪与 OpenTelemetry",
"config.meta.bindings.label": "按键绑定",
"config.meta.bindings.desc": "快捷键与按键绑定",
"config.meta.broadcast.label": "广播",
"config.meta.broadcast.desc": "广播与通知设置",
"config.meta.audio.label": "音频",
"config.meta.audio.desc": "音频输入/输出设置",
"config.meta.media.label": "媒体",
"config.meta.media.desc": "媒体文件处理",
"config.meta.session.label": "会话",
"config.meta.session.desc": "会话管理与持久化",
"config.meta.approvals.label": "审批",
"config.meta.approvals.desc": "Exec 等操作的审批转发",
"config.meta.cron.label": "定时任务",
"config.meta.cron.desc": "计划任务与自动化",
"config.meta.web.label": "Web 服务",
"config.meta.web.desc": "Web 服务与 API 设置",
"config.meta.discovery.label": "服务发现",
"config.meta.discovery.desc": "服务发现与网络",
"config.meta.canvasHost.label": "Canvas 宿主",
"config.meta.canvasHost.desc": "Canvas 渲染与展示",
"config.meta.talk.label": "对话",
"config.meta.talk.desc": "语音与语音识别设置",
"config.meta.plugins.label": "插件",
"config.meta.plugins.desc": "插件管理与扩展",
"config.meta.nodeHost.label": "节点宿主",
"config.meta.nodeHost.desc": "管理节点连接、代理与权限",
"config.label.auth": "认证",
"config.label.bind": "绑定",
"config.label.controlUi": "控制界面",
"config.label.http": "HTTP",
"config.label.mode": "模式",
"config.label.nodes": "节点",
"config.label.port": "端口",
"config.label.reload": "重新加载",
"config.label.remote": "远程",
"config.label.tailscale": "Tailscale",
"config.label.tls": "TLS",
"config.label.trustedProxies": "信任代理",
"config.label.web": "Web 服务",
"config.label.discovery": "服务发现",
"config.label.canvasHost": "Canvas 宿主",
"config.label.talk": "对话",
"config.label.plugins": "插件",
"config.label.nodeHost": "节点宿主",
"config.label.approvals": "审批",
"config.label.wizard": "安装向导",
"config.label.env": "环境",
"config.label.update": "更新",
"config.label.agents": "智能体",
"config.label.channels": "渠道",
"config.label.messages": "消息",
"config.label.commands": "命令",
"config.label.hooks": "钩子",
"config.label.skills": "技能",
"config.label.tools": "工具",
"config.label.logging": "日志",
"config.label.browser": "浏览器",
"config.label.ui": "界面",
"config.label.models": "模型",
"config.label.diagnostics": "诊断",
"config.label.bindings": "按键绑定",
"config.label.broadcast": "广播",
"config.label.audio": "音频",
"config.label.media": "媒体",
"config.label.session": "会话",
"config.label.cron": "定时任务",
"config.label.allowTailscale": "允许 Tailscale",
"config.label.gatewayPassword": "网关密码",
"config.label.gatewayToken": "网关 Token",
"config.label.account": "账号",
"config.label.accounts": "账号",
"config.label.token": "Token",
"config.label.password": "密码",
"config.label.url": "URL",
"config.label.host": "主机",
"config.label.path": "路径",
"config.label.enabled": "已启用",
"config.label.disabled": "已禁用",
"config.label.name": "名称",
"config.label.description": "描述",
"config.label.id": "ID",
"config.label.version": "版本",
"config.label.uptime": "运行时间",
"config.label.status": "状态",
"config.label.health": "健康",
"config.label.lastRun": "最后运行",
"config.label.lastRunAt": "最后运行于",
"config.label.lastRunCommand": "最后运行命令",
"config.label.lastRunCommit": "最后运行提交",
"config.label.lastRunMode": "最后运行模式",
"config.label.lastRunVersion": "最后运行版本",
"config.label.browserProxy": "浏览器代理",
"config.label.nodeBrowserProxyAllowedProfiles": "允许的配置文件",
"config.label.nodeBrowserProxyEnabled": "启用代理",
"config.label.allowedProfiles": "允许的配置文件",
"config.help.nodeBrowserProxyAllowedProfiles": "通过节点代理公开的浏览器配置文件名称的可选白名单。",
"config.help.nodeBrowserProxyEnabled": "通过节点代理公开本地浏览器控制服务器。",
"config.help.gatewayPassword": "使用 Tailscale Funnel 时必填。",
"config.help.gatewayToken": "访问网关默认必填(除非使用 Tailscale Serve 身份);非回环绑定必填。",
"config.option.token": "Token",
"config.option.password": "密码",
"config.option.local": "本地",
"config.option.remote": "远程",
"config.field.unsupportedNode": "不支持的 schema 节点,请使用“原始”模式。",
"config.field.unsupportedType": "不支持的类型:${type}。请使用“原始”模式。",
"config.field.defaultPlaceholder": "默认值:${value}",
"config.field.resetDefault": "重置为默认值",
"config.field.selectPlaceholder": "请选择…",
"config.field.unsupportedArray": "不支持的数组 schema请使用“原始”模式。",
"config.array.count.one": "1 项",
"config.array.count.many": "${count} 项",
"config.array.add": "添加",
"config.array.empty": "暂无条目。点击“添加”创建一个。",
"config.array.removeItem": "移除条目",
"config.map.title": "自定义条目",
"config.map.addEntry": "添加条目",
"config.map.empty": "暂无自定义条目。",
"config.map.keyPlaceholder": "键",
"config.map.jsonPlaceholder": "JSON 值",
"config.map.removeEntry": "移除条目",
"config.hints.diagnostics.cacheTrace.label": "缓存追踪",
"config.hints.diagnostics.enabled.label": "启用诊断",
"config.hints.diagnostics.enabled.help": "启用诊断日志输出。",
"config.hints.diagnostics.flags.label": "诊断标记",
"config.hints.diagnostics.flags.help": "按标记启用特定诊断日志(例如 [\"telegram.http\"])。支持通配符 \"telegram.*\" 或 \"*\"。",
"config.hints.diagnostics.otel.label": "OpenTelemetry",
"config.hints.diagnostics.cacheTrace.enabled.label": "启用缓存追踪",
"config.hints.diagnostics.cacheTrace.enabled.help": "为内嵌智能体运行记录缓存追踪快照默认false。",
"config.hints.diagnostics.cacheTrace.filePath.label": "缓存追踪文件路径",
"config.hints.diagnostics.cacheTrace.filePath.help": "缓存追踪日志的 JSONL 输出路径(默认:$CLAWDBOT_STATE_DIR/logs/cache-trace.jsonl。",
"config.hints.diagnostics.cacheTrace.includeMessages.label": "包含消息内容",
"config.hints.diagnostics.cacheTrace.includeMessages.help": "在追踪输出中包含完整消息载荷默认true。",
"config.hints.diagnostics.cacheTrace.includePrompt.label": "包含提示词",
"config.hints.diagnostics.cacheTrace.includePrompt.help": "在追踪输出中包含提示词文本默认true。",
"config.hints.diagnostics.cacheTrace.includeSystem.label": "包含系统提示词",
"config.hints.diagnostics.cacheTrace.includeSystem.help": "在追踪输出中包含系统提示词默认true。",
"config.hints.diagnostics.otel.enabled": "启用 OpenTelemetry",
"config.hints.diagnostics.otel.endpoint": "OpenTelemetry Endpoint",
"config.hints.diagnostics.otel.protocol": "OpenTelemetry 协议",
"config.hints.diagnostics.otel.headers": "OpenTelemetry Headers",
"config.hints.diagnostics.otel.serviceName": "OpenTelemetry 服务名",
"config.hints.diagnostics.otel.traces": "启用 Trace",
"config.hints.diagnostics.otel.metrics": "启用 Metrics",
"config.hints.diagnostics.otel.logs": "启用 Logs",
"config.hints.diagnostics.otel.sampleRate": "Trace 采样率",
"config.hints.diagnostics.otel.flushIntervalMs": "Flush 间隔(毫秒)",
"config.hints.approvals.exec.label": "执行Exec",
"config.hints.approvals.exec.agentFilter": "智能体过滤",
"config.hints.approvals.exec.enabled": "启用",
"config.hints.approvals.exec.mode": "模式",
"config.hints.approvals.exec.sessionFilter": "会话过滤",
"config.hints.approvals.exec.targets": "目标列表",
"config.hints.approvals.exec.targets.channel": "渠道",
"config.hints.approvals.exec.targets.to": "目标",
"config.hints.approvals.exec.targets.accountId": "账号 ID",
"config.hints.approvals.exec.targets.threadId": "线程 ID",
"config.options.approvals.exec.mode.session": "原会话",
"config.options.approvals.exec.mode.targets": "配置目标",
"config.options.approvals.exec.mode.both": "两者",
"config.hints.media.preserveFilenames.label": "保留文件名",
"config.hints.media.preserveFilenames.help": "保存媒体附件时保留原始文件名。",
};

View File

@ -1,13 +1,15 @@
import type { IconName } from "./icons.js";
import { t } from "./i18n";
export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] },
{ id: "chat", labelKey: "nav.chat", tabs: ["chat"] },
{
label: "Control",
id: "control",
labelKey: "nav.control",
tabs: ["overview", "channels", "instances", "sessions", "cron"],
},
{ label: "Agent", tabs: ["skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] },
{ id: "agent", labelKey: "nav.agent", tabs: ["skills", "nodes"] },
{ id: "settings", labelKey: "nav.settings", tabs: ["config", "debug", "logs"] },
] as const;
export type Tab =
@ -132,56 +134,56 @@ export function iconForTab(tab: Tab): IconName {
export function titleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Overview";
return t("tab.overview.title");
case "channels":
return "Channels";
return t("tab.channels.title");
case "instances":
return "Instances";
return t("tab.instances.title");
case "sessions":
return "Sessions";
return t("tab.sessions.title");
case "cron":
return "Cron Jobs";
return t("tab.cron.title");
case "skills":
return "Skills";
return t("tab.skills.title");
case "nodes":
return "Nodes";
return t("tab.nodes.title");
case "chat":
return "Chat";
return t("tab.chat.title");
case "config":
return "Config";
return t("tab.config.title");
case "debug":
return "Debug";
return t("tab.debug.title");
case "logs":
return "Logs";
return t("tab.logs.title");
default:
return "Control";
return t("tab.control.title");
}
}
export function subtitleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Gateway status, entry points, and a fast health read.";
return t("tab.overview.subtitle");
case "channels":
return "Manage channels and settings.";
return t("tab.channels.subtitle");
case "instances":
return "Presence beacons from connected clients and nodes.";
return t("tab.instances.subtitle");
case "sessions":
return "Inspect active sessions and adjust per-session defaults.";
return t("tab.sessions.subtitle");
case "cron":
return "Schedule wakeups and recurring agent runs.";
return t("tab.cron.subtitle");
case "skills":
return "Manage skill availability and API key injection.";
return t("tab.skills.subtitle");
case "nodes":
return "Paired devices, capabilities, and command exposure.";
return t("tab.nodes.subtitle");
case "chat":
return "Direct gateway chat session for quick interventions.";
return t("tab.chat.subtitle");
case "config":
return "Edit ~/.clawdbot/clawdbot.json safely.";
return t("tab.config.subtitle");
case "debug":
return "Gateway snapshots, events, and manual RPC calls.";
return t("tab.debug.subtitle");
case "logs":
return "Live tail of the gateway file logs.";
return t("tab.logs.subtitle");
default:
return "";
}

View File

@ -1,8 +1,10 @@
import { formatAgo, formatDurationMs, formatMs } from "./format";
import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types";
import { t } from "./i18n";
import { CronJob } from "../../src/infra/cron-job";
import type { GatewaySessionRow, PresenceEntry } from "./types";
export function formatPresenceSummary(entry: PresenceEntry): string {
const host = entry.host ?? "unknown";
const host = entry.host ?? t("common.unknown");
const ip = entry.ip ? `(${entry.ip})` : "";
const mode = entry.mode ?? "";
const version = entry.version ?? "";
@ -11,16 +13,16 @@ export function formatPresenceSummary(entry: PresenceEntry): string {
export function formatPresenceAge(entry: PresenceEntry): string {
const ts = entry.ts ?? null;
return ts ? formatAgo(ts) : "n/a";
return ts ? formatAgo(ts) : t("common.na");
}
export function formatNextRun(ms?: number | null) {
if (!ms) return "n/a";
if (!ms) return t("common.na");
return `${formatMs(ms)} (${formatAgo(ms)})`;
}
export function formatSessionTokens(row: GatewaySessionRow) {
if (row.totalTokens == null) return "n/a";
if (row.totalTokens == null) return t("common.na");
const total = row.totalTokens ?? 0;
const ctx = row.contextTokens ?? 0;
return ctx ? `${total} / ${ctx}` : String(total);
@ -35,23 +37,24 @@ export function formatEventPayload(payload: unknown): string {
}
}
export function formatCronState(job: CronJob) {
const state = job.state ?? {};
const next = state.nextRunAtMs ? formatMs(state.nextRunAtMs) : "n/a";
const last = state.lastRunAtMs ? formatMs(state.lastRunAtMs) : "n/a";
const status = state.lastStatus ?? "n/a";
return `${status} · next ${next} · last ${last}`;
export function presentCronSchedule(job: CronJob): string {
const parts: string[] = [];
if (job.schedule.at) parts.push(t("cron.schedule.atValue", { time: formatMs(job.schedule.at) }));
if (job.schedule.every) parts.push(t("cron.schedule.everyValue", { duration: formatDurationMs(job.schedule.every) }));
if (job.schedule.cron) parts.push(t("cron.schedule.cronValue", { expr: job.schedule.cron }));
return parts.join(", ") || t("common.unknown");
}
export function formatCronSchedule(job: CronJob) {
const s = job.schedule;
if (s.kind === "at") return `At ${formatMs(s.atMs)}`;
if (s.kind === "every") return `Every ${formatDurationMs(s.everyMs)}`;
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
export function presentCronStatus(job: CronJob): string {
const parts: string[] = [];
if (job.status.nextWake) parts.push(`${t("cron.status.next")} ${formatMs(job.status.nextWake)}`);
if (job.status.lastWake) parts.push(`${t("cron.status.last")} ${formatMs(job.status.lastWake)}`);
return parts.join(", ");
}
export function formatCronPayload(job: CronJob) {
const p = job.payload;
if (p.kind === "systemEvent") return `System: ${p.text}`;
return `Agent: ${p.message}`;
export function presentCronPayload(job: CronJob): string {
const parts: string[] = [];
if (job.payload.system) parts.push(t("cron.payload.system", { text: job.payload.system }));
if (job.payload.agent) parts.push(t("cron.payload.agent", { message: job.payload.agent }));
return parts.join(", ");
}

View File

@ -1,6 +1,7 @@
const KEY = "clawdbot.control.settings.v1";
import type { ThemeMode } from "./theme";
import { resolveInitialLocale, setLocale, type Locale } from "./i18n";
export type UiSettings = {
gatewayUrl: string;
@ -8,6 +9,7 @@ export type UiSettings = {
sessionKey: string;
lastActiveSessionKey: string;
theme: ThemeMode;
locale: Locale;
chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
@ -27,6 +29,7 @@ export function loadSettings(): UiSettings {
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
locale: "en",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
@ -36,8 +39,39 @@ export function loadSettings(): UiSettings {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return defaults;
const parsed = JSON.parse(raw) as Partial<UiSettings>;
if (!raw) {
const resolvedLocale = resolveInitialLocale(null);
setLocale(resolvedLocale);
return { ...defaults, locale: resolvedLocale };
}
const parsed = JSON.parse(raw) as Partial<UiSettings> & { locale?: string };
const resolvedLocale = resolveInitialLocale(
typeof parsed.locale === "string" ? parsed.locale : null,
);
setLocale(resolvedLocale);
const rawNavGroupsCollapsed =
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
? (parsed.navGroupsCollapsed as Record<string, boolean>)
: defaults.navGroupsCollapsed;
const navGroupsCollapsed: Record<string, boolean> = { ...rawNavGroupsCollapsed };
if (typeof navGroupsCollapsed.Chat === "boolean" && typeof navGroupsCollapsed.chat !== "boolean") {
navGroupsCollapsed.chat = navGroupsCollapsed.Chat;
}
if (
typeof navGroupsCollapsed.Control === "boolean" &&
typeof navGroupsCollapsed.control !== "boolean"
) {
navGroupsCollapsed.control = navGroupsCollapsed.Control;
}
if (typeof navGroupsCollapsed.Agent === "boolean" && typeof navGroupsCollapsed.agent !== "boolean") {
navGroupsCollapsed.agent = navGroupsCollapsed.Agent;
}
if (
typeof navGroupsCollapsed.Settings === "boolean" &&
typeof navGroupsCollapsed.settings !== "boolean"
) {
navGroupsCollapsed.settings = navGroupsCollapsed.Settings;
}
return {
gatewayUrl:
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
@ -61,6 +95,7 @@ export function loadSettings(): UiSettings {
parsed.theme === "system"
? parsed.theme
: defaults.theme,
locale: resolvedLocale,
chatFocusMode:
typeof parsed.chatFocusMode === "boolean"
? parsed.chatFocusMode
@ -80,16 +115,16 @@ export function loadSettings(): UiSettings {
? parsed.navCollapsed
: defaults.navCollapsed,
navGroupsCollapsed:
typeof parsed.navGroupsCollapsed === "object" &&
parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed
: defaults.navGroupsCollapsed,
navGroupsCollapsed,
};
} catch {
return defaults;
const resolvedLocale = resolveInitialLocale(null);
setLocale(resolvedLocale);
return { ...defaults, locale: resolvedLocale };
}
}
export function saveSettings(next: UiSettings) {
setLocale(next.locale);
localStorage.setItem(KEY, JSON.stringify(next));
}

View File

@ -2,6 +2,7 @@ import { html } from "lit";
import type { ConfigUiHints } from "../types";
import type { ChannelsProps } from "./channels.types";
import { t } from "../i18n";
import {
analyzeConfigSchema,
renderNode,
@ -71,11 +72,11 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema;
if (!normalized) {
return html`<div class="callout danger">Schema unavailable. Use Raw.</div>`;
return html`<div class="callout danger">${t("channels.config.schemaUnavailable")}</div>`;
}
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
if (!node) {
return html`<div class="callout danger">Channel config schema unavailable.</div>`;
return html`<div class="callout danger">${t("channels.config.channelSchemaUnavailable")}</div>`;
}
const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId);
@ -104,7 +105,7 @@ export function renderChannelConfigSection(params: {
return html`
<div style="margin-top: 16px;">
${props.configSchemaLoading
? html`<div class="muted">Loading config schema…</div>`
? html`<div class="muted">${t("channels.config.loadingSchema")}</div>`
: renderChannelConfigForm({
channelId,
configValue: props.configForm,
@ -119,14 +120,14 @@ export function renderChannelConfigSection(params: {
?disabled=${disabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save"}
${props.configSaving ? t("common.loading") : t("common.save")}
</button>
<button
class="btn"
?disabled=${disabled}
@click=${() => props.onConfigReload()}
>
Reload
${t("channels.config.reload")}
</button>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { formatAgo } from "../format";
import type { DiscordStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import { t } from "../i18n";
export function renderDiscordCard(params: {
props: ChannelsProps;
@ -14,26 +15,26 @@ export function renderDiscordCard(params: {
return html`
<div class="card">
<div class="card-title">Discord</div>
<div class="card-sub">Bot status and channel configuration.</div>
<div class="card-title">${t("channels.discord.title")}</div>
<div class="card-sub">${t("channels.discord.subtitle")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${discord?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>${discord?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>${discord?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastStart")}</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastProbe")}</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -45,7 +46,7 @@ export function renderDiscordCard(params: {
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${t("channels.probe")} ${discord.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
@ -54,7 +55,7 @@ export function renderDiscordCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.probe")}
</button>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { formatAgo } from "../format";
import type { GoogleChatStatus } from "../types";
import { renderChannelConfigSection } from "./channels.config";
import type { ChannelsProps } from "./channels.types";
import { t } from "../i18n";
export function renderGoogleChatCard(params: {
props: ChannelsProps;
@ -14,38 +15,50 @@ export function renderGoogleChatCard(params: {
return html`
<div class="card">
<div class="card-title">Google Chat</div>
<div class="card-sub">Chat API webhook status and channel configuration.</div>
<div class="card-title">${t("channels.googlechat.title")}</div>
<div class="card-sub">${t("channels.googlechat.subtitle")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
</div>
<div>
<span class="label">Credential</span>
<span>${googleChat?.credentialSource ?? "n/a"}</span>
</div>
<div>
<span class="label">Audience</span>
<span class="label">${t("channels.status.configured")}</span>
<span>
${googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"}
${googleChat
? googleChat.configured
? t("common.yes")
: t("common.no")
: t("common.na")}
</span>
</div>
<div>
<span class="label">Last start</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>
${googleChat
? googleChat.running
? t("common.yes")
: t("common.no")
: t("common.na")}
</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.googlechat.credential")}</span>
<span>${googleChat?.credentialSource ?? t("common.na")}</span>
</div>
<div>
<span class="label">${t("channels.googlechat.audience")}</span>
<span>
${googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: t("common.na")}
</span>
</div>
<div>
<span class="label">${t("channels.discord.lastStart")}</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">${t("channels.discord.lastProbe")}</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -57,7 +70,7 @@ export function renderGoogleChatCard(params: {
${googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${t("channels.probe")} ${googleChat.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>`
: nothing}
@ -66,7 +79,7 @@ export function renderGoogleChatCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.probe")}
</button>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { formatAgo } from "../format";
import type { IMessageStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import { t } from "../i18n";
export function renderIMessageCard(params: {
props: ChannelsProps;
@ -14,26 +15,26 @@ export function renderIMessageCard(params: {
return html`
<div class="card">
<div class="card-title">iMessage</div>
<div class="card-sub">macOS bridge status and channel configuration.</div>
<div class="card-title">${t("channels.imessage.title")}</div>
<div class="card-sub">${t("channels.imessage.subtitle")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${imessage?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>${imessage?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>${imessage?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastStart")}</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastProbe")}</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -45,7 +46,7 @@ export function renderIMessageCard(params: {
${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${t("channels.probe")} ${imessage.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${imessage.probe.error ?? ""}
</div>`
: nothing}
@ -54,7 +55,7 @@ export function renderIMessageCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.probe")}
</button>
</div>
</div>

View File

@ -7,6 +7,7 @@
import { html, nothing, type TemplateResult } from "lit";
import type { NostrProfile as NostrProfileType } from "../types";
import { t } from "../i18n";
// ============================================================================
// Types
@ -147,7 +148,7 @@ export function renderNostrProfileForm(params: {
<div style="margin-bottom: 12px;">
<img
src=${picture}
alt="Profile picture preview"
alt=${t("channels.nostrProfile.picturePreviewAlt")}
style="max-width: 80px; max-height: 80px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => {
const img = e.target as HTMLImageElement;
@ -165,8 +166,8 @@ export function renderNostrProfileForm(params: {
return html`
<div class="nostr-profile-form" style="padding: 16px; background: var(--bg-secondary); border-radius: 8px; margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<div style="font-weight: 600; font-size: 16px;">Edit Profile</div>
<div style="font-size: 12px; color: var(--text-muted);">Account: ${accountId}</div>
<div style="font-weight: 600; font-size: 16px;">${t("channels.nostr.editProfile")}</div>
<div style="font-size: 12px; color: var(--text-muted);">${t("channels.nostrProfile.account", { accountId })}</div>
</div>
${state.error
@ -179,56 +180,56 @@ export function renderNostrProfileForm(params: {
${renderPicturePreview()}
${renderField("name", "Username", {
${renderField("name", t("channels.nostrProfile.username"), {
placeholder: "satoshi",
maxLength: 256,
help: "Short username (e.g., satoshi)",
help: t("channels.nostrProfile.usernameHelp"),
})}
${renderField("displayName", "Display Name", {
${renderField("displayName", t("channels.nostrProfile.displayName"), {
placeholder: "Satoshi Nakamoto",
maxLength: 256,
help: "Your full display name",
help: t("channels.nostrProfile.displayNameHelp"),
})}
${renderField("about", "Bio", {
${renderField("about", t("channels.nostrProfile.bio"), {
type: "textarea",
placeholder: "Tell people about yourself...",
placeholder: t("channels.nostrProfile.bioPlaceholder"),
maxLength: 2000,
help: "A brief bio or description",
help: t("channels.nostrProfile.bioHelp"),
})}
${renderField("picture", "Avatar URL", {
${renderField("picture", t("channels.nostrProfile.avatarUrl"), {
type: "url",
placeholder: "https://example.com/avatar.jpg",
help: "HTTPS URL to your profile picture",
help: t("channels.nostrProfile.avatarUrlHelp"),
})}
${state.showAdvanced
? html`
<div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;">
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div>
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">${t("channels.nostrProfile.advanced")}</div>
${renderField("banner", "Banner URL", {
${renderField("banner", t("channels.nostrProfile.bannerUrl"), {
type: "url",
placeholder: "https://example.com/banner.jpg",
help: "HTTPS URL to a banner image",
help: t("channels.nostrProfile.bannerUrlHelp"),
})}
${renderField("website", "Website", {
${renderField("website", t("channels.nostrProfile.website"), {
type: "url",
placeholder: "https://example.com",
help: "Your personal website",
help: t("channels.nostrProfile.websiteHelp"),
})}
${renderField("nip05", "NIP-05 Identifier", {
${renderField("nip05", t("channels.nostrProfile.nip05"), {
placeholder: "you@example.com",
help: "Verifiable identifier (e.g., you@domain.com)",
help: t("channels.nostrProfile.nip05Help"),
})}
${renderField("lud16", "Lightning Address", {
${renderField("lud16", t("channels.nostrProfile.lud16"), {
placeholder: "you@getalby.com",
help: "Lightning address for tips (LUD-16)",
help: t("channels.nostrProfile.lud16Help"),
})}
</div>
`
@ -240,7 +241,7 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onSave}
?disabled=${state.saving || !isDirty}
>
${state.saving ? "Saving..." : "Save & Publish"}
${state.saving ? t("common.loading") : t("channels.nostrProfile.savePublish")}
</button>
<button
@ -248,14 +249,14 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onImport}
?disabled=${state.importing || state.saving}
>
${state.importing ? "Importing..." : "Import from Relays"}
${state.importing ? t("common.loading") : t("channels.nostrProfile.import")}
</button>
<button
class="btn"
@click=${callbacks.onToggleAdvanced}
>
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
${state.showAdvanced ? t("channels.nostrProfile.hideAdvanced") : t("channels.nostrProfile.showAdvanced")}
</button>
<button
@ -263,13 +264,13 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onCancel}
?disabled=${state.saving}
>
Cancel
${t("common.cancel")}
</button>
</div>
${isDirty
? html`<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px;">
You have unsaved changes
${t("channels.nostrProfile.unsaved")}
</div>`
: nothing}
</div>

View File

@ -4,6 +4,7 @@ import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, NostrStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import { t } from "../i18n";
import {
renderNostrProfileForm,
type NostrProfileFormState,
@ -14,7 +15,7 @@ import {
* Truncate a pubkey for display (shows first and last 8 chars)
*/
function truncatePubkey(pubkey: string | null | undefined): string {
if (!pubkey) return "n/a";
if (!pubkey) return t("common.na");
if (pubkey.length <= 20) return pubkey;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
@ -64,20 +65,20 @@ export function renderNostrCard(params: {
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>${account.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>${account.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="label">${t("channels.nostr.publicKey")}</span>
<span class="monospace" title="${publicKey ?? ""}">${truncatePubkey(publicKey)}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span class="label">${t("channels.status.lastInbound")}</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}</span>
</div>
${account.lastError
? html`
@ -117,7 +118,7 @@ export function renderNostrCard(params: {
return html`
<div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 500;">Profile</div>
<div style="font-weight: 500;">${t("channels.nostr.profile")}</div>
${summaryConfigured
? html`
<button
@ -125,7 +126,7 @@ export function renderNostrCard(params: {
@click=${onEditProfile}
style="font-size: 12px; padding: 4px 8px;"
>
Edit Profile
${t("channels.nostr.editProfile")}
</button>
`
: nothing}
@ -138,7 +139,7 @@ export function renderNostrCard(params: {
<div style="margin-bottom: 8px;">
<img
src=${picture}
alt="Profile picture"
alt=${t("channels.nostr.profilePictureAlt")}
style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none";
@ -147,19 +148,19 @@ export function renderNostrCard(params: {
</div>
`
: nothing}
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : nothing}
${name ? html`<div><span class="label">${t("channels.nostr.name")}</span><span>${name}</span></div>` : nothing}
${displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
? html`<div><span class="label">${t("channels.nostr.displayName")}</span><span>${displayName}</span></div>`
: nothing}
${about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
? html`<div><span class="label">${t("channels.nostr.about")}</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing}
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
</div>
`
: html`
<div style="color: var(--text-muted); font-size: 13px;">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
${t("channels.nostr.noProfile")}
</div>
`}
</div>
@ -168,8 +169,8 @@ export function renderNostrCard(params: {
return html`
<div class="card">
<div class="card-title">Nostr</div>
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
<div class="card-title">${t("channels.nostr.title")}</div>
<div class="card-sub">${t("channels.nostr.subtitle")}</div>
${accountCountLabel}
${hasMultipleAccounts
@ -181,22 +182,22 @@ export function renderNostrCard(params: {
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${summaryConfigured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>${summaryConfigured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${summaryRunning ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>${summaryRunning ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="label">${t("channels.nostr.publicKey")}</span>
<span class="monospace" title="${summaryPublicKey ?? ""}"
>${truncatePubkey(summaryPublicKey)}</span
>
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastStart")}</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : t("common.na")}</span>
</div>
</div>
`}
@ -210,7 +211,7 @@ export function renderNostrCard(params: {
${renderChannelConfigSection({ channelId: "nostr", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button>
<button class="btn" @click=${() => props.onRefresh(false)}>${t("common.refresh")}</button>
</div>
</div>
`;

View File

@ -2,9 +2,10 @@ import { html, nothing } from "lit";
import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ChannelsProps } from "./channels.types";
import { t } from "../i18n";
export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) return "n/a";
if (!ms && ms !== 0) return t("common.na");
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60);
@ -41,5 +42,5 @@ export function renderChannelAccountCount(
) {
const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing;
return html`<div class="account-count">Accounts (${count})</div>`;
return html`<div class="account-count">${t("channels.accountsCount", { count })}</div>`;
}

View File

@ -4,6 +4,7 @@ import { formatAgo } from "../format";
import type { SignalStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import { t } from "../i18n";
export function renderSignalCard(params: {
props: ChannelsProps;
@ -14,30 +15,30 @@ export function renderSignalCard(params: {
return html`
<div class="card">
<div class="card-title">Signal</div>
<div class="card-sub">signal-cli status and channel configuration.</div>
<div class="card-title">${t("channels.signal.title")}</div>
<div class="card-sub">${t("channels.signal.subtitle")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>${signal?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>${signal?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
<span class="label">${t("channels.signal.baseUrl")}</span>
<span>${signal?.baseUrl ?? t("common.na")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastStart")}</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastProbe")}</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -49,7 +50,7 @@ export function renderSignalCard(params: {
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${t("channels.probe")} ${signal.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
@ -58,7 +59,7 @@ export function renderSignalCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.probe")}
</button>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { formatAgo } from "../format";
import type { SlackStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import { t } from "../i18n";
export function renderSlackCard(params: {
props: ChannelsProps;
@ -14,26 +15,26 @@ export function renderSlackCard(params: {
return html`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and channel configuration.</div>
<div class="card-title">${t("channels.slack.title")}</div>
<div class="card-sub">${t("channels.slack.subtitle")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>${slack?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>${slack?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastStart")}</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastProbe")}</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -45,7 +46,7 @@ export function renderSlackCard(params: {
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${t("channels.probe")} ${slack.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing}
@ -54,7 +55,7 @@ export function renderSlackCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.probe")}
</button>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import { t } from "../i18n";
export function renderTelegramCard(params: {
props: ChannelsProps;
@ -28,16 +29,16 @@ export function renderTelegramCard(params: {
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>${account.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>${account.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span class="label">${t("channels.status.lastInbound")}</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}</span>
</div>
${account.lastError
? html`
@ -53,8 +54,8 @@ export function renderTelegramCard(params: {
return html`
<div class="card">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot status and channel configuration.</div>
<div class="card-title">${t("channels.telegram.title")}</div>
<div class="card-sub">${t("channels.telegram.subtitle")}</div>
${accountCountLabel}
${hasMultipleAccounts
@ -66,24 +67,24 @@ export function renderTelegramCard(params: {
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${telegram?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>${telegram?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${telegram?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>${telegram?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Mode</span>
<span>${telegram?.mode ?? "n/a"}</span>
<span class="label">${t("channels.telegram.mode")}</span>
<span>${telegram?.mode ?? t("common.na")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.telegram.lastStart")}</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.telegram.lastProbe")}</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : t("common.na")}</span>
</div>
</div>
`}
@ -96,7 +97,7 @@ export function renderTelegramCard(params: {
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${t("channels.telegram.probe")} ${telegram.probe.ok ? t("channels.telegram.probeOk") : t("channels.telegram.probeFailed")} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing}
@ -105,7 +106,7 @@ export function renderTelegramCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.telegram.probe")}
</button>
</div>
</div>

View File

@ -30,6 +30,7 @@ import { renderSignalCard } from "./channels.signal";
import { renderSlackCard } from "./channels.slack";
import { renderTelegramCard } from "./channels.telegram";
import { renderWhatsAppCard } from "./channels.whatsapp";
import { t } from "../i18n";
export function renderChannels(props: ChannelsProps) {
const channels = props.snapshot?.channels as Record<string, unknown> | null;
@ -77,10 +78,10 @@ export function renderChannels(props: ChannelsProps) {
<section class="card" style="margin-top: 18px;">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Channel health</div>
<div class="card-sub">Channel status snapshots from the gateway.</div>
<div class="card-title">${t("channels.health.title")}</div>
<div class="card-sub">${t("channels.health.subtitle")}</div>
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : t("common.na")}</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
@ -88,7 +89,7 @@ export function renderChannels(props: ChannelsProps) {
</div>`
: nothing}
<pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : t("channels.health.noSnapshot")}
</pre>
</section>
`;
@ -215,7 +216,7 @@ function renderGenericChannelCard(
return html`
<div class="card">
<div class="card-title">${label}</div>
<div class="card-sub">Channel status and configuration.</div>
<div class="card-sub">${t("channels.generic.subtitle")}</div>
${accountCountLabel}
${accounts.length > 0
@ -227,16 +228,22 @@ function renderGenericChannelCard(
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>
${configured == null ? t("common.na") : configured ? t("common.yes") : t("common.no")}
</span>
</div>
<div>
<span class="label">Running</span>
<span>${running == null ? "n/a" : running ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>
${running == null ? t("common.na") : running ? t("common.yes") : t("common.no")}
</span>
</div>
<div>
<span class="label">Connected</span>
<span>${connected == null ? "n/a" : connected ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.connected")}</span>
<span>
${connected == null ? t("common.na") : connected ? t("common.yes") : t("common.no")}
</span>
</div>
</div>
`}
@ -274,24 +281,38 @@ function hasRecentActivity(account: ChannelAccountSnapshot): boolean {
return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS;
}
function deriveRunningStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" {
if (account.running) return "Yes";
function deriveRunningStatus(account: ChannelAccountSnapshot): "yes" | "no" | "active" {
if (account.running) return "yes";
// If we have recent inbound activity, the channel is effectively running
if (hasRecentActivity(account)) return "Active";
return "No";
if (hasRecentActivity(account)) return "active";
return "no";
}
function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" {
if (account.connected === true) return "Yes";
if (account.connected === false) return "No";
function deriveConnectedStatus(account: ChannelAccountSnapshot): "yes" | "no" | "active" | "na" {
if (account.connected === true) return "yes";
if (account.connected === false) return "no";
// If connected is null/undefined but we have recent activity, show as active
if (hasRecentActivity(account)) return "Active";
return "n/a";
if (hasRecentActivity(account)) return "active";
return "na";
}
function renderGenericAccount(account: ChannelAccountSnapshot) {
const runningStatus = deriveRunningStatus(account);
const connectedStatus = deriveConnectedStatus(account);
const runningText =
runningStatus === "active"
? t("channels.status.active")
: runningStatus === "yes"
? t("common.yes")
: t("common.no");
const connectedText =
connectedStatus === "active"
? t("channels.status.active")
: connectedStatus === "na"
? t("common.na")
: connectedStatus === "yes"
? t("common.yes")
: t("common.no");
return html`
<div class="account-card">
@ -301,20 +322,20 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${runningStatus}</span>
<span class="label">${t("channels.status.running")}</span>
<span>${runningText}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>${account.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${connectedStatus}</span>
<span class="label">${t("channels.status.connected")}</span>
<span>${connectedText}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span class="label">${t("channels.status.lastInbound")}</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}</span>
</div>
${account.lastError
? html`

View File

@ -5,6 +5,7 @@ import type { WhatsAppStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import { formatDuration } from "./channels.shared";
import { t } from "../i18n";
export function renderWhatsAppCard(params: {
props: ChannelsProps;
@ -15,47 +16,47 @@ export function renderWhatsAppCard(params: {
return html`
<div class="card">
<div class="card-title">WhatsApp</div>
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
<div class="card-title">${t("channels.whatsapp.title")}</div>
<div class="card-sub">${t("channels.whatsapp.subtitle")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.configured")}</span>
<span>${whatsapp?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Linked</span>
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
<span class="label">${t("channels.whatsapp.linked")}</span>
<span>${whatsapp?.linked ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${whatsapp?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.running")}</span>
<span>${whatsapp?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
<span class="label">${t("channels.status.connected")}</span>
<span>${whatsapp?.connected ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Last connect</span>
<span class="label">${t("channels.whatsapp.lastConnect")}</span>
<span>
${whatsapp?.lastConnectedAt
? formatAgo(whatsapp.lastConnectedAt)
: "n/a"}
: t("common.na")}
</span>
</div>
<div>
<span class="label">Last message</span>
<span class="label">${t("channels.whatsapp.lastMessage")}</span>
<span>
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : t("common.na")}
</span>
</div>
<div>
<span class="label">Auth age</span>
<span class="label">${t("channels.whatsapp.authAge")}</span>
<span>
${whatsapp?.authAgeMs != null
? formatDuration(whatsapp.authAgeMs)
: "n/a"}
: t("common.na")}
</span>
</div>
</div>
@ -74,7 +75,7 @@ export function renderWhatsAppCard(params: {
${props.whatsappQrDataUrl
? html`<div class="qr-wrap">
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
<img src=${props.whatsappQrDataUrl} alt=${t("channels.whatsapp.qrAlt")} />
</div>`
: nothing}
@ -84,31 +85,31 @@ export function renderWhatsAppCard(params: {
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(false)}
>
${props.whatsappBusy ? "Working…" : "Show QR"}
${props.whatsappBusy ? t("common.loading") : t("channels.whatsapp.showQr")}
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(true)}
>
Relink
${t("channels.whatsapp.relink")}
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppWait()}
>
Wait for scan
${t("channels.whatsapp.waitForScan")}
</button>
<button
class="btn danger"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppLogout()}
>
Logout
${t("channels.whatsapp.logout")}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Refresh
${t("common.refresh")}
</button>
</div>

View File

@ -15,6 +15,7 @@ import {
} from "../chat/grouped-render";
import { renderMarkdownSidebar } from "./markdown-sidebar";
import "../components/resizable-divider";
import { t } from "../i18n";
export type CompactionIndicatorStatus = {
active: boolean;
@ -78,7 +79,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (status.active) {
return html`
<div class="callout info compaction-indicator compaction-indicator--active">
${icons.loader} Compacting context...
${icons.loader} ${t("chat.compacting")}
</div>
`;
}
@ -89,7 +90,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
<div class="callout success compaction-indicator compaction-indicator--complete">
${icons.check} Context compacted
${icons.check} ${t("chat.compacted")}
</div>
`;
}
@ -151,13 +152,13 @@ function renderAttachmentPreview(props: ChatProps) {
<div class="chat-attachment">
<img
src=${att.dataUrl}
alt="Attachment preview"
alt=${t("chat.attachmentPreview")}
class="chat-attachment__img"
/>
<button
class="chat-attachment__remove"
type="button"
aria-label="Remove attachment"
aria-label=${t("chat.removeAttachment")}
@click=${() => {
const next = (props.attachments ?? []).filter(
(a) => a.id !== att.id,
@ -191,9 +192,9 @@ export function renderChat(props: ChatProps) {
const hasAttachments = (props.attachments?.length ?? 0) > 0;
const composePlaceholder = props.connected
? hasAttachments
? "Add a message or paste more images..."
: "Message (↩ to send, Shift+↩ for line breaks, paste images)"
: "Connect to the gateway to start chatting…";
? t("chat.placeholder.connectedWithAttachments")
: t("chat.placeholder.connected")
: t("chat.placeholder.disconnected");
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
@ -204,7 +205,7 @@ export function renderChat(props: ChatProps) {
aria-live="polite"
@scroll=${props.onChatScroll}
>
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
${props.loading ? html`<div class="muted">${t("chat.loading")}</div>` : nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
@ -251,8 +252,8 @@ export function renderChat(props: ChatProps) {
class="chat-focus-exit"
type="button"
@click=${props.onToggleFocusMode}
aria-label="Exit focus mode"
title="Exit focus mode"
aria-label=${t("chat.exitFocusMode")}
title=${t("chat.exitFocusMode")}
>
${icons.x}
</button>
@ -294,7 +295,9 @@ export function renderChat(props: ChatProps) {
${props.queue.length
? html`
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__title">
${t("chat.queued", { count: props.queue.length })}
</div>
<div class="chat-queue__list">
${props.queue.map(
(item) => html`
@ -302,13 +305,13 @@ export function renderChat(props: ChatProps) {
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length
? `Image (${item.attachments.length})`
? t("chat.imageCount", { count: item.attachments.length })
: "")}
</div>
<button
class="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
aria-label=${t("chat.removeQueuedMessage")}
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
@ -325,7 +328,7 @@ export function renderChat(props: ChatProps) {
${renderAttachmentPreview(props)}
<div class="chat-compose__row">
<label class="field chat-compose__field">
<span>Message</span>
<span>${t("chat.messageLabel")}</span>
<textarea
.value=${props.draft}
?disabled=${!props.connected}
@ -349,14 +352,14 @@ export function renderChat(props: ChatProps) {
?disabled=${!props.connected || (!canAbort && props.sending)}
@click=${canAbort ? props.onAbort : props.onNewSession}
>
${canAbort ? "Stop" : "New session"}
${canAbort ? t("chat.stop") : t("chat.newSession")}
</button>
<button
class="btn primary"
?disabled=${!props.connected}
@click=${props.onSend}
>
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd"></kbd>
${isBusy ? t("chat.queue") : t("chat.send")}<kbd class="btn-kbd"></kbd>
</button>
</div>
</div>
@ -415,7 +418,10 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
key: "chat:history:notice",
message: {
role: "system",
content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`,
content: t("chat.showingLast", {
limit: CHAT_HISTORY_RENDER_LIMIT,
hidden: historyStart,
}),
timestamp: Date.now(),
},
});

View File

@ -1,5 +1,6 @@
import { html, nothing, type TemplateResult } from "lit";
import type { ConfigUiHints } from "../types";
import { t } from "../i18n";
import {
defaultValue,
hintForPath,
@ -7,6 +8,8 @@ import {
isSensitivePath,
pathKey,
schemaType,
translateHelp,
translateLabel,
type JsonSchema,
} from "./config-form.shared";
@ -35,6 +38,28 @@ const icons = {
edit: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
};
function optionLabel(hint: unknown, value: unknown): string {
const raw = value == null ? "" : String(value);
const translated = t(`config.option.${raw}`);
const fallback = translated !== `config.option.${raw}` ? translated : raw;
if (!hint || typeof hint !== "object") return fallback;
const record = hint as Record<string, unknown>;
const direct = record.optionLabels;
const itemTemplate =
record.itemTemplate && typeof record.itemTemplate === "object"
? (record.itemTemplate as Record<string, unknown>)
: null;
const fromTemplate = itemTemplate?.optionLabels;
const map = (direct && typeof direct === "object" ? direct : null) ??
(fromTemplate && typeof fromTemplate === "object" ? fromTemplate : null);
if (!map) return fallback;
const mapRecord = map as Record<string, unknown>;
const mapped = mapRecord[raw];
if (mapped == null) return fallback;
return String(mapped);
}
export function renderNode(params: {
schema: JsonSchema;
value: unknown;
@ -49,14 +74,15 @@ export function renderNode(params: {
const showLabel = params.showLabel ?? true;
const type = schemaType(schema);
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const lastKey = String(path.at(-1));
const label = hint?.label ?? translateLabel(lastKey, schema.title);
const help = hint?.help ?? translateHelp(lastKey, schema.description);
const key = pathKey(path);
if (unsupported.has(key)) {
return html`<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported schema node. Use Raw mode.</div>
<div class="cfg-field__error">${t("config.field.unsupportedNode")}</div>
</div>`;
}
@ -95,7 +121,7 @@ export function renderNode(params: {
?disabled=${disabled}
@click=${() => onPatch(path, lit)}
>
${String(lit)}
${optionLabel(hint, lit)}
</button>
`)}
</div>
@ -154,7 +180,7 @@ export function renderNode(params: {
?disabled=${disabled}
@click=${() => onPatch(path, opt)}
>
${String(opt)}
${optionLabel(hint, opt)}
</button>
`)}
</div>
@ -210,7 +236,7 @@ export function renderNode(params: {
return html`
<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported type: ${type}. Use Raw mode.</div>
<div class="cfg-field__error">${t("config.field.unsupportedType", { type })}</div>
</div>
`;
}
@ -228,12 +254,17 @@ function renderTextInput(params: {
const { schema, value, path, hints, disabled, onPatch, inputType } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const lastKey = String(path.at(-1));
const label = hint?.label ?? translateLabel(lastKey, schema.title);
const help = hint?.help ?? translateHelp(lastKey, schema.description);
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder =
hint?.placeholder ??
(isSensitive ? "••••" : schema.default !== undefined ? `Default: ${schema.default}` : "");
(isSensitive
? "••••"
: schema.default !== undefined
? t("config.field.defaultPlaceholder", { value: String(schema.default) })
: "");
const displayValue = value ?? "";
return html`
@ -265,7 +296,7 @@ function renderTextInput(params: {
<button
type="button"
class="cfg-input__reset"
title="Reset to default"
title=${t("config.field.resetDefault")}
?disabled=${disabled}
@click=${() => onPatch(path, schema.default)}
></button>
@ -287,8 +318,9 @@ function renderNumberInput(params: {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const lastKey = String(path.at(-1));
const label = hint?.label ?? translateLabel(lastKey, schema.title);
const help = hint?.help ?? translateHelp(lastKey, schema.description);
const displayValue = value ?? schema.default ?? "";
const numValue = typeof displayValue === "number" ? displayValue : 0;
@ -338,8 +370,9 @@ function renderSelect(params: {
const { schema, value, path, hints, disabled, options, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const lastKey = String(path.at(-1));
const label = hint?.label ?? translateLabel(lastKey, schema.title);
const help = hint?.help ?? translateHelp(lastKey, schema.description);
const resolvedValue = value ?? schema.default;
const currentIndex = options.findIndex(
(opt) => opt === resolvedValue || String(opt) === String(resolvedValue),
@ -359,9 +392,9 @@ function renderSelect(params: {
onPatch(path, val === unset ? undefined : options[Number(val)]);
}}
>
<option value=${unset}>Select...</option>
<option value=${unset}>${t("config.field.selectPlaceholder")}</option>
${options.map((opt, idx) => html`
<option value=${String(idx)}>${String(opt)}</option>
<option value=${String(idx)}>${optionLabel(hint, opt)}</option>
`)}
</select>
</div>
@ -381,8 +414,9 @@ function renderObject(params: {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const lastKey = String(path.at(-1));
const label = hint?.label ?? translateLabel(lastKey, schema.title);
const help = hint?.help ?? translateHelp(lastKey, schema.description);
const fallback = value ?? schema.default;
const obj = fallback && typeof fallback === "object" && !Array.isArray(fallback)
@ -480,26 +514,31 @@ function renderArray(params: {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const lastKey = String(path.at(-1));
const label = hint?.label ?? translateLabel(lastKey, schema.title);
const help = hint?.help ?? translateHelp(lastKey, schema.description);
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
return html`
<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported array schema. Use Raw mode.</div>
<div class="cfg-field__error">${t("config.field.unsupportedArray")}</div>
</div>
`;
}
const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : [];
const countText =
arr.length === 1
? t("config.array.count.one")
: t("config.array.count.many", { count: arr.length });
return html`
<div class="cfg-array">
<div class="cfg-array__header">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? 's' : ''}</span>
<span class="cfg-array__count">${countText}</span>
<button
type="button"
class="cfg-array__add"
@ -510,14 +549,14 @@ function renderArray(params: {
}}
>
<span class="cfg-array__add-icon">${icons.plus}</span>
Add
${t("config.array.add")}
</button>
</div>
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
${arr.length === 0 ? html`
<div class="cfg-array__empty">
No items yet. Click "Add" to create one.
${t("config.array.empty")}
</div>
` : html`
<div class="cfg-array__items">
@ -528,7 +567,7 @@ function renderArray(params: {
<button
type="button"
class="cfg-array__item-remove"
title="Remove item"
title=${t("config.array.removeItem")}
?disabled=${disabled}
@click=${() => {
const next = [...arr];
@ -576,7 +615,7 @@ function renderMapField(params: {
return html`
<div class="cfg-map">
<div class="cfg-map__header">
<span class="cfg-map__label">Custom entries</span>
<span class="cfg-map__label">${t("config.map.title")}</span>
<button
type="button"
class="cfg-map__add"
@ -594,12 +633,12 @@ function renderMapField(params: {
}}
>
<span class="cfg-map__add-icon">${icons.plus}</span>
Add Entry
${t("config.map.addEntry")}
</button>
</div>
${entries.length === 0 ? html`
<div class="cfg-map__empty">No custom entries.</div>
<div class="cfg-map__empty">${t("config.map.empty")}</div>
` : html`
<div class="cfg-map__items">
${entries.map(([key, entryValue]) => {
@ -611,7 +650,7 @@ function renderMapField(params: {
<input
type="text"
class="cfg-input cfg-input--sm"
placeholder="Key"
placeholder=${t("config.map.keyPlaceholder")}
.value=${key}
?disabled=${disabled}
@change=${(e: Event) => {
@ -630,7 +669,7 @@ function renderMapField(params: {
? html`
<textarea
class="cfg-textarea cfg-textarea--sm"
placeholder="JSON value"
placeholder=${t("config.map.jsonPlaceholder")}
rows="2"
.value=${fallback}
?disabled=${disabled}
@ -663,7 +702,7 @@ function renderMapField(params: {
<button
type="button"
class="cfg-map__item-remove"
title="Remove entry"
title=${t("config.map.removeEntry")}
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };

View File

@ -1,10 +1,13 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { icons } from "../icons";
import { t } from "../i18n";
import {
hintForPath,
humanize,
schemaType,
translateHelp,
translateLabel,
type JsonSchema,
} from "./config-form.shared";
import { renderNode } from "./config-form.node";
@ -55,35 +58,38 @@ const sectionIcons = {
};
// Section metadata
export const SECTION_META: Record<string, { label: string; description: string }> = {
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
update: { label: "Updates", description: "Auto-update settings and release channel" },
agents: { label: "Agents", description: "Agent configurations, models, and identities" },
auth: { label: "Authentication", description: "API keys and authentication profiles" },
channels: { label: "Channels", description: "Messaging channels (Telegram, Discord, Slack, etc.)" },
messages: { label: "Messages", description: "Message handling and routing settings" },
commands: { label: "Commands", description: "Custom slash commands" },
hooks: { label: "Hooks", description: "Webhooks and event hooks" },
skills: { label: "Skills", description: "Skill packs and capabilities" },
tools: { label: "Tools", description: "Tool configurations (browser, search, etc.)" },
gateway: { label: "Gateway", description: "Gateway server settings (port, auth, binding)" },
wizard: { label: "Setup Wizard", description: "Setup wizard state and history" },
// Additional sections
meta: { label: "Metadata", description: "Gateway metadata and version information" },
logging: { label: "Logging", description: "Log levels and output configuration" },
browser: { label: "Browser", description: "Browser automation settings" },
ui: { label: "UI", description: "User interface preferences" },
models: { label: "Models", description: "AI model configurations and providers" },
bindings: { label: "Bindings", description: "Key bindings and shortcuts" },
broadcast: { label: "Broadcast", description: "Broadcast and notification settings" },
audio: { label: "Audio", description: "Audio input/output settings" },
session: { label: "Session", description: "Session management and persistence" },
cron: { label: "Cron", description: "Scheduled tasks and automation" },
web: { label: "Web", description: "Web server and API settings" },
discovery: { label: "Discovery", description: "Service discovery and networking" },
canvasHost: { label: "Canvas Host", description: "Canvas rendering and display" },
talk: { label: "Talk", description: "Voice and speech settings" },
plugins: { label: "Plugins", description: "Plugin management and extensions" },
export const SECTION_META: Record<string, { labelKey: string; descriptionKey: string }> = {
env: { labelKey: "config.meta.env.label", descriptionKey: "config.meta.env.desc" },
update: { labelKey: "config.meta.update.label", descriptionKey: "config.meta.update.desc" },
agents: { labelKey: "config.meta.agents.label", descriptionKey: "config.meta.agents.desc" },
auth: { labelKey: "config.meta.auth.label", descriptionKey: "config.meta.auth.desc" },
channels: { labelKey: "config.meta.channels.label", descriptionKey: "config.meta.channels.desc" },
messages: { labelKey: "config.meta.messages.label", descriptionKey: "config.meta.messages.desc" },
commands: { labelKey: "config.meta.commands.label", descriptionKey: "config.meta.commands.desc" },
hooks: { labelKey: "config.meta.hooks.label", descriptionKey: "config.meta.hooks.desc" },
skills: { labelKey: "config.meta.skills.label", descriptionKey: "config.meta.skills.desc" },
tools: { labelKey: "config.meta.tools.label", descriptionKey: "config.meta.tools.desc" },
gateway: { labelKey: "config.meta.gateway.label", descriptionKey: "config.meta.gateway.desc" },
wizard: { labelKey: "config.meta.wizard.label", descriptionKey: "config.meta.wizard.desc" },
meta: { labelKey: "config.meta.meta.label", descriptionKey: "config.meta.meta.desc" },
logging: { labelKey: "config.meta.logging.label", descriptionKey: "config.meta.logging.desc" },
browser: { labelKey: "config.meta.browser.label", descriptionKey: "config.meta.browser.desc" },
ui: { labelKey: "config.meta.ui.label", descriptionKey: "config.meta.ui.desc" },
models: { labelKey: "config.meta.models.label", descriptionKey: "config.meta.models.desc" },
diagnostics: { labelKey: "config.meta.diagnostics.label", descriptionKey: "config.meta.diagnostics.desc" },
bindings: { labelKey: "config.meta.bindings.label", descriptionKey: "config.meta.bindings.desc" },
broadcast: { labelKey: "config.meta.broadcast.label", descriptionKey: "config.meta.broadcast.desc" },
audio: { labelKey: "config.meta.audio.label", descriptionKey: "config.meta.audio.desc" },
media: { labelKey: "config.meta.media.label", descriptionKey: "config.meta.media.desc" },
session: { labelKey: "config.meta.session.label", descriptionKey: "config.meta.session.desc" },
cron: { labelKey: "config.meta.cron.label", descriptionKey: "config.meta.cron.desc" },
web: { labelKey: "config.meta.web.label", descriptionKey: "config.meta.web.desc" },
discovery: { labelKey: "config.meta.discovery.label", descriptionKey: "config.meta.discovery.desc" },
canvasHost: { labelKey: "config.meta.canvasHost.label", descriptionKey: "config.meta.canvasHost.desc" },
talk: { labelKey: "config.meta.talk.label", descriptionKey: "config.meta.talk.desc" },
plugins: { labelKey: "config.meta.plugins.label", descriptionKey: "config.meta.plugins.desc" },
approvals: { labelKey: "config.meta.approvals.label", descriptionKey: "config.meta.approvals.desc" },
nodeHost: { labelKey: "config.meta.nodeHost.label", descriptionKey: "config.meta.nodeHost.desc" },
};
function getSectionIcon(key: string) {
@ -100,8 +106,8 @@ function matchesSearch(key: string, schema: JsonSchema, query: string): boolean
// Check label and description
if (meta) {
if (meta.label.toLowerCase().includes(q)) return true;
if (meta.description.toLowerCase().includes(q)) return true;
if (t(meta.labelKey).toLowerCase().includes(q)) return true;
if (t(meta.descriptionKey).toLowerCase().includes(q)) return true;
}
return schemaMatches(schema, q);
@ -142,12 +148,12 @@ function schemaMatches(schema: JsonSchema, query: string): boolean {
export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) {
return html`<div class="muted">Schema unavailable.</div>`;
return html`<div class="muted">${t("config.schemaUnavailable")}</div>`;
}
const schema = props.schema;
const value = props.value ?? {};
if (schemaType(schema) !== "object" || !schema.properties) {
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`;
return html`<div class="callout danger">${t("config.unsupportedSchema")}</div>`;
}
const unsupported = new Set(props.unsupportedPaths ?? []);
const properties = schema.properties;
@ -193,8 +199,8 @@ export function renderConfigForm(props: ConfigFormProps) {
<div class="config-empty__icon">${icons.search}</div>
<div class="config-empty__text">
${searchQuery
? `No settings match "${searchQuery}"`
: "No settings in this section"}
? t("config.empty.search", { query: searchQuery })
: t("config.empty.section")}
</div>
</div>
`;
@ -206,7 +212,7 @@ export function renderConfigForm(props: ConfigFormProps) {
? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const label = hint?.label ?? translateLabel(subsectionKey, node.title);
const description = hint?.help ?? node.description ?? "";
const sectionValue = (value as Record<string, unknown>)[sectionKey];
const scopedValue =
@ -241,19 +247,18 @@ export function renderConfigForm(props: ConfigFormProps) {
`;
})()
: filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
const meta = SECTION_META[key];
const label = meta ? t(meta.labelKey) : key.charAt(0).toUpperCase() + key.slice(1);
const description = meta ? t(meta.descriptionKey) : (node.description ?? "");
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description
? html`<p class="config-section-card__desc">${meta.description}</p>`
<h3 class="config-section-card__title">${label}</h3>
${description
? html`<p class="config-section-card__desc">${description}</p>`
: nothing}
</div>
</div>

View File

@ -1,5 +1,7 @@
import type { ConfigUiHints } from "../types";
import { t } from "../i18n";
export type JsonSchema = {
type?: string | string[];
title?: string;
@ -79,6 +81,32 @@ export function humanize(raw: string) {
.replace(/^./, (m) => m.toUpperCase());
}
export function translateLabel(key: string, schemaTitle?: string): string {
const tKey = `config.label.${key}`;
const translated = t(tKey);
if (translated !== tKey) return translated;
// Try lowercase
const tKeyLower = `config.label.${key.toLowerCase()}`;
const translatedLower = t(tKeyLower);
if (translatedLower !== tKeyLower) return translatedLower;
return schemaTitle ?? humanize(key);
}
export function translateHelp(key: string, schemaDescription?: string): string | undefined {
const tKey = `config.help.${key}`;
const translated = t(tKey);
if (translated !== tKey) return translated;
// Try lowercase
const tKeyLower = `config.help.${key.toLowerCase()}`;
const translatedLower = t(tKeyLower);
if (translatedLower !== tKeyLower) return translatedLower;
return schemaDescription;
}
export function isSensitivePath(path: Array<string | number>): boolean {
const key = pathKey(path).toLowerCase();
return (

View File

@ -0,0 +1,142 @@
import type { ConfigUiHints } from "../types";
import { t } from "../i18n";
export function configUiHintOverrides(): ConfigUiHints {
return {
diagnostics: {
label: t("config.meta.diagnostics.label"),
help: t("config.meta.diagnostics.desc"),
},
"diagnostics.cacheTrace": { label: t("config.hints.diagnostics.cacheTrace.label") },
"diagnostics.enabled": {
label: t("config.hints.diagnostics.enabled.label"),
help: t("config.hints.diagnostics.enabled.help"),
},
"diagnostics.flags": {
label: t("config.hints.diagnostics.flags.label"),
help: t("config.hints.diagnostics.flags.help"),
},
"diagnostics.otel": { label: t("config.hints.diagnostics.otel.label") },
"diagnostics.cacheTrace.enabled": {
label: t("config.hints.diagnostics.cacheTrace.enabled.label"),
help: t("config.hints.diagnostics.cacheTrace.enabled.help"),
},
"diagnostics.cacheTrace.filePath": {
label: t("config.hints.diagnostics.cacheTrace.filePath.label"),
help: t("config.hints.diagnostics.cacheTrace.filePath.help"),
},
"diagnostics.cacheTrace.includeMessages": {
label: t("config.hints.diagnostics.cacheTrace.includeMessages.label"),
help: t("config.hints.diagnostics.cacheTrace.includeMessages.help"),
},
"diagnostics.cacheTrace.includePrompt": {
label: t("config.hints.diagnostics.cacheTrace.includePrompt.label"),
help: t("config.hints.diagnostics.cacheTrace.includePrompt.help"),
},
"diagnostics.cacheTrace.includeSystem": {
label: t("config.hints.diagnostics.cacheTrace.includeSystem.label"),
help: t("config.hints.diagnostics.cacheTrace.includeSystem.help"),
},
"diagnostics.otel.enabled": { label: t("config.hints.diagnostics.otel.enabled") },
"diagnostics.otel.endpoint": { label: t("config.hints.diagnostics.otel.endpoint") },
"diagnostics.otel.protocol": { label: t("config.hints.diagnostics.otel.protocol") },
"diagnostics.otel.headers": { label: t("config.hints.diagnostics.otel.headers") },
"diagnostics.otel.serviceName": { label: t("config.hints.diagnostics.otel.serviceName") },
"diagnostics.otel.traces": { label: t("config.hints.diagnostics.otel.traces") },
"diagnostics.otel.metrics": { label: t("config.hints.diagnostics.otel.metrics") },
"diagnostics.otel.logs": { label: t("config.hints.diagnostics.otel.logs") },
"diagnostics.otel.sampleRate": { label: t("config.hints.diagnostics.otel.sampleRate") },
"diagnostics.otel.flushIntervalMs": { label: t("config.hints.diagnostics.otel.flushIntervalMs") },
approvals: { label: t("config.meta.approvals.label"), help: t("config.meta.approvals.desc") },
"approvals.exec": { label: t("config.hints.approvals.exec.label") },
"approvals.exec.agentFilter": { label: t("config.hints.approvals.exec.agentFilter") },
"approvals.exec.enabled": { label: t("config.hints.approvals.exec.enabled") },
"approvals.exec.mode": {
label: t("config.hints.approvals.exec.mode"),
itemTemplate: {
optionLabels: {
session: t("config.options.approvals.exec.mode.session"),
targets: t("config.options.approvals.exec.mode.targets"),
both: t("config.options.approvals.exec.mode.both"),
},
},
},
"approvals.exec.sessionFilter": { label: t("config.hints.approvals.exec.sessionFilter") },
"approvals.exec.targets": { label: t("config.hints.approvals.exec.targets") },
"approvals.exec.targets.channel": { label: t("config.hints.approvals.exec.targets.channel") },
"approvals.exec.targets.to": { label: t("config.hints.approvals.exec.targets.to") },
"approvals.exec.targets.accountId": { label: t("config.hints.approvals.exec.targets.accountId") },
"approvals.exec.targets.threadId": { label: t("config.hints.approvals.exec.targets.threadId") },
media: { label: t("config.meta.media.label"), help: t("config.meta.media.desc") },
"media.preserveFilenames": {
label: t("config.hints.media.preserveFilenames.label"),
help: t("config.hints.media.preserveFilenames.help"),
},
// Gateway section and subsections
gateway: { label: "网关", help: "网关服务器设置(端口、认证、绑定)" },
"gateway.auth": { label: "认证" },
"gateway.bind": { label: "绑定" },
"gateway.controlUi": { label: "控制界面" },
"gateway.http": { label: "HTTP" },
"gateway.mode": { label: "模式" },
"gateway.nodes": { label: "节点" },
"gateway.port": { label: "端口" },
"gateway.reload": { label: "重新加载" },
"gateway.remote": { label: "远程" },
"gateway.tailscale": { label: "Tailscale" },
"gateway.tls": { label: "TLS" },
"gateway.trustedProxies": { label: "信任代理" },
// Other top-level sections
env: { label: "环境", help: "环境变量与全局配置" },
update: { label: "更新", help: "系统更新与版本管理" },
agents: { label: "智能体", help: "AI 智能体配置与默认设置" },
channels: { label: "渠道", help: "消息平台连接配置Telegram, Discord 等)" },
messages: { label: "消息", help: "消息处理与响应设置" },
commands: { label: "命令", help: "系统与聊天命令权限管理" },
hooks: { label: "钩子", help: "系统事件钩子配置" },
skills: { label: "技能", help: "智能体可用技能管理" },
tools: { label: "工具", help: "外部工具集成配置" },
wizard: { label: "安装向导", help: "初次运行安装向导设置" },
logging: { label: "日志", help: "系统日志与审计记录配置" },
browser: { label: "浏览器", help: "自动化浏览器与快照设置" },
ui: { label: "界面", help: "控制台界面外观与主题设置" },
models: { label: "模型", help: "AI 模型供应商与端点配置" },
bindings: { label: "按键绑定", help: "界面快捷键映射" },
broadcast: { label: "广播", help: "系统广播与通知设置" },
audio: { label: "音频", help: "语音输出与音频处理设置" },
session: { label: "会话", help: "用户会话与上下文管理" },
cron: { label: "定时任务", help: "计划任务与定时执行配置" },
web: { label: "Web 服务", help: "Web 访问与公开端点设置" },
discovery: { label: "服务发现", help: "网络发现与 mDNS 设置" },
canvasHost: { label: "Canvas 宿主", help: "Canvas 渲染与展示设置" },
talk: { label: "对话", help: "语音识别与交互设置" },
plugins: { label: "插件", help: "系统插件管理与扩展" },
nodeHost: { label: "节点宿主", help: "节点连接与代理管理" },
// Gateway Auth fields
"gateway.auth.allowTailscale": { label: "允许 Tailscale" },
"gateway.auth.mode": {
label: "模式",
itemTemplate: {
optionLabels: {
token: "Token",
password: "密码",
},
},
},
"gateway.auth.password": {
label: "网关密码",
help: "使用 Tailscale Funnel 时必填。",
},
"gateway.auth.token": {
label: "网关 Token",
help: "访问网关默认必填(除非使用 Tailscale Serve 身份);非回环绑定必填。",
},
};
}

View File

@ -1,12 +1,16 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import { configUiHintOverrides } from "./config-uihints.overrides";
import {
hintForPath,
humanize,
schemaType,
translateHelp,
translateLabel,
type JsonSchema,
} from "./config-form.shared";
import { t } from "../i18n";
export type ConfigProps = {
raw: string;
@ -73,20 +77,26 @@ const sidebarIcons = {
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
};
// Section definitions
const SECTIONS: Array<{ key: string; label: string }> = [
{ key: "env", label: "Environment" },
{ key: "update", label: "Updates" },
{ key: "agents", label: "Agents" },
{ key: "auth", label: "Authentication" },
{ key: "channels", label: "Channels" },
{ key: "messages", label: "Messages" },
{ key: "commands", label: "Commands" },
{ key: "hooks", label: "Hooks" },
{ key: "skills", label: "Skills" },
{ key: "tools", label: "Tools" },
{ key: "gateway", label: "Gateway" },
{ key: "wizard", label: "Setup Wizard" },
const SECTIONS: Array<{ key: string; labelKey: string }> = [
{ key: "env", labelKey: "config.section.env" },
{ key: "update", labelKey: "config.section.update" },
{ key: "agents", labelKey: "config.section.agents" },
{ key: "auth", labelKey: "config.section.auth" },
{ key: "channels", labelKey: "config.section.channels" },
{ key: "messages", labelKey: "config.section.messages" },
{ key: "commands", labelKey: "config.section.commands" },
{ key: "hooks", labelKey: "config.section.hooks" },
{ key: "skills", labelKey: "config.section.skills" },
{ key: "tools", labelKey: "config.section.tools" },
{ key: "gateway", labelKey: "config.section.gateway" },
{ key: "wizard", labelKey: "config.section.wizard" },
{ key: "web", labelKey: "config.section.web" },
{ key: "discovery", labelKey: "config.section.discovery" },
{ key: "canvasHost", labelKey: "config.section.canvasHost" },
{ key: "talk", labelKey: "config.section.talk" },
{ key: "plugins", labelKey: "config.section.plugins" },
{ key: "nodeHost", labelKey: "config.section.nodeHost" },
{ key: "approvals", labelKey: "config.section.approvals" },
];
type SubsectionEntry = {
@ -107,13 +117,26 @@ function resolveSectionMeta(key: string, schema?: JsonSchema): {
description?: string;
} {
const meta = SECTION_META[key];
if (meta) return meta;
if (meta) {
return {
label: t(meta.labelKey),
description: t(meta.descriptionKey),
};
}
return {
label: schema?.title ?? humanize(key),
label: translateLabel(key, schema?.title),
description: schema?.description ?? "",
};
}
function mergeUiHints(base: ConfigUiHints, overrides: ConfigUiHints): ConfigUiHints {
const merged: ConfigUiHints = { ...base };
for (const [key, patch] of Object.entries(overrides)) {
merged[key] = { ...(merged[key] ?? {}), ...(patch ?? {}) };
}
return merged;
}
function resolveSubsections(params: {
key: string;
schema: JsonSchema | undefined;
@ -123,7 +146,7 @@ function resolveSubsections(params: {
if (!schema || schemaType(schema) !== "object" || !schema.properties) return [];
const entries = Object.entries(schema.properties).map(([subKey, node]) => {
const hint = hintForPath([key, subKey], uiHints);
const label = hint?.label ?? node.title ?? humanize(subKey);
const label = hint?.label ?? translateLabel(subKey, node.title);
const description = hint?.help ?? node.description ?? "";
const order = hint?.order ?? 50;
return { key: subKey, label, description, order };
@ -182,8 +205,10 @@ function truncateValue(value: unknown, maxLen = 40): string {
}
export function renderConfig(props: ConfigProps) {
const uiHints = mergeUiHints(props.uiHints, configUiHintOverrides());
const validity =
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const validityLabel = t(`config.validity.${validity}`);
const analysis = analyzeConfigSchema(props.schema);
const formUnsafe = analysis.schema
? analysis.unsupportedPaths.length > 0
@ -197,7 +222,10 @@ export function renderConfig(props: ConfigProps) {
const knownKeys = new Set(SECTIONS.map(s => s.key));
const extraSections = Object.keys(schemaProps)
.filter(k => !knownKeys.has(k))
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
.map((key) => ({
key,
label: resolveSectionMeta(key, schemaProps[key] as JsonSchema | undefined).label,
}));
const allSections = [...availableSections, ...extraSections];
@ -212,7 +240,7 @@ export function renderConfig(props: ConfigProps) {
? resolveSubsections({
key: props.activeSection,
schema: activeSectionSchema,
uiHints: props.uiHints,
uiHints,
})
: [];
const allowSubnav =
@ -255,8 +283,8 @@ export function renderConfig(props: ConfigProps) {
<!-- Sidebar -->
<aside class="config-sidebar">
<div class="config-sidebar__header">
<div class="config-sidebar__title">Settings</div>
<span class="pill pill--sm ${validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""}">${validity}</span>
<div class="config-sidebar__title">${t("config.sidebar.title")}</div>
<span class="pill pill--sm ${validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""}">${validityLabel}</span>
</div>
<!-- Search -->
@ -268,7 +296,7 @@ export function renderConfig(props: ConfigProps) {
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
placeholder=${t("config.search.placeholder")}
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
@ -287,7 +315,7 @@ export function renderConfig(props: ConfigProps) {
@click=${() => props.onSectionChange(null)}
>
<span class="config-nav__icon">${sidebarIcons.all}</span>
<span class="config-nav__label">All Settings</span>
<span class="config-nav__label">${t("config.section.all")}</span>
</button>
${allSections.map(section => html`
<button
@ -295,7 +323,7 @@ export function renderConfig(props: ConfigProps) {
@click=${() => props.onSectionChange(section.key)}
>
<span class="config-nav__icon">${getSectionIcon(section.key)}</span>
<span class="config-nav__label">${section.label}</span>
<span class="config-nav__label">${"labelKey" in section ? t(section.labelKey) : section.label}</span>
</button>
`)}
</nav>
@ -308,13 +336,13 @@ export function renderConfig(props: ConfigProps) {
?disabled=${props.schemaLoading || !props.schema}
@click=${() => props.onFormModeChange("form")}
>
Form
${t("config.mode.form")}
</button>
<button
class="config-mode-toggle__btn ${props.formMode === "raw" ? "active" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
${t("config.mode.raw")}
</button>
</div>
</div>
@ -325,36 +353,42 @@ export function renderConfig(props: ConfigProps) {
<!-- Action bar -->
<div class="config-actions">
<div class="config-actions__left">
${hasChanges ? html`
<span class="config-changes-badge">${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span>
` : html`
<span class="config-status muted">No changes</span>
`}
${hasChanges
? html`
<span class="config-changes-badge">
${props.formMode === "raw"
? t("config.changes.raw")
: diff.length === 1
? t("config.changes.form.one")
: t("config.changes.form.many", { count: diff.length })}
</span>
`
: html`<span class="config-status muted">${t("config.changes.none")}</span>`}
</div>
<div class="config-actions__right">
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? "Loading…" : "Reload"}
${props.loading ? t("common.loading") : t("config.reload")}
</button>
<button
class="btn btn--sm primary"
?disabled=${!canSave}
@click=${props.onSave}
>
${props.saving ? "Saving…" : "Save"}
${props.saving ? t("common.loading") : t("common.save")}
</button>
<button
class="btn btn--sm"
?disabled=${!canApply}
@click=${props.onApply}
>
${props.applying ? "Applying…" : "Apply"}
${props.applying ? t("common.loading") : t("common.apply")}
</button>
<button
class="btn btn--sm"
?disabled=${!canUpdate}
@click=${props.onUpdate}
>
${props.updating ? "Updating…" : "Update"}
${props.updating ? t("common.loading") : t("config.update")}
</button>
</div>
</div>
@ -363,7 +397,11 @@ export function renderConfig(props: ConfigProps) {
${hasChanges && props.formMode === "form" ? html`
<details class="config-diff">
<summary class="config-diff__summary">
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>
<span>
${diff.length === 1
? t("config.diff.view.one")
: t("config.diff.view.many", { count: diff.length })}
</span>
<svg class="config-diff__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
@ -404,7 +442,7 @@ export function renderConfig(props: ConfigProps) {
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
@click=${() => props.onSubsectionChange(ALL_SUBSECTION)}
>
All
${t("config.subsection.all")}
</button>
${subsections.map(
(entry) => html`
@ -430,11 +468,11 @@ export function renderConfig(props: ConfigProps) {
${props.schemaLoading
? html`<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema</span>
<span>${t("config.loadingSchema")}</span>
</div>`
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
uiHints,
value: props.formValue,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
@ -445,14 +483,13 @@ export function renderConfig(props: ConfigProps) {
})}
${formUnsafe
? html`<div class="callout danger" style="margin-top: 12px;">
Form view can't safely edit some fields.
Use Raw to avoid losing config entries.
${t("config.formUnsafe")}
</div>`
: nothing}
`
: html`
<label class="field config-raw-field">
<span>Raw JSON5</span>
<span>${t("config.rawJson5")}</span>
<textarea
.value=${props.raw}
@input=${(e: Event) =>

View File

@ -1,11 +1,12 @@
import { html, nothing } from "lit";
import { formatMs } from "../format";
import { t } from "../i18n";
import {
formatCronPayload,
formatCronSchedule,
formatCronState,
formatNextRun,
presentCronPayload,
presentCronSchedule,
presentCronStatus,
} from "../presenter";
import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types";
import type { CronFormState } from "../ui-types";
@ -57,42 +58,42 @@ export function renderCron(props: CronProps) {
return html`
<section class="grid grid-cols-2">
<div class="card">
<div class="card-title">Scheduler</div>
<div class="card-sub">Gateway-owned cron scheduler status.</div>
<div class="card-title">${t("cron.scheduler.title")}</div>
<div class="card-sub">${t("cron.scheduler.subtitle")}</div>
<div class="stat-grid" style="margin-top: 16px;">
<div class="stat">
<div class="stat-label">Enabled</div>
<div class="stat-label">${t("cron.scheduler.enabled")}</div>
<div class="stat-value">
${props.status
? props.status.enabled
? "Yes"
: "No"
: "n/a"}
? t("common.yes")
: t("common.no")
: t("common.na")}
</div>
</div>
<div class="stat">
<div class="stat-label">Jobs</div>
<div class="stat-value">${props.status?.jobs ?? "n/a"}</div>
<div class="stat-label">${t("cron.scheduler.jobs")}</div>
<div class="stat-value">${props.status?.jobs ?? t("common.na")}</div>
</div>
<div class="stat">
<div class="stat-label">Next wake</div>
<div class="stat-label">${t("cron.scheduler.nextWake")}</div>
<div class="stat-value">${formatNextRun(props.status?.nextWakeAtMs ?? null)}</div>
</div>
</div>
<div class="row" style="margin-top: 12px;">
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Refreshing…" : "Refresh"}
${props.loading ? t("cron.refreshing") : t("common.refresh")}
</button>
${props.error ? html`<span class="muted">${props.error}</span>` : nothing}
</div>
</div>
<div class="card">
<div class="card-title">New Job</div>
<div class="card-sub">Create a scheduled wakeup or agent run.</div>
<div class="card-title">${t("cron.newJob.title")}</div>
<div class="card-sub">${t("cron.newJob.subtitle")}</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Name</span>
<span>${t("cron.newJob.name")}</span>
<input
.value=${props.form.name}
@input=${(e: Event) =>
@ -100,7 +101,7 @@ export function renderCron(props: CronProps) {
/>
</label>
<label class="field">
<span>Description</span>
<span>${t("cron.newJob.description")}</span>
<input
.value=${props.form.description}
@input=${(e: Event) =>
@ -108,7 +109,7 @@ export function renderCron(props: CronProps) {
/>
</label>
<label class="field">
<span>Agent ID</span>
<span>${t("cron.newJob.agentId")}</span>
<input
.value=${props.form.agentId}
@input=${(e: Event) =>
@ -117,7 +118,7 @@ export function renderCron(props: CronProps) {
/>
</label>
<label class="field checkbox">
<span>Enabled</span>
<span>${t("cron.newJob.enabled")}</span>
<input
type="checkbox"
.checked=${props.form.enabled}
@ -126,7 +127,7 @@ export function renderCron(props: CronProps) {
/>
</label>
<label class="field">
<span>Schedule</span>
<span>${t("cron.newJob.schedule")}</span>
<select
.value=${props.form.scheduleKind}
@change=${(e: Event) =>
@ -134,16 +135,16 @@ export function renderCron(props: CronProps) {
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
})}
>
<option value="every">Every</option>
<option value="at">At</option>
<option value="cron">Cron</option>
<option value="every">${t("cron.schedule.every")}</option>
<option value="at">${t("cron.schedule.at")}</option>
<option value="cron">${t("cron.schedule.cron")}</option>
</select>
</label>
</div>
${renderScheduleFields(props)}
<div class="form-grid" style="margin-top: 12px;">
<label class="field">
<span>Session</span>
<span>${t("cron.newJob.session")}</span>
<select
.value=${props.form.sessionTarget}
@change=${(e: Event) =>
@ -151,12 +152,12 @@ export function renderCron(props: CronProps) {
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
})}
>
<option value="main">Main</option>
<option value="isolated">Isolated</option>
<option value="main">${t("cron.session.main")}</option>
<option value="isolated">${t("cron.session.isolated")}</option>
</select>
</label>
<label class="field">
<span>Wake mode</span>
<span>${t("cron.newJob.wakeMode")}</span>
<select
.value=${props.form.wakeMode}
@change=${(e: Event) =>
@ -164,12 +165,12 @@ export function renderCron(props: CronProps) {
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
})}
>
<option value="next-heartbeat">Next heartbeat</option>
<option value="now">Now</option>
<option value="next-heartbeat">${t("cron.wakeMode.nextHeartbeat")}</option>
<option value="now">${t("cron.wakeMode.now")}</option>
</select>
</label>
<label class="field">
<span>Payload</span>
<span>${t("cron.newJob.payload")}</span>
<select
.value=${props.form.payloadKind}
@change=${(e: Event) =>
@ -177,13 +178,13 @@ export function renderCron(props: CronProps) {
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
})}
>
<option value="systemEvent">System event</option>
<option value="agentTurn">Agent turn</option>
<option value="systemEvent">${t("cron.payload.systemEvent")}</option>
<option value="agentTurn">${t("cron.payload.agentTurn")}</option>
</select>
</label>
</div>
<label class="field" style="margin-top: 12px;">
<span>${props.form.payloadKind === "systemEvent" ? "System text" : "Agent message"}</span>
<span>${props.form.payloadKind === "systemEvent" ? t("cron.newJob.systemText") : t("cron.newJob.agentMessage")}</span>
<textarea
.value=${props.form.payloadText}
@input=${(e: Event) =>
@ -193,11 +194,11 @@ export function renderCron(props: CronProps) {
rows="4"
></textarea>
</label>
${props.form.payloadKind === "agentTurn"
? html`
<div class="form-grid" style="margin-top: 12px;">
${props.form.payloadKind === "agentTurn"
? html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field checkbox">
<span>Deliver</span>
<span>${t("cron.newJob.deliver")}</span>
<input
type="checkbox"
.checked=${props.form.deliver}
@ -206,35 +207,35 @@ export function renderCron(props: CronProps) {
deliver: (e.target as HTMLInputElement).checked,
})}
/>
</label>
<label class="field">
<span>Channel</span>
<select
.value=${props.form.channel || "last"}
@change=${(e: Event) =>
props.onFormChange({
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
})}
>
${channelOptions.map(
(channel) =>
html`<option value=${channel}>
${resolveChannelLabel(props, channel)}
</option>`,
)}
</label>
<label class="field">
<span>${t("cron.newJob.channel")}</span>
<select
.value=${props.form.channel || "last"}
@change=${(e: Event) =>
props.onFormChange({
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
})}
>
${channelOptions.map(
(channel) =>
html`<option value=${channel}>
${resolveChannelLabel(props, channel)}
</option>`,
)}
</select>
</label>
<label class="field">
<span>To</span>
<span>${t("cron.newJob.to")}</span>
<input
.value=${props.form.to}
@input=${(e: Event) =>
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
placeholder="+1555… or chat id"
placeholder=${t("cron.newJob.toPlaceholder")}
/>
</label>
<label class="field">
<span>Timeout (seconds)</span>
<span>${t("cron.newJob.timeoutSeconds")}</span>
<input
.value=${props.form.timeoutSeconds}
@input=${(e: Event) =>
@ -246,7 +247,7 @@ export function renderCron(props: CronProps) {
${props.form.sessionTarget === "isolated"
? html`
<label class="field">
<span>Post to main prefix</span>
<span>${t("cron.newJob.postToMainPrefix")}</span>
<input
.value=${props.form.postToMainPrefix}
@input=${(e: Event) =>
@ -262,17 +263,17 @@ export function renderCron(props: CronProps) {
: nothing}
<div class="row" style="margin-top: 14px;">
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
${props.busy ? "Saving…" : "Add job"}
${props.busy ? t("common.loading") : t("cron.newJob.addJob")}
</button>
</div>
</div>
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Jobs</div>
<div class="card-sub">All scheduled jobs stored in the gateway.</div>
<div class="card-title">${t("cron.jobs.title")}</div>
<div class="card-sub">${t("cron.jobs.subtitle")}</div>
${props.jobs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
? html`<div class="muted" style="margin-top: 12px;">${t("cron.jobs.empty")}</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.jobs.map((job) => renderJob(job, props))}
@ -281,16 +282,20 @@ export function renderCron(props: CronProps) {
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Run history</div>
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
<div class="card-title">${t("cron.runs.title")}</div>
<div class="card-sub">
${props.runsJobId
? t("cron.runs.subtitleFor", { jobId: props.runsJobId })
: t("cron.runs.subtitle")}
</div>
${props.runsJobId == null
? html`
<div class="muted" style="margin-top: 12px;">
Select a job to inspect run history.
${t("cron.runs.selectJob")}
</div>
`
: props.runs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
? html`<div class="muted" style="margin-top: 12px;">${t("cron.runs.empty")}</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.runs.map((entry) => renderRun(entry))}
@ -305,7 +310,7 @@ function renderScheduleFields(props: CronProps) {
if (form.scheduleKind === "at") {
return html`
<label class="field" style="margin-top: 12px;">
<span>Run at</span>
<span>${t("cron.schedule.runAt")}</span>
<input
type="datetime-local"
.value=${form.scheduleAt}
@ -321,7 +326,7 @@ function renderScheduleFields(props: CronProps) {
return html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field">
<span>Every</span>
<span>${t("cron.schedule.everyLabel")}</span>
<input
.value=${form.everyAmount}
@input=${(e: Event) =>
@ -331,7 +336,7 @@ function renderScheduleFields(props: CronProps) {
/>
</label>
<label class="field">
<span>Unit</span>
<span>${t("cron.schedule.unit")}</span>
<select
.value=${form.everyUnit}
@change=${(e: Event) =>
@ -339,9 +344,9 @@ function renderScheduleFields(props: CronProps) {
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
})}
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="minutes">${t("cron.schedule.unit.minutes")}</option>
<option value="hours">${t("cron.schedule.unit.hours")}</option>
<option value="days">${t("cron.schedule.unit.days")}</option>
</select>
</label>
</div>
@ -350,7 +355,7 @@ function renderScheduleFields(props: CronProps) {
return html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field">
<span>Expression</span>
<span>${t("cron.schedule.expression")}</span>
<input
.value=${form.cronExpr}
@input=${(e: Event) =>
@ -358,7 +363,7 @@ function renderScheduleFields(props: CronProps) {
/>
</label>
<label class="field">
<span>Timezone (optional)</span>
<span>${t("cron.schedule.timezoneOptional")}</span>
<input
.value=${form.cronTz}
@input=${(e: Event) =>
@ -376,17 +381,17 @@ function renderJob(job: CronJob, props: CronProps) {
<div class=${itemClass} @click=${() => props.onLoadRuns(job.id)}>
<div class="list-main">
<div class="list-title">${job.name}</div>
<div class="list-sub">${formatCronSchedule(job)}</div>
<div class="muted">${formatCronPayload(job)}</div>
<div class="list-sub">${presentCronSchedule(job)}</div>
<div class="muted">${presentCronPayload(job)}</div>
${job.agentId ? html`<div class="muted">Agent: ${job.agentId}</div>` : nothing}
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${job.enabled ? "enabled" : "disabled"}</span>
<span class="chip">${job.enabled ? t("common.enabled") : t("common.disabled")}</span>
<span class="chip">${job.sessionTarget}</span>
<span class="chip">${job.wakeMode}</span>
</div>
</div>
<div class="list-meta">
<div>${formatCronState(job)}</div>
<div>${presentCronStatus(job)}</div>
<div class="row" style="justify-content: flex-end; margin-top: 8px;">
<button
class="btn"
@ -396,7 +401,7 @@ function renderJob(job: CronJob, props: CronProps) {
props.onToggle(job, !job.enabled);
}}
>
${job.enabled ? "Disable" : "Enable"}
${job.enabled ? t("common.disable") : t("common.enable")}
</button>
<button
class="btn"
@ -406,7 +411,7 @@ function renderJob(job: CronJob, props: CronProps) {
props.onRun(job);
}}
>
Run
${t("common.run")}
</button>
<button
class="btn"
@ -416,7 +421,7 @@ function renderJob(job: CronJob, props: CronProps) {
props.onLoadRuns(job.id);
}}
>
Runs
${t("cron.runs.title")}
</button>
<button
class="btn danger"
@ -426,7 +431,7 @@ function renderJob(job: CronJob, props: CronProps) {
props.onRemove(job);
}}
>
Remove
${t("common.remove")}
</button>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import { formatEventPayload } from "../presenter";
import { t } from "../i18n";
import type { EventLogEntry } from "../app-events";
export type DebugProps = {
@ -32,51 +33,55 @@ export function renderDebug(props: DebugProps) {
const securityTone = critical > 0 ? "danger" : warn > 0 ? "warn" : "success";
const securityLabel =
critical > 0
? `${critical} critical`
? t("debug.snapshots.securityAudit.critical", { count: critical })
: warn > 0
? `${warn} warnings`
: "No critical issues";
? t("debug.snapshots.securityAudit.warnings", { count: warn })
: t("debug.snapshots.securityAudit.none");
return html`
<section class="grid grid-cols-2">
<div class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Snapshots</div>
<div class="card-sub">Status, health, and heartbeat data.</div>
<div class="card-title">${t("debug.snapshots.title")}</div>
<div class="card-sub">${t("debug.snapshots.subtitle")}</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Refreshing…" : "Refresh"}
${props.loading ? t("cron.refreshing") : t("common.refresh")}
</button>
</div>
<div class="stack" style="margin-top: 12px;">
<div>
<div class="muted">Status</div>
<div class="muted">${t("debug.snapshots.status")}</div>
${securitySummary
? html`<div class="callout ${securityTone}" style="margin-top: 8px;">
Security audit: ${securityLabel}${info > 0 ? ` · ${info} info` : ""}. Run
<span class="mono">clawdbot security audit --deep</span> for details.
${t("debug.snapshots.securityAudit", {
label: securityLabel,
info,
})}
<span class="mono">clawdbot security audit --deep</span>
${t("debug.snapshots.securityAuditSuffix")}
</div>`
: nothing}
<pre class="code-block">${JSON.stringify(props.status ?? {}, null, 2)}</pre>
</div>
<div>
<div class="muted">Health</div>
<div class="muted">${t("debug.snapshots.health")}</div>
<pre class="code-block">${JSON.stringify(props.health ?? {}, null, 2)}</pre>
</div>
<div>
<div class="muted">Last heartbeat</div>
<div class="muted">${t("debug.snapshots.lastHeartbeat")}</div>
<pre class="code-block">${JSON.stringify(props.heartbeat ?? {}, null, 2)}</pre>
</div>
</div>
</div>
<div class="card">
<div class="card-title">Manual RPC</div>
<div class="card-sub">Send a raw gateway method with JSON params.</div>
<div class="card-title">${t("debug.rpc.title")}</div>
<div class="card-sub">${t("debug.rpc.subtitle")}</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Method</span>
<span>${t("debug.rpc.method")}</span>
<input
.value=${props.callMethod}
@input=${(e: Event) =>
@ -85,7 +90,7 @@ export function renderDebug(props: DebugProps) {
/>
</label>
<label class="field">
<span>Params (JSON)</span>
<span>${t("debug.rpc.params")}</span>
<textarea
.value=${props.callParams}
@input=${(e: Event) =>
@ -95,7 +100,7 @@ export function renderDebug(props: DebugProps) {
</label>
</div>
<div class="row" style="margin-top: 12px;">
<button class="btn primary" @click=${props.onCall}>Call</button>
<button class="btn primary" @click=${props.onCall}>${t("debug.rpc.call")}</button>
</div>
${props.callError
? html`<div class="callout danger" style="margin-top: 12px;">
@ -109,8 +114,8 @@ export function renderDebug(props: DebugProps) {
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Models</div>
<div class="card-sub">Catalog from models.list.</div>
<div class="card-title">${t("debug.models.title")}</div>
<div class="card-sub">${t("debug.models.subtitle")}</div>
<pre class="code-block" style="margin-top: 12px;">${JSON.stringify(
props.models ?? [],
null,
@ -119,10 +124,10 @@ export function renderDebug(props: DebugProps) {
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Event Log</div>
<div class="card-sub">Latest gateway events.</div>
<div class="card-title">${t("debug.events.title")}</div>
<div class="card-sub">${t("debug.events.subtitle")}</div>
${props.eventLog.length === 0
? html`<div class="muted" style="margin-top: 12px;">No events yet.</div>`
? html`<div class="muted" style="margin-top: 12px;">${t("debug.events.empty")}</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.eventLog.map(

View File

@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import type { AppViewState } from "../app-view-state";
import { t } from "../i18n";
function formatRemaining(ms: number): string {
const remaining = Math.max(0, ms);
@ -22,29 +23,31 @@ export function renderExecApprovalPrompt(state: AppViewState) {
if (!active) return nothing;
const request = active.request;
const remainingMs = active.expiresAtMs - Date.now();
const remaining = remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : "expired";
const remaining = remainingMs > 0
? t("execApproval.expiresIn", { remaining: formatRemaining(remainingMs) })
: t("execApproval.expired");
const queueCount = state.execApprovalQueue.length;
return html`
<div class="exec-approval-overlay" role="dialog" aria-live="polite">
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">Exec approval needed</div>
<div class="exec-approval-title">${t("execApproval.title")}</div>
<div class="exec-approval-sub">${remaining}</div>
</div>
${queueCount > 1
? html`<div class="exec-approval-queue">${queueCount} pending</div>`
? html`<div class="exec-approval-queue">${t("execApproval.pendingCount", { count: queueCount })}</div>`
: nothing}
</div>
<div class="exec-approval-command mono">${request.command}</div>
<div class="exec-approval-meta">
${renderMetaRow("Host", request.host)}
${renderMetaRow("Agent", request.agentId)}
${renderMetaRow("Session", request.sessionKey)}
${renderMetaRow("CWD", request.cwd)}
${renderMetaRow("Resolved", request.resolvedPath)}
${renderMetaRow("Security", request.security)}
${renderMetaRow("Ask", request.ask)}
${renderMetaRow(t("execApproval.meta.host"), request.host)}
${renderMetaRow(t("execApproval.meta.agent"), request.agentId)}
${renderMetaRow(t("execApproval.meta.session"), request.sessionKey)}
${renderMetaRow(t("execApproval.meta.cwd"), request.cwd)}
${renderMetaRow(t("execApproval.meta.resolved"), request.resolvedPath)}
${renderMetaRow(t("execApproval.meta.security"), request.security)}
${renderMetaRow(t("execApproval.meta.ask"), request.ask)}
</div>
${state.execApprovalError
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
@ -55,21 +58,21 @@ export function renderExecApprovalPrompt(state: AppViewState) {
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("allow-once")}
>
Allow once
${t("execApproval.allowOnce")}
</button>
<button
class="btn"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("allow-always")}
>
Always allow
${t("execApproval.allowAlways")}
</button>
<button
class="btn danger"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("deny")}
>
Deny
${t("execApproval.deny")}
</button>
</div>
</div>

View File

@ -2,6 +2,7 @@ import { html, nothing } from "lit";
import { formatPresenceAge, formatPresenceSummary } from "../presenter";
import type { PresenceEntry } from "../types";
import { t } from "../i18n";
export type InstancesProps = {
loading: boolean;
@ -16,11 +17,11 @@ export function renderInstances(props: InstancesProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Connected Instances</div>
<div class="card-sub">Presence beacons from the gateway and clients.</div>
<div class="card-title">${t("instances.title")}</div>
<div class="card-sub">${t("instances.subtitle")}</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
</div>
${props.lastError
@ -35,7 +36,7 @@ export function renderInstances(props: InstancesProps) {
: nothing}
<div class="list" style="margin-top: 16px;">
${props.entries.length === 0
? html`<div class="muted">No instances reported yet.</div>`
? html`<div class="muted">${t("instances.empty")}</div>`
: props.entries.map((entry) => renderEntry(entry))}
</div>
</section>
@ -45,21 +46,21 @@ export function renderInstances(props: InstancesProps) {
function renderEntry(entry: PresenceEntry) {
const lastInput =
entry.lastInputSeconds != null
? `${entry.lastInputSeconds}s ago`
: "n/a";
? t("instances.secondsAgo", { seconds: entry.lastInputSeconds })
: t("common.na");
const mode = entry.mode ?? "unknown";
const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
const scopesLabel =
scopes.length > 0
? scopes.length > 3
? `${scopes.length} scopes`
: `scopes: ${scopes.join(", ")}`
? t("instances.scopesCount", { count: scopes.length })
: t("instances.scopesList", { list: scopes.join(", ") })
: null;
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${entry.host ?? "unknown host"}</div>
<div class="list-title">${entry.host ?? t("instances.unknownHost")}</div>
<div class="list-sub">${formatPresenceSummary(entry)}</div>
<div class="chip-row">
<span class="chip">${mode}</span>
@ -77,8 +78,8 @@ function renderEntry(entry: PresenceEntry) {
</div>
<div class="list-meta">
<div>${formatPresenceAge(entry)}</div>
<div class="muted">Last input ${lastInput}</div>
<div class="muted">Reason ${entry.reason ?? ""}</div>
<div class="muted">${t("instances.lastInput")} ${lastInput}</div>
<div class="muted">${t("instances.reason")} ${entry.reason ?? ""}</div>
</div>
</div>
`;

View File

@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import type { LogEntry, LogLevel } from "../types";
import { t } from "../i18n";
const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
@ -45,40 +46,44 @@ export function renderLogs(props: LogsProps) {
return matchesFilter(entry, needle);
});
const exportLabel = needle || levelFiltered ? "filtered" : "visible";
const exportLabelText =
exportLabel === "filtered"
? t("common.export.filtered")
: t("common.export.visible");
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Logs</div>
<div class="card-sub">Gateway file logs (JSONL).</div>
<div class="card-title">${t("tab.logs.title")}</div>
<div class="card-sub">${t("logs.subtitle")}</div>
</div>
<div class="row" style="gap: 8px;">
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
<button
class="btn"
?disabled=${filtered.length === 0}
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
>
Export ${exportLabel}
${t("common.export")} ${exportLabelText}
</button>
</div>
</div>
<div class="filters" style="margin-top: 14px;">
<label class="field" style="min-width: 220px;">
<span>Filter</span>
<span>${t("common.filter")}</span>
<input
.value=${props.filterText}
@input=${(e: Event) =>
props.onFilterTextChange((e.target as HTMLInputElement).value)}
placeholder="Search logs"
placeholder=${t("logs.searchPlaceholder")}
/>
</label>
<label class="field checkbox">
<span>Auto-follow</span>
<span>${t("logs.autoFollow")}</span>
<input
type="checkbox"
.checked=${props.autoFollow}
@ -105,11 +110,13 @@ export function renderLogs(props: LogsProps) {
</div>
${props.file
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>`
? html`<div class="muted" style="margin-top: 10px;">
${t("common.file")}: ${props.file}
</div>`
: nothing}
${props.truncated
? html`<div class="callout" style="margin-top: 10px;">
Log output truncated; showing latest chunk.
${t("logs.truncated")}
</div>`
: nothing}
${props.error
@ -118,7 +125,7 @@ export function renderLogs(props: LogsProps) {
<div class="log-stream" style="margin-top: 12px;" @scroll=${props.onScroll}>
${filtered.length === 0
? html`<div class="muted" style="padding: 12px;">No log entries.</div>`
? html`<div class="muted" style="padding: 12px;">${t("logs.empty")}</div>`
: filtered.map(
(entry) => html`
<div class="log-row">

View File

@ -3,6 +3,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { icons } from "../icons";
import { toSanitizedMarkdownHtml } from "../markdown";
import { t } from "../i18n";
export type MarkdownSidebarProps = {
content: string | null;
@ -15,8 +16,8 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
return html`
<div class="sidebar-panel">
<div class="sidebar-header">
<div class="sidebar-title">Tool Output</div>
<button @click=${props.onClose} class="btn" title="Close sidebar">
<div class="sidebar-title">${t("sidebar.toolOutput")}</div>
<button @click=${props.onClose} class="btn" title=${t("common.close")}>
${icons.x}
</button>
</div>
@ -25,12 +26,12 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
? html`
<div class="callout danger">${props.error}</div>
<button @click=${props.onViewRawText} class="btn" style="margin-top: 12px;">
View Raw Text
${t("sidebar.viewRawText")}
</button>
`
: props.content
? html`<div class="sidebar-markdown">${unsafeHTML(toSanitizedMarkdownHtml(props.content))}</div>`
: html`<div class="muted">No content available</div>`}
: html`<div class="muted">${t("sidebar.noContent")}</div>`}
</div>
</div>
`;

View File

@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import { clampText, formatAgo, formatList } from "../format";
import { t } from "../i18n";
import type {
ExecApprovalsAllowlistEntry,
ExecApprovalsFile,
@ -60,16 +61,16 @@ export function renderNodes(props: NodesProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Nodes</div>
<div class="card-sub">Paired devices and live links.</div>
<div class="card-title">${t("tab.nodes.title")}</div>
<div class="card-sub">${t("nodes.subtitle")}</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
</div>
<div class="list" style="margin-top: 16px;">
${props.nodes.length === 0
? html`<div class="muted">No nodes found.</div>`
? html`<div class="muted">${t("nodes.empty")}</div>`
: props.nodes.map((n) => renderNode(n))}
</div>
</section>
@ -84,11 +85,11 @@ function renderDevices(props: NodesProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Devices</div>
<div class="card-sub">Pairing requests + role tokens.</div>
<div class="card-title">${t("nodes.devices.title")}</div>
<div class="card-sub">${t("nodes.devices.subtitle")}</div>
</div>
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
${props.devicesLoading ? "Loading…" : "Refresh"}
${props.devicesLoading ? t("common.loading") : t("common.refresh")}
</button>
</div>
${props.devicesError
@ -97,18 +98,20 @@ function renderDevices(props: NodesProps) {
<div class="list" style="margin-top: 16px;">
${pending.length > 0
? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div>
<div class="muted" style="margin-bottom: 8px;">${t("nodes.devices.pending")}</div>
${pending.map((req) => renderPendingDevice(req, props))}
`
: nothing}
${paired.length > 0
? html`
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">
${t("nodes.devices.paired")}
</div>
${paired.map((device) => renderPairedDevice(device, props))}
`
: nothing}
${pending.length === 0 && paired.length === 0
? html`<div class="muted">No paired devices.</div>`
? html`<div class="muted">${t("nodes.devices.empty")}</div>`
: nothing}
</div>
</section>
@ -117,9 +120,11 @@ function renderDevices(props: NodesProps) {
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
const name = req.displayName?.trim() || req.deviceId;
const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a";
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
const repair = req.isRepair ? " · repair" : "";
const age = typeof req.ts === "number" ? formatAgo(req.ts) : t("common.na");
const role = req.role?.trim()
? t("nodes.devices.roleWithValue", { role: req.role })
: t("nodes.devices.roleEmpty");
const repair = req.isRepair ? ` · ${t("nodes.devices.repair")}` : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
return html`
<div class="list-item">
@ -127,16 +132,17 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
<div class="list-title">${name}</div>
<div class="list-sub">${req.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">
${role} · requested ${age}${repair}
${role} · ${t("nodes.devices.requestedAt", { age })}${repair}
</div>
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
Approve
<button class="btn btn--sm primary" @click=${() =>
props.onDeviceApprove(req.requestId)}>
${t("nodes.devices.approve")}
</button>
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
Reject
${t("nodes.devices.reject")}
</button>
</div>
</div>
@ -147,8 +153,8 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
function renderPairedDevice(device: PairedDevice, props: NodesProps) {
const name = device.displayName?.trim() || device.deviceId;
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
const roles = `roles: ${formatList(device.roles)}`;
const scopes = `scopes: ${formatList(device.scopes)}`;
const roles = t("nodes.devices.roles", { list: formatList(device.roles) });
const scopes = t("nodes.devices.scopes", { list: formatList(device.scopes) });
const tokens = Array.isArray(device.tokens) ? device.tokens : [];
return html`
<div class="list-item">
@ -157,9 +163,9 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
<div class="list-sub">${device.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
${tokens.length === 0
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
? html`<div class="muted" style="margin-top: 6px;">${t("nodes.devices.tokensNone")}</div>`
: html`
<div class="muted" style="margin-top: 10px;">Tokens</div>
<div class="muted" style="margin-top: 10px;">${t("nodes.devices.tokens")}</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
</div>
@ -171,17 +177,25 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
const status = token.revokedAtMs ? "revoked" : "active";
const scopes = `scopes: ${formatList(token.scopes)}`;
const scopes = t("nodes.devices.scopes", { list: formatList(token.scopes) });
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
return html`
<div class="row" style="justify-content: space-between; gap: 8px;">
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
<div class="list-sub">
${token.role} ·
${t(
status === "revoked"
? "nodes.devices.tokenRevoked"
: "nodes.devices.tokenActive",
)} ·
${scopes} · ${when}
</div>
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
<button
class="btn btn--sm"
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
>
Rotate
${t("nodes.devices.rotate")}
</button>
${token.revokedAtMs
? nothing
@ -190,7 +204,7 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
class="btn btn--sm danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
>
Revoke
${t("nodes.devices.revoke")}
</button>
`}
</div>
@ -274,15 +288,15 @@ type ExecApprovalsState = {
const EXEC_APPROVALS_DEFAULT_SCOPE = "__defaults__";
const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string }> = [
{ value: "deny", label: "Deny" },
{ value: "allowlist", label: "Allowlist" },
{ value: "full", label: "Full" },
{ value: "deny", label: t("nodes.execApprovals.policy.security.deny") },
{ value: "allowlist", label: t("nodes.execApprovals.policy.security.allowlist") },
{ value: "full", label: t("nodes.execApprovals.policy.security.full") },
];
const ASK_OPTIONS: Array<{ value: ExecAsk; label: string }> = [
{ value: "off", label: "Off" },
{ value: "on-miss", label: "On miss" },
{ value: "always", label: "Always" },
{ value: "off", label: t("nodes.execApprovals.policy.ask.off") },
{ value: "on-miss", label: t("nodes.execApprovals.policy.ask.on-miss") },
{ value: "always", label: t("nodes.execApprovals.policy.ask.always") },
];
function resolveBindingsState(props: NodesProps): BindingState {
@ -436,9 +450,9 @@ function renderBindings(state: BindingState) {
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<div>
<div class="card-title">Exec node binding</div>
<div class="card-title">${t("nodes.binding.title")}</div>
<div class="card-sub">
Pin agents to a specific node when using <span class="mono">exec host=node</span>.
${t("nodes.binding.subtitle", { example: "exec host=node" })}
</div>
</div>
<button
@ -446,33 +460,33 @@ function renderBindings(state: BindingState) {
?disabled=${state.disabled || !state.configDirty}
@click=${state.onSave}
>
${state.configSaving ? "Saving…" : "Save"}
${state.configSaving ? t("common.loading") : t("common.save")}
</button>
</div>
${state.formMode === "raw"
? html`<div class="callout warn" style="margin-top: 12px;">
Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
${t("nodes.binding.switchMode")}
</div>`
: nothing}
${!state.ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load config to edit bindings.</div>
<div class="muted">${t("nodes.binding.loadPrompt")}</div>
<button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
${state.configLoading ? "Loading…" : "Load config"}
${state.configLoading ? t("common.loading") : t("nodes.binding.loadConfig")}
</button>
</div>`
: html`
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Default binding</div>
<div class="list-sub">Used when agents do not override a node binding.</div>
<div class="list-title">${t("nodes.binding.defaultTitle")}</div>
<div class="list-sub">${t("nodes.binding.defaultSubtitle")}</div>
</div>
<div class="list-meta">
<label class="field">
<span>Node</span>
<span>${t("nodes.binding.node")}</span>
<select
?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => {
@ -481,7 +495,9 @@ function renderBindings(state: BindingState) {
state.onBindDefault(value ? value : null);
}}
>
<option value="" ?selected=${defaultValue === ""}>Any node</option>
<option value="" ?selected=${defaultValue === ""}>
${t("nodes.binding.anyNode")}
</option>
${state.nodes.map(
(node) =>
html`<option
@ -494,13 +510,13 @@ function renderBindings(state: BindingState) {
</select>
</label>
${!supportsBinding
? html`<div class="muted">No nodes with system.run available.</div>`
? html`<div class="muted">${t("nodes.binding.noNodes")}</div>`
: nothing}
</div>
</div>
${state.agents.length === 0
? html`<div class="muted">No agents found.</div>`
? html`<div class="muted">${t("nodes.binding.noAgents")}</div>`
: state.agents.map((agent) =>
renderAgentBinding(agent, state),
)}
@ -517,9 +533,9 @@ function renderExecApprovals(state: ExecApprovalsState) {
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<div>
<div class="card-title">Exec approvals</div>
<div class="card-title">${t("nodes.execApprovals.title")}</div>
<div class="card-sub">
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>.
${t("nodes.execApprovals.subtitle", { example: "exec host=gateway/node" })}
</div>
</div>
<button
@ -527,7 +543,7 @@ function renderExecApprovals(state: ExecApprovalsState) {
?disabled=${state.disabled || !state.dirty || !targetReady}
@click=${state.onSave}
>
${state.saving ? "Saving…" : "Save"}
${state.saving ? t("common.loading") : t("common.save")}
</button>
</div>
@ -535,10 +551,9 @@ function renderExecApprovals(state: ExecApprovalsState) {
${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div>
<div class="muted">${t("nodes.execApprovals.loadPrompt")}</div>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"}
</button>
${state.loading ? t("common.loading") : t("nodes.execApprovals.load")}</button>
</div>`
: html`
${renderExecApprovalsTabs(state)}
@ -558,14 +573,14 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
<div class="list" style="margin-top: 12px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Target</div>
<div class="list-title">${t("nodes.execApprovals.target.title")}</div>
<div class="list-sub">
Gateway edits local approvals; node edits the selected node.
${t("nodes.execApprovals.target.subtitle")}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Host</span>
<span>${t("nodes.execApprovals.target.host")}</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
@ -579,14 +594,18 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
}
}}
>
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option>
<option value="node" ?selected=${state.target === "node"}>Node</option>
<option value="gateway" ?selected=${state.target === "gateway"}>
${t("nodes.execApprovals.target.gateway")}
</option>
<option value="node" ?selected=${state.target === "node"}>
${t("nodes.execApprovals.target.node")}
</option>
</select>
</label>
${state.target === "node"
? html`
<label class="field">
<span>Node</span>
<span>${t("nodes.execApprovals.target.nodeLabel")}</span>
<select
?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => {
@ -595,7 +614,9 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
state.onSelectTarget("node", value ? value : null);
}}
>
<option value="" ?selected=${nodeValue === ""}>Select node</option>
<option value="" ?selected=${nodeValue === ""}>
${t("nodes.execApprovals.target.selectNode")}
</option>
${state.targetNodes.map(
(node) =>
html`<option
@ -612,7 +633,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
</div>
</div>
${state.target === "node" && !hasNodes
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
? html`<div class="muted">${t("nodes.execApprovals.target.noNodes")}</div>`
: nothing}
</div>
`;
@ -621,13 +642,13 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
function renderExecApprovalsTabs(state: ExecApprovalsState) {
return html`
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
<span class="label">Scope</span>
<span class="label">${t("nodes.execApprovals.scope")}</span>
<div class="row" style="gap: 8px; flex-wrap: wrap;">
<button
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
>
Defaults
${t("nodes.execApprovals.defaults")}
</button>
${state.agents.map((agent) => {
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
@ -668,16 +689,16 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Security</div>
<div class="list-title">${t("nodes.execApprovals.policy.security")}</div>
<div class="list-sub">
${isDefaults
? "Default security mode."
: `Default: ${defaults.security}.`}
? t("nodes.execApprovals.policy.securityDefault")
: t("nodes.execApprovals.policy.defaultValue", { value: defaults.security })}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Mode</span>
<span>${t("nodes.execApprovals.policy.mode")}</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
@ -692,7 +713,7 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
>
${!isDefaults
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
Use default (${defaults.security})
${t("common.useDefaultWith", { value: defaults.security })}
</option>`
: nothing}
${SECURITY_OPTIONS.map(
@ -711,14 +732,16 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-item">
<div class="list-main">
<div class="list-title">Ask</div>
<div class="list-title">${t("nodes.execApprovals.policy.ask")}</div>
<div class="list-sub">
${isDefaults ? "Default prompt policy." : `Default: ${defaults.ask}.`}
${isDefaults
? t("nodes.execApprovals.policy.askDefault")
: t("nodes.execApprovals.policy.defaultValue", { value: defaults.ask })}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Mode</span>
<span>${t("nodes.execApprovals.policy.mode")}</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
@ -733,7 +756,7 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
>
${!isDefaults
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
Use default (${defaults.ask})
${t("common.useDefaultWith", { value: defaults.ask })}
</option>`
: nothing}
${ASK_OPTIONS.map(
@ -752,16 +775,16 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-item">
<div class="list-main">
<div class="list-title">Ask fallback</div>
<div class="list-title">${t("nodes.execApprovals.policy.askFallback")}</div>
<div class="list-sub">
${isDefaults
? "Applied when the UI prompt is unavailable."
: `Default: ${defaults.askFallback}.`}
? t("nodes.execApprovals.policy.askFallbackDefault")
: t("nodes.execApprovals.policy.defaultValue", { value: defaults.askFallback })}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Fallback</span>
<span>${t("nodes.execApprovals.policy.fallback")}</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
@ -776,7 +799,7 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
>
${!isDefaults
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
Use default (${defaults.askFallback})
${t("common.useDefaultWith", { value: defaults.askFallback })}
</option>`
: nothing}
${SECURITY_OPTIONS.map(
@ -795,18 +818,22 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-item">
<div class="list-main">
<div class="list-title">Auto-allow skill CLIs</div>
<div class="list-title">${t("nodes.execApprovals.policy.autoAllowSkills")}</div>
<div class="list-sub">
${isDefaults
? "Allow skill executables listed by the Gateway."
? t("nodes.execApprovals.policy.autoAllowSkillsDefault")
: autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
: `Override (${autoEffective ? "on" : "off"}).`}
? t("nodes.execApprovals.policy.usingDefault", {
value: defaults.autoAllowSkills ? t("common.on") : t("common.off"),
})
: t("nodes.execApprovals.policy.override", {
value: autoEffective ? t("common.on") : t("common.off"),
})}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Enabled</span>
<span>${t("common.enabled")}</span>
<input
type="checkbox"
?disabled=${state.disabled}
@ -823,7 +850,7 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
?disabled=${state.disabled}
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
>
Use default
${t("common.useDefault")}
</button>`
: nothing}
</div>
@ -838,8 +865,8 @@ function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
return html`
<div class="row" style="margin-top: 18px; justify-content: space-between;">
<div>
<div class="card-title">Allowlist</div>
<div class="card-sub">Case-insensitive glob patterns.</div>
<div class="card-title">${t("nodes.allowlist.title")}</div>
<div class="card-sub">${t("nodes.allowlist.subtitle")}</div>
</div>
<button
class="btn btn--sm"
@ -849,12 +876,12 @@ function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
state.onPatch(allowlistPath, next);
}}
>
Add pattern
${t("nodes.allowlist.addPattern")}
</button>
</div>
<div class="list" style="margin-top: 12px;">
${entries.length === 0
? html`<div class="muted">No allowlist entries yet.</div>`
? html`<div class="muted">${t("nodes.allowlist.empty")}</div>`
: entries.map((entry, index) =>
renderAllowlistEntry(state, entry, index),
)}
@ -867,7 +894,7 @@ function renderAllowlistEntry(
entry: ExecApprovalsAllowlistEntry,
index: number,
) {
const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : "never";
const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : t("common.never");
const lastCommand = entry.lastUsedCommand
? clampText(entry.lastUsedCommand, 120)
: null;
@ -877,14 +904,14 @@ function renderAllowlistEntry(
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${entry.pattern?.trim() ? entry.pattern : "New pattern"}</div>
<div class="list-sub">Last used: ${lastUsed}</div>
<div class="list-title">${entry.pattern?.trim() ? entry.pattern : t("nodes.allowlist.newPattern")}</div>
<div class="list-sub">${t("nodes.allowlist.lastUsed", { when: lastUsed })}</div>
${lastCommand ? html`<div class="list-sub mono">${lastCommand}</div>` : nothing}
${lastPath ? html`<div class="list-sub mono">${lastPath}</div>` : nothing}
</div>
<div class="list-meta">
<label class="field">
<span>Pattern</span>
<span>${t("nodes.allowlist.pattern")}</span>
<input
type="text"
.value=${entry.pattern ?? ""}
@ -909,7 +936,7 @@ function renderAllowlistEntry(
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
}}
>
Remove
${t("common.remove")}
</button>
</div>
</div>
@ -925,15 +952,15 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
<div class="list-main">
<div class="list-title">${label}</div>
<div class="list-sub">
${agent.isDefault ? "default agent" : "agent"} ·
${agent.isDefault ? t("nodes.binding.defaultAgent") : t("nodes.binding.agent")} ·
${bindingValue === "__default__"
? `uses default (${state.defaultBinding ?? "any"})`
: `override: ${agent.binding}`}
? t("nodes.binding.usesDefault", { value: state.defaultBinding ?? t("nodes.binding.any") })
: t("nodes.binding.override", { value: agent.binding ?? "" })}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Binding</span>
<span>${t("nodes.binding.binding")}</span>
<select
?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => {
@ -943,7 +970,7 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
}}
>
<option value="__default__" ?selected=${bindingValue === "__default__"}>
Use default
${t("common.useDefault")}
</option>
${state.nodes.map(
(node) =>
@ -1059,7 +1086,7 @@ function renderNode(node: Record<string, unknown>) {
const paired = Boolean(node.paired);
const title =
(typeof node.displayName === "string" && node.displayName.trim()) ||
(typeof node.nodeId === "string" ? node.nodeId : "unknown");
(typeof node.nodeId === "string" ? node.nodeId : t("common.unknown"));
const caps = Array.isArray(node.caps) ? (node.caps as unknown[]) : [];
const commands = Array.isArray(node.commands) ? (node.commands as unknown[]) : [];
return html`
@ -1072,9 +1099,9 @@ function renderNode(node: Record<string, unknown>) {
${typeof node.version === "string" ? ` · ${node.version}` : ""}
</div>
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${paired ? "paired" : "unpaired"}</span>
<span class="chip">${paired ? t("nodes.status.paired") : t("nodes.status.unpaired")}</span>
<span class="chip ${connected ? "chip-ok" : "chip-warn"}">
${connected ? "connected" : "offline"}
${connected ? t("nodes.status.connected") : t("nodes.status.offline")}
</span>
${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)}
${commands

View File

@ -4,6 +4,7 @@ import type { GatewayHelloOk } from "../gateway";
import { formatAgo, formatDurationMs } from "../format";
import { formatNextRun } from "../presenter";
import type { UiSettings } from "../storage";
import { t, type Locale } from "../i18n";
export type OverviewProps = {
connected: boolean;
@ -27,10 +28,10 @@ export function renderOverview(props: OverviewProps) {
const snapshot = props.hello?.snapshot as
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
| undefined;
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a";
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : t("common.na");
const tick = snapshot?.policy?.tickIntervalMs
? `${snapshot.policy.tickIntervalMs}ms`
: "n/a";
: t("common.na");
const authHint = (() => {
if (props.connected || !props.lastError) return null;
const lower = props.lastError.toLowerCase();
@ -41,10 +42,10 @@ export function renderOverview(props: OverviewProps) {
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">clawdbot dashboard --no-open</span> tokenized URL<br />
<span class="mono">clawdbot doctor --generate-gateway-token</span> set token
<span class="mono">clawdbot dashboard --no-open</span> ${t("overview.auth.tokenizedUrl")}<br />
<span class="mono">clawdbot doctor --generate-gateway-token</span> ${t("overview.auth.setToken")}
</div>
<div style="margin-top: 6px;">
<a
@ -52,8 +53,8 @@ export function renderOverview(props: OverviewProps) {
href="https://docs.clawd.bot/web/dashboard"
target="_blank"
rel="noreferrer"
title="Control UI auth docs (opens in new tab)"
>Docs: Control UI auth</a
title=${t("overview.auth.docsTitle")}
>${t("overview.auth.docsLabel")}</a
>
</div>
</div>
@ -61,17 +62,16 @@ export function renderOverview(props: OverviewProps) {
}
return html`
<div class="muted" style="margin-top: 8px;">
Auth failed. Re-copy a tokenized URL with
<span class="mono">clawdbot dashboard --no-open</span>, or update the token,
then click Connect.
${t("overview.auth.failed")}
<span class="mono">clawdbot dashboard --no-open</span>${t("overview.auth.failedAfterCommand")}
<div style="margin-top: 6px;">
<a
class="session-link"
href="https://docs.clawd.bot/web/dashboard"
target="_blank"
rel="noreferrer"
title="Control UI auth docs (opens in new tab)"
>Docs: Control UI auth</a
title=${t("overview.auth.docsTitle")}
>${t("overview.auth.docsLabel")}</a
>
</div>
</div>
@ -87,11 +87,11 @@ export function renderOverview(props: OverviewProps) {
}
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.insecureHttp")}
<span class="mono">http://127.0.0.1:18789</span>${t("overview.insecureHttpAfterUrl")}
<div style="margin-top: 6px;">
If you must stay on HTTP, set
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
${t("overview.insecureHttpOption")}
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span>${t("overview.insecureHttpOptionAfter")}
</div>
<div style="margin-top: 6px;">
<a
@ -99,8 +99,8 @@ export function renderOverview(props: OverviewProps) {
href="https://docs.clawd.bot/gateway/tailscale"
target="_blank"
rel="noreferrer"
title="Tailscale Serve docs (opens in new tab)"
>Docs: Tailscale Serve</a
title=${t("overview.docs.opensNewTab")}
>${t("overview.docs.tailscaleServe")}</a
>
<span class="muted"> · </span>
<a
@ -108,8 +108,8 @@ export function renderOverview(props: OverviewProps) {
href="https://docs.clawd.bot/web/control-ui#insecure-http"
target="_blank"
rel="noreferrer"
title="Insecure HTTP docs (opens in new tab)"
>Docs: Insecure HTTP</a
title=${t("overview.docs.opensNewTab")}
>${t("overview.docs.insecureHttp")}</a
>
</div>
</div>
@ -119,11 +119,24 @@ export function renderOverview(props: OverviewProps) {
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.gatewayAccess.title")}</div>
<div class="card-sub">${t("overview.gatewayAccess.subtitle")}</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>WebSocket URL</span>
<span>${t("settings.language")}</span>
<select
.value=${props.settings.locale}
@change=${(e: Event) => {
const v = (e.target as HTMLSelectElement).value as Locale;
props.onSettingsChange({ ...props.settings, locale: v });
}}
>
<option value="zh-CN">${t("settings.lang.zhCN")}</option>
<option value="en">${t("settings.lang.en")}</option>
</select>
</label>
<label class="field">
<span>${t("overview.websocketUrl")}</span>
<input
.value=${props.settings.gatewayUrl}
@input=${(e: Event) => {
@ -134,7 +147,7 @@ export function renderOverview(props: OverviewProps) {
/>
</label>
<label class="field">
<span>Gateway Token</span>
<span>${t("overview.gatewayToken")}</span>
<input
.value=${props.settings.token}
@input=${(e: Event) => {
@ -145,7 +158,7 @@ export function renderOverview(props: OverviewProps) {
/>
</label>
<label class="field">
<span>Password (not stored)</span>
<span>${t("overview.passwordNotStored")}</span>
<input
type="password"
.value=${props.password}
@ -153,11 +166,11 @@ export function renderOverview(props: OverviewProps) {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
placeholder=${t("overview.passwordPlaceholder")}
/>
</label>
<label class="field">
<span>Default Session Key</span>
<span>${t("overview.defaultSessionKey")}</span>
<input
.value=${props.settings.sessionKey}
@input=${(e: Event) => {
@ -168,36 +181,36 @@ export function renderOverview(props: OverviewProps) {
</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">Click Connect to apply connection changes.</span>
<button class="btn" @click=${() => props.onConnect()}>${t("overview.connect")}</button>
<button class="btn" @click=${() => props.onRefresh()}>${t("overview.refresh")}</button>
<span class="muted">${t("overview.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.status")}</div>
<div class="stat-value ${props.connected ? "ok" : "warn"}">
${props.connected ? "Connected" : "Disconnected"}
${props.connected ? t("overview.connected") : t("overview.disconnected")}
</div>
</div>
<div class="stat">
<div class="stat-label">Uptime</div>
<div class="stat-label">${t("overview.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.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.lastChannelsRefresh")}</div>
<div class="stat-value">
${props.lastChannelsRefresh
? formatAgo(props.lastChannelsRefresh)
: "n/a"}
: t("common.na")}
</div>
</div>
</div>
@ -208,52 +221,50 @@ export function renderOverview(props: OverviewProps) {
${insecureContextHint ?? ""}
</div>`
: html`<div class="callout" style="margin-top: 14px;">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
${t("overview.channelsHint")}
</div>`}
</div>
</section>
<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.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.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.sessions")}</div>
<div class="stat-value">${props.sessionsCount ?? t("common.na")}</div>
<div class="muted">${t("overview.sessionsHint")}</div>
</div>
<div class="card stat-card">
<div class="stat-label">Cron</div>
<div class="stat-label">${t("overview.cron")}</div>
<div class="stat-value">
${props.cronEnabled == null
? "n/a"
? t("common.na")
: props.cronEnabled
? "Enabled"
: "Disabled"}
? t("overview.enabled")
: t("overview.disabled")}
</div>
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
<div class="muted">${t("overview.nextWake", { when: 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="muted">
Prefer serve mode to keep the gateway on loopback with tailnet auth.
</div>
<div class="note-title">${t("overview.notes.tailscaleServe.title")}</div>
<div class="muted">${t("overview.notes.tailscaleServe.body")}</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.sessionHygiene.title")}</div>
<div class="muted">${t("overview.notes.sessionHygiene.body")}</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.cronReminders.title")}</div>
<div class="muted">${t("overview.notes.cronReminders.body")}</div>
</div>
</div>
</section>

View File

@ -4,6 +4,7 @@ import { formatAgo } from "../format";
import { formatSessionTokens } from "../presenter";
import { pathForTab } from "../navigation";
import type { GatewaySessionRow, SessionsListResult } from "../types";
import { t } from "../i18n";
export type SessionsProps = {
loading: boolean;
@ -36,9 +37,9 @@ export type SessionsProps = {
const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const;
const BINARY_THINK_LEVELS = ["", "off", "on"] as const;
const VERBOSE_LEVELS = [
{ value: "", label: "inherit" },
{ value: "off", label: "off (explicit)" },
{ value: "on", label: "on" },
{ value: "", labelKey: "common.inherit" },
{ value: "off", labelKey: "sessions.verbose.offExplicit" },
{ value: "on", labelKey: "sessions.verbose.on" },
] as const;
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
@ -76,17 +77,17 @@ export function renderSessions(props: SessionsProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Sessions</div>
<div class="card-sub">Active session keys and per-session overrides.</div>
<div class="card-title">${t("tab.sessions.title")}</div>
<div class="card-sub">${t("sessions.subtitle")}</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
</div>
<div class="filters" style="margin-top: 14px;">
<label class="field">
<span>Active within (minutes)</span>
<span>${t("sessions.filters.activeWithin")}</span>
<input
.value=${props.activeMinutes}
@input=${(e: Event) =>
@ -99,7 +100,7 @@ export function renderSessions(props: SessionsProps) {
/>
</label>
<label class="field">
<span>Limit</span>
<span>${t("sessions.filters.limit")}</span>
<input
.value=${props.limit}
@input=${(e: Event) =>
@ -112,7 +113,7 @@ export function renderSessions(props: SessionsProps) {
/>
</label>
<label class="field checkbox">
<span>Include global</span>
<span>${t("sessions.filters.includeGlobal")}</span>
<input
type="checkbox"
.checked=${props.includeGlobal}
@ -126,7 +127,7 @@ export function renderSessions(props: SessionsProps) {
/>
</label>
<label class="field checkbox">
<span>Include unknown</span>
<span>${t("sessions.filters.includeUnknown")}</span>
<input
type="checkbox"
.checked=${props.includeUnknown}
@ -146,23 +147,23 @@ export function renderSessions(props: SessionsProps) {
: nothing}
<div class="muted" style="margin-top: 12px;">
${props.result ? `Store: ${props.result.path}` : ""}
${props.result ? t("sessions.store", { path: props.result.path }) : ""}
</div>
<div class="table" style="margin-top: 16px;">
<div class="table-head">
<div>Key</div>
<div>Label</div>
<div>Kind</div>
<div>Updated</div>
<div>Tokens</div>
<div>Thinking</div>
<div>Verbose</div>
<div>Reasoning</div>
<div>Actions</div>
<div>${t("sessions.table.key")}</div>
<div>${t("sessions.table.label")}</div>
<div>${t("sessions.table.kind")}</div>
<div>${t("sessions.table.updated")}</div>
<div>${t("sessions.table.tokens")}</div>
<div>${t("sessions.table.thinking")}</div>
<div>${t("sessions.table.verbose")}</div>
<div>${t("sessions.table.reasoning")}</div>
<div>${t("sessions.table.actions")}</div>
</div>
${rows.length === 0
? html`<div class="muted">No sessions found.</div>`
? html`<div class="muted">${t("sessions.empty")}</div>`
: rows.map((row) =>
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)}
@ -200,14 +201,14 @@ function renderRow(
<input
.value=${row.label ?? ""}
?disabled=${disabled}
placeholder="(optional)"
placeholder=${t("sessions.optional")}
@change=${(e: Event) => {
const value = (e.target as HTMLInputElement).value.trim();
onPatch(row.key, { label: value || null });
}}
/>
</div>
<div>${row.kind}</div>
<div>${t(`sessions.kind.${row.kind}`)}</div>
<div>${updated}</div>
<div>${formatSessionTokens(row)}</div>
<div>
@ -222,7 +223,7 @@ function renderRow(
}}
>
${thinkLevels.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
html`<option value=${level}>${level ? t(`sessions.level.${level}`) : t("common.inherit")}</option>`,
)}
</select>
</div>
@ -236,7 +237,7 @@ function renderRow(
}}
>
${VERBOSE_LEVELS.map(
(level) => html`<option value=${level.value}>${level.label}</option>`,
(level) => html`<option value=${level.value}>${t(level.labelKey)}</option>`,
)}
</select>
</div>
@ -250,13 +251,13 @@ function renderRow(
}}
>
${REASONING_LEVELS.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
html`<option value=${level}>${level ? t(`sessions.level.${level}`) : t("common.inherit")}</option>`,
)}
</select>
</div>
<div>
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
Delete
${t("common.delete")}
</button>
</div>
</div>

View File

@ -3,6 +3,7 @@ import { html, nothing } from "lit";
import { clampText } from "../format";
import type { SkillStatusEntry, SkillStatusReport } from "../types";
import type { SkillMessageMap } from "../controllers/skills";
import { t } from "../i18n";
export type SkillsProps = {
loading: boolean;
@ -36,25 +37,25 @@ export function renderSkills(props: SkillsProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Skills</div>
<div class="card-sub">Bundled, managed, and workspace skills.</div>
<div class="card-title">${t("tab.skills.title")}</div>
<div class="card-sub">${t("skills.subtitle")}</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
</div>
<div class="filters" style="margin-top: 14px;">
<label class="field" style="flex: 1;">
<span>Filter</span>
<span>${t("common.filter")}</span>
<input
.value=${props.filter}
@input=${(e: Event) =>
props.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Search skills"
placeholder=${t("skills.searchPlaceholder")}
/>
</label>
<div class="muted">${filtered.length} shown</div>
<div class="muted">${t("skills.shownCount", { count: filtered.length })}</div>
</div>
${props.error
@ -62,7 +63,7 @@ export function renderSkills(props: SkillsProps) {
: nothing}
${filtered.length === 0
? html`<div class="muted" style="margin-top: 16px;">No skills found.</div>`
? html`<div class="muted" style="margin-top: 16px;">${t("skills.empty")}</div>`
: html`
<div class="list" style="margin-top: 16px;">
${filtered.map((skill) => renderSkill(skill, props))}
@ -85,8 +86,8 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
...skill.missing.os.map((o) => `os:${o}`),
];
const reasons: string[] = [];
if (skill.disabled) reasons.push("disabled");
if (skill.blockedByAllowlist) reasons.push("blocked by allowlist");
if (skill.disabled) reasons.push(t("skills.reason.disabled"));
if (skill.blockedByAllowlist) reasons.push(t("skills.reason.blockedByAllowlist"));
return html`
<div class="list-item">
<div class="list-main">
@ -97,21 +98,21 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${skill.source}</span>
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
${skill.eligible ? "eligible" : "blocked"}
${skill.eligible ? t("skills.eligible") : t("skills.blocked")}
</span>
${skill.disabled ? html`<span class="chip chip-warn">disabled</span>` : nothing}
${skill.disabled ? html`<span class="chip chip-warn">${t("skills.disabled")}</span>` : nothing}
</div>
${missing.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Missing: ${missing.join(", ")}
${t("skills.missing")}: ${missing.join(", ")}
</div>
`
: nothing}
${reasons.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Reason: ${reasons.join(", ")}
${t("skills.reason")}: ${reasons.join(", ")}
</div>
`
: nothing}
@ -123,7 +124,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
?disabled=${busy}
@click=${() => props.onToggle(skill.skillKey, skill.disabled)}
>
${skill.disabled ? "Enable" : "Disable"}
${skill.disabled ? t("skills.enable") : t("skills.disable")}
</button>
${canInstall
? html`<button
@ -132,7 +133,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
@click=${() =>
props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
>
${busy ? "Installing…" : skill.install[0].label}
${busy ? t("skills.installing") : skill.install[0].label}
</button>`
: nothing}
</div>
@ -151,7 +152,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
${skill.primaryEnv
? html`
<div class="field" style="margin-top: 10px;">
<span>API key</span>
<span>${t("skills.apiKey")}</span>
<input
type="password"
.value=${apiKey}
@ -165,7 +166,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
?disabled=${busy}
@click=${() => props.onSaveKey(skill.skillKey)}
>
Save key
${t("skills.saveKey")}
</button>
`
: nothing}