feat: add zh-CN localization and i18n support
This commit is contained in:
parent
9daa846457
commit
ae17ea9c1b
@ -1 +1 @@
|
||||
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
|
||||
9d9765de99758bf1005206ba11ab8f0e5ea80a1d46c974ae579779abc199e1b1
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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")}
|
||||
/>
|
||||
|
||||
@ -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>`
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
84
ui/src/ui/i18n.ts
Normal 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);
|
||||
}
|
||||
45
ui/src/ui/locales/README.md
Normal file
45
ui/src/ui/locales/README.md
Normal 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
801
ui/src/ui/locales/en.ts
Normal 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
801
ui/src/ui/locales/zh-CN.ts
Normal 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,浏览器会阻止设备身份能力。请使用 HTTPS(Tailscale 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": "保存媒体附件时保留原始文件名。",
|
||||
};
|
||||
@ -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 "";
|
||||
}
|
||||
|
||||
@ -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(", ");
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 ?? {}) };
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
142
ui/src/ui/views/config-uihints.overrides.ts
Normal file
142
ui/src/ui/views/config-uihints.overrides.ts
Normal 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 身份);非回环绑定必填。",
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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) =>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user