- Setting up this bot's computer…
+ {t("Setting up this bot's computer…")}
)}
@@ -1156,10 +1175,10 @@ export function ChatView({ bot }: { bot: Bot }) {
{!follow && (
- Jump to latest
+ {t("Jump to latest")}
)}
@@ -1212,6 +1231,7 @@ function UsageChip({ bot }: { bot: Bot }) {
* folder a first turn would pin. Click opens bot settings to change it. */
function WorkingFolderChip({ bot }: { bot: Bot }) {
const { dispatch } = useStore();
+ const { t } = useI18n();
const task = bot.tasks?.find((t) => t.threadId === bot.threadId);
const folder = task?.cwd === undefined ? bot.cwd : (task.cwd ?? undefined);
if (!folder) return null;
@@ -1223,7 +1243,7 @@ function WorkingFolderChip({ bot }: { bot: Bot }) {
"flex max-w-[180px] items-center gap-1.5 rounded-full border border-hairline/40 bg-raised/60 px-2.5 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink",
COMPACT_SQUARE,
)}
- title={`Working folder: ${folder}`}
+ title={t("Working folder: {folder}", { folder })}
>
{name}
diff --git a/src/components/CloudBackendPicker.tsx b/src/components/CloudBackendPicker.tsx
index 35e099aa9..3621d8b4f 100644
--- a/src/components/CloudBackendPicker.tsx
+++ b/src/components/CloudBackendPicker.tsx
@@ -4,6 +4,7 @@
// rules can never drift apart.
import type { CloudBackend } from "../../server/contracts.ts";
import { cn } from "@/lib/cn";
+import { useI18n } from "@/lib/i18n-context";
export function CloudBackendPicker({
value,
@@ -14,13 +15,14 @@ export function CloudBackendPicker({
vpsSupported: boolean;
onChange: (backend: CloudBackend) => void;
}) {
+ const { t } = useI18n();
return (
-
Cloud backend
+
{t("Cloud backend")}
{value === "vps"
- ? "Auto only attaches to a VPS container that is already running — a stopped or missing one is never provisioned or started, and the bot quietly works as if no cloud computer existed. Choose Cloud to provision or start it. No interactive desktop tunnel is exposed."
- : "Box is the default hosted computer. Choose Self-hosted VPS to use your SSH-configured Linux Docker host."}
+ ? t("Auto only attaches to a VPS container that is already running — a stopped or missing one is never provisioned or started, and the bot quietly works as if no cloud computer existed. Choose Cloud to provision or start it. No interactive desktop tunnel is exposed.")
+ : t("Box is the default hosted computer. Choose Self-hosted VPS to use your SSH-configured Linux Docker host.")}
{(["box", "vps"] as const).map((backend, i) => {
@@ -29,7 +31,7 @@ export function CloudBackendPicker({
onChange(backend)}
className={cn(
"flex-1 py-1.5 text-[12px]",
@@ -38,7 +40,7 @@ export function CloudBackendPicker({
value === backend ? "bg-raised text-ink" : "text-ink-secondary hover:bg-raised/60 hover:text-ink",
)}
>
- {backend === "vps" ? "Self-hosted VPS" : "Box"}
+ {backend === "vps" ? t("Self-hosted VPS") : "Box"}
);
})}
diff --git a/src/components/CompanionSection.tsx b/src/components/CompanionSection.tsx
index aadafd30b..dfb816867 100644
--- a/src/components/CompanionSection.tsx
+++ b/src/components/CompanionSection.tsx
@@ -16,6 +16,8 @@ import { useCallback, useEffect, useState } from "react";
import { Loader2, Smartphone, Trash2 } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { companionPairingLink } from "../lib/companion-pairing";
+import { useI18n } from "../lib/i18n-context";
+import type { TranslationValues } from "../lib/i18n";
import { Card } from "./SettingsPrimitives";
interface Device {
@@ -64,17 +66,18 @@ const bridge = (): Bridge | null =>
// SAFETY: the preload owns `ogb.companion`; every call is still guarded for browser builds where it is absent.
(globalThis as { ogb?: { companion?: Bridge } }).ogb?.companion ?? null;
-const relative = (at: number) => {
+const relative = (at: number, t: (source: string, values?: TranslationValues) => string) => {
const seconds = Math.round((Date.now() - at) / 1000);
- if (seconds < 90) return "just now";
+ if (seconds < 90) return t("just now");
const minutes = Math.round(seconds / 60);
- if (minutes < 60) return `${minutes} min ago`;
+ if (minutes < 60) return t("{count} min ago", { count: minutes });
const hours = Math.round(minutes / 60);
- if (hours < 24) return `${hours} h ago`;
- return `${Math.round(hours / 24)} d ago`;
+ if (hours < 24) return t("{count} h ago", { count: hours });
+ return t("{count} d ago", { count: Math.round(hours / 24) });
};
export function CompanionSection() {
+ const { t } = useI18n();
const [state, setState] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
@@ -133,8 +136,8 @@ export function CompanionSection() {
if (!bridge()) {
return (
@@ -143,7 +146,7 @@ export function CompanionSection() {
if (!state) {
return (
-
+
);
@@ -178,19 +181,19 @@ export function CompanionSection() {
return (
-
{state.enabled ? "On" : "Off"}
+
{state.enabled ? t("On") : t("Off")}
{!state.enabled
- ? "Nothing on this computer is reachable from the network."
+ ? t("Nothing on this computer is reachable from the network.")
: !address
- ? `Listening on port ${state.port} — no network address yet.`
+ ? t("Listening on port {port} — no network address yet.", { port: state.port })
: tailnet
- ? `Enter ${tailnet}:${state.port} on your phone — that works from anywhere on your tailnet, on any network.`
+ ? t("Enter {address}:{port} on your phone — that works from anywhere on your tailnet, on any network.", { address: tailnet, port: state.port })
: state.discovery?.advertising
? // The address is shown even when Bonjour is working.
// Discovery can be advertising happily and still not
@@ -198,14 +201,14 @@ export function CompanionSection() {
// clients blocks multicast — and when that happens the
// typed address is the way out. Hiding it behind a
// failure the panel cannot detect is no help at all.
- `Your phone will find this computer as "${state.discovery.name}", or you can enter ${address}:${state.port}.`
- : `Listening on ${address}:${state.port} — enter that on your phone.`}
+ t("Your phone will find this computer as \"{name}\", or you can enter {address}:{port}.", { name: state.discovery.name, address, port: state.port })
+ : t("Listening on {address}:{port} — enter that on your phone.", { address, port: state.port })}
- On this network only: {state.lan}:{state.port}
+ {t("On this network only: {address}:{port}", { address: state.lan, port: state.port })}
)}
{/* A tailnet address with no name is workable on a laptop and not on
@@ -223,16 +226,12 @@ export function CompanionSection() {
unexplained policy error. */}
{state.enabled && state.tailscale && !state.tailnetName && (
- You're on a tailnet, but this computer's MagicDNS name couldn't be read from the
- Tailscale app — either MagicDNS is off, or the Tailscale command line tool isn't
- where we looked. iPhones can't connect to a bare tailnet address, so check the
- OpenMausBot log for which paths were tried.
+ {t("You're on a tailnet, but this computer's MagicDNS name couldn't be read from the Tailscale app — either MagicDNS is off, or the Tailscale command line tool isn't where we looked. iPhones can't connect to a bare tailnet address, so check the OpenMausBot log for which paths were tried.")}
)}
{state.enabled && !state.tailscale && (
- Only reachable on this network. Install Tailscale on both this computer and your phone
- to reach it from anywhere — including networks that stop devices from seeing each other.
+ {t("Only reachable on this network. Install Tailscale on both this computer and your phone to reach it from anywhere — including networks that stop devices from seeing each other.")}
- Or open the mobile app and choose “{state.discovery.name}” under On this network.
+ {t("Or open the mobile app and choose “{name}” under On this network.", { name: state.discovery.name })}
- Add a Box API key to give this bot a cloud computer — it spins up right here.
+ {t("Add a Box API key to give this bot a cloud computer — it spins up right here.")}
- Configure the VPS SSH alias in App Settings → Connections. Auto only reuses an existing ready container.
+ {t("Configure the VPS SSH alias in App Settings → Connections. Auto only reuses an existing ready container.")}
- You have the wheel — the bot's clicks and keystrokes are refused until you hand it back.
- {phase === "ready" && cloudBackend === "box" && " Use Open desktop to drive."}
- {phase === "vm" && " Use Open desktop to drive — the preview here is watch-only."}
+ {t("You have the wheel — the bot's clicks and keystrokes are refused until you hand it back.")}
+ {phase === "ready" && cloudBackend === "box" && <> {t("Use Open desktop to drive.")}>}
+ {phase === "vm" && <> {t("Use Open desktop to drive — the preview here is watch-only.")}>}
controlAction("release")}
@@ -848,7 +859,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
className="mt-2 flex w-full items-center justify-center gap-2 rounded-lg bg-accent py-2 text-[13px] font-medium text-white hover:brightness-110 disabled:opacity-50"
>
- Hand control back
+ {t("Hand control back")}
)}
@@ -857,10 +868,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
onClick={() => void openDesktop()}
disabled={pending === "join"}
className="mt-3 flex w-full items-center justify-center gap-2 rounded-lg bg-control py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50"
- title="Open the Local VM's live desktop inside OpenMausBot"
+ title={t("Open the Local VM's live desktop inside OpenMausBot")}
>
{pending === "join" ? : }
- Open live desktop
+ {t("Open live desktop")}
)}
{phase === "vm" && !control.held && !control.helpReason && (
@@ -868,10 +879,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
onClick={() => void openDesktop()}
disabled={controlPending || pending === "join" || !vmViewerUrl}
className="mt-3 flex w-full items-center justify-center gap-2 rounded-lg bg-control py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50"
- title="Pause the bot's hands and open the Local VM's live desktop"
+ title={t("Pause the bot's hands and open the Local VM's live desktop")}
>
{pending === "join" ? : }
- Take control
+ {t("Take control")}
)}
{phase === "vm" && vmStatus?.mode === "per-bot" && (
@@ -879,10 +890,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
onClick={() => void runVmAction("vm-delete")}
disabled={pending !== null || bot.busy}
className="mt-2 flex w-full items-center justify-center gap-2 rounded-lg border border-danger/30 py-2 text-[13px] text-danger hover:bg-danger/10 disabled:opacity-50"
- title={bot.busy ? "Stop this bot's turn before deleting its VM" : `Delete ${bot.name}'s Local VM`}
+ title={bot.busy ? t("Stop this bot's turn before deleting its VM") : t("Delete {name}'s Local VM", { name: bot.name })}
>
{pending === "vm-delete" ? : }
- Delete this bot's VM
+ {t("Delete this bot's VM")}
)}
{/* Cloud-only actions */}
@@ -895,10 +906,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
}
disabled={controlPending || pending === "join"}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-control py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50"
- title="Pause the bot's hands and drive this computer yourself"
+ title={t("Pause the bot's hands and drive this computer yourself")}
>
{pending === "join" ? : }
- Take control
+ {t("Take control")}
)}
{cloudBackend === "box" && control.held && (
@@ -908,7 +919,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-control py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50"
>
{pending === "join" ? : }
- Open live desktop
+ {t("Open live desktop")}
)}
{(cloudBackend === "vps" || boxState !== "archived") && (
@@ -916,10 +927,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
onClick={() => run("sleep")}
disabled={pending === "sleep"}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-control px-3 py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50"
- title="Put the computer to sleep"
+ title={t("Put the computer to sleep")}
>
{pending === "sleep" ? : }
- Sleep
+ {t("Sleep")}
)}
{!bot.computer &&
(isLinux || !localSelectable
? cloudBackend === "vps"
- ? "Auto reuses a ready VPS when one is configured; otherwise computer use stays off. "
- : `${linuxAutoDescription()} `
+ ? t("Auto reuses a ready VPS when one is configured; otherwise computer use stays off. ")
+ : `${t(linuxAutoDescription())} `
: cloudBackend === "vps"
- ? "Auto reuses a ready VPS when one exists, otherwise this computer. "
- : "Auto uses a cloud box when one exists, otherwise this computer. ")}
- Pick where this bot's computer lives. Local VM is a Cua-controlled Linux desktop
- in a container on this machine — free and separate from your own desktop. Set it up in App
- Settings → Local VM.
+ ? t("Auto reuses a ready VPS when one exists, otherwise this computer. ")
+ : t("Auto uses a cloud box when one exists, otherwise this computer. "))}
+ {t("Pick where this bot's computer lives. Local VM is a Cua-controlled Linux desktop in a container on this machine — free and separate from your own desktop. Set it up in App Settings → Local VM.")}
- Schedule work for {bot.name}. Use its current setup, or run the whole job inside its cloud VM.
+ {t("Schedule work for {name}. Use its current setup, or run the whole job inside its cloud VM.", { name: bot.name })}
{!computerDestination && (
- Scheduled tasks on this computer will not have desktop access while this is Off. Choose Cloud VM in the schedule editor to run the whole job there.
+ {t("Scheduled tasks on this computer will not have desktop access while this is Off. Choose Cloud VM in the schedule editor to run the whole job there.")}
}
@@ -282,6 +285,7 @@ function EngineRow({ instance }: { instance: InstanceInfo }) {
export function EnginesSettings() {
const { state } = useStore();
+ const { t } = useI18n();
// every KNOWN-driver instance has cliDefault; unknown-driver shadows have
// neither unless an override was set. Including them keeps a Reset-able row
// (and a Set CLI… path) for engines the running build doesn't recognize.
@@ -290,17 +294,17 @@ export function EnginesSettings() {
return (
- Set CLI points an engine at a specific binary — a versioned build, a wrapper script, or an
- absolute path. Saving reloads providers and interrupts any running turns.
+ {t("Set CLI points an engine at a specific binary — a versioned build, a wrapper script, or an absolute path. Saving reloads providers and interrupts any running turns.")}
);
diff --git a/src/components/GroupView.tsx b/src/components/GroupView.tsx
index 4ac2953dc..ba05d24e3 100644
--- a/src/components/GroupView.tsx
+++ b/src/components/GroupView.tsx
@@ -16,7 +16,7 @@ import {
} from "@/state/store";
import { MausAvatar } from "./Avatar";
import { normalizeState } from "@/lib/mascot";
-import { effectiveDefaultResponder, groupResponseHint } from "@/lib/group-routing";
+import { defaultResponderName, effectiveDefaultResponder, groupResponseHint } from "@/lib/group-routing";
import { ChatMarkdown } from "./ChatMarkdown";
import { Composer } from "./Composer";
import { ConnectorCard } from "./ConnectorCard";
@@ -37,15 +37,19 @@ import {
resolveTranscriptWindow,
tailWindowStart,
} from "@/lib/transcript-window";
+import { useI18n } from "@/lib/i18n-context";
+import type { TranslationValues } from "@/lib/i18n";
-function dayLabel(at: number): string {
+type Translate = (source: string, values?: TranslationValues) => string;
+
+function dayLabel(at: number, locale: string, t: Translate): string {
const d = new Date(at);
const now = new Date();
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diffDays = Math.round((startOfDay(now) - startOfDay(d)) / 86_400_000);
- if (diffDays === 0) return "Today";
- if (diffDays === 1) return "Yesterday";
- return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
+ if (diffDays === 0) return t("Today");
+ if (diffDays === 1) return t("Yesterday");
+ return d.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric" });
}
/** 16px maus + name, shown once per sender cluster. */
@@ -68,6 +72,7 @@ function ClusterLabel({ bot, name, color }: { bot?: Bot; name: string; color: st
/** Pin toggle for one room message — one pin per room, patchGroup path. */
function PinToggle({ group, message }: { group: Group; message: Message }) {
const { dispatch } = useStore();
+ const { t } = useI18n();
const pinned = group.pinnedMessageId === message.id;
return (
{pinned ? : }
@@ -97,6 +102,7 @@ const Transcript = memo(function Transcript({
/** The windowed suffix of group.messages — the boundary lives in GroupView. */
messages: Message[];
}) {
+ const { locale, t } = useI18n();
const memberOf = (id?: string) => members.find((b) => b.id === id);
const textMessages = messages;
return (
@@ -145,7 +151,7 @@ const Transcript = memo(function Transcript({
)}
{!user && m.from && newCluster && (
@@ -184,15 +190,16 @@ function StreamingBubble({ text }: { text: string }) {
function DefaultResponderSelect({ group, members }: { group: Group; members: Bot[] }) {
const { dispatch } = useStore();
+ const { t } = useI18n();
const responder = effectiveDefaultResponder(group, members);
const value = responder.kind === "member" ? `member:${responder.botId}` : responder.kind;
const lead = responder.kind === "member" ? members.find((member) => member.id === responder.botId) : undefined;
const title =
responder.kind === "everyone"
- ? "Plain messages go to every channel member; @mentions override this"
+ ? t("Plain messages go to every channel member; @mentions override this")
: responder.kind === "mentions"
- ? "Only explicitly @mentioned bots respond"
- : `Plain messages go to ${lead?.name ?? "the lead bot"}; @mentions override this`;
+ ? t("Only explicitly @mentioned bots respond")
+ : t("Plain messages go to {name}; @mentions override this", { name: lead?.name ?? t("the lead bot") });
const change = (nextValue: string) => {
let next: GroupDefaultResponder;
@@ -205,21 +212,21 @@ function DefaultResponderSelect({ group, members }: { group: Group; members: Bot
return (
(null);
const [error, setError] = useState(null);
@@ -268,28 +276,28 @@ function RoomWorkingFolder({ group }: { group: Group }) {
return (
-
Working folder
-
Where every bot in this channel runs its shell and file tools.
+
{t("Working folder")}
+
{t("Where every bot in this channel runs its shell and file tools.")}
{locked ? (
- {shownCwd ? shortPath(shownCwd, home) : Each bot's own folder}
+ {shownCwd ? shortPath(shownCwd, home) : {t("Each bot's own folder")}}
- Fixed after this channel's first turn. Create a new channel and choose its folder before sending the first message to work somewhere else.
+ {t("Fixed after this channel's first turn. Create a new channel and choose its folder before sending the first message to work somewhere else.")}
) : canPick ? (
- {group.cwd ? shortPath(group.cwd, home) : Each bot's own folder}
+ {group.cwd ? shortPath(group.cwd, home) : {t("Each bot's own folder")}}
@@ -304,12 +312,12 @@ function RoomWorkingFolder({ group }: { group: Group }) {
>
setDraft(e.target.value)}
/>
- Save
+ {t("Save")}
)}
@@ -322,13 +330,14 @@ function RoomWorkingFolder({ group }: { group: Group }) {
* else the room folder a first turn would pin. Always present so the desk
* is settable before any folder exists; quiet (icon only) until then. */
function RoomWorkingFolderChip({ group, onToggle }: { group: Group; onToggle: () => void }) {
+ const { t } = useI18n();
const folder = group.pinnedCwd === undefined ? group.cwd : (group.pinnedCwd ?? undefined);
if (!folder) {
return (
@@ -339,7 +348,7 @@ function RoomWorkingFolderChip({ group, onToggle }: { group: Group; onToggle: ()
{name}
@@ -386,6 +395,7 @@ function roomNeedsSetup(group: Group): boolean {
function RoomSetup({ group, members }: { group: Group; members: Bot[] }) {
const { dispatch } = useStore();
+ const { t } = useI18n();
const [folder, setFolder] = useState(group.cwd ?? "");
const [behavior, setBehavior] = useState(setupResponderMode(group.defaultResponder));
const [leadId, setLeadId] = useState(
@@ -470,9 +480,9 @@ function RoomSetup({ group, members }: { group: Group; members: Bot[] }) {
1
-
Set up {group.name}
+
{t("Set up {name}", { name: group.name })}
- Give this room a shared workspace, response style, and a little context before the first conversation starts.
+ {t("Give this room a shared workspace, response style, and a little context before the first conversation starts.")}
@@ -485,13 +495,13 @@ function RoomSetup({ group, members }: { group: Group; members: Bot[] }) {
}}
>
- OK
+ {t("OK")}
diff --git a/src/components/LocalComputerSection.tsx b/src/components/LocalComputerSection.tsx
index 8dfd1fa9e..99b878e99 100644
--- a/src/components/LocalComputerSection.tsx
+++ b/src/components/LocalComputerSection.tsx
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { Card, CommandLine } from "./SettingsPrimitives";
import { cn } from "@/lib/cn";
+import { useI18n } from "@/lib/i18n-context";
type Action = "pull" | "run" | "start" | "stop" | "remove" | "recreate";
@@ -101,6 +102,7 @@ function ActionButton({
}
export function LocalComputerSection() {
+ const { t } = useI18n();
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState(null);
@@ -111,10 +113,10 @@ export function LocalComputerSection() {
const refresh = useCallback(async (signal?: AbortSignal) => {
const response = await fetch("/api/local-computer", { signal });
const body = await response.json().catch(() => ({}));
- if (!response.ok) throw new Error(body.error ?? `Status request failed (${response.status})`);
+ if (!response.ok) throw new Error(body.error ?? t("Status request failed ({status})", { status: response.status }));
setStatus(body as Status);
setError(null);
- }, []);
+ }, [t]);
useEffect(() => {
let active = true;
@@ -158,11 +160,11 @@ export function LocalComputerSection() {
const act = async (action: Action) => {
if (
action === "remove" &&
- !window.confirm("Delete the Local VM? Files and browser sign-ins in its durable workspace will remain.")
+ !window.confirm(t("Delete the Local VM? Files and browser sign-ins in its durable workspace will remain."))
) return;
if (
action === "recreate" &&
- !window.confirm("Replace the existing Local VM with the pinned image and safety limits? Files and browser sign-ins in its durable workspace will remain.")
+ !window.confirm(t("Replace the existing Local VM with the pinned image and safety limits? Files and browser sign-ins in its durable workspace will remain."))
) return;
setPending(action);
setError(null);
@@ -193,7 +195,7 @@ export function LocalComputerSection() {
body: JSON.stringify({ localVm: { mode, maxInstances } }),
});
const body = await response.json().catch(() => ({}));
- if (!response.ok) throw new Error(body.error ?? "Could not save the Local VM isolation policy");
+ if (!response.ok) throw new Error(body.error ?? t("Could not save the Local VM isolation policy"));
setStatus((current) => current ? { ...current, mode, max_instances: maxInstances } : current);
await refresh();
} catch (e) {
@@ -216,7 +218,7 @@ export function LocalComputerSection() {
status?.persistence === "unsafe"),
);
const unavailable = !loading && !status;
- const host = status?.platform === "darwin" ? "Mac" : "computer";
+ const host = status?.platform === "darwin" ? "Mac" : t("computer");
const perBot = status?.mode === "per-bot";
const perBotRuntimeUnsupported = perBot && status?.runtime === "container";
const headerReady = perBot ? Boolean(status?.daemonUp && status?.image && !perBotRuntimeUnsupported) : ready;
@@ -224,10 +226,10 @@ export function LocalComputerSection() {
return (
<>
Limits storage and host resource use; each running desktop may use up to 4 GB and 2 CPUs.
+
{t("Maximum per-bot desktops")}
+
{t("Limits storage and host resource use; each running desktop may use up to 4 GB and 2 CPUs.")}
- {policyPending &&
Saving…
}
+ {policyPending &&
{t("Saving…")}
}
-
+
-
+
- Podman and Colima are free. Docker Desktop may require a paid licence for larger companies and government use.
+ {t("Podman and Colima are free. Docker Desktop may require a paid licence for larger companies and government use.")}
{perBotRuntimeUnsupported
- ? "Apple container requires an explicit host port, so OpenMausBot will not guess or expose one. Install or start Docker or Podman for safe per-bot dynamic loopback ports."
+ ? t("Apple container requires an explicit host port, so OpenMausBot will not guess or expose one. Install or start Docker or Podman for safe per-bot dynamic loopback ports.")
: <>
- Choose Local VM for a bot, open that bot's Computer panel, then create its desktop there. OpenMausBot assigns a private workspace and an available loopback viewer port automatically.
+ {t("Choose Local VM for a bot, open that bot's Computer panel, then create its desktop there. OpenMausBot assigns a private workspace and an available loopback viewer port automatically.")}
>}
{status?.image ? (
void act("recreate")} danger>
- Delete and recreate
+ {t("Delete and recreate")}
) : (
-
Prepare the pinned Cua desktop above before replacing this VM.
+
{t("Prepare the pinned Cua desktop above before replacing this VM.")}
)}
>
) : status?.container === "stopped" ? (
- void act("start")}>Start Local VM
+ void act("start")}>{t("Start Local VM")}
) : status?.container === "running" ? (
-
Waiting for the desktop…
+
{t("Waiting for the desktop…")}
) : status?.image ? (
- void act("run")}>Create Local VM
+ void act("run")}>{t("Create Local VM")}
) : null}
- {c?.run && Show command
}
+ {c?.run && {t("Show command")}
}
@@ -389,33 +391,33 @@ export function LocalComputerSection() {
- OpenMausBot could not inspect the container runtime. Re-check, or review the app logs.
+ {t("OpenMausBot could not inspect the container runtime. Re-check, or review the app logs.")}
- OpenMausBot needs Accessibility and Screen Recording in System Settings before a bot can
- use this Mac. After you grant both, click Retry — macOS may still ask you to relaunch the app.
+ {t("OpenMausBot needs Accessibility and Screen Recording in System Settings before a bot can use this Mac. After you grant both, click Retry — macOS may still ask you to relaunch the app.")}
{error && (
@@ -76,7 +77,7 @@ export function MacLocalControl() {
disabled={pending}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-[12.5px] font-medium text-white hover:brightness-110 disabled:opacity-50"
>
- Open System Settings
+ {t("Open System Settings")}
{pending && }
- Retry
+ {t("Retry")}
diff --git a/src/components/ManageMembersPanel.tsx b/src/components/ManageMembersPanel.tsx
index 368b86fcb..884e9773b 100644
--- a/src/components/ManageMembersPanel.tsx
+++ b/src/components/ManageMembersPanel.tsx
@@ -7,6 +7,7 @@ import { track } from "@/lib/analytics";
import { useStore, type Group } from "@/state/store";
import { BotPickerList } from "./BotPickerList";
import { nextMemberIds } from "@/lib/room-members";
+import { useI18n } from "@/lib/i18n-context";
export function ManageMembersPanel({
group,
@@ -18,6 +19,7 @@ export function ManageMembersPanel({
triggerRef: RefObject;
}) {
const { state, dispatch } = useStore();
+ const { t } = useI18n();
const [picked, setPicked] = useState>(() => new Set(group.memberIds));
const [saveError, setSaveError] = useState(null);
const openedMemberIds = useRef([...group.memberIds]);
@@ -85,7 +87,7 @@ export function ManageMembersPanel({
const rosterChanged =
opened.length !== group.memberIds.length || opened.some((id, index) => id !== group.memberIds[index]);
if (rosterChanged) {
- setSaveError("This channel's members changed while the panel was open. Close it and try again.");
+ setSaveError(t("This channel's members changed while the panel was open. Close it and try again."));
return;
}
if (changed) {
@@ -108,13 +110,13 @@ export function ManageMembersPanel({
ref={dialogRef}
role="dialog"
aria-modal="true"
- aria-label={`Manage members of ${group.name}`}
+ aria-label={t("Manage members of {name}", { name: group.name })}
className="w-[340px] rounded-2xl border border-hairline/50 bg-card p-4 shadow-2xl"
>
-
diff --git a/src/components/PluginsPanel.tsx b/src/components/PluginsPanel.tsx
index 08e81174c..a06b4c4d9 100644
--- a/src/components/PluginsPanel.tsx
+++ b/src/components/PluginsPanel.tsx
@@ -6,6 +6,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Check, Loader2, RefreshCw, Search, X } from "lucide-react";
import { api, useStore } from "@/state/store";
import { cn } from "@/lib/cn";
+import { useI18n } from "@/lib/i18n-context";
+
+type Translate = ReturnType["t"];
interface ToolkitCard {
slug: string;
@@ -29,9 +32,13 @@ export interface ConnectorStatus {
export function disconnectAccountConfirmation(
service: string,
account: { id: string; alias?: string },
+ t?: Translate,
) {
const identity = account.alias ? `“${account.alias}” (${account.id})` : `“${account.id}”`;
- return `Disconnect ${identity} from ${service}? Only this ${service} account will be revoked. Your other ${service} accounts will stay connected.`;
+ const source = "Disconnect {identity} from {service}? Only this {service} account will be revoked. Your other {service} accounts will stay connected.";
+ return t
+ ? t(source, { identity, service })
+ : source.replaceAll("{identity}", identity).replaceAll("{service}", service);
}
export function mergeCurrentConnectorStatus(
@@ -89,6 +96,7 @@ function ServiceIcon({ card }: { card: ToolkitCard }) {
export function PluginsPanel() {
const { dispatch } = useStore();
+ const { t } = useI18n();
const dialogRef = useRef(null);
const [cards, setCards] = useState(null);
const [source, setSource] = useState<"api" | "curated">("curated");
@@ -238,7 +246,7 @@ export function PluginsPanel() {
// asynchronous open, the visible Continue button retries from a direct
// user gesture using the URL retained in pendingUrls.
const opened = window.open("", "_blank");
- if (!opened) throw new Error("Your browser blocked the connection page. Click Continue to open it.");
+ if (!opened) throw new Error(t("Your browser blocked the connection page. Click Continue to open it."));
// Open a same-origin blank page first so the OAuth origin never receives
// an opener reference, while a real null remains a reliable blocked signal.
opened.opener = null;
@@ -322,20 +330,20 @@ export function PluginsPanel() {
>
- Connected apps are temporarily unavailable. You can retry after restarting, or configure your own connection service.{" "}
+ {t("Connected apps are temporarily unavailable. You can retry after restarting, or configure your own connection service.")}{" "}
{
@@ -390,13 +398,13 @@ export function PluginsPanel() {
dispatch({ type: "toggleAppSettings", open: true });
}}
>
- Open settings
+ {t("Open settings")}
- Showing featured apps.{" "}
+ {t("Showing featured apps.")}{" "}
{
@@ -404,9 +412,9 @@ export function PluginsPanel() {
dispatch({ type: "toggleAppSettings", open: true });
}}
>
- Update your Composio key
+ {t("Update your Composio key")}
{" "}
- for the full catalog.
+ {t("for the full catalog.")}
)}
{error &&
{error}
}
@@ -414,12 +422,12 @@ export function PluginsPanel() {
);
@@ -514,7 +522,7 @@ export function PluginsPanel() {
event.preventDefault();
const alias = aliasDraft.trim();
if (!alias) {
- setError("Enter a label for the account, such as work or personal.");
+ setError(t("Enter a label for the account, such as work or personal."));
return;
}
void connect(card.slug, alias);
@@ -525,8 +533,8 @@ export function PluginsPanel() {
value={aliasDraft}
maxLength={64}
onChange={(event) => setAliasDraft(event.target.value)}
- placeholder="Account label (work, personal…)"
- aria-label={`Label for another ${card.label} account`}
+ placeholder={t("Account label (work, personal…)")}
+ aria-label={t("Label for another {service} account", { service: card.label })}
className="min-w-0 flex-1 rounded-lg bg-raised px-3 py-2 text-[12px] text-ink placeholder:text-ink-secondary focus:outline-none focus:ring-1 focus:ring-accent"
/>
- Continue
+ {t("Continue")}
)}
@@ -547,10 +555,10 @@ export function PluginsPanel() {
{cards !== null && visible.length === 0 && (
- {tab === "connected" ? "No connected apps yet" : "No apps found"}
+ {t(tab === "connected" ? "No connected apps yet" : "No apps found")}
- {tab === "connected" ? "Connect an app from Marketplace and it will appear here." : "Try a different search."}
+ {t(tab === "connected" ? "Connect an app from Marketplace and it will appear here." : "Try a different search.")}
)}
diff --git a/src/components/RenameTitle.tsx b/src/components/RenameTitle.tsx
index b9689cbdf..b7a19b72f 100644
--- a/src/components/RenameTitle.tsx
+++ b/src/components/RenameTitle.tsx
@@ -6,6 +6,7 @@ import { Pencil } from "lucide-react";
import { nextRename } from "@/lib/rename";
import { cn } from "@/lib/cn";
import { BOT_PROFILE_LIMITS } from "../../shared/bot-profile";
+import { useI18n } from "@/lib/i18n-context";
export function RenameTitle({
value,
@@ -26,6 +27,7 @@ export function RenameTitle({
className?: string;
inputClassName?: string;
}) {
+ const { t } = useI18n();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
@@ -51,7 +53,7 @@ export function RenameTitle({
autoFocus
value={draft}
maxLength={BOT_PROFILE_LIMITS.name}
- aria-label="Rename"
+ aria-label={t("Rename")}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => setDraft(event.target.value)}
onBlur={() => finish(true)}
@@ -87,9 +89,9 @@ export function RenameTitle({
{value}
@@ -99,8 +101,8 @@ export function RenameTitle({
@@ -112,10 +114,10 @@ export function RenameTitle({
return (
{
if (event.key === "Enter" || event.key === " ") {
diff --git a/src/components/RoomTurnTimeoutSettings.tsx b/src/components/RoomTurnTimeoutSettings.tsx
index 713c2fb52..d285081cb 100644
--- a/src/components/RoomTurnTimeoutSettings.tsx
+++ b/src/components/RoomTurnTimeoutSettings.tsx
@@ -6,10 +6,12 @@ import {
createExclusiveSaveGate,
saveRoomTurnTimeoutMinutes,
} from "@/lib/room-turn-timeout";
+import { useI18n } from "@/lib/i18n-context";
import { api, useStore, type ConfigStatus } from "@/state/store";
export function RoomTurnTimeoutSettings() {
const { state, dispatch } = useStore();
+ const { t } = useI18n();
const confirmedMinutes = state.config?.rooms.turnTimeoutMinutes ?? 5;
const [value, setValue] = useState(String(confirmedMinutes));
const [dirty, setDirty] = useState(false);
@@ -53,7 +55,7 @@ export function RoomTurnTimeoutSettings() {
return (
- minutes
+ {t("minutes")}
- Applies to every bot turn in channels. Direct chats use the inactivity watchdog instead.
+ {t("Applies to every bot turn in channels. Direct chats use the inactivity watchdog instead.")}
Each run starts a fresh task for this agent. No cron syntax required.
+
{routine ? t("Edit routine") : t("New routine")}
+
{t("Each run starts a fresh task for this agent. No cron syntax required.")}
-
Where does it run?
+
{t("Where does it run?")}
-
This computer
-
Uses this MAUS's selected model and computer setting.
+
{t("This computer")}
+
{t("Uses this MAUS's selected model and computer setting.")}
-
Cloud VM
-
Runs the MAUS and its tools inside its Box virtual machine.
+
{t("Cloud VM")}
+
{t("Runs the MAUS and its tools inside its Box virtual machine.")}
{runOn === "cloud" && (
{cloudReady
- ? "The VM wakes automatically for each run. Keep OpenMausBot running so its scheduler can launch the job."
- : "Cloud VM needs a working Box API key in App Settings before this routine can run."}
+ ? t("The VM wakes automatically for each run. Keep OpenMausBot running so its scheduler can launch the job.")
+ : t("Cloud VM needs a working Box API key in App Settings before this routine can run.")}
{section === "calendar" ? (
- <>Task = one conversation and result. Routine = a reusable schedule that creates a fresh task each run, using that agent's model, tools, permissions, computer, and connected apps.>
+ <>{t("Task")}{t(" = one conversation and result. ")}{t("Routine")}{t(" = a reusable schedule that creates a fresh task each run, using that agent's model, tools, permissions, computer, and connected apps.")}>
) : (
- <>Webhook = an event endpoint that creates a fresh task. Connected services can call it when something happens; the receiving agent keeps its existing tools and permissions.>
+ <>{t("Webhook")}{t(" = an event endpoint that creates a fresh task. Connected services can call it when something happens; the receiving agent keeps its existing tools and permissions.")}>
)}
- {hasFiniteCost(usage.costUsd) ? `Cost ${costCaption(instance?.snapshot.billing)}.` : "This engine doesn't report a price; tokens are counted."}
+ {hasFiniteCost(usage.costUsd)
+ ? t("Cost is {description}.", { description: t(costCaption(instance?.snapshot.billing)) })
+ : t("This engine doesn't report a price; tokens are counted.")}
);
@@ -80,6 +84,7 @@ const inputCls =
* PATCH is made directly rather than through updateBot: the server
* validates the path and a rejected folder must not stick in local state. */
function WorkingFolder({ bot }: { bot: Bot }) {
+ const { t } = useI18n();
const { capabilities } = useDesktopCapabilities();
const home = capabilities.host.homeDir;
const [draft, setDraft] = useState(null);
@@ -109,19 +114,19 @@ function WorkingFolder({ bot }: { bot: Bot }) {
return (
-
Working folder
-
Where this bot runs its shell and file tools.
+
{t("Working folder")}
+
{t("Where this bot runs its shell and file tools.")}
- New tasks start here. This task is pinned to {pinned ? {shortPath(pinned, home)} : "the home folder"} — start a new task to use the new folder.
+ {t("New tasks start here. This task is pinned to {folder} — start a new task to use the new folder.", {
+ folder: pinned ? shortPath(pinned, home) : t("the home folder"),
+ })}
)}
@@ -167,6 +174,7 @@ const formatBytes = (bytes: number) => (bytes < 1024 ? `${bytes} B` : `${Math.ro
* every bot and most visits never look at memory — and an expand also
* re-reads, so notes the bot wrote mid-session show up on the next open. */
function MemoryCard({ bot }: { bot: Bot }) {
+ const { t } = useI18n();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -234,15 +242,15 @@ function MemoryCard({ bot }: { bot: Bot }) {
}}
>
-
Memory
+
{t("Memory")}
- Notes this bot keeps between tasks — plain files you can edit.
+ {t("Notes this bot keeps between tasks — plain files you can edit.")}
{bot.chiefOfStaff && !canCoordinate
- ? "This bot still holds the role, but its current engine cannot contact teammates. Choose a Claude or ACP engine to restore coordination."
+ ? t("This bot still holds the role, but its current engine cannot contact teammates. Choose a Claude or ACP engine to restore coordination.")
: bot.chiefOfStaff
- ? `This is the primary contact for ${sectionName}. It can create and coordinate specialists in this section, then combine their work into one answer.`
+ ? t("This is the primary contact for {section}. It can create and coordinate specialists in this section, then combine their work into one answer.", { section: sectionName })
: !canCoordinate
- ? "Choose a Claude or ACP engine to let this bot coordinate teammates."
+ ? t("Choose a Claude or ACP engine to let this bot coordinate teammates.")
: currentChief
- ? `Make this bot the ${sectionName} Chief and hand the role over from ${currentChief.name}.`
- : `Make this bot the primary contact for the ${sectionName} section.`}
+ ? t("Make this bot the {section} Chief and hand the role over from {name}.", { section: sectionName, name: currentChief.name })
+ : t("Make this bot the primary contact for the {section} section.", { section: sectionName })}
- Ask me before contacting other bots
+ {t("Ask me before contacting other bots")}
{bot.approvePeerComms
- ? "This bot will stop and ask before it reaches out to another bot."
- : "Let this bot talk to teammates on its own, without a confirmation step."}
+ ? t("This bot will stop and ask before it reaches out to another bot.")
+ : t("Let this bot talk to teammates on its own, without a confirmation step.")}
{!connectedAppsConfigured
- ? "Connect apps in App Settings before giving this bot access."
+ ? t("Connect apps in App Settings before giving this bot access.")
: !canUseConnectedApps
- ? "This bot's current engine cannot use connected apps."
+ ? t("This bot's current engine cannot use connected apps.")
: connectedAppsEnabled
- ? "Let this bot use your connected Gmail, Calendar, Slack, and other apps."
- : "Keep your connected apps unavailable to this bot."}
+ ? t("Let this bot use your connected Gmail, Calendar, Slack, and other apps.")
+ : t("Keep your connected apps unavailable to this bot.")}
{/* Says what the app does, not what the engine ends up at:
Codex applies a level to the whole thread and has no way to
take one back, so "currently: engine default" was a promise
we could not keep for a thread that had already been sent
one. Sending nothing is true on every engine. */}
- How hard this bot thinks{bot.modelSelection.effort ? "" : " (Default: no level is sent)"}
+ {t("How hard this bot thinks")}{bot.modelSelection.effort ? "" : t(" (Default: no level is sent)")}
{bot.computer === "local"
? bot.autoApprove
- ? "Keeps going on this computer — you'll still be asked about anything destructive, and about questions it asks you."
- : "Approve each action on this computer yourself. Turn on to let this bot keep working without stopping to ask."
+ ? t("Keeps going on this computer — you'll still be asked about anything destructive, and about questions it asks you.")
+ : t("Approve each action on this computer yourself. Turn on to let this bot keep working without stopping to ask.")
: bot.autoApprove
- ? "Keeps going on its own — you'll still be asked about anything destructive, and about questions it asks you."
- : "Approve each action yourself. Turn on to let this bot keep working without stopping to ask."}
+ ? t("Keeps going on its own — you'll still be asked about anything destructive, and about questions it asks you.")
+ : t("Approve each action yourself. Turn on to let this bot keep working without stopping to ask.")}
diff --git a/src/components/SkinPicker.tsx b/src/components/SkinPicker.tsx
index f049adb17..77eabc413 100644
--- a/src/components/SkinPicker.tsx
+++ b/src/components/SkinPicker.tsx
@@ -8,6 +8,7 @@ import { useState } from "react";
import { Check } from "lucide-react";
import { SKINS, applySkin, readSkin, type SkinId } from "@/lib/skins";
import { cn } from "@/lib/cn";
+import { useI18n } from "@/lib/i18n-context";
/**
* The app's own layout at roughly 1/14 scale: rail, sidebar with a selected
@@ -64,6 +65,7 @@ function Miniature({ skin }: { skin: SkinId }) {
}
export function SkinPicker() {
+ const { t } = useI18n();
// The document is the source of truth, not storage: main.tsx has already
// stamped it, and reading it back keeps the checkmark honest even if the
// skin was set some other way.
@@ -100,7 +102,7 @@ export function SkinPicker() {
{skin.name}
- {skin.tagline}
+ {t(skin.tagline)}
{selected && }
diff --git a/src/components/TaskPicker.tsx b/src/components/TaskPicker.tsx
index 62afc6530..a37808a24 100644
--- a/src/components/TaskPicker.tsx
+++ b/src/components/TaskPicker.tsx
@@ -10,6 +10,7 @@ import { useStore, formatTime, type Bot, type Task } from "@/state/store";
import { cn } from "@/lib/cn";
import { COMPACT_BUBBLE } from "@/lib/compact-chip";
import { formatTokens } from "@/lib/format-tokens";
+import { useI18n } from "@/lib/i18n-context";
/** Quiet per-task token tally — input+output combined, because one honest
* total reads faster than a split; the split lives in the hover title. */
@@ -27,6 +28,7 @@ function TaskUsage({ usage }: { usage: Task["usage"] }) {
export function TaskPicker({ bot }: { bot: Bot }) {
const { dispatch } = useStore();
+ const { locale, t } = useI18n();
const [open, setOpen] = useState(false);
const [renaming, setRenaming] = useState(null);
const [draft, setDraft] = useState("");
@@ -57,14 +59,14 @@ export function TaskPicker({ bot }: { bot: Bot }) {
dispatch({ type: "newTask", botId: bot.id })}
disabled={bot.busy}
- title={bot.busy ? "Let this turn finish first" : "New task — a fresh context on this bot"}
+ title={bot.busy ? t("Let this turn finish first") : t("New task — a fresh context on this bot")}
className={cn(
"flex items-center gap-1 rounded-full border border-hairline/40 px-2.5 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink disabled:opacity-40",
COMPACT_BUBBLE,
)}
>
- Task
+ {t("Task")}
);
}
@@ -81,8 +83,12 @@ export function TaskPicker({ bot }: { bot: Bot }) {
const currentLabel = u ? formatTokens(u.input + u.output) : null;
const switchTitle =
u && currentLabel
- ? `Switch task · ${currentLabel} (${u.input.toLocaleString()} in · ${u.output.toLocaleString()} out)`
- : "Switch task";
+ ? t("Switch task · {tokens} ({input} in · {output} out)", {
+ tokens: currentLabel,
+ input: u.input.toLocaleString(),
+ output: u.output.toLocaleString(),
+ })
+ : t("Switch task");
return (
@@ -94,7 +100,7 @@ export function TaskPicker({ bot }: { bot: Bot }) {
COMPACT_BUBBLE,
)}
>
- {current?.title ?? "Task"}
+ {current?.title ?? t("Task")}
{/* folded: just the count in the bubble — the title rides the tooltip */}
{tasks.length}
@@ -134,11 +140,11 @@ export function TaskPicker({ bot }: { bot: Bot }) {
setRenaming(task.threadId);
}}
className="min-w-0 flex-1 text-left"
- title="Click to switch · double-click to rename"
+ title={t("Click to switch · double-click to rename")}
>
- {pending ? `${pending.members.length} ready-to-load bots` : "Start with a complete team or bring your own."}
+ {pending
+ ? t("{count} ready-to-load bots", { count: pending.members.length })
+ : t("Start with a complete team or bring your own.")}
@@ -419,10 +423,10 @@ export function TeamLibraryPanel({
void openExternal(catalog?.repositoryUrl ?? COMMUNITY_TEAMS_REPOSITORY)}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-2 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink"
- title="Open the community teams repository"
+ title={t("Open the community teams repository")}
>
- Community repo
+ {t("Community repo")}
)}
@@ -430,7 +434,7 @@ export function TeamLibraryPanel({
onClick={onClose}
disabled={importing}
className="rounded-lg p-2 text-ink-secondary hover:bg-raised hover:text-ink disabled:opacity-50"
- aria-label="Close teams"
+ aria-label={t("Close teams")}
>
@@ -443,7 +447,7 @@ export function TeamLibraryPanel({
{pending.description && (
{pending.description}
)}
-
Team members
+
{t("Team members")}
{pending.members.map((member, index) => (
@@ -452,7 +456,7 @@ export function TeamLibraryPanel({
{member.name}
-
{member.title || "General assistant"}
+
{member.title || t("General assistant")}
))}
@@ -460,8 +464,8 @@ export function TeamLibraryPanel({
- Only roles and appearance are loaded. Your conversations, account connections, permissions, and computer access stay private.
- {source === "library" && " Playbooks remain available in the community repo for review."}
+ {t("Only roles and appearance are loaded. Your conversations, account connections, permissions, and computer access stay private.")}
+ {source === "library" && ` ${t("Playbooks remain available in the community repo for review.")}`}
{error &&
{error}
}
@@ -472,17 +476,17 @@ export function TeamLibraryPanel({
{currentBotCount > 0 ? (
importMode === "replace" ? (
<>
- Replaces your {currentBotCount} current {currentBotCount === 1 ? "bot" : "bots"}. They'll be archived with conversations intact.{" "}
- setImportMode("add")} className="font-medium text-ink hover:underline">Add alongside instead
+ {t("Replaces your {count} current bots. They'll be archived with conversations intact.", { count: currentBotCount })}{" "}
+ setImportMode("add")} className="font-medium text-ink hover:underline">{t("Add alongside instead")}
>
) : (
<>
- This team will be added alongside your current bots.{" "}
- setImportMode("replace")} className="font-medium text-ink hover:underline">Replace current team instead
+ {t("This team will be added alongside your current bots.")}{" "}
+ setImportMode("replace")} className="font-medium text-ink hover:underline">{t("Replace current team instead")}
>
)
) : (
- "No channel is created—you can make one later if you want."
+ t("No channel is created—you can make one later if you want.")
)}
fileInputRef.current?.click()}
@@ -646,21 +650,21 @@ export function TeamLibraryPanel({
)}
>
- Choose a team file
- or drop a .mausteam.json here
+ {t("Choose a team file")}
+ {t("or drop a .mausteam.json here")}
-
Load from GitHub
-
Paste a public repo or a direct team JSON link.
+
{t("Load from GitHub")}
+
{t("Paste a public repo or a direct team JSON link.")}
- Point the scout at a folder. It reads what's in there — README, dependencies, layout — and
- suggests a team for it. Nothing is created until you say so.
+ {t("Point the scout at a folder. It reads what's in there — README, dependencies, layout — and suggests a team for it. Nothing is created until you say so.")}
- Creates the team as new bots, opens a channel for them, and points the channel at this folder.
+ {t("Creates the team as new bots, opens a channel for them, and points the channel at this folder.")}
)}
diff --git a/src/components/TranscriptionSettings.tsx b/src/components/TranscriptionSettings.tsx
index aee4d533f..a31905c07 100644
--- a/src/components/TranscriptionSettings.tsx
+++ b/src/components/TranscriptionSettings.tsx
@@ -2,9 +2,11 @@ import { Check, ExternalLink, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/cn";
+import { useI18n } from "@/lib/i18n-context";
import { announceTranscriptionStatus } from "@/lib/transcription-status";
export function TranscriptionSettings() {
+ const { t } = useI18n();
const bridge = window.ogb?.transcription;
const [configured, setConfigured] = useState(null);
const [value, setValue] = useState("");
@@ -40,11 +42,11 @@ export function TranscriptionSettings() {
- Live narration for recorded skills. Audio is sent to AssemblyAI while recording; the API key is protected by your operating system.
+ {t("Live narration for recorded skills. Audio is sent to AssemblyAI while recording; the API key is protected by your operating system.")}
- Cost is {billings.size === 1 ? costCaption([...billings][0]) : "as each engine reports it — on a subscription it's an equivalent, not a charge"}.
+ {t("Cost is {description}.", {
+ description: t(billings.size === 1 ? costCaption([...billings][0]) : "as each engine reports it — on a subscription it's an equivalent, not a charge"),
+ })}
)}
diff --git a/src/components/VoiceSettings.tsx b/src/components/VoiceSettings.tsx
index d68b8d304..37e56559a 100644
--- a/src/components/VoiceSettings.tsx
+++ b/src/components/VoiceSettings.tsx
@@ -9,6 +9,7 @@ import { Check, Loader2, Volume2 } from "lucide-react";
import { api, useStore, type Bot, type ConfigStatus } from "@/state/store";
import { speaker } from "@/lib/tts";
import { cn } from "@/lib/cn";
+import { useI18n } from "@/lib/i18n-context";
const SAMPLE = "Morning. Overnight the tests went green, and I left two notes for you in the thread.";
@@ -20,6 +21,7 @@ export function VoiceSettings({
onPatch: (patch: Partial>) => void;
}) {
const { state, dispatch } = useStore();
+ const { t } = useI18n();
const tts = state.config?.tts;
const [key, setKey] = useState("");
@@ -74,17 +76,16 @@ export function VoiceSettings({
return (
-
Voice
+
{t("Voice")}
- Give this agent a voice for calls and spoken replies. The ElevenLabs key is shared by the workspace;
- the voice choice belongs to this agent.
+ {t("Give this agent a voice for calls and spoken replies. The ElevenLabs key is shared by the workspace; the voice choice belongs to this agent.")}
Copy this command into Terminal and press Return. It starts a real task in {selectedBot?.name ?? "this MAUS"}'s chat; edit the task text for whatever you want done.
+
{t("Send a task")}
+
{t("Copy this command into Terminal and press Return. It starts a real task in {name}'s chat; edit the task text for whatever you want done.", { name: selectedBot?.name ?? t("this MAUS") })}
diff --git a/src/lib/desktop.test.ts b/src/lib/desktop.test.ts
index d08fc45cf..ef67b75b6 100644
--- a/src/lib/desktop.test.ts
+++ b/src/lib/desktop.test.ts
@@ -35,6 +35,17 @@ afterEach(() => {
});
describe("desktop capability cache", () => {
+ it("uses the active locale when dictation ends", async () => {
+ const { dictationError } = await import("./desktop");
+ const translator = { current: (source: string) => source };
+
+ translator.current = (source) => source === "Dictation is only available on macOS for now."
+ ? "听写功能目前仅支持 macOS。"
+ : source;
+
+ expect(dictationError(2, translator.current)).toBe("听写功能目前仅支持 macOS。");
+ });
+
it("does not let an older initial query replace a newer IPC update", async () => {
let resolveInitial!: (value: DesktopCapabilities) => void;
const initial = new Promise((resolve) => {
diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts
index 2751e152b..6fee65c83 100644
--- a/src/lib/desktop.ts
+++ b/src/lib/desktop.ts
@@ -33,6 +33,14 @@ export function browserDesktopCapabilities(): DesktopCapabilities {
return browserCapabilities;
}
+export function dictationError(code: number | null, t: (source: string) => string): string | null {
+ if (code === 2) return t("Dictation is only available on macOS for now.");
+ if (code === 1) {
+ return t("Dictation needs Microphone + Speech Recognition access — System Settings → Privacy & Security.");
+ }
+ return null;
+}
+
export function initialDesktopCapabilities(): DesktopCapabilities {
const platform = window.ogb?.platform;
if (!platform) return browserCapabilities;
diff --git a/src/lib/group-routing.ts b/src/lib/group-routing.ts
index 9d1a9878a..6bb6b3b43 100644
--- a/src/lib/group-routing.ts
+++ b/src/lib/group-routing.ts
@@ -23,8 +23,7 @@ export function groupResponseHint(group: Group, members: Bot[]): string {
const value = effectiveDefaultResponder(group, members);
if (value.kind === "everyone") return "Everyone responds unless you @mention specific bots.";
if (value.kind === "mentions") return "Mention a bot with @ to bring them in.";
- const name = defaultResponderName(group, members) ?? "The lead bot";
- return `${name} responds by default — @mention someone else to choose them instead.`;
+ return "{name} responds by default — @mention someone else to choose them instead.";
}
export function groupComposerHint(group: Group, members: Bot[]): string {
@@ -32,7 +31,7 @@ export function groupComposerHint(group: Group, members: Bot[]): string {
const value = effectiveDefaultResponder(group, members);
if (value.kind === "everyone") return "everyone responds";
if (value.kind === "mentions") return "@ to bring a bot in";
- return `${defaultResponderName(group, members) ?? "Lead"} responds`;
+ return "{name} responds";
}
/** Same routing sendGroup uses: explicit @mentions win, otherwise the
diff --git a/src/lib/i18n-context.tsx b/src/lib/i18n-context.tsx
new file mode 100644
index 000000000..df1d99500
--- /dev/null
+++ b/src/lib/i18n-context.tsx
@@ -0,0 +1,42 @@
+import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
+import {
+ applyLocale,
+ readLocale,
+ saveLocale,
+ translate,
+ type LocaleId,
+ type TranslationValues,
+} from "./i18n";
+
+type I18nValue = {
+ locale: LocaleId;
+ setLocale: (locale: LocaleId) => void;
+ t: (source: string, values?: TranslationValues) => string;
+};
+
+const FALLBACK_I18N: I18nValue = {
+ locale: "en",
+ setLocale() {},
+ t: (source, values) => translate("en", source, values),
+};
+
+const I18nContext = createContext(FALLBACK_I18N);
+
+export function I18nProvider({ children }: { children: ReactNode }) {
+ const [locale, setLocaleState] = useState(readLocale);
+ const value = useMemo(() => ({
+ locale,
+ setLocale(next) {
+ applyLocale(next);
+ saveLocale(next);
+ setLocaleState(next);
+ },
+ t: (source, values) => translate(locale, source, values),
+ }), [locale]);
+
+ return {children};
+}
+
+export function useI18n(): I18nValue {
+ return useContext(I18nContext);
+}
diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts
new file mode 100644
index 000000000..e1dc3b4cb
--- /dev/null
+++ b/src/lib/i18n.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_LOCALE, readLocale, saveLocale, translate } from "./i18n";
+
+function memoryStorage(value?: string): Storage {
+ const data = new Map();
+ if (value !== undefined) data.set("omb-locale", value);
+ return {
+ get length() { return data.size; },
+ clear: () => data.clear(),
+ getItem: (key) => data.get(key) ?? null,
+ key: (index) => [...data.keys()][index] ?? null,
+ removeItem: (key) => { data.delete(key); },
+ setItem: (key, next) => { data.set(key, next); },
+ };
+}
+
+describe("i18n", () => {
+ it("persists supported locales and rejects stale values", () => {
+ const storage = memoryStorage();
+ saveLocale("zh-CN", storage);
+ expect(readLocale(storage)).toBe("zh-CN");
+ expect(readLocale(memoryStorage("not-a-locale"))).toBe(DEFAULT_LOCALE);
+ });
+
+ it("translates, interpolates, and falls back to English source copy", () => {
+ expect(translate("zh-CN", "Nothing matches “{query}”", { query: "测试" })).toBe("没有与“测试”匹配的内容");
+ expect(translate("zh-CN", "Untranslated copy")).toBe("Untranslated copy");
+ expect(translate("en", "Version {version}", { version: 2 })).toBe("Version 2");
+ });
+});
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
new file mode 100644
index 000000000..561ab3b2d
--- /dev/null
+++ b/src/lib/i18n.ts
@@ -0,0 +1,53 @@
+import { zhCN } from "@/locales/zh-CN";
+
+export const LOCALES = [
+ { id: "en", name: "English" },
+ { id: "zh-CN", name: "简体中文" },
+] as const;
+
+export type LocaleId = (typeof LOCALES)[number]["id"];
+export type TranslationValues = Record;
+
+export const DEFAULT_LOCALE: LocaleId = "en";
+const STORAGE_KEY = "omb-locale";
+const messages: Partial>> = { "zh-CN": zhCN };
+
+function isLocaleId(value: unknown): value is LocaleId {
+ return LOCALES.some((locale) => locale.id === value);
+}
+
+function browserStorage(): Storage | undefined {
+ try {
+ return typeof localStorage === "undefined" ? undefined : localStorage;
+ } catch {
+ return undefined;
+ }
+}
+
+export function readLocale(storage: Storage | undefined = browserStorage()): LocaleId {
+ try {
+ const stored = storage?.getItem(STORAGE_KEY);
+ return isLocaleId(stored) ? stored : DEFAULT_LOCALE;
+ } catch {
+ return DEFAULT_LOCALE;
+ }
+}
+
+export function saveLocale(locale: LocaleId, storage: Storage | undefined = browserStorage()): void {
+ try {
+ storage?.setItem(STORAGE_KEY, locale);
+ } catch {
+ // A locked-down renderer may reject storage; the in-memory choice still works.
+ }
+}
+
+export function applyLocale(locale: LocaleId): void {
+ if (typeof document !== "undefined") document.documentElement.lang = locale;
+}
+
+export function translate(locale: LocaleId, source: string, values: TranslationValues = {}): string {
+ const template = messages[locale]?.[source] ?? source;
+ return template.replace(/\{(\w+)\}/g, (match, name: string) =>
+ Object.hasOwn(values, name) ? String(values[name]) : match,
+ );
+}
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
new file mode 100644
index 000000000..614c19be6
--- /dev/null
+++ b/src/locales/zh-CN.ts
@@ -0,0 +1,1028 @@
+// Keys are the English source copy shown by the app. Keeping English as the
+// fallback means a new UI string remains readable even before translators add
+// it here, and adding another language is one file plus one registry entry.
+export const zhCN = {
+ "Language": "语言",
+ "Choose the language used by OpenMausBot.": "选择 OpenMausBot 使用的界面语言。",
+ "Settings": "设置",
+ "General": "通用",
+ "Connections": "连接",
+ "Engines": "引擎",
+ "Companion": "伴侣设备",
+ "Local VM": "本地虚拟机",
+ "Close settings": "关闭设置",
+ "Profile": "个人资料",
+ "Shown in the sidebar. Saved as you go.": "显示在侧边栏中,修改后自动保存。",
+ "Your name": "你的名字",
+ "Skin": "主题",
+ "Applies instantly and is remembered on this machine.": "立即应用,并保存在这台设备上。",
+ "Room turns": "房间轮次",
+ "Set one maximum duration for every bot turn in a room.": "设置房间内每个 Bot 单次回复的最长时间。",
+ "Maximum turn length": "最长轮次时间",
+ "minutes": "分钟",
+ "Applies to every bot turn in rooms. Direct chats use the inactivity watchdog instead.": "应用于房间内每个 Bot 的每轮回复。直接对话仍使用无活动监测机制。",
+ "Updates": "更新",
+ "Checking…": "正在检查…",
+ "{version} available": "发现新版本 {version}",
+ "Downloading {percent}%": "正在下载 {percent}%",
+ "{version} ready — restart to apply": "{version} 已就绪,重启后应用",
+ "Check failed: {message}": "检查失败:{message}",
+ "unknown error": "未知错误",
+ "You're on the latest version we know of.": "当前已是最新版本。",
+ "Download": "下载",
+ "Restart and install": "重启并安装",
+ "Check for updates": "检查更新",
+ "Usage analytics": "使用情况分析",
+ "Anonymous product events — app opened, which features get used. Never conversations, prompts, file contents, or bot output. Your email is only attached if you shared it during setup.": "匿名产品事件,包括应用启动和功能使用情况。绝不包含对话、提示词、文件内容或 Bot 输出。只有你在设置时主动提供邮箱,才会关联邮箱。",
+ "Send usage analytics": "发送使用情况分析",
+ "Connected apps work automatically in the installed app. Other optional service keys stay on this computer.": "已连接的应用会在桌面版中自动工作,其他可选服务密钥只保存在这台设备上。",
+ "Connected apps service is ready": "已连接应用服务已就绪",
+ "Self-host connected apps": "自托管已连接应用",
+ "Engine CLIs": "引擎命令行工具",
+ "Which binary each engine runs. Saved as you go.": "设置各引擎使用的可执行文件,修改后自动保存。",
+ "Open bot list": "打开 Bot 列表",
+ "No bots yet": "还没有 Bot",
+ "Connecting to the bot server…": "正在连接 Bot 服务…",
+ "Start it with": "使用以下命令启动:",
+ "Bots and navigation": "Bot 与导航",
+ "Expand sidebar": "展开侧边栏",
+ "Collapse sidebar to avatars": "将侧边栏折叠为头像",
+ "Collapse to avatars": "折叠为头像",
+ "Choose sidebar density": "选择侧边栏密度",
+ "Sidebar density": "侧边栏密度",
+ "Comfortable": "舒适",
+ "Compact": "紧凑",
+ "Avatars only": "仅头像",
+ "New or share": "新建或分享",
+ "New Bot": "新建 Bot",
+ "New Room": "新建房间",
+ "Exporting…": "正在导出…",
+ "Export all bots": "导出所有 Bot",
+ "Teams": "团队",
+ "Archived bots": "已归档的 Bot",
+ "Search": "搜索",
+ "Search bots and messages": "搜索 Bot 和消息",
+ "Nothing matches “{query}”": "没有与“{query}”匹配的内容",
+ "Tasks and routines": "任务和例程",
+ "Tasks & routines": "任务和例程",
+ "Routines start fresh agent tasks on a schedule.": "例程会按计划为 Agent 创建全新任务。",
+ "Webhooks start fresh agent tasks when an event arrives.": "事件到达时,Webhook 会为 Agent 创建全新任务。",
+ "Connected apps": "已连接的应用",
+ "App settings": "应用设置",
+ "You": "你",
+ "Undo": "撤销",
+ "Waiting for you…": "正在等待你…",
+ "Working…": "正在处理…",
+ "Screen frame": "屏幕画面",
+ "A bot is working…": "一个 Bot 正在处理…",
+ "{name} is working…": "{name} 正在处理…",
+ "No messages yet": "还没有消息",
+ "You: {text}": "你:{text}",
+ "Version {version} available — download": "发现新版本 {version},点击下载",
+ "Starting download…": "正在开始下载…",
+ "Downloading… {percent}%": "正在下载… {percent}%",
+ "Version {version} ready — restart to update": "版本 {version} 已就绪,点击重启更新",
+ "Restarting to update…": "正在重启并更新…",
+ "Checking for updates…": "正在检查更新…",
+ "You're up to date": "当前已是最新版本",
+ "Rename {name}": "重命名 {name}",
+ "Save room name": "保存房间名称",
+ "Save": "保存",
+ "Cancel room rename": "取消重命名房间",
+ "Cancel": "取消",
+ "Rename Room": "重命名房间",
+ "Copy conversation ID": "复制对话 ID",
+ "Delete Room": "删除房间",
+ "Room name (optional)": "房间名称(可选)",
+ "Create a bot first — rooms are made of bots.": "请先创建一个 Bot,房间由 Bot 组成。",
+ "Create Room": "创建房间",
+ "{count} bots": "{count} 个 Bot",
+ "Move to section": "移动到分组",
+ "New section…": "新建分组…",
+ "New section name": "新分组名称",
+ "Add": "添加",
+ "Remove from section": "移出分组",
+ "Choose another Chief of Staff first": "请先选择另一位首席助理",
+ "Keep at least one active bot": "至少保留一个启用的 Bot",
+ "Pin": "置顶",
+ "Remove Chief of Staff": "取消首席助理",
+ "Make Chief of Staff": "设为首席助理",
+ "Choose a Claude or ACP engine first": "请先选择 Claude 或 ACP 引擎",
+ "Mark as Unread": "标为未读",
+ "Edit Profile": "编辑资料",
+ "Duplicate": "复制",
+ "Archive": "归档",
+ "Delete": "删除",
+ "Chief of Staff": "首席助理",
+ "Archive {name}": "归档 {name}",
+ "{name} restored": "已恢复 {name}",
+ "{count} bots restored": "已恢复 {count} 个 Bot",
+ "Conversations are kept until you choose to delete a bot.": "对话会一直保留,直到你选择删除 Bot。",
+ "Restore all": "全部恢复",
+ "Close archived bots": "关闭已归档 Bot",
+ "{count} archived": "已归档 {count} 个",
+ "Bot": "Bot",
+ "Restore": "恢复",
+ "{count} bots exported": "已导出 {count} 个 Bot",
+ "Previous team restored": "已恢复之前的团队",
+ "{name} archived": "已归档 {name}",
+ "{name} loaded · {count} bots": "已加载 {name} · {count} 个 Bot",
+ "A bot": "一个 Bot",
+ "The bot": "Bot",
+ "The selected responder does not support image attachments.": "所选回复者不支持图片附件。",
+ "Dictation is only available on macOS for now.": "听写功能目前仅支持 macOS。",
+ "Dictation needs Microphone + Speech Recognition access — System Settings → Privacy & Security.": "听写需要麦克风和语音识别权限,请前往“系统设置 → 隐私与安全性”开启。",
+ "Dictation isn't available in this build.": "当前版本不支持听写。",
+ "Queued — sends when {name} finishes: “{message}”": "已排队,将在 {name} 完成后发送:“{message}”",
+ "Discard queued message": "丢弃排队消息",
+ "Tag a bot": "提及 Bot",
+ "Agent": "Agent",
+ "Room": "房间",
+ "Answer the approval above to continue": "请先处理上方的授权请求",
+ "Listening…": "正在聆听…",
+ "{name} is working — Enter sends this into the running turn": "{name} 正在处理,按 Enter 将消息发送到当前轮次",
+ "{name} is working — Enter queues your message": "{name} 正在处理,按 Enter 将消息加入队列",
+ "{name} is working — sends when this turn finishes": "{name} 正在处理,消息将在当前轮次结束后发送",
+ "Message {name} — {hint}": "发送消息到 {name} — {hint}",
+ "Message {name}": "发送消息到 {name}",
+ "continue the conversation": "继续对话",
+ "everyone responds": "所有 Bot 都会回复",
+ "@ to bring a bot in": "使用 @ 邀请 Bot 回复",
+ "Stop this turn": "停止当前轮次",
+ "Stop": "停止",
+ "Stop dictation": "停止听写",
+ "Start dictation": "开始听写",
+ "Stop dictation (Esc)": "停止听写(Esc)",
+ "Dictate": "听写",
+ "Send into the running turn": "发送到当前轮次",
+ "Queue message": "将消息加入队列",
+ "Send message": "发送消息",
+ "Sends when the current turn finishes": "在当前轮次结束后发送",
+ "Send": "发送",
+ "Today": "今天",
+ "Yesterday": "昨天",
+ "Copy message": "复制消息",
+ "Thinking…": "正在思考…",
+ "Thought process": "思考过程",
+ "Retry": "重试",
+ "Edit message": "编辑消息",
+ "Unpin message": "取消置顶消息",
+ "Pin message": "置顶消息",
+ "Unpin this message": "取消置顶此消息",
+ "Pin this message to the top of the thread": "将此消息置顶到对话顶部",
+ "Webhook task": "Webhook 任务",
+ "View event payload": "查看事件数据",
+ "Attached image": "附加图片",
+ "Sent while the bot was working — it saw this before its next step, inside the same turn.": "此消息在 Bot 处理过程中发送,Bot 会在同一轮次的下一步之前看到它。",
+ "sent mid-turn": "已在轮次中发送",
+ "Show full message": "显示完整消息",
+ "Show less": "收起",
+ "Regenerate response": "重新生成回复",
+ "Queued — sends when this turn finishes": "已排队,将在当前轮次结束后发送",
+ "Previous version": "上一个版本",
+ "Next version": "下一个版本",
+ "Open the conversation with {name}": "打开与 {name} 的对话",
+ "Bot's screen": "Bot 的屏幕",
+ "Working for {seconds}s": "已处理 {seconds} 秒",
+ "Send a message to start the conversation.": "发送消息以开始对话。",
+ "Jump to the pinned message": "跳转到置顶消息",
+ "Unpin": "取消置顶",
+ "Open agent profile": "打开 Agent 资料",
+ "Open {name}'s profile": "打开 {name} 的资料",
+ "Bot's computer": "Bot 的电脑",
+ "Inspector": "检查器",
+ "Inspector — runtime events and raw protocol for this thread": "检查器 — 查看此对话的运行时事件和原始协议",
+ "Conversation with {name}": "与 {name} 的对话",
+ "Show earlier messages ({count} more)": "显示更早的消息(还有 {count} 条)",
+ "Show later messages ({count} more)": "显示更晚的消息(还有 {count} 条)",
+ "Setting up this bot's computer…": "正在设置此 Bot 的电脑…",
+ "Jump to latest messages": "跳转到最新消息",
+ "Jump to latest": "跳转到最新",
+ "Working folder: {folder}": "工作目录:{folder}",
+ "Pin this message to the top of the room": "将此消息置顶到房间顶部",
+ "Plain messages go to every room member; @mentions override this": "普通消息会发送给房间内所有成员,@ 提及会覆盖此设置",
+ "Only explicitly @mentioned bots respond": "只有被明确 @ 提及的 Bot 才会回复",
+ "Plain messages go to {name}; @mentions override this": "普通消息会发送给 {name},@ 提及会覆盖此设置",
+ "the lead bot": "首席 Bot",
+ "Default responder": "默认回复者",
+ "Room lead": "房间负责人",
+ "Lead: {name}": "负责人:{name}",
+ "Room behavior": "房间行为",
+ "Everyone responds": "所有成员回复",
+ "Mentions only": "仅被提及者回复",
+ "Working folder": "工作目录",
+ "Where every bot in this room runs its shell and file tools.": "房间内所有 Bot 都会在此目录中运行命令和文件工具。",
+ "Each bot's own folder": "每个 Bot 各自的目录",
+ "Fixed after this room's first turn. Create a new room and choose its folder before sending the first message to work somewhere else.": "房间首次运行后目录将固定。如需更换,请新建房间并在发送第一条消息前选择目录。",
+ "Choose…": "选择…",
+ "Clear": "清除",
+ "Each bot's own folder — or an absolute path": "每个 Bot 各自的目录,或输入绝对路径",
+ "Room working folder": "房间工作目录",
+ "{name} — working…": "{name} — 正在处理…",
+ "Room instructions — every bot in this room follows them (who does what, tone, goals, a task checklist…)": "房间说明 — 此房间中的每个 Bot 都会遵循(分工、语气、目标、任务清单等)",
+ "Room bulletin — shared instructions for every bot here": "房间公告 — 所有 Bot 共享的说明",
+ "Add room instructions…": "添加房间说明…",
+ "Room {name}": "房间 {name}",
+ "Reply here to continue the bot-to-bot conversation.": "在这里回复以继续 Bot 之间的对话。",
+ "Everyone responds unless you @mention specific bots.": "所有 Bot 都会回复,除非你 @ 提及特定 Bot。",
+ "Mention a bot with @ to bring them in.": "使用 @ 提及 Bot 以邀请其加入。",
+ "The original. Cool and dark.": "经典风格,冷静深邃。",
+ "Daylight on paper, warm and quiet.": "纸张般的日光,温暖安静。",
+ "Night shift. Dark, warm, lit in brass.": "夜班氛围,深色温暖,以黄铜点亮。",
+ "Cool daylight. Porcelain and deep teal.": "冷色日光,瓷白与深青。",
+ "Usage": "用量",
+ "Tokens and cost per bot, added up from every settled turn. Only engines that report a price show one.": "汇总每个 Bot 已完成轮次的 Token 和成本;仅显示引擎上报的价格。",
+ "Nothing spent yet — figures appear after a bot's first turn.": "暂无用量,Bot 完成第一个轮次后会显示统计。",
+ "Turns": "轮次",
+ "Tokens": "Token",
+ "Cost": "成本",
+ "All bots": "全部 Bot",
+ "Cost is {description}.": "成本{description}。",
+ "equivalent — on your subscription, not billed": "为订阅额度的等值估算,并非实际扣费",
+ "billed to your API key": "将计入你的 API 密钥账单",
+ "as reported by the engine": "以引擎上报值为准",
+ "as each engine reports it — on a subscription it's an equivalent, not a charge": "按各引擎上报值计算;订阅模式下为等值估算,并非实际扣费",
+ "The companion runs as its own process, which only the desktop app can start. Open OpenMausBot on this computer to turn it on.": "伴侣设备服务作为独立进程运行,仅桌面应用可以启动。请在此电脑上打开 OpenMausBot 后启用。",
+ "Loading…": "正在加载…",
+ "Let a phone open your chats, answer approvals, and send new work. Off by default: your bots run commands on this computer, so only pair a device you trust, on a network you trust.": "允许手机查看对话、处理授权并发送新任务。此功能默认关闭;Bot 会在此电脑上执行命令,请只在可信网络中配对可信设备。",
+ "On": "开启",
+ "Off": "关闭",
+ "Nothing on this computer is reachable from the network.": "网络无法访问此电脑上的任何内容。",
+ "Listening on port {port} — no network address yet.": "正在监听端口 {port},尚未获得网络地址。",
+ "Enter {address}:{port} on your phone — that works from anywhere on your tailnet, on any network.": "在手机上输入 {address}:{port},即可从 Tailnet 内的任意网络访问。",
+ "Your phone will find this computer as \"{name}\", or you can enter {address}:{port}.": "手机会以“{name}”发现此电脑,也可以输入 {address}:{port}。",
+ "Listening on {address}:{port} — enter that on your phone.": "正在监听 {address}:{port},请在手机上输入该地址。",
+ "On this network only: {address}:{port}": "仅限当前网络:{address}:{port}",
+ "You're on a tailnet, but this computer's MagicDNS name couldn't be read from the Tailscale app — either MagicDNS is off, or the Tailscale command line tool isn't where we looked. iPhones can't connect to a bare tailnet address, so check the OpenMausBot log for which paths were tried.": "当前已连接 Tailnet,但无法从 Tailscale 读取此电脑的 MagicDNS 名称。可能是 MagicDNS 未启用,或未找到 Tailscale 命令行工具。iPhone 无法直接连接裸 Tailnet 地址,请在 OpenMausBot 日志中查看尝试过的路径。",
+ "Only reachable on this network. Install Tailscale on both this computer and your phone to reach it from anywhere — including networks that stop devices from seeing each other.": "目前仅能从当前网络访问。请在电脑和手机上安装 Tailscale,以便从任意网络访问,包括禁止设备互相发现的网络。",
+ "Connect a phone": "连接手机",
+ "Scan with your phone's Camera, then confirm in OpenMausMobile. You can also use the code manually.": "使用手机相机扫描,然后在 OpenMausMobile 中确认;也可以手动输入配对码。",
+ "Open OpenMausMobile, choose this computer, and enter the code.": "打开 OpenMausMobile,选择此电脑并输入配对码。",
+ "Open a short, single-use pairing window for a trusted phone.": "为可信手机开启一个短时有效的一次性配对窗口。",
+ "This turns on Companion and opens a short, single-use pairing window in one step.": "这会启用伴侣设备服务,并同时打开一个短时有效的一次性配对窗口。",
+ "Phone pairing QR code": "手机配对二维码",
+ "Manual code": "手动配对码",
+ "Expires in {seconds}s": "{seconds} 秒后过期",
+ "Or open the mobile app and choose “{name}” under On this network.": "也可以打开移动应用,在“当前网络”下选择“{name}”。",
+ "Preparing…": "正在准备…",
+ "Pair another phone": "配对另一部手机",
+ "Set up a phone": "设置手机",
+ "Paired devices": "已配对设备",
+ "Cloud desktop is full interactive access. Enable it only for a phone you trust; removing a device signs it out immediately.": "云桌面提供完整交互权限,仅应为可信手机启用;移除设备后会立即退出登录。",
+ "No phones are paired yet.": "尚未配对手机。",
+ "Last seen {time}": "上次在线:{time}",
+ "Cloud desktop": "云桌面",
+ "Cloud desktop access for {name}": "{name} 的云桌面访问权限",
+ "Remove {name}": "移除 {name}",
+ "just now": "刚刚",
+ "{count} min ago": "{count} 分钟前",
+ "{count} h ago": "{count} 小时前",
+ "{count} d ago": "{count} 天前",
+ "computer": "电脑",
+ "Private Cua Linux desktops on this {host}, with one container and durable workspace per bot. Distinct bots can work concurrently and idle desktops stop after 8 hours.": "在此 {host} 上为每个 Bot 提供独立的 Cua Linux 桌面、容器和持久工作区。不同 Bot 可并行工作,闲置桌面会在 8 小时后停止。",
+ "A shared Cua Linux sandbox on this {host} for bots to browse and work in — isolated, backed by one durable workspace, and automatically recycled after 8 hours without activity.": "此 {host} 上的共享 Cua Linux 沙箱,可供 Bot 浏览和工作;环境相互隔离,使用持久工作区,并在闲置 8 小时后自动回收。",
+ "Status unavailable": "状态不可用",
+ "Ready for per-bot desktops": "已可使用独立 Bot 桌面",
+ "Per-bot mode requires Docker or Podman": "独立 Bot 模式需要 Docker 或 Podman",
+ "Ready": "就绪",
+ "Not ready": "未就绪",
+ "Re-check": "重新检查",
+ "Watch screen": "查看屏幕",
+ "Isolation": "隔离方式",
+ "Shared keeps the original single-desktop behavior. Per bot gives each bot its own container, workspace, viewer port, lease, and idle timer.": "共享模式沿用单桌面行为;独立 Bot 模式为每个 Bot 分配各自的容器、工作区、查看端口、租约和闲置计时器。",
+ "Shared": "共享",
+ "Per bot": "每个 Bot 独立",
+ "Maximum per-bot desktops": "最多独立 Bot 桌面数",
+ "Limits storage and host resource use; each running desktop may use up to 4 GB and 2 CPUs.": "限制存储和主机资源占用;每个运行中的桌面最多可使用 4 GB 内存和 2 个 CPU。",
+ "Saving…": "正在保存…",
+ "Setup": "设置",
+ "Once a container runtime is open, OpenMausBot prepares Cua and the VM for you.": "容器运行时启动后,OpenMausBot 会为你准备 Cua 和虚拟机。",
+ "Install a container runtime": "安装容器运行时",
+ "Podman and Colima are free. Docker Desktop may require a paid licence for larger companies and government use.": "Podman 和 Colima 免费;大型企业和政府机构使用 Docker Desktop 可能需要付费许可证。",
+ "Open the Podman installation guide": "打开 Podman 安装指南",
+ "Open and start {runtime}": "打开并启动 {runtime}",
+ "Start the container runtime": "启动容器运行时",
+ "Open the installed runtime and start its engine, then re-check.": "打开已安装的运行时并启动其引擎,然后重新检查。",
+ "Prepare the Cua desktop (one-time download and build)": "准备 Cua 桌面(首次下载并构建)",
+ "Prepare Cua desktop": "准备 Cua 桌面",
+ "Show base-image download": "显示基础镜像下载命令",
+ "Create a private desktop from each bot's Computer panel": "在每个 Bot 的 Computer 面板中创建独立桌面",
+ "Replace the older or unsafe VM": "替换旧版或不安全的虚拟机",
+ "Create and start the Local VM": "创建并启动本地虚拟机",
+ "Apple container requires an explicit host port, so OpenMausBot will not guess or expose one. Install or start Docker or Podman for safe per-bot dynamic loopback ports.": "Apple container 需要明确指定主机端口,OpenMausBot 不会猜测或暴露端口。请安装或启动 Docker、Podman,以安全地为各 Bot 分配动态回环端口。",
+ "Choose Local VM for a bot, open that bot's Computer panel, then create its desktop there. OpenMausBot assigns a private workspace and an available loopback viewer port automatically.": "为 Bot 选择“本地虚拟机”,打开该 Bot 的 Computer 面板并创建桌面。OpenMausBot 会自动分配独立工作区和可用的回环查看端口。",
+ "Delete and recreate": "删除并重新创建",
+ "Prepare the pinned Cua desktop above before replacing this VM.": "替换虚拟机前,请先准备上方指定版本的 Cua 桌面。",
+ "Start Local VM": "启动本地虚拟机",
+ "Waiting for the desktop…": "正在等待桌面启动…",
+ "Create Local VM": "创建本地虚拟机",
+ "Show command": "显示命令",
+ "OpenMausBot could not inspect the container runtime. Re-check, or review the app logs.": "OpenMausBot 无法检查容器运行时。请重新检查或查看应用日志。",
+ "Safety and storage": "安全与存储",
+ "Cua Driver operates only each VM's desktop. Every bot gets a private host folder mounted at {path}; its files and browser profile survive VM replacement. Viewers bind only to loopback, and exact bot-derived targets prevent one bot from attaching to another bot's container. Each VM keeps the existing 4 GB, 2 CPU, 512-process and dropped-capability limits. VMs can still reach the internet.": "Cua Driver 仅操作各虚拟机的桌面。每个 Bot 都有挂载到 {path} 的独立主机文件夹,其中的文件和浏览器配置会在更换虚拟机后保留。查看器仅绑定回环地址,严格的 Bot 目标可防止跨容器连接。每台虚拟机限制为 4 GB 内存、2 个 CPU、512 个进程,并移除多余权限;虚拟机仍可访问互联网。",
+ "Cua Driver operates only the VM's desktop. Exactly one private host folder is mounted at {path}; files and browser sign-ins there survive VM replacement, while everything elsewhere in the VM remains disposable. The password-protected viewer is available only on this machine. Docker and Podman runs are limited to 4 GB memory, 2 CPUs and 512 processes; all Linux capabilities are dropped except the two the desktop supervisor needs to switch to its unprivileged user. The VM can still reach the internet, and bots share it one at a time.": "Cua Driver 仅操作虚拟机桌面。只有一个独立主机文件夹挂载到 {path},其中的文件和浏览器登录会在更换虚拟机后保留,虚拟机其他内容均可丢弃。受密码保护的查看器仅能从本机访问。Docker 和 Podman 限制为 4 GB 内存、2 个 CPU 和 512 个进程,仅保留桌面管理程序切换到非特权用户所需的两项 Linux 权限。虚拟机仍可访问互联网,Bot 会依次共享使用。",
+ "Delete legacy shared VM": "删除旧版共享虚拟机",
+ "Delete VM": "删除虚拟机",
+ "Durable workspace": "持久工作区",
+ "Local image": "本地镜像",
+ "not created": "尚未创建",
+ "not prepared": "尚未准备",
+ "Status request failed ({status})": "状态请求失败({status})",
+ "Delete the Local VM? Files and browser sign-ins in its durable workspace will remain.": "确定删除本地虚拟机吗?持久工作区中的文件和浏览器登录信息会保留。",
+ "Replace the existing Local VM with the pinned image and safety limits? Files and browser sign-ins in its durable workspace will remain.": "确定使用指定镜像和安全限制替换现有本地虚拟机吗?持久工作区中的文件和浏览器登录信息会保留。",
+ "Could not save the Local VM isolation policy": "无法保存本地虚拟机隔离策略",
+ "Computer": "电脑",
+ "Bot settings": "Bot 设置",
+ "{name}'s screen": "{name} 的屏幕",
+ "this computer": "此电脑",
+ "self-hosted VPS": "自托管 VPS",
+ "Watch-only preview — use Open desktop to click and type": "仅供查看;请打开实时桌面进行点击和输入。",
+ "Waiting for the first frame…": "正在等待首个画面…",
+ "Capturing the Local VM screen…": "正在捕获本地虚拟机屏幕…",
+ "Ready for approved bot actions. Start the separate preview below when you want to watch the screen.": "已可执行获批的 Bot 操作。如需查看屏幕,请启动下方的独立预览。",
+ "No frames yet — the preview needs Screen Recording permission. After granting, relaunch the app.": "尚无画面;预览需要屏幕录制权限。授权后请重新启动应用。",
+ "Capturing this computer's screen…": "正在捕获此电脑的屏幕…",
+ "Open Settings": "打开设置",
+ "Create {name}'s VM": "创建 {name} 的虚拟机",
+ "Replace {name}'s VM": "替换 {name} 的虚拟机",
+ "Open Local VM setup": "打开本地虚拟机设置",
+ "Open VPS settings": "打开 VPS 设置",
+ "Start VPS computer": "启动 VPS 电脑",
+ "Add a Box API key to give this bot a cloud computer — it spins up right here.": "添加 Box API 密钥,为此 Bot 提供可在这里直接启动的云电脑。",
+ "Configure the VPS SSH alias in App Settings → Connections. Auto only reuses an existing ready container.": "请在“应用设置 → 连接”中配置 VPS SSH 别名。自动模式只会复用已就绪的容器。",
+ "{name} asked for your hands: {reason}": "{name} 请求你接管操作:{reason}",
+ "Take control": "接管控制",
+ "Dismiss": "忽略",
+ "You have the wheel — the bot's clicks and keystrokes are refused until you hand it back.": "你正在控制电脑;在交还控制权之前,Bot 的点击和键盘操作都会被拒绝。",
+ "Use Open desktop to drive.": "请打开实时桌面进行操作。",
+ "Use Open desktop to drive — the preview here is watch-only.": "请打开实时桌面进行操作;此处预览仅供查看。",
+ "Hand control back": "交还控制权",
+ "Open the Local VM's live desktop inside OpenMausBot": "在 OpenMausBot 中打开本地虚拟机实时桌面",
+ "Open live desktop": "打开实时桌面",
+ "Pause the bot's hands and open the Local VM's live desktop": "暂停 Bot 操作并打开本地虚拟机实时桌面",
+ "Stop this bot's turn before deleting its VM": "删除虚拟机前请先停止此 Bot 的当前轮次",
+ "Delete {name}'s Local VM": "删除 {name} 的本地虚拟机",
+ "Delete this bot's VM": "删除此 Bot 的虚拟机",
+ "Pause the bot's hands and drive this computer yourself": "暂停 Bot 操作并由你控制此电脑",
+ "Put the computer to sleep": "让电脑休眠",
+ "Sleep": "休眠",
+ "Runs on": "运行位置",
+ "Auto reuses a ready VPS when one is configured; otherwise computer use stays off. ": "自动复用已配置且就绪的 VPS,否则保持电脑功能关闭。",
+ "Auto reuses a ready VPS when one exists, otherwise this computer. ": "自动优先复用就绪的 VPS,否则使用此电脑。",
+ "Auto uses a cloud box when one exists, otherwise this computer. ": "自动优先使用现有云电脑,否则使用此电脑。",
+ "Auto uses a cloud box when one is configured; otherwise computer use stays off.": "配置云电脑后自动使用,否则保持电脑功能关闭。",
+ "Pick where this bot's computer lives. Local VM is a Cua-controlled Linux desktop in a container on this machine — free and separate from your own desktop. Set it up in App Settings → Local VM.": "选择此 Bot 的电脑运行位置。本地虚拟机是在本机容器中由 Cua 控制的 Linux 桌面,免费且与你自己的桌面隔离。可前往“应用设置 → 本地虚拟机”进行配置。",
+ "Cloud": "云端",
+ "This computer": "此电脑",
+ "Scheduled tasks": "计划任务",
+ "Schedule work for {name}. Use its current setup, or run the whole job inside its cloud VM.": "为 {name} 安排任务。可以沿用当前配置,也可以在其云端虚拟机中运行整个任务。",
+ "Scheduled tasks on this computer will not have desktop access while this is Off. Choose Cloud VM in the schedule editor to run the whole job there.": "此项关闭时,在本机运行的计划任务无法访问桌面。请在计划编辑器中选择云端虚拟机,以在那里运行整个任务。",
+ "needs you": "需要你处理",
+ "queued": "已排队",
+ "running": "运行中",
+ "waiting": "等待中",
+ "runs on VM": "在虚拟机中运行",
+ "Create schedule": "创建计划",
+ "Open schedules": "打开计划",
+ "Schedules": "计划",
+ "Every day": "每天",
+ "Weekdays": "工作日",
+ "Sun": "周日",
+ "Mon": "周一",
+ "Tue": "周二",
+ "Wed": "周三",
+ "Thu": "周四",
+ "Fri": "周五",
+ "Sat": "周六",
+ "Paused": "已暂停",
+ "{name}'s live desktop": "{name} 的实时桌面",
+ "Delete {name}'s Local VM? Its private durable workspace will remain.": "确定删除 {name} 的本地虚拟机吗?其独立持久工作区会保留。",
+ "Replace {name}'s Local VM? Its private durable workspace will remain.": "确定替换 {name} 的本地虚拟机吗?其独立持久工作区会保留。",
+ "Starting your bot's computer…": "正在启动 Bot 的电脑…",
+ "No cloud computer configured": "尚未配置云电脑",
+ "No managed VPS computer is configured for this bot": "尚未为此 Bot 配置托管 VPS 电脑",
+ "The managed VPS computer is stopped": "托管 VPS 电脑已停止",
+ "Local computer control isn't ready.": "本机控制尚未就绪。",
+ "The Local VM isn't available for this bot": "此 Bot 无法使用本地虚拟机",
+ "This bot's computer is off": "此 Bot 的电脑已关闭",
+ "Couldn't reach the computer": "无法连接电脑",
+ "The selected provider cannot request approvals for local computer actions.": "所选提供商无法请求本机操作授权。",
+ "Wayland local control is currently limited to GNOME. Xorg remains available on supported desktops.": "Wayland 本机控制目前仅支持 GNOME;受支持桌面仍可使用 Xorg。",
+ "Enable the local control beta and complete the Cua Driver checks first.": "请先启用本机控制 Beta 并完成 Cua Driver 检查。",
+ "Cua Driver is not ready for local control.": "Cua Driver 尚未准备好进行本机控制。",
+ "Local computer control requires the desktop app.": "本机控制需要桌面应用。",
+ "CUA Driver is not ready for local computer control.": "CUA Driver 尚未准备好进行本机控制。",
+ "Preview this computer": "预览此电脑",
+ "Preview only — starting a preview does not grant local control.": "仅供预览;启动预览不会授予本机控制权限。",
+ "Preview only": "仅预览",
+ "Live preview of the selected screen": "所选屏幕的实时预览",
+ "Checking screen preview…": "正在检查屏幕预览…",
+ "Sharing": "正在共享",
+ "Choose a screen…": "正在选择屏幕…",
+ "Stop preview": "停止预览",
+ "Try again": "重试",
+ "Choose a screen": "选择屏幕",
+ "Start preview": "开始预览",
+ "Start a private, view-only preview when you need it.": "需要时可启动私密的只读预览。",
+ "Screen selection was cancelled. Nothing is being shared.": "已取消选择屏幕,当前未共享任何内容。",
+ "Screen sharing ended. Nothing is being shared.": "屏幕共享已结束,当前未共享任何内容。",
+ "Screen preview isn't available in this desktop session.": "当前桌面会话无法使用屏幕预览。",
+ "Couldn't start screen preview.": "无法启动屏幕预览。",
+ "Waiting for screen selection…": "正在等待选择屏幕…",
+ "Selected screen": "所选屏幕",
+ "Couldn't display screen preview.": "无法显示屏幕预览。",
+ "Preview active. Previewing does not grant local control; local actions still require approval.": "预览已启动。预览不会授予本机控制权限,本机操作仍需授权。",
+ "Local control": "本机控制",
+ "Needs attention": "需要处理",
+ "Enabling lets bots you explicitly assign to This computer inspect the active desktop and request mouse or keyboard actions. Every local action asks you first.": "启用后,明确分配到“此电脑”的 Bot 可以检查当前桌面,并请求鼠标或键盘操作。每项本机操作都会先征得你的同意。",
+ " GNOME may also ask you to allow foreground input for this desktop session.": " GNOME 还可能要求你允许当前桌面会话接收前台输入。",
+ "Ready for bots explicitly assigned to this computer.": "已可供明确分配到此电脑的 Bot 使用。",
+ "Checking the driver and desktop session…": "正在检查驱动和桌面会话…",
+ "Bundled Cua Driver": "内置 Cua Driver",
+ "Enable local control (Beta)": "启用本机控制(Beta)",
+ "Disable local control": "关闭本机控制",
+ "Local control guide": "本机控制指南",
+ "Driver setup and troubleshooting": "驱动设置与故障排除",
+ "Allow control of this computer": "允许控制此电脑",
+ "OpenMausBot needs Accessibility and Screen Recording in System Settings before a bot can use this Mac. After you grant both, click Retry — macOS may still ask you to relaunch the app.": "Bot 使用此 Mac 前,OpenMausBot 需要在系统设置中获得辅助功能和屏幕录制权限。授予两项权限后请点击“重试”;macOS 仍可能要求重启应用。",
+ "Open System Settings": "打开系统设置",
+ "Allow Auto mode on this computer?": "允许在此电脑上使用自动模式吗?",
+ "Auto mode will let this bot click, type, and run tools on this computer without asking first. Destructive and sensitive actions still stop. Continue only if you are watching.": "自动模式允许此 Bot 无需逐次询问即可在此电脑上点击、输入和运行工具;破坏性及敏感操作仍会暂停。请仅在你能持续关注时继续。",
+ "OK": "确定",
+ "Cloud backend": "云端后端",
+ "Auto only attaches to a VPS container that is already running — a stopped or missing one is never provisioned or started, and the bot quietly works as if no cloud computer existed. Choose Cloud to provision or start it. No interactive desktop tunnel is exposed.": "自动模式只连接已在运行的 VPS 容器,不会配置或启动已停止或缺失的容器;此时 Bot 会像没有云电脑一样继续工作。选择“云端”可配置或启动容器。该模式不提供交互式桌面通道。",
+ "Box is the default hosted computer. Choose Self-hosted VPS to use your SSH-configured Linux Docker host.": "Box 是默认托管电脑。选择“自托管 VPS”可使用通过 SSH 配置的 Linux Docker 主机。",
+ "Self-hosted VPS requires Claude or an ACP engine": "自托管 VPS 需要 Claude 或 ACP 引擎",
+ "Self-hosted VPS": "自托管 VPS",
+ "Composio project key": "Composio 项目密钥",
+ "Connect Gmail, GitHub, Slack, Notion, and other apps through your own Composio project.": "通过你自己的 Composio 项目连接 Gmail、GitHub、Slack、Notion 等应用。",
+ "Create or copy a project key": "创建或复制项目密钥",
+ "Box API key": "Box API 密钥",
+ "Paste your Box API key": "粘贴 Box API 密钥",
+ "Give bots an isolated remote Linux computer with a desktop and terminal.": "为 Bot 提供带桌面和终端的隔离远程 Linux 电脑。",
+ "Open Box API key guide": "打开 Box API 密钥指南",
+ "Box is a paid service after its trial. Usage may incur charges.": "Box 试用期结束后为付费服务,使用时可能产生费用。",
+ "OpenCode Go API key": "OpenCode Go API 密钥",
+ "Paste your OpenCode Go API key": "粘贴 OpenCode Go API 密钥",
+ "Run OpenCode Go models through the maintained OpenCode CLI and ACP.": "通过维护中的 OpenCode CLI 和 ACP 运行 OpenCode Go 模型。",
+ "Open OpenCode Go setup guide": "打开 OpenCode Go 设置指南",
+ "About {label}": "关于{label}",
+ "{label} help": "{label}帮助",
+ "Optional": "可选",
+ "•••••••• (paste to replace)": "•••••••• (粘贴以替换)",
+ "Remove the saved key": "移除已保存的密钥",
+ "SSH config alias for the Linux VPS. OpenMausBot uses your normal SSH config and agent; it does not store keys or passwords.": "Linux VPS 的 SSH 配置别名。OpenMausBot 使用你现有的 SSH 配置和 Agent,不会存储密钥或密码。",
+ "See the": "请参阅",
+ "setup guide": "设置指南",
+ "for the required SSH alias shape.": "了解所需的 SSH 别名格式。",
+ "Self-hosted VPS SSH config alias": "自托管 VPS 的 SSH 配置别名",
+ "Remove the saved alias": "移除已保存的别名",
+ "{name} detected CLI": "检测到的 {name} CLI",
+ "Select a detected binary…": "选择检测到的可执行文件…",
+ "Enter path manually…": "手动输入路径…",
+ "{name} custom CLI path": "{name} 自定义 CLI 路径",
+ "Test failed — {message}": "测试失败:{message}",
+ "Register this path anyway?": "仍要注册此路径吗?",
+ "Test passed — {version}": "测试通过:{version}",
+ "Edit path": "编辑路径",
+ "Save anyway": "仍然保存",
+ "default": "默认",
+ "Resetting…": "正在重置…",
+ "Reset": "重置",
+ "Set CLI…": "设置 CLI…",
+ "No CLI engines detected yet.": "尚未检测到 CLI 引擎。",
+ "Local": "本地",
+ "Set CLI points an engine at a specific binary — a versioned build, a wrapper script, or an absolute path. Saving reloads providers and interrupts any running turns.": "“设置 CLI”可让引擎使用指定的可执行文件,包括特定版本、包装脚本或绝对路径。保存后会重新加载提供商,并中断正在运行的轮次。",
+ "Copy command": "复制命令",
+ "Install a supported container runtime first": "请先安装受支持的容器运行时",
+ "Base image": "基础镜像",
+ "Connected directly over USB. The screen and controls stay on this computer.": "通过 USB 直接连接,屏幕画面和控制操作均保留在此电脑上。",
+ "Unlock the Android phone, accept the “Allow USB debugging” prompt, and optionally choose Always allow from this computer.": "解锁 Android 手机,接受“允许 USB 调试”提示;也可以选择始终允许此电脑。",
+ "The phone is {state}. Reconnect the USB cable and keep USB debugging enabled.": "手机状态为 {state}。请重新连接 USB 数据线,并保持 USB 调试开启。",
+ "Interactive Android screen for {name}": "{name} 的交互式 Android 屏幕",
+ "{name} screen": "{name} 的屏幕",
+ "Back": "返回",
+ "Home": "主页",
+ "Recent": "最近任务",
+ "Click to tap, drag or use a trackpad to scroll, and type after selecting a field.": "单击可轻触,拖动或使用触控板可滚动;选择输入框后可直接键入。",
+ "First-time USB setup": "首次 USB 设置",
+ "Connect the phone with a data-capable USB cable and keep it unlocked.": "使用支持数据传输的 USB 线连接手机,并保持解锁。",
+ "Enable Developer options by tapping Build number seven times in About phone, then turn on USB debugging.": "在“关于手机”中连续点击七次“版本号”以启用开发者选项,然后打开 USB 调试。",
+ "Accept Allow USB debugging on the phone. You can choose Always allow for this trusted computer.": "在手机上接受“允许 USB 调试”。对于可信电脑,可以选择始终允许。",
+ "Agent control uses this same authorized USB connection. No phone companion app, account, Tailscale, or wireless pairing is needed.": "Agent 控制使用同一条已授权的 USB 连接,无需手机伴侣应用、账户、Tailscale 或无线配对。",
+ "Once connected, ask any compatible Maus to open an Android app or complete a task on your phone. The bundled Phone Harness skill loads automatically for phone requests.": "连接后,可让任意兼容的 Maus 打开 Android 应用或在手机上完成任务。处理手机请求时会自动加载内置的 Phone Harness 技能。",
+ "device": "已连接",
+ "unauthorized": "未授权",
+ "offline": "离线",
+ "Rename": "重命名",
+ "Rename agent": "重命名 Agent",
+ "Double-click to rename": "双击重命名",
+ "Task": "任务",
+ "Let this turn finish first": "请先等待当前轮次结束",
+ "New task — a fresh context on this bot": "新建任务,为此 Bot 创建全新上下文",
+ "Switch task": "切换任务",
+ "Click to switch · double-click to rename": "单击切换,双击重命名",
+ "Delete task": "删除任务",
+ "Delete this task and its conversation": "删除此任务及其对话",
+ "New task": "新建任务",
+ "Switch task · {tokens} ({input} in · {output} out)": "切换任务 · {tokens}(输入 {input} · 输出 {output})",
+ "Hang up on {name}": "挂断与 {name} 的通话",
+ "Checking call availability": "正在检查通话可用性",
+ "Calls currently need the macOS desktop app": "通话功能目前需要 macOS 桌面应用",
+ "Add an ElevenLabs key in an agent profile to make calls": "请在 Agent 资料中添加 ElevenLabs 密钥以使用通话",
+ "Pick a voice in an agent profile to make calls": "请在 Agent 资料中选择语音以使用通话",
+ "Call {name}": "呼叫 {name}",
+ "Checking whether this device can make calls.": "正在检查此设备能否进行通话。",
+ "Calls require OpenMausBot for macOS because speech recognition runs on-device.": "通话需要 macOS 版 OpenMausBot,因为语音识别在设备本地运行。",
+ "The speech service is unavailable in this app build. Restart or update OpenMausBot.": "当前应用版本无法使用语音服务,请重启或更新 OpenMausBot。",
+ "Add an ElevenLabs API key so the bot can speak during calls.": "请添加 ElevenLabs API 密钥,让 Bot 能在通话中发声。",
+ "Give every room member an ElevenLabs voice before starting a room call.": "开始房间通话前,请为每位成员设置 ElevenLabs 语音。",
+ "Choose an ElevenLabs voice before starting a call.": "开始通话前,请选择 ElevenLabs 语音。",
+ "Call unavailable": "通话不可用",
+ "Open agent settings": "打开 Agent 设置",
+ "Hang up": "挂断",
+ "Push to talk": "按住说话",
+ "Listening": "正在聆听",
+ "One moment": "请稍候",
+ "Working": "正在处理",
+ "Release Control + Option to send…": "松开 Control + Option 发送…",
+ "Say something…": "请说话…",
+ "Try microphone again": "重新尝试麦克风",
+ "Push to talk couldn't start. Check Microphone and Speech Recognition access.": "无法启动按住说话,请检查麦克风和语音识别权限。",
+ "The microphone couldn't start. Check Microphone and Speech Recognition access.": "无法启动麦克风,请检查麦克风和语音识别权限。",
+ "Dictation stopped unexpectedly. Check Microphone and Speech Recognition access.": "听写意外停止,请检查麦克风和语音识别权限。",
+ "Calls need macOS dictation, which isn't available here yet.": "通话需要 macOS 听写功能,当前环境暂不支持。",
+ "The dictation helper couldn't be built. Install Apple's Command Line Tools and try again.": "无法构建听写辅助程序,请安装 Apple Command Line Tools 后重试。",
+ "Dictation needs Microphone + Speech Recognition access in System Settings.": "听写需要在系统设置中获得麦克风和语音识别权限。",
+ "Agent profile": "Agent 资料",
+ "Collapse agent profile": "收起 Agent 资料",
+ "Close agent profile": "关闭 Agent 资料",
+ "Name": "名称",
+ "Title": "职位",
+ "Description": "描述",
+ "Describe what your agent does": "描述此 Agent 的职责",
+ "What this agent is for": "此 Agent 的用途",
+ "One per workspace": "每个工作区仅一个",
+ "This engine cannot contact other bots": "此引擎无法联系其他 Bot",
+ "This bot still holds the role, but its current engine cannot contact teammates. Choose a Claude or ACP engine to restore coordination.": "此 Bot 仍担任该角色,但当前引擎无法联系队友。请选择 Claude 或 ACP 引擎以恢复协调能力。",
+ "This is your primary contact. It can coordinate the other bots and combine their work into one answer.": "这是你的主要联系人,可以协调其他 Bot 并将它们的工作整合为一个回答。",
+ "Choose a Claude or ACP engine to let this bot coordinate teammates.": "请选择 Claude 或 ACP 引擎,让此 Bot 能够协调队友。",
+ "Make this bot your primary contact and hand the role over from {name}.": "将此 Bot 设为主要联系人,并从 {name} 手中接任该角色。",
+ "Make this bot your primary contact for work that may involve several bots.": "将此 Bot 设为可能涉及多个 Bot 工作的主要联系人。",
+ "Ask me before contacting other bots": "联系其他 Bot 前先询问我",
+ "This bot will stop and ask before it reaches out to another bot.": "此 Bot 联系其他 Bot 前会暂停并征求你的同意。",
+ "Let this bot talk to teammates on its own, without a confirmation step.": "允许此 Bot 无需确认即可自行与队友沟通。",
+ "Connect apps in App Settings before giving this bot access.": "请先在应用设置中连接应用,再授予此 Bot 访问权限。",
+ "This bot's current engine cannot use connected apps.": "此 Bot 当前使用的引擎无法访问已连接应用。",
+ "Let this bot use your connected Gmail, Calendar, Slack, and other apps.": "允许此 Bot 使用你已连接的 Gmail、日历、Slack 等应用。",
+ "Keep your connected apps unavailable to this bot.": "不允许此 Bot 使用已连接应用。",
+ "Allow this bot to use connected apps": "允许此 Bot 使用已连接应用",
+ "Connect apps in App Settings first": "请先在应用设置中连接应用",
+ "This engine cannot use connected apps": "此引擎无法使用已连接应用",
+ "Model": "模型",
+ "Which provider and model this bot runs on": "此 Bot 使用的提供商和模型",
+ "Effort": "思考强度",
+ "How hard this bot thinks": "此 Bot 的思考强度",
+ " (Default: no level is sent)": "(默认:不发送强度等级)",
+ "Default": "默认",
+ "low": "低",
+ "medium": "中",
+ "high": "高",
+ "max": "最高",
+ "Where this bot's computer runs": "此 Bot 的电脑运行位置",
+ " (currently: auto)": "(当前:自动)",
+ "This engine doesn't report a price; tokens are counted.": "此引擎不报告价格,仅统计 Token。",
+ "Where this bot runs its shell and file tools.": "此 Bot 运行命令行和文件工具的位置。",
+ "Private bot workspace": "Bot 独立工作区",
+ "Private bot workspace — or an absolute path": "Bot 独立工作区,或输入绝对路径",
+ "New tasks start here. This task is pinned to {folder} — start a new task to use the new folder.": "新任务将从此处开始。当前任务固定在 {folder};请新建任务以使用新目录。",
+ "the home folder": "主目录",
+ "Memory": "记忆",
+ "Notes this bot keeps between tasks — plain files you can edit.": "此 Bot 在任务之间保留的笔记,均为可编辑的纯文本文件。",
+ "Nothing remembered yet. The bot writes durable notes here — or add your own.": "尚无记忆。Bot 会在这里写入持久笔记,你也可以自行添加。",
+ "Bot memory": "Bot 记忆",
+ "Over the budget — only the top of this file loads each turn.": "内容已超出预算,每个轮次只会加载文件开头部分。",
+ "Topic files": "主题文件",
+ "Auto mode": "自动模式",
+ "Keeps going on this computer — you'll still be asked about anything destructive, and about questions it asks you.": "在此电脑上持续工作;遇到破坏性操作或需要向你提问时仍会征求确认。",
+ "Approve each action on this computer yourself. Turn on to let this bot keep working without stopping to ask.": "由你逐项批准此电脑上的操作。开启后,Bot 可持续工作而无需频繁暂停询问。",
+ "Keeps going on its own — you'll still be asked about anything destructive, and about questions it asks you.": "自行持续工作;遇到破坏性操作或需要向你提问时仍会征求确认。",
+ "Approve each action yourself. Turn on to let this bot keep working without stopping to ask.": "由你逐项批准操作。开启后,Bot 可持续工作而无需频繁暂停询问。",
+ "Notifications": "通知",
+ "Get notified when this agent finishes or needs input": "当此 Agent 完成任务或需要输入时通知你",
+ "Agent notifications": "Agent 通知",
+ "Avatar": "头像",
+ "Reset mascot": "重置吉祥物",
+ "Upload image": "上传图片",
+ "Remove custom avatar image": "移除自定义头像图片",
+ "Remove custom image": "移除自定义图片",
+ "PNG, JPEG, GIF, or WebP · up to 10 MB": "PNG、JPEG、GIF 或 WebP,最大 10 MB",
+ "Shape": "形状",
+ "Mascot": "吉祥物",
+ "Circle": "圆形",
+ "Rounded": "圆角",
+ "Square": "方形",
+ "Expression": "表情",
+ "Color": "颜色",
+ "Use {expression} expression": "使用{expression}表情",
+ "Use {color} mascot color": "使用{color}吉祥物颜色",
+ "Generate with GPT Image 2": "使用 GPT Image 2 生成",
+ "Uses a low-quality square draft to keep cost down. OpenAI bills your API account.": "使用低质量方形草图以降低成本,费用由 OpenAI 计入你的 API 账户。",
+ "Paste OpenAI image API key": "粘贴 OpenAI 图片 API 密钥",
+ "OpenAI image API key": "OpenAI 图片 API 密钥",
+ "Stored in the operating system's encrypted credential store in the installed app.": "在已安装的应用中,密钥会存储在操作系统的加密凭据存储中。",
+ "Optional direction, e.g. “a calm navigator inspired by {name}”": "可选说明,例如“以 {name} 为灵感的沉稳领航员”",
+ "Avatar generation direction": "头像生成说明",
+ "Generating…": "正在生成…",
+ "Generate avatar": "生成头像",
+ "Replace OpenAI image key": "替换 OpenAI 图片密钥",
+ "Paste replacement key": "粘贴替换密钥",
+ "Replacement OpenAI image API key": "替换用 OpenAI 图片 API 密钥",
+ "Voice": "语音",
+ "Give this agent a voice for calls and spoken replies. The ElevenLabs key is shared by the workspace; the voice choice belongs to this agent.": "为此 Agent 设置通话和语音回复所用的声音。ElevenLabs 密钥由工作区共享,声音选择仅属于此 Agent。",
+ "ElevenLabs key": "ElevenLabs 密钥",
+ "Paste your ElevenLabs API key": "粘贴 ElevenLabs API 密钥",
+ "Get a key from ElevenLabs": "从 ElevenLabs 获取密钥",
+ "{name}'s voice": "{name} 的语音",
+ "Loading voices…": "正在加载语音…",
+ "Workspace default": "工作区默认",
+ "Pick a voice": "选择语音",
+ "Current agent voice": "当前 Agent 语音",
+ "Hear this voice": "试听此语音",
+ "Pick a voice first": "请先选择语音",
+ "Try": "试听",
+ "Read replies aloud": "朗读回复",
+ "Speak this agent's answers as they arrive, even from another chat.": "收到此 Agent 的回答时立即朗读,即使回答来自其他对话。",
+ "Read this bot's replies aloud": "朗读此 Bot 的回复",
+ "idle": "空闲",
+ "happy": "开心",
+ "curious": "好奇",
+ "drowsy": "困倦",
+ "working": "工作中",
+ "thinking": "思考中",
+ "listening": "聆听中",
+ "sleeping": "睡眠中",
+ "suspicious": "怀疑",
+ "proud": "自豪",
+ "green": "绿色",
+ "blue": "蓝色",
+ "red": "红色",
+ "orange": "橙色",
+ "purple": "紫色",
+ "cyan": "青色",
+ "pink": "粉色",
+ "yellow": "黄色",
+ "teal": "蓝绿色",
+ "coral": "珊瑚色",
+ "That team file is too large.": "团队文件过大。",
+ "That team file is not valid JSON.": "团队文件不是有效的 JSON。",
+ "Back to teams": "返回团队列表",
+ "{count} ready-to-load bots": "{count} 个 Bot 已准备加载",
+ "Start with a complete team or bring your own.": "选择一个完整团队,或导入你自己的团队。",
+ "Open the community teams repository": "打开社区团队仓库",
+ "Community repo": "社区仓库",
+ "Close teams": "关闭团队",
+ "Team members": "团队成员",
+ "General assistant": "通用助理",
+ "Only roles and appearance are loaded. Your conversations, account connections, permissions, and computer access stay private.": "仅加载角色和外观。你的对话、账户连接、权限和电脑访问设置仍保持私密。",
+ "Playbooks remain available in the community repo for review.": "你仍可在社区仓库中查看工作手册。",
+ "Replaces your {count} current bots. They'll be archived with conversations intact.": "将替换当前 {count} 个 Bot;它们会被归档,并完整保留对话。",
+ "Add alongside instead": "改为并列添加",
+ "This team will be added alongside your current bots.": "该团队将与当前 Bot 一并保留。",
+ "Replace current team instead": "改为替换当前团队",
+ "No room is created—you can make one later if you want.": "不会自动创建房间;你可以稍后按需创建。",
+ "Load team": "加载团队",
+ "Replace team": "替换团队",
+ "Add team": "添加团队",
+ "Team source": "团队来源",
+ "Explore": "探索",
+ "Import": "导入",
+ "Search teams": "搜索团队",
+ "Search results": "搜索结果",
+ "Community teams": "社区团队",
+ "Loading teams…": "正在加载团队…",
+ "{bots} bots · {playbooks} playbooks": "{bots} 个 Bot · {playbooks} 个工作手册",
+ "Loading": "正在加载",
+ "Load": "加载",
+ "No teams found": "未找到团队",
+ "Try a different search.": "请尝试其他关键词。",
+ "Bring your own team": "导入自己的团队",
+ "Choose a team file": "选择团队文件",
+ "or drop a .mausteam.json here": "或将 .mausteam.json 拖到这里",
+ "Load from GitHub": "从 GitHub 加载",
+ "Paste a public repo or a direct team JSON link.": "粘贴公开仓库地址或团队 JSON 的直达链接。",
+ "GitHub team URL": "GitHub 团队链接",
+ "{days} at {time}": "{days} {time}",
+ "Routine": "例程",
+ "Webhook": "Webhook",
+ "Edit routine": "编辑例程",
+ "New routine": "新建例程",
+ "Each run starts a fresh task for this agent. No cron syntax required.": "每次运行都会为此 Agent 新建任务,无需编写 cron 表达式。",
+ "Routine name": "例程名称",
+ "Morning research brief": "晨间研究简报",
+ "Where does it run?": "在哪里运行?",
+ "Uses this MAUS's selected model and computer setting.": "使用此 MAUS 已选择的模型和电脑设置。",
+ "Cloud VM": "云端虚拟机",
+ "Runs the MAUS and its tools inside its Box virtual machine.": "在 Box 虚拟机内运行 MAUS 及其工具。",
+ "The VM wakes automatically for each run. Keep OpenMausBot running so its scheduler can launch the job.": "每次运行时虚拟机会自动唤醒。请保持 OpenMausBot 运行,以便调度器启动任务。",
+ "Cloud VM needs a working Box API key in App Settings before this routine can run.": "运行此例程前,需要先在应用设置中配置有效的 Box API 密钥。",
+ "Who does it?": "由谁执行?",
+ "Assigned from Computer": "由电脑面板指定",
+ "What should this MAUS do?": "这个 MAUS 应该做什么?",
+ "Check the latest project activity, summarize what changed, and call out anything that needs my attention…": "检查最新项目动态,总结变更,并指出需要我关注的事项…",
+ "When?": "何时运行?",
+ "Repeating": "重复",
+ "Once": "单次",
+ "Calendar block": "日历时段",
+ "{count} minutes": "{count} 分钟",
+ "{count} hours": "{count} 小时",
+ "Save changes": "保存更改",
+ "Create routine": "创建例程",
+ "Needs you": "需要你处理",
+ "completed": "已完成",
+ "failed": "失败",
+ "missed": "已错过",
+ "cancelled": "已取消",
+ "scheduled": "已计划",
+ "Schedule": "计划",
+ "MAUS setup": "MAUS 配置",
+ "Duration": "时长",
+ "Triggered by": "触发方式",
+ "Delivery ID": "投递 ID",
+ "Instructions": "指令",
+ "Webhook event data": "Webhook 事件数据",
+ "Last output": "最近输出",
+ "This MAUS needs your answer. Open its task to continue the run.": "此 MAUS 正在等待你的回答。打开对应任务以继续运行。",
+ "Run now": "立即运行",
+ "Open task": "打开任务",
+ "Cancel run": "取消运行",
+ "Edit": "编辑",
+ "Pause": "暂停",
+ "Resume": "继续",
+ "Delete “{name}”? Its past run receipts will stay in the calendar.": "删除“{name}”?以往运行记录仍会保留在日历中。",
+ "Delete routine": "删除例程",
+ "Paused routines": "已暂停的例程",
+ "They keep their history and will not create new runs.": "它们会保留历史记录,但不会创建新的运行。",
+ "Deleted MAUS": "已删除的 MAUS",
+ "Delete “{name}”?": "删除“{name}”?",
+ "{count} active": "{count} 个运行中",
+ "{count} need attention": "{count} 个需要处理",
+ "{count} paused": "{count} 个已暂停",
+ "Routines": "例程",
+ "Webhooks": "Webhook",
+ " = one conversation and result. ": " = 一次对话及其结果。",
+ " = a reusable schedule that creates a fresh task each run, using that agent's model, tools, permissions, computer, and connected apps.": " = 可复用的计划,每次运行都会使用该 Agent 的模型、工具、权限、电脑和已连接应用来创建新任务。",
+ " = an event endpoint that creates a fresh task. Connected services can call it when something happens; the receiving agent keeps its existing tools and permissions.": " = 用于创建新任务的事件端点。已连接的服务可以在事件发生时调用它,接收任务的 Agent 会沿用现有工具和权限。",
+ "Previous dates": "上一时间段",
+ "Next dates": "下一时间段",
+ "All MAUSes": "所有 MAUS",
+ "Day": "日",
+ "3 days": "3 日",
+ "Week": "周",
+ "Put your MAUS team on a rhythm": "让你的 MAUS 团队按计划运转",
+ "Plan research briefs, daily check-ins, recurring reviews, or one-time work. Every run becomes a separate task with its own result.": "安排研究简报、每日检查、定期回顾或一次性工作。每次运行都会成为独立任务,并生成自己的结果。",
+ "Create your first routine": "创建第一个例程",
+ "Create a bot first, then come back to schedule it.": "请先创建 Bot,再回来为它安排计划。",
+ "Never": "从未",
+ "Just now": "刚刚",
+ "{count}m ago": "{count} 分钟前",
+ "{count}h ago": "{count} 小时前",
+ "{name} webhook": "{name} Webhook",
+ "Edit webhook": "编辑 Webhook",
+ "New local webhook": "新建本地 Webhook",
+ "Each request starts a new task in the MAUS chat.": "每个请求都会在 MAUS 对话中创建一个新任务。",
+ "Who receives the tasks?": "由谁接收任务?",
+ "Send the task in the request:": "在请求中发送任务:",
+ ". The MAUS keeps its model, tools, permissions, and computer setup.": "。MAUS 会沿用其模型、工具、权限和电脑配置。",
+ "Advanced options": "高级选项",
+ "optional": "可选",
+ "Default instructions": "默认指令",
+ "For every event, summarize what happened and suggest the next step…": "为每个事件总结发生的情况,并建议下一步…",
+ "Use this only when every event needs the same handling rule. Otherwise the request’s task is used.": "仅在所有事件都需要相同处理规则时使用;否则将使用请求中的任务。",
+ "Run on": "运行位置",
+ "Only accept event types": "仅接受以下事件类型",
+ "Comma-separated values from the sender’s event-type header.": "使用逗号分隔发送方事件类型请求头中的值。",
+ "Delete “{name}”? Existing task history will stay available.": "删除“{name}”?已有任务历史仍会保留。",
+ "Send a request before turning this webhook on": "启用此 Webhook 前请先发送一个请求",
+ "Replace this private URL? Every previously copied command will stop working.": "替换此私密链接?之前复制的所有命令都将失效。",
+ "Could not create a terminal command": "无法创建终端命令",
+ "Local beta": "本地 Beta",
+ "Send a task to a MAUS when another tool reports an event.": "当其他工具报告事件时,向 MAUS 发送任务。",
+ "Receiver running": "接收器运行中",
+ "Receiver unavailable": "接收器不可用",
+ "New webhook": "新建 Webhook",
+ "Create your first webhook": "创建第一个 Webhook",
+ "Choose a MAUS, copy one command, and every request becomes a new task in its chat.": "选择一个 MAUS 并复制一条命令,此后的每个请求都会成为其对话中的新任务。",
+ "Create local webhook": "创建本地 Webhook",
+ "Create a MAUS first, then come back here.": "请先创建一个 MAUS,再返回此处。",
+ "Your webhooks": "你的 Webhook",
+ "Edit settings": "编辑设置",
+ "Activity": "活动",
+ "Send a task": "发送任务",
+ "Copy this command into Terminal and press Return. It starts a real task in {name}'s chat; edit the task text for whatever you want done.": "将此命令复制到终端并按回车,它会在 {name} 的对话中创建真实任务;你可以按需修改任务文本。",
+ "this MAUS": "此 MAUS",
+ "Terminal": "终端",
+ "Copied": "已复制",
+ "Rotate private URL": "更换私密链接",
+ "The private URL is shown once. Generate a replacement to copy your webhook command; any older command for this webhook will stop working.": "私密链接只显示一次。生成新链接以复制 Webhook 命令;此 Webhook 的旧命令将全部失效。",
+ "Generate new private URL": "生成新的私密链接",
+ "Local only for now. Keep OpenMausBot open while sending the request.": "目前仅支持本地使用。发送请求时请保持 OpenMausBot 打开。",
+ "Request received": "已收到请求",
+ "Empty payload": "空载荷",
+ "Turn on": "启用",
+ "Tasks go to": "任务发送至",
+ "Advanced": "高级",
+ "Default instruction:": "默认指令:",
+ "The task or message sent with each request becomes the MAUS instruction.": "每个请求携带的任务或消息都会成为 MAUS 的指令。",
+ "Accepted events:": "接受的事件:",
+ "All event types are accepted.": "接受所有事件类型。",
+ "Recent deliveries": "最近投递",
+ "Accepted and rejected requests update automatically.": "已接受和已拒绝的请求会自动更新。",
+ "No requests yet. Use the command in Setup to send one.": "尚无请求。请使用“设置”中的命令发送一个请求。",
+ "Open this execution in the MAUS chat": "在 MAUS 对话中打开此次执行",
+ "Open chat": "打开对话",
+ "Waiting for test": "等待测试",
+ "Ready to enable": "可以启用",
+ "Active": "已启用",
+ "Test received": "已收到测试",
+ "Ignored": "已忽略",
+ "Rejected": "已拒绝",
+ "Accepted": "已接受",
+ "Rejected request": "已拒绝的请求",
+ "Webhook event": "Webhook 事件",
+ "Enable": "启用",
+ "Your browser blocked the connection page. Click Continue to open it.": "浏览器阻止了连接页面。请点击“继续”将其打开。",
+ "Connect the apps your bots can use.": "连接你的 Bot 可以使用的应用。",
+ "Refresh connection status": "刷新连接状态",
+ "Close connected apps": "关闭已连接应用",
+ "Connected apps view": "已连接应用视图",
+ "Marketplace": "应用市场",
+ "Connected": "已连接",
+ "Search apps": "搜索应用",
+ "Connected apps are temporarily unavailable. You can retry after restarting, or configure your own connection service.": "已连接应用暂时不可用。你可以重启后重试,或配置自己的连接服务。",
+ "Open settings": "打开设置",
+ "Showing featured apps.": "当前显示精选应用。",
+ "Update your Composio key": "更新 Composio 密钥",
+ "for the full catalog.": "以查看完整目录。",
+ "Loading catalog…": "正在加载应用目录…",
+ "Your connections": "你的连接",
+ "Available apps": "可用应用",
+ "Finish setup in your browser": "请在浏览器中完成设置",
+ "Authorization expired — try again": "授权已过期,请重试",
+ "Continue": "继续",
+ "Add account": "添加账户",
+ "Included": "已包含",
+ "Connect": "连接",
+ "Disconnect {identity} from {service}? Only this {service} account will be revoked. Your other {service} accounts will stay connected.": "断开 {identity} 与 {service} 的连接?仅会撤销此 {service} 账户,其他 {service} 账户仍会保持连接。",
+ "Disconnect {account} from {service}": "断开 {account} 与 {service} 的连接",
+ "Disconnect": "断开连接",
+ "Enter a label for the account, such as work or personal.": "请输入账户标签,例如“工作”或“个人”。",
+ "Account label (work, personal…)": "账户标签(工作、个人等)",
+ "Label for another {service} account": "另一个 {service} 账户的标签",
+ "No connected apps yet": "尚未连接应用",
+ "No apps found": "未找到应用",
+ "Connect an app from Marketplace and it will appear here.": "从应用市场连接应用后,它会显示在这里。",
+ "active": "已启用",
+ "connected": "已连接",
+ "not_connected": "未连接",
+ "initiated": "正在连接",
+ "pending": "等待中",
+ "expired": "已过期",
+ "Post updates and read channels": "发布动态并读取频道",
+ "Issues, pull requests, and code": "议题、拉取请求与代码",
+ "Read and send email": "读取和发送邮件",
+ "Read and create events": "读取和创建日程",
+ "Read and update spreadsheets": "读取和更新电子表格",
+ "Read and write documents": "读取和编辑文档",
+ "Browse and manage files": "浏览和管理文件",
+ "Pages and databases": "页面与数据库",
+ "Issues and project tracking": "议题与项目跟踪",
+ "Errors and alerts": "错误与告警",
+ "Analytics, feature flags, experiments": "分析、功能开关与实验",
+ "Messages and channels": "消息与频道",
+ "Post and read on X": "在 X 上发布和读取内容",
+ "Browse and post": "浏览和发布内容",
+ "Connect 9,000+ apps": "连接 9,000 多个应用",
+ "CRM search & updates": "CRM 搜索与更新",
+ "CRM records and reports": "CRM 记录与报告",
+ "Issues and sprints": "议题与迭代",
+ "Tasks and projects": "任务与项目",
+ "Boards and cards": "看板与卡片",
+ "Files and folders": "文件与文件夹",
+ "Bases and records": "数据表与记录",
+ "Files and comments": "文件与评论",
+ "Payments and customers": "付款与客户",
+ "{name} responds by default — @mention someone else to choose them instead.": "默认由 {name} 回复;也可以 @ 提及其他 Bot。",
+ "{name} responds": "{name} 回复",
+ "The lead bot": "主 Bot",
+ "Lead": "主 Bot",
+ "Interrupt": "打断",
+ "Hold Control + Option to talk · Space interrupts · Esc hangs up": "按住 Control + Option 说话 · 空格键打断 · Esc 挂断",
+ "/path/to/your/project": "/path/to/your/project",
+ "A channel needs at least one bot.": "频道至少需要一个 Bot。",
+ "A shared brief every member sees on each turn. You can edit it later.": "所有成员每轮都会看到的共享说明,之后仍可编辑。",
+ "Add channel instructions…": "添加频道说明…",
+ "All room members": "所有频道成员",
+ "Android": "Android",
+ "Applies to every bot turn in channels. Direct chats use the inactivity watchdog instead.": "适用于频道中的每个 Bot 任务轮次。私聊改用空闲监测机制。",
+ "bot": "Bot",
+ "bots": "Bot",
+ "Bots": "Bot",
+ "Browse": "浏览",
+ "Cancel channel rename": "取消频道重命名",
+ "Channel": "频道",
+ "Channels": "频道",
+ "Channel behavior": "频道回复方式",
+ "Channel bulletin — shared instructions for every bot here": "频道公告——这里所有 Bot 共用的说明",
+ "Channel context": "频道分类",
+ "Channel instructions — every bot in this channel follows them (who does what, tone, goals, a task checklist…)": "频道说明——此频道中的每个 Bot 都会遵循(分工、语气、目标、任务清单等)",
+ "Channel lead": "频道负责人",
+ "Channel name (for example, Website launch)": "频道名称(例如:网站发布)",
+ "Channel turns": "频道任务轮次",
+ "Channel working folder": "频道工作目录",
+ "Choose": "选择",
+ "Choose a lead": "选择负责人",
+ "Choose a teammate": "选择一名成员",
+ "Choose who answers when nobody is mentioned.": "选择未提及任何成员时由谁回复。",
+ "Context (optional): Work, Personal, Client…": "分类(可选):工作、个人、客户…",
+ "Create a bot first — channels are made of bots.": "请先创建 Bot,频道由 Bot 组成。",
+ "Create Channel": "创建频道",
+ "Create project channel": "创建项目频道",
+ "Creates the team as new bots, opens a channel for them, and points the channel at this folder.": "将团队创建为新的 Bot,为它们建立频道,并把频道工作目录设为此文件夹。",
+ "Creating…": "正在创建…",
+ "Delete Channel": "删除频道",
+ "Diagnostics": "诊断信息",
+ "Export diagnostics to a text file": "将诊断信息导出为文本文件",
+ "Export Diagnostics…": "导出诊断信息…",
+ "Finish room setup to start chatting": "完成频道设置后即可开始聊天",
+ "Fixed after this channel's first turn. Create a new channel and choose its folder before sending the first message to work somewhere else.": "首次任务后工作目录将被固定。如需使用其他目录,请新建频道并在发送第一条消息前完成选择。",
+ "From a folder": "从文件夹生成",
+ "From the community directory — tick to add": "来自社区目录——勾选即可添加",
+ "github.com/owner/repo": "github.com/owner/repo",
+ "Give every channel member an ElevenLabs voice before starting a channel call.": "开始频道通话前,请为每个频道成员设置 ElevenLabs 语音。",
+ "Give this room a shared workspace, response style, and a little context before the first conversation starts.": "首次对话前,为此频道设置共享工作目录、回复方式和背景说明。",
+ "Goals, tone, ownership, constraints…": "目标、语气、分工、限制…",
+ "Manage members": "管理成员",
+ "Manage Members": "管理成员",
+ "Manage members — {count} {unit} in this channel": "管理成员——此频道中有 {count} 个{unit}",
+ "Manage members of {name}": "管理 {name} 的成员",
+ "Matches {matches}": "匹配:{matches}",
+ "Move to context": "移动到分类",
+ "New Channel": "新建频道",
+ "New context name": "新分类名称",
+ "New context…": "新建分类…",
+ "No channel is created—you can make one later if you want.": "不会创建频道;如有需要,可以稍后再创建。",
+ "Only @mentioned members": "仅 @ 提及的成员",
+ "Only when mentioned": "仅被提及时",
+ "Open {name} on botdirectory.ai": "在 botdirectory.ai 打开 {name}",
+ "Pin this message to the top of the channel": "将此消息置顶到频道顶部",
+ "Plain messages go to every channel member; @mentions override this": "普通消息会发送给所有频道成员;@ 提及会覆盖此设置",
+ "Plain messages go to this teammate.": "普通消息会发送给此成员。",
+ "Point the scout at a folder. It reads what's in there — README, dependencies, layout — and suggests a team for it. Nothing is created until you say so.": "选择一个文件夹后,分析器会读取其中的 README、依赖和目录结构,并推荐适合的团队。确认前不会创建任何内容。",
+ "Project channel name": "项目频道名称",
+ "Project folder to scout": "要分析的项目文件夹",
+ "Read this bot's page before adding it": "添加前查看此 Bot 的详情页",
+ "Remove from context": "从分类中移除",
+ "Rename Channel": "重命名频道",
+ "Room instructions": "频道说明",
+ "Save & continue": "保存并继续",
+ "Save channel name": "保存频道名称",
+ "Saved to {path}": "已保存到 {path}",
+ "Scout": "分析",
+ "Scouting…": "正在分析…",
+ "Set one maximum duration for every bot turn in a channel.": "为频道中的每个 Bot 任务轮次设置统一的最长时间。",
+ "Set up {name}": "设置 {name}",
+ "Skip for now": "暂时跳过",
+ "Specific lead": "指定负责人",
+ "Start from a project folder": "从项目文件夹开始",
+ "Suggested team": "建议团队",
+ "This channel's members changed while the panel was open. Close it and try again.": "面板打开期间频道成员已发生变化。请关闭后重试。",
+ "Versions, configuration on/off state and a redacted server log tail. Review the file before sharing it.": "包含版本、配置启用状态和已脱敏的服务器日志末尾。分享前请检查文件内容。",
+ "Where every bot in this channel runs its shell and file tools.": "此频道中所有 Bot 运行终端和文件工具的目录。",
+ "Where room members run file and shell tools.": "频道成员运行文件和终端工具的目录。",
+ "Search settings": "搜索设置",
+ "Experimental features": "实验性功能",
+ "Early features may change while we test them. They stay off unless you enable them.": "早期功能在测试期间可能会发生变化;除非你主动启用,否则将保持关闭。",
+ "Teach a skill": "教授技能",
+ "Show the workflow recorder in the sidebar.": "在侧边栏显示工作流录制器。",
+ "Show Teach a skill": "显示“教授技能”",
+ "Could not save the experimental feature setting.": "无法保存实验性功能设置。",
+ "One for {section}": "{section} 分类一个",
+ "This is the primary contact for {section}. It can create and coordinate specialists in this section, then combine their work into one answer.": "这是 {section} 分类的主要联系人。它可以创建并协调该分类中的专业 Bot,再将结果整合为一个答案。",
+ "Make this bot the {section} Chief and hand the role over from {name}.": "将此 Bot 设为 {section} 分类负责人,并从 {name} 接管该角色。",
+ "Make this bot the primary contact for the {section} section.": "将此 Bot 设为 {section} 分类的主要联系人。",
+ "Execution timeline": "执行时间线",
+ "complete": "已完成",
+ "observed": "已记录",
+ "Task started": "任务已开始",
+ "User input": "用户输入",
+ "Screen observed": "已观察屏幕",
+ "Response recorded": "已记录回复",
+ "AssemblyAI transcription": "AssemblyAI 转录",
+ "Live narration for recorded skills. Audio is sent to AssemblyAI while recording; the API key is protected by your operating system.": "为录制的技能提供实时旁白转录。录制期间音频会发送至 AssemblyAI,API 密钥由操作系统保护。",
+ "Paste your AssemblyAI API key": "粘贴你的 AssemblyAI API 密钥",
+ "AssemblyAI API key": "AssemblyAI API 密钥",
+ "Open AssemblyAI dashboard": "打开 AssemblyAI 控制台",
+ "Available in the installed desktop app.": "可在已安装的桌面应用中使用。",
+ "you@example.com": "you@example.com",
+} satisfies Record;
diff --git a/src/main.tsx b/src/main.tsx
index 28a9719f2..23d44a27d 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,14 +2,19 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { applySkin, readSkin } from "./lib/skins";
+import { I18nProvider } from "./lib/i18n-context";
+import { applyLocale, readLocale } from "./lib/i18n";
import "./styles.css";
// Before the first paint, not inside a component: stamping the skin during
// render would show one frame of the default palette first.
applySkin(readSkin());
+applyLocale(readLocale());
createRoot(document.getElementById("root")!).render(
-
+
+
+ ,
);
diff --git a/src/state/store.test.ts b/src/state/store.test.ts
index 4c94035be..1c4177940 100644
--- a/src/state/store.test.ts
+++ b/src/state/store.test.ts
@@ -253,6 +253,30 @@ describe("section Chiefs", () => {
expect(next.bots.find((candidate) => candidate.id === workCandidate.id)?.chiefOfStaff).toBe(true);
expect(next.bots.find((candidate) => candidate.id === personalChief.id)?.chiefOfStaff).toBe(true);
});
+
+ it("keeps one Chief when an existing or newly announced Chief changes sections", () => {
+ const workChief = bot("work", "Work", true);
+ const personalChief = bot("personal", "Personal", true);
+ const state = {
+ ...initialState,
+ bots: [workChief, personalChief].map((candidate) => ({ ...candidate, messages: [] })),
+ };
+
+ const moved = reducer(state, {
+ type: "updateBot",
+ botId: workChief.id,
+ patch: { section: "Personal" },
+ });
+ expect(moved.bots.find((candidate) => candidate.id === workChief.id)?.chiefOfStaff).toBe(true);
+ expect(moved.bots.find((candidate) => candidate.id === personalChief.id)?.chiefOfStaff).toBe(false);
+
+ const announced = reducer(state, {
+ type: "botPatched",
+ bot: { ...bot("remote", "Personal", true), messages: [] },
+ });
+ expect(announced.bots.find((candidate) => candidate.id === "remote")?.chiefOfStaff).toBe(true);
+ expect(announced.bots.find((candidate) => candidate.id === personalChief.id)?.chiefOfStaff).toBe(false);
+ });
});
describe("pending queued chip", () => {
diff --git a/src/state/store.tsx b/src/state/store.tsx
index 2d6716a23..c9a139ba2 100644
--- a/src/state/store.tsx
+++ b/src/state/store.tsx
@@ -698,9 +698,16 @@ export function reducer(state: AppState, action: Action): AppState {
// team import), so add it now; the following message frames will fill
// its greeting without waiting for a full-page hydration.
if (!before) {
+ const bots = action.bot.chiefOfStaff
+ ? state.bots.map((bot) =>
+ (bot.section?.trim() || "") === (action.bot.section?.trim() || "")
+ ? { ...bot, chiefOfStaff: false }
+ : bot,
+ )
+ : state.bots;
return {
...state,
- bots: [{ ...action.bot, messages: action.bot.messages ?? [] }, ...state.bots],
+ bots: [{ ...action.bot, messages: action.bot.messages ?? [] }, ...bots],
};
}
const kind =
@@ -910,7 +917,7 @@ export function reducer(state: AppState, action: Action): AppState {
: state;
const target = animated.bots.find((bot) => bot.id === action.botId);
const chiefSection = (action.patch.section ?? target?.section)?.trim() || "";
- const next = action.patch.chiefOfStaff
+ const next = (action.patch.chiefOfStaff ?? target?.chiefOfStaff)
? {
...animated,
bots: animated.bots.map((b) =>
@@ -1758,8 +1765,8 @@ export function useStore() {
return ctx;
}
-export function formatTime(at: number) {
- return new Date(at).toLocaleTimeString([], {
+export function formatTime(at: number, locale: string) {
+ return new Date(at).toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
});