i18n(desktop): move the Bot Mode kickoff and the residual owned strings into the bundle

The intro a new bot is born with was the first line of its forever-chat and
shipped in English, so a non-English user met their bot in a foreign language
and the bot's reply followed the prompt's language. It now resolves through
the plugin bundle in all four locales. Attribution — the other half of #91827
— still needs the lazy or silent birth that issue proposes, since
prompt.submit IS the user-turn API; the intro itself stays, per AGENTS.md.

The rest is the class the review named rather than only the lines it cited:
every user-visible string the group room and the bot-scoped cron pane own now
lives in the bundle. Where core already ships the vocabulary in every locale —
Remove, weekday names, Daily/Hourly — the plugin reuses it instead of shipping
a second, worse translation. The frequency and weekday option lists stop being
module consts frozen at import, which pinned whichever locale loaded first.

Prompts addressed to a model, cron syntax, and the 'You' author marker stay
hardcoded on purpose, each for a stated reason, recorded in the bundle header.
This commit is contained in:
Brooklyn Nicholson
2026-08-28 12:02:16 -05:00
parent d22e8e4022
commit a7db531a2a
9 changed files with 613 additions and 160 deletions

View File

@@ -21,9 +21,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RosterRow } from './types'
const { hostMock, persistMock, requestForBotMock, saveBotMetaMock } = vi.hoisted(() => ({
const { hostMock, persistMock, pluginCtx, requestForBotMock, saveBotMetaMock } = vi.hoisted(() => ({
hostMock: { openSession: vi.fn(), request: vi.fn() },
persistMock: vi.fn(),
// Null unless a test installs one — the plugin ctx is genuinely absent until
// register() runs, which is why every read of it carries an English floor.
pluginCtx: { current: null as null | { i18n?: { t: (key: string) => string } } },
requestForBotMock: vi.fn(),
saveBotMetaMock: vi.fn()
}))
@@ -53,7 +56,7 @@ vi.mock('./data', () => ({
saveBotMeta: saveBotMetaMock
}))
vi.mock('./shared', () => ({ getPluginCtx: () => null }))
vi.mock('./shared', () => ({ getPluginCtx: () => pluginCtx.current }))
/** Ordered log of everything creation did — RPCs and navigations interleaved,
* because the ORDER between them is most of what this suite pins. */
@@ -75,11 +78,35 @@ async function loadModule() {
beforeEach(() => {
vi.clearAllMocks()
events = []
pluginCtx.current = null
hostMock.openSession.mockImplementation(async (id: string) => {
events.push(`open:${id}`)
})
})
/** Runs a kickoff creation and hands back the text the intro turn submitted. */
async function kickoffTextSent(): Promise<string> {
let sent = ''
respondWith((method, params) => {
if (method === 'session.create') {
return { session_id: 'runtime-1', stored_session_id: 'stored-1' }
}
if (method === 'prompt.submit') {
sent = String(params.text ?? '')
}
return {}
})
const { createCanonicalChat } = await loadModule()
await createCanonicalChat('ops', { kickoff: true })
return sent
}
describe('the lazy row is materialized before anything else touches it', () => {
it('titles the created row, then opens it, and sends no intro', async () => {
respondWith((method, params) => {
@@ -157,6 +184,20 @@ describe('the lazy row is materialized before anything else touches it', () => {
expect(events).toEqual(['session.list', 'session.create', 'session.title', 'open:stored-1', 'prompt.submit'])
})
it('speaks the intro in the active locale (#91827)', async () => {
// The first line of the forever-chat, and the bot's reply follows its
// language — so a hardcoded English intro biased the whole conversation.
pluginCtx.current = { i18n: { t: key => (key === 'bot.kickoff' ? 'こんにちは、自己紹介をしてください!' : key) } }
expect(await kickoffTextSent()).toBe('こんにちは、自己紹介をしてください!')
})
it('falls back to English when the bundle has not registered yet', async () => {
// Creation can race plugin registration; an unresolved key must never
// reach the model as the literal `bot.kickoff`.
expect(await kickoffTextSent()).toBe('Hey, tell me about yourself!')
})
it('retries navigation after the compat kickoff when the eager title is unsupported', async () => {
let attempts = 0

View File

@@ -220,6 +220,23 @@ interface CreateCanonicalChatOptions {
openingStillCurrent?: (() => boolean) | null
}
/** The self-introduction a brand-new bot is born with (#91827).
*
* Localized because it is the FIRST line of the forever-chat: shipped in
* English it opened every non-English user's relationship with their bot in a
* foreign language, and the bot's reply followed the prompt's language, so one
* hardcoded string biased the whole conversation.
*
* It is still submitted on the user's side, which is the half of #91827 this
* cannot close from here: `prompt.submit` IS the user-turn API, so an
* unattributed birth needs the lazy/silent path that issue proposes — a
* gateway contract change, not a rename. The intro itself is deliberate and
* documented in AGENTS.md ("kicked off with the bot's intro"); what this
* narrows is who has to read it in English. */
function kickoffText(): string {
return getPluginCtx()?.i18n?.t('bot.kickoff') ?? 'Hey, tell me about yourself!'
}
/** Create the bot's ONE forever chat: a real session titled "Bot Chat".
* Adopts the existing "Bot Chat" row instead of creating when the profile
* already has one — minting while a "Bot Chat" row exists is always wrong
@@ -406,7 +423,7 @@ export function createCanonicalChat(
try {
await requestForBot(bot, 'prompt.submit', {
session_id: runtime,
text: 'Hey, tell me about yourself!'
text: kickoffText()
})
if (!opened && sid && typeof host.openSession === 'function' && canNavigate()) {

View File

@@ -14,6 +14,8 @@ import type * as HermesSdk from '@hermes/plugin-sdk'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { translateBots } from './i18n-test-helper'
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@@ -29,7 +31,13 @@ const { notify, request } = vi.hoisted(() => ({
vi.mock('@hermes/plugin-sdk', async importOriginal => {
const sdk = await importOriginal<typeof HermesSdk>()
return { ...sdk, host: { ...sdk.host, notify, request } }
return {
...sdk,
host: { ...sdk.host, notify, request },
// The plugin bundle normally lands via `ctx.i18n.register` at load, so
// without this every localized label renders empty.
usePluginI18n: () => translateBots
}
})
const { $botMeta } = await import('./data')

View File

@@ -21,6 +21,7 @@ import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import type { atom } from 'nanostores'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { translateBots } from './i18n-test-helper'
import type { RoutineJob } from './types'
// Radix calls these on open; jsdom doesn't implement them.
@@ -44,7 +45,10 @@ vi.mock('@hermes/plugin-sdk', async importOriginal => {
// The pane's owner ladder starts here, so it has to be a real store:
// `$focusedBotOwner` is resolved once at bot-state module load.
state: { ...sdk.host.state, focusedSessionOwner: nanoAtom(null) }
}
},
// The plugin bundle normally lands via `ctx.i18n.register` at load, so
// without this every localized label renders empty.
usePluginI18n: () => translateBots
}
})
@@ -85,13 +89,13 @@ describe('the pane follows the roster hydrating after mount', () => {
it('starts fail-closed, then paints once the row arrives', async () => {
renderPane()
expect(screen.getByText('This agent has to appear in the roster first.')).toBeTruthy()
expect(screen.getByText('This bot has to appear in the roster first.')).toBeTruthy()
// The same focused bot chat now resolves to its exact roster row. Nothing
// else changes — only a store write the pane must be subscribed to see.
act(() => $lastRoster.set([{ connectionId: 'local', name: 'research' }]))
await waitFor(() => expect(screen.queryByText('This agent has to appear in the roster first.')).toBeNull())
await waitFor(() => expect(screen.queryByText('This bot has to appear in the roster first.')).toBeNull())
expect(await screen.findByText('Report')).toBeTruthy()
expect(screen.getByText('Scheduled jobs')).toBeTruthy()
})
@@ -125,7 +129,7 @@ describe('identity matching stays exact', () => {
act(() => $lastRoster.set([{ connectionId: 'local', name: 'research' }]))
await waitFor(() => expect(screen.getByText('This agent has to appear in the roster first.')).toBeTruthy())
await waitFor(() => expect(screen.getByText('This bot has to appear in the roster first.')).toBeTruthy())
expect(request).not.toHaveBeenCalled()
})
})

View File

@@ -31,6 +31,7 @@ import {
Switch,
Textarea,
Tip,
translateNow,
useI18n,
useQuery,
useValue
@@ -41,6 +42,7 @@ import { avatarColor, botAppearance, BotFace } from './avatar'
import { $focusedBotOwner, $selectedBot, focusedRosterOwner } from './bot-state'
import { $botMeta, $lastRoster, botHandle, botRosterKey, botSelectionKey, isActiveRosterBot } from './data'
import { labeled } from './dialog-parts'
import { botsText, useBots } from './i18n'
import { displayName } from './labels'
import { botConnectionRoute, botRosterMeta, requestForBot } from './routing'
import { ID } from './shared'
@@ -242,10 +244,7 @@ export function routineFilterHint(all: RoutineJob[], jobs: RoutineJob[]): null |
return null
}
return (
'Scheduled jobs exist in this profile but none are tagged for this bot. ' +
'Name a job "[bot:<name>] …" to show it here, or see them in Cron below.'
)
return botsText().cron.filterHint
}
export function normalizedProfileName(profile: unknown): string {
@@ -287,16 +286,17 @@ export function routinePrompt(
}
function scheduleLabel(schedule: string | undefined): string {
const c = botsText().cron
const once = /^once in (.+)$/.exec(schedule || '')
if (once) {
return `Once (${once[1]})`
return c.onceIn(once[1])
}
const bare = /^(\d+)([mhd])$/.exec(schedule || '')
if (bare) {
return `Once (${bare[1]}${bare[2]})`
return c.onceIn(`${bare[1]}${bare[2]}`)
}
const match = /^every (\d+)m$/.exec(schedule || '')
@@ -307,16 +307,17 @@ function scheduleLabel(schedule: string | undefined): string {
if (minutes % 1440 === 0) {
const d = minutes / 1440
return d === 1 ? 'Daily' : `Every ${d} days`
// Daily/Hourly are core's own schedule vocabulary — reuse, don't retranslate.
return d === 1 ? translateNow('cron.scheduleLabels.daily') : c.everyNDays(d)
}
if (minutes % 60 === 0) {
const h = minutes / 60
return h === 1 ? 'Hourly' : `Every ${h}h`
return h === 1 ? translateNow('cron.scheduleLabels.hourly') : c.everyNHours(h)
}
return `Every ${minutes}m`
return c.everyNMinutes(minutes)
}
return schedule || ''
@@ -385,6 +386,7 @@ interface RoutineDetailDialogProps {
* pane already holds — no extra RPC, and no second mutation path beside the
* row's own switch and delete. */
export function RoutineDetailDialog({ job, onClose, open }: RoutineDetailDialogProps) {
const b = useBots()
const { t } = useI18n()
const rows = job ? routineDetailRows(job) : []
const issue = job ? routineDetailIssue(job) : null
@@ -420,7 +422,7 @@ export function RoutineDetailDialog({ job, onClose, open }: RoutineDetailDialogP
</div>
{instruction
? labeled(
'Instruction',
b.cron.instruction,
<div className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-secondary) px-3 py-2 text-xs leading-5 text-(--ui-text-secondary)">
{instruction}
</div>
@@ -576,71 +578,28 @@ interface ScheduleState {
weekday: string
}
const FREQUENCIES: Array<{ id: ScheduleFreq; label: string }> = [
{
id: 'once',
label: 'Once, in\u2026'
},
{
id: 'hourly',
label: 'Every hour'
},
{
id: 'daily',
label: 'Every day'
},
{
id: 'weekdays',
label: 'Weekdays'
},
{
id: 'weekly',
label: 'Every week'
},
{
id: 'monthly',
label: 'Every month'
},
{
id: 'interval',
label: 'Interval'
},
{
id: 'advanced',
label: 'Advanced\u2026'
}
]
/** Built per call, not frozen at module load: the labels are translated, and
* a module const would pin whichever locale happened to be active at import. */
function frequencies(): Array<{ id: ScheduleFreq; label: string }> {
const c = botsText().cron
const WEEKDAYS = [
{
id: '1',
label: 'Monday'
},
{
id: '2',
label: 'Tuesday'
},
{
id: '3',
label: 'Wednesday'
},
{
id: '4',
label: 'Thursday'
},
{
id: '5',
label: 'Friday'
},
{
id: '6',
label: 'Saturday'
},
{
id: '0',
label: 'Sunday'
}
]
return [
{ id: 'once', label: c.freqOnce },
{ id: 'hourly', label: c.freqHourly },
{ id: 'daily', label: c.freqDaily },
{ id: 'weekdays', label: c.freqWeekdays },
{ id: 'weekly', label: c.freqWeekly },
{ id: 'monthly', label: c.freqMonthly },
{ id: 'interval', label: c.freqInterval },
{ id: 'advanced', label: c.freqAdvanced }
]
}
/** Weekday names come from core's cron section — it already ships them in
* every locale, keyed by the same cron day numbers. */
function weekdays(): Array<{ id: string; label: string }> {
return ['1', '2', '3', '4', '5', '6', '0'].map(id => ({ id, label: translateNow(`cron.days.${id}`) }))
}
const TIMES = (() => {
const out = []
@@ -698,39 +657,42 @@ function composeSchedule(state: ScheduleState): string {
}
function scheduleSummary(state: ScheduleState): string {
const c = botsText().cron
const t = TIMES.find(x => x.id === state.time)
const tl = t ? t.label : '9:00 AM'
const unitWord = (u: string) => (u === 'm' ? 'minute(s)' : u === 'd' ? 'day(s)' : 'hour(s)')
const unitWord = (u: string) => (u === 'm' ? c.unitMinutes : u === 'd' ? c.unitDays : c.unitHours)
const cap =
state.freq !== 'once' && String(state.repeatN || '').trim()
? `, ${Math.max(1, parseInt(state.repeatN, 10) || 1)} time(s) total`
? c.timesTotal(Math.max(1, parseInt(state.repeatN, 10) || 1))
: ''
switch (state.freq) {
case 'once':
return `Runs once, ${Math.max(1, parseInt(state.onceN, 10) || 1)} ${unitWord(state.onceUnit)} from now`
return c.runsOnce(Math.max(1, parseInt(state.onceN, 10) || 1), unitWord(state.onceUnit))
case 'hourly':
return 'Runs at the top of every hour' + cap
return c.runsHourly + cap
case 'daily':
return `Runs every day at ${tl}` + cap
return c.runsDaily(tl) + cap
case 'weekdays':
return `Runs Monday\u2013Friday at ${tl}` + cap
return c.runsWeekdays(tl) + cap
case 'weekly': {
const days = weekdays()
case 'weekly':
return `Runs every ${(WEEKDAYS.find(w => w.id === state.weekday) || WEEKDAYS[0]).label} at ${tl}` + cap
return c.runsWeekly((days.find(w => w.id === state.weekday) || days[0]).label, tl) + cap
}
case 'monthly':
return `Runs on day ${state.monthday || '1'} of each month at ${tl}` + cap
return c.runsMonthly(state.monthday || '1', tl) + cap
case 'interval':
return `Runs every ${Math.max(1, parseInt(state.intervalN, 10) || 1)} ${unitWord(state.intervalUnit)}` + cap
return c.runsInterval(Math.max(1, parseInt(state.intervalN, 10) || 1), unitWord(state.intervalUnit)) + cap
default:
return 'Raw schedule \u2014 every Nm/Nh/Nd or 5-field cron'
return c.runsRaw
}
}
@@ -761,6 +723,8 @@ interface SchedulePickerProps {
}
function SchedulePicker({ state, setState }: SchedulePickerProps) {
const b = useBots()
const upd = (patch: Partial<ScheduleState>) =>
setState(prev => ({
...prev,
@@ -778,7 +742,7 @@ function SchedulePicker({ state, setState }: SchedulePickerProps) {
upd({
freq: v
}),
FREQUENCIES
frequencies()
)}
{needsTime
? pickerSelect(
@@ -833,12 +797,12 @@ function SchedulePicker({ state, setState }: SchedulePickerProps) {
upd({
weekday: v
}),
WEEKDAYS
weekdays()
)
: null}
{state.freq === 'monthly'
? labeled(
'Day of month',
b.cron.dayOfMonth,
<Input
className="h-8"
onChange={event =>
@@ -944,6 +908,7 @@ interface CreateRoutineDialogProps {
}
export function CreateRoutineDialog({ bot, open, onClose }: CreateRoutineDialogProps) {
const b = useBots()
const { t } = useI18n()
const c = t.cron
const [name, setName] = useState('')
@@ -1057,7 +1022,7 @@ export function CreateRoutineDialog({ bot, open, onClose }: CreateRoutineDialogP
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{c.createTitle}</DialogTitle>
<DialogDescription>{`A recurring task ${ownerLabel} runs on a schedule. Runs land in its own chat history.`}</DialogDescription>
<DialogDescription>{b.cron.createDesc(ownerLabel)}</DialogDescription>
</DialogHeader>
<div className="grid gap-3.5">
{labeled(
@@ -1078,23 +1043,23 @@ export function CreateRoutineDialog({ bot, open, onClose }: CreateRoutineDialogP
value={instruction}
/>
)}
{labeled('When to run', <SchedulePicker setState={setSched} state={sched} />)}
{labeled(b.cron.whenToRun, <SchedulePicker setState={setSched} state={sched} />)}
{labeled(
'Send results to',
b.cron.sendResultsTo,
pickerSelect(target, setTarget, [
{
id: 'history',
label: 'Run history only'
label: b.cron.runHistoryOnly
},
{
id: 'bot-chat',
label: `${ownerLabel}\u2019s chat (bot responds)`
label: b.cron.botChatTarget(ownerLabel)
}
])
)}
<label className="flex items-center gap-2 text-xs text-(--ui-text-tertiary) cursor-pointer select-none">
<Checkbox checked={continuity} onCheckedChange={value => setContinuity(Boolean(value))} />
Continuity: each run sees the previous run’s output (dedupe, continue where it left off)
{b.cron.continuity}
</label>
{error ? (
<div className="rounded-md border border-(--ui-stroke-secondary) px-3 py-2 text-xs text-(--ui-accent)">
@@ -1206,6 +1171,7 @@ export function RoutinesPane() {
const meta = owner ? botRosterMeta(owner, allMeta) : null
const { shape, color, image } = botAppearance(bot, meta)
const { data, error, isLoading, refetch } = useRoutines(owner)
const b = useBots()
const { t } = useI18n()
const c = t.cron
const [createOpen, setCreateOpen] = useState(false)
@@ -1226,7 +1192,7 @@ export function RoutinesPane() {
}
if (!owner) {
return <PanelEmpty description="This agent has to appear in the roster first." icon="hubot" title={c.title} />
return <PanelEmpty description={b.cron.needsRosterFirst} icon="hubot" title={c.title} />
}
const view = selectRoutineJobs(data, error, $lastJobs.get(), bot)
@@ -1238,8 +1204,7 @@ export function RoutinesPane() {
const jobs = view.jobs
const detailJob = detailJobId ? jobs.find(job => job.job_id === detailJobId) || null : null
const staleNotice =
error && !view.live && view.all.length ? 'Could not refresh scheduled jobs. Showing the last list we had.' : null
const staleNotice = error && !view.live && view.all.length ? b.cron.staleNotice : null
const filterHint = routineFilterHint(view.all, jobs)
@@ -1286,7 +1251,7 @@ export function RoutinesPane() {
{t.common.retry}
</Button>
}
description="The list may still be there — this was a read failure, not a delete."
description={b.cron.readFailure}
icon="warning"
title={c.failedLoad}
/>

View File

@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { translateBots } from './i18n-test-helper'
import type { GroupMember } from './types'
// Group-composer mentions (#89049): the core composer's @-completion area
@@ -23,7 +24,10 @@ vi.mock('@hermes/plugin-sdk', async () => {
Input: (props: React.ComponentProps<'input'>) => <input {...props} />,
RowButton: (props: React.ComponentProps<'button'>) => <button type="button" {...props} />,
Textarea: (props: React.ComponentProps<'textarea'>) => <textarea {...props} />,
useI18n: () => ({ t: (_key: string, fallback: string) => fallback })
useI18n: () => ({ t: (_key: string, fallback: string) => fallback }),
// The plugin bundle normally lands via `ctx.i18n.register` at load, so
// without this every localized label renders empty.
usePluginI18n: () => translateBots
}
})

View File

@@ -8,7 +8,7 @@
* controls without either surface importing the other.
*/
import { Button, cn, Codicon, host, Input, RowButton, Textarea, useValue } from '@hermes/plugin-sdk'
import { Button, cn, Codicon, host, Input, RowButton, Textarea, useI18n, useValue } from '@hermes/plugin-sdk'
import type { ClipboardEvent } from 'react'
import { useRef, useState } from 'react'
@@ -44,6 +44,7 @@ interface GroupImageControlsProps {
* image.generate probe) so room pictures cost the same as bot avatars. */
export function GroupImageControls({ image, onImage, seedName, seedMembers }: GroupImageControlsProps) {
const b = useBots()
const { t } = useI18n()
const imagen = useValue($imagenAvailable)
const [busy, setBusy] = useState(false)
@@ -108,12 +109,12 @@ export function GroupImageControls({ image, onImage, seedName, seedMembers }: Gr
</Button>
{imagen ? (
<Button disabled={busy} onClick={generate} size="sm" type="button" variant="secondary">
{busy ? 'Generating…' : 'Generate'}
{busy ? b.avatar.generating : b.avatar.generate}
</Button>
) : null}
{image ? (
<Button onClick={() => onImage(null)} size="sm" type="button" variant="ghost">
Remove
{t.common.remove}
</Button>
) : null}
</div>
@@ -174,6 +175,7 @@ interface GroupMentionInputProps {
* Enter/Tab insert (Enter falls through to submit when the popover is
* closed), Escape dismisses. */
export function GroupMentionInput({ members, onChange, onSubmitDraft, value, ...inputProps }: GroupMentionInputProps) {
const b = useBots()
const allMeta: Record<string, BotMeta> = useValue($botMeta)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const [token, setToken] = useState<MentionToken | null>(null)
@@ -185,7 +187,7 @@ export function GroupMentionInput({ members, onChange, onSubmitDraft, value, ...
if (pick.startsWith(token.query)) {
options.push({
handle: pick,
meta: 'Every bot in the room'
meta: b.group.everyoneMeta
})
}
}
@@ -371,6 +373,7 @@ interface GroupClarifyCardProps {
* (once/session/always/deny) as buttons — no free text; approvals are a
* closed choice. Answer sends via the member's own source. */
export function GroupClarifyCard({ entry, members }: GroupClarifyCardProps) {
const b = useBots()
const { group } = entry
const isApproval = entry.kind === 'approval'
const member = members.find(m => groupMemberKey(m) === entry.memberKey) || members.find(m => m.name === entry.member)
@@ -429,7 +432,7 @@ export function GroupClarifyCard({ entry, members }: GroupClarifyCardProps) {
// Echo the exchange into the room log so the thread reads complete.
const summary = isApproval
? `${answerFor(questions[0])} — ${entry.command || entry.question || 'command approval'}`
? `${answerFor(questions[0])} — ${entry.command || entry.question || b.group.commandApproval}`
: questions.map(q => (questions.length > 1 ? `${q.question}: ${answerFor(q)}` : answerFor(q))).join('\n')
appendGroupChatEntry(
@@ -444,7 +447,7 @@ export function GroupClarifyCard({ entry, members }: GroupClarifyCardProps) {
} catch (err: any) {
host.notify({
kind: 'error',
message: `Could not send the answer to @${botHandle(entry.member, member)}: ${err?.message || err}`
message: b.group.answerFailed(botHandle(entry.member, member), String(err?.message || err))
})
} finally {
setSending(false)
@@ -456,8 +459,8 @@ export function GroupClarifyCard({ entry, members }: GroupClarifyCardProps) {
<div className="flex items-center gap-1.5 text-xs font-medium">
<Codicon className="shrink-0 text-(--ui-accent)" name={isApproval ? 'shield' : 'question'} />
{isApproval
? `@${botHandle(entry.member, member)} wants to run a command:`
: `@${botHandle(entry.member, member)} asks:`}
? b.group.wantsToRunCommand(botHandle(entry.member, member))
: b.group.asks(botHandle(entry.member, member))}
</div>
{isApproval && entry.command ? (
<code className="block overflow-x-auto rounded bg-(--ui-bg-secondary,rgba(0,0,0,0.25)) px-2 py-1 font-mono text-[0.7rem] whitespace-pre-wrap break-all">
@@ -513,7 +516,7 @@ export function GroupClarifyCard({ entry, members }: GroupClarifyCardProps) {
{/* Approvals are a closed choice set — no free-text input. */}
{isApproval ? null : (
<Input
aria-label={`Answer @${entry.member}`}
aria-label={b.group.answerTo(entry.member)}
className="h-7 text-xs"
key={`input:${q.qid}`}
onChange={event => {

View File

@@ -94,7 +94,7 @@ import {
import type { GroupComposerDraft, GroupDraftSetter } from './group-panes'
import { sendToGroupChat, stopGroupThread } from './group-rounds'
import { clearGroupClarify } from './group-turns'
import { useBots } from './i18n'
import { botsText, useBots } from './i18n'
import { displayName, slugify, stripPreviewMarkdown } from './labels'
import { botRosterMeta, setBotsWorkspaceOwner } from './routing'
import { bumpBotOpenGeneration, getPluginCtx, ID } from './shared'
@@ -262,7 +262,7 @@ async function renameGroupChat(oldName: string, newName: string, members: GroupM
if (taken.has(next)) {
host.notify({
kind: 'error',
message: `A group named “${next}” already exists.`
message: botsText().group.nameTaken(next)
})
return null
@@ -681,12 +681,14 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
members.length > 0 && availableMembers < members.length && 'text-amber-600 dark:text-amber-300'
)}
>
{members.length > 0 && availableMembers < members.length ? availabilityLabel : `${members.length} bots`}
{members.length > 0 && availableMembers < members.length
? availabilityLabel
: b.group.memberCount(members.length)}
</span>
</Tip>
<Tip label={`Group settings — rename ${group} or set a room picture`}>
<Tip label={b.group.settingsHint(group)}>
<Button
aria-label={`Group settings for ${group}`}
aria-label={b.group.settingsLabel(group)}
className="shrink-0 text-(--ui-text-tertiary) hover:text-foreground"
onClick={() => setSettingsOpen(true)}
size="sm"
@@ -695,9 +697,9 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
<Codicon name="gear" />
</Button>
</Tip>
<Tip label={`Disband the ${group} group chat`}>
<Tip label={b.group.disbandHint(group)}>
<Button
aria-label={`Disband ${group}`}
aria-label={b.group.disbandLabel(group)}
className="shrink-0 text-(--ui-text-tertiary) hover:text-destructive"
onClick={() => setConfirmDisband(true)}
size="sm"
@@ -729,7 +731,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
await stopGroupThread(group, latestActivity?.thread || null, memberDescriptors())
host.notify({
kind: 'success',
message: `Stopped ${group} — remaining turns are held until you resume`
message: b.group.stopped(group)
})
}
@@ -903,9 +905,9 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
<img alt="" className="size-6 rounded object-cover" src={img.data} />
)}
<span className="max-w-32 truncate text-[0.65rem] text-(--ui-text-tertiary)">{img.name || 'image'}</span>
<Tip label="Remove attachment">
<Tip label={b.group.removeAttachment}>
<Button
aria-label="Remove attachment"
aria-label={b.group.removeAttachment}
onClick={() => removeImage(thread, index)}
size="icon-xs"
variant="ghost"
@@ -1114,8 +1116,8 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
title={b.group.openThread}
>
<Codicon className="shrink-0 text-[0.65rem]" name="chevron-right" />
<span className="min-w-0 flex-1 truncate">{headText || 'Thread'}</span>
<span className="shrink-0 text-[0.625rem] text-(--ui-text-quaternary)">{`${replies} ${replies === 1 ? 'reply' : 'replies'} · ${relativeTime(entries[entries.length - 1].entry.at)}`}</span>
<span className="min-w-0 flex-1 truncate">{headText || b.group.threadFallback}</span>
<span className="shrink-0 text-[0.625rem] text-(--ui-text-quaternary)">{`${b.group.replyCount(replies)} · ${relativeTime(entries[entries.length - 1].entry.at)}`}</span>
</RowButton>
)
@@ -1227,7 +1229,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center border-2 border-dashed border-(--ui-accent) text-sm font-medium text-(--ui-accent)"
key={'dropzone'}
>
{replyThread ? 'Drop to attach to this thread reply' : 'Drop to attach — every responding bot sees it'}
{replyThread ? b.group.dropToThread : b.group.dropToRoom}
</div>
) : null}
{header}
@@ -1247,10 +1249,10 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
{room.running ? (
<div className="px-2 py-1 text-[0.7rem] italic text-(--ui-text-quaternary)" key={'working'}>
{roomClarifies.length
? 'Waiting for your answer…'
? b.group.waitingForAnswer
: room.turn
? `${groupSpeakerLabel(room.turn)} is thinking…`
: 'The room is working…'}
? b.group.memberThinking(groupSpeakerLabel(room.turn))
: b.group.roomWorking}
</div>
) : null}
{/* Scroll anchor (#89835): rooms opened at scroll position 0, mid- */
@@ -1270,12 +1272,12 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
{attachmentRow(null)}
<div className="flex items-center gap-1.5">
<GroupMentionInput
aria-label={`Message ${group}`}
aria-label={b.group.messageRoom(group)}
members={members}
onChange={setDraft}
onPaste={event => pasteImages(null, event)}
onSubmitDraft={submit}
placeholder={`New thread in ${group}… (@name to direct, @everyone for all)`}
placeholder={b.group.newThreadPlaceholder(group)}
value={draft}
/>
{attachButton(null)}
@@ -1292,28 +1294,28 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
open={settingsOpen}
/>
<ConfirmDialog
busyLabel="Disbanding…"
confirmLabel="Disband"
busyLabel={b.group.disbanding}
confirmLabel={b.group.disbandAction}
description={
/* New rooms title member sessions by roomId, legacy rooms by name — */
/* so the copy names the concept, not a literal session title. The */
/* name is bolded mid-sentence, so the copy splits around it and the */
/* prefix goes empty where the name leads (core's deleteDesc* shape). */
<span>
{'This removes the '}
{b.group.disbandDescPrefix}
<span className="font-medium text-foreground">{group}</span>
{' grouping from its '}
{String(members.length)}
{/* New rooms title member sessions by roomId, legacy rooms by name — */
/* so the copy names the concept, not a literal session title. */}
{' bots and clears the shared room log. The bots themselves and their per-group sessions are kept.'}
{b.group.disbandDescSuffix(members.length)}
</span>
}
destructive
doneLabel="Disbanded"
doneLabel={b.group.disbandDone}
onClose={() => setConfirmDisband(false)}
onConfirm={async () => {
clearGroupComposerDraft(composerKeyRef.current)
await disbandGroupChat(group, members)
host.notify({
kind: 'success',
message: `Disbanded “${group}”`
message: b.group.disbanded(group)
})
}}
open={confirmDisband}

View File

@@ -4,10 +4,24 @@
* `usePluginI18n` returns a stringly-typed `t(key, …)`, and `useBots()` binds it
* to the message SHAPE so components keep typed `b.roster.search` access.
*
* Only strings Bot Mode OWNS live here. Generic verbs (Cancel, Delete, Retry,
* Close, Loading…) and shared concepts (Scheduled jobs) resolve against core's
* `useI18n()` instead — core already carries them in every locale, so
* duplicating them here would be a second, worse translation.
* Only strings Bot Mode OWNS live here. Generic verbs (Cancel, Delete, Remove,
* Retry, Close, Loading…) and shared vocabulary core already ships in every
* locale — weekday names, Daily/Hourly, Scheduled jobs — resolve against core
* via `useI18n()` / `translateNow()`. Duplicating those here would be a
* second, worse translation that drifts.
*
* Three kinds of literal deliberately stay hardcoded, and none of them is a
* missed key:
*
* - **Prompts sent to a model**, not shown as chrome: the room-picture image
* prompt and the scheduled-routine instruction. They are addressed to the
* model, which reads English best.
* - **Syntax and identifiers**: cron expressions and their examples, React
* keys, workspace ids.
* - **`'You'`**, the author marker on room-log entries. It is persisted into
* the log and compared as a sentinel (`group-activity.ts`), so translating
* it in place would break both. Localizing it needs the marker and its
* rendering split apart — worth doing, not doable as a rename.
*
* Locales follow kanban: `en` / `ja` / `zh` / `zh-hant`. Arabic falls through
* the resolution chain (active locale → this plugin's `en` → the key) the
@@ -18,6 +32,8 @@
import { type PluginLocaleBundles, type PluginTranslate, usePluginI18n } from '@hermes/plugin-sdk'
import { useMemo } from 'react'
import { getPluginCtx } from './shared'
type BotsMessages = {
/** Left rail: the bot + group-chat roster. */
roster: {
@@ -82,6 +98,8 @@ type BotsMessages = {
remoteConnectionsUnsupported: string
/** Stands under the bot's name in a chat it has not spoken in yet. */
chatEmpty: string
/** First line of a brand-new bot's forever-chat — see `kickoffText`. */
kickoff: string
}
/** Avatar picker: shapes, blobs, pets, uploads, generation. */
avatar: {
@@ -105,6 +123,8 @@ type BotsMessages = {
generationFailed: string
savedLocally: string
savedLocallyDescriptionFailed: string
generate: string
generating: string
}
/** Group chats: the room, its composer, threads and activity feed. */
group: {
@@ -137,6 +157,37 @@ type BotsMessages = {
stopHint: string
needsYourInput: string
pictureGenerationFailed: string
nameTaken: (name: string) => string
memberCount: (count: number) => string
settingsHint: (group: string) => string
settingsLabel: (group: string) => string
disbandHint: (group: string) => string
disbandLabel: (group: string) => string
disbandAction: string
disbanding: string
disbandDone: string
disbanded: (group: string) => string
/** Wraps the bolded group name, so the name can lead the sentence in
* languages that put it there — see core's cron.deleteDesc* pair. */
disbandDescPrefix: string
disbandDescSuffix: (count: number) => string
stopped: (group: string) => string
removeAttachment: string
threadFallback: string
replyCount: (replies: number) => string
dropToThread: string
dropToRoom: string
waitingForAnswer: string
memberThinking: (name: string) => string
roomWorking: string
messageRoom: (group: string) => string
newThreadPlaceholder: (group: string) => string
everyoneMeta: string
commandApproval: string
answerFailed: (handle: string, error: string) => string
wantsToRunCommand: (handle: string) => string
asks: (handle: string) => string
answerTo: (member: string) => string
}
/** Skills hub + MCP setup surfaces embedded in the bot editor. */
tools: {
@@ -145,6 +196,49 @@ type BotsMessages = {
searchHub: string
noMcpServers: string
}
/** Bot-scoped scheduled jobs. Generic scheduling chrome (weekday names,
* Daily/Hourly, the job verbs) resolves against core's `cron` section. */
cron: {
filterHint: string
needsRosterFirst: string
staleNotice: string
readFailure: string
createDesc: (bot: string) => string
instruction: string
whenToRun: string
dayOfMonth: string
sendResultsTo: string
runHistoryOnly: string
botChatTarget: (bot: string) => string
continuity: string
onceIn: (when: string) => string
everyNDays: (days: number) => string
everyNHours: (hours: number) => string
everyNMinutes: (minutes: number) => string
/** The frequency picker's eight options, in menu order. */
freqOnce: string
freqHourly: string
freqDaily: string
freqWeekdays: string
freqWeekly: string
freqMonthly: string
freqInterval: string
freqAdvanced: string
unitMinutes: string
unitHours: string
unitDays: string
/** One-line plain-language read-back of the picker's current state. */
runsOnce: (count: number, unit: string) => string
runsHourly: string
runsDaily: (time: string) => string
runsWeekdays: (time: string) => string
runsWeekly: (day: string, time: string) => string
runsMonthly: (day: string, time: string) => string
runsInterval: (count: number, unit: string) => string
runsRaw: string
timesTotal: (count: number) => string
}
}
const en: BotsMessages = {
@@ -204,7 +298,8 @@ const en: BotsMessages = {
advancedFailed: 'Advanced configuration failed',
openAnotherChatUnsupported: 'Update Hermes Desktop to open another Bot chat.',
remoteConnectionsUnsupported: 'Update Hermes Desktop to chat with bots on other connections.',
chatEmpty: 'Say something to get started.'
chatEmpty: 'Say something to get started.',
kickoff: 'Hey, tell me about yourself!'
},
avatar: {
classicShapes: 'Classic shapes',
@@ -225,7 +320,9 @@ const en: BotsMessages = {
imageTooLarge: 'Image too large (max 15MB).',
generationFailed: 'Avatar generation failed',
savedLocally: 'Saved look locally; remote persistence failed',
savedLocallyDescriptionFailed: 'Saved look locally; description update failed'
savedLocallyDescriptionFailed: 'Saved look locally; description update failed',
generate: 'Generate',
generating: 'Generating…'
},
group: {
newTitle: 'New Group Chat',
@@ -256,13 +353,82 @@ const en: BotsMessages = {
stop: 'Stop',
stopHint: 'Stop this run — interrupts the member on turn and holds the rest',
needsYourInput: 'A bot in this group chat needs your input',
pictureGenerationFailed: 'Group picture generation failed'
pictureGenerationFailed: 'Group picture generation failed',
nameTaken: name => `A group named “${name}” already exists.`,
memberCount: count => `${count} bots`,
settingsHint: group => `Group settings — rename ${group} or set a room picture`,
settingsLabel: group => `Group settings for ${group}`,
disbandHint: group => `Disband the ${group} group chat`,
disbandLabel: group => `Disband ${group}`,
disbandAction: 'Disband',
disbanding: 'Disbanding…',
disbandDone: 'Disbanded',
disbanded: group => `Disbanded “${group}”`,
disbandDescPrefix: 'This removes the ',
disbandDescSuffix: count =>
` grouping from its ${count} bots and clears the shared room log. The bots themselves and their per-group sessions are kept.`,
stopped: group => `Stopped ${group} — remaining turns are held until you resume`,
removeAttachment: 'Remove attachment',
threadFallback: 'Thread',
replyCount: replies => `${replies} ${replies === 1 ? 'reply' : 'replies'}`,
dropToThread: 'Drop to attach to this thread reply',
dropToRoom: 'Drop to attach — every responding bot sees it',
waitingForAnswer: 'Waiting for your answer…',
memberThinking: name => `${name} is thinking…`,
roomWorking: 'The room is working…',
messageRoom: group => `Message ${group}`,
newThreadPlaceholder: group => `New thread in ${group}… (@name to direct, @everyone for all)`,
everyoneMeta: 'Every bot in the room',
commandApproval: 'command approval',
answerFailed: (handle, error) => `Could not send the answer to @${handle}: ${error}`,
wantsToRunCommand: handle => `@${handle} wants to run a command:`,
asks: handle => `@${handle} asks:`,
answerTo: member => `Answer @${member}`
},
tools: {
skillsHub: 'Hermes Skills Hub',
filterSkills: 'Filter skills…',
searchHub: 'Search the hub (community + well-known sources)…',
noMcpServers: 'No MCP servers configured or in the catalog.'
},
cron: {
filterHint:
'Scheduled jobs exist in this profile but none are tagged for this bot. Name a job "[bot:<name>] …" to show it here, or see them in Cron below.',
needsRosterFirst: 'This bot has to appear in the roster first.',
staleNotice: 'Could not refresh scheduled jobs. Showing the last list we had.',
readFailure: 'The list may still be there — this was a read failure, not a delete.',
createDesc: bot => `A recurring task ${bot} runs on a schedule. Runs land in its own chat history.`,
instruction: 'Instruction',
whenToRun: 'When to run',
dayOfMonth: 'Day of month',
sendResultsTo: 'Send results to',
runHistoryOnly: 'Run history only',
botChatTarget: bot => `${bot}’s chat (bot responds)`,
continuity: 'Continuity: each run sees the previous run’s output (dedupe, continue where it left off)',
onceIn: when => `Once (${when})`,
everyNDays: days => `Every ${days} days`,
everyNHours: hours => `Every ${hours}h`,
everyNMinutes: minutes => `Every ${minutes}m`,
freqOnce: 'Once, in…',
freqHourly: 'Every hour',
freqDaily: 'Every day',
freqWeekdays: 'Weekdays',
freqWeekly: 'Every week',
freqMonthly: 'Every month',
freqInterval: 'Interval',
freqAdvanced: 'Advanced…',
unitMinutes: 'minute(s)',
unitHours: 'hour(s)',
unitDays: 'day(s)',
runsOnce: (count, unit) => `Runs once, ${count} ${unit} from now`,
runsHourly: 'Runs at the top of every hour',
runsDaily: time => `Runs every day at ${time}`,
runsWeekdays: time => `Runs Monday–Friday at ${time}`,
runsWeekly: (day, time) => `Runs every ${day} at ${time}`,
runsMonthly: (day, time) => `Runs on day ${day} of each month at ${time}`,
runsInterval: (count, unit) => `Runs every ${count} ${unit}`,
runsRaw: 'Raw schedule — every Nm/Nh/Nd or 5-field cron',
timesTotal: count => `, ${count} time(s) total`
}
}
@@ -322,7 +488,8 @@ const ja: BotsMessages = {
advancedFailed: '詳細設定に失敗しました',
openAnotherChatUnsupported: '別のボットチャットを開くには Hermes Desktop を更新してください。',
remoteConnectionsUnsupported: '他の接続上のボットとチャットするには Hermes Desktop を更新してください。',
chatEmpty: '何か書いて始めましょう。'
chatEmpty: '何か書いて始めましょう。',
kickoff: 'こんにちは、自己紹介をしてください!'
},
avatar: {
classicShapes: 'クラシックシェイプ',
@@ -343,7 +510,9 @@ const ja: BotsMessages = {
imageTooLarge: '画像が大きすぎます(最大 15MB)。',
generationFailed: 'アバターの生成に失敗しました',
savedLocally: '見た目はローカルに保存されましたが、リモートへの保存に失敗しました',
savedLocallyDescriptionFailed: '見た目はローカルに保存されましたが、説明の更新に失敗しました'
savedLocallyDescriptionFailed: '見た目はローカルに保存されましたが、説明の更新に失敗しました',
generate: '生成',
generating: '生成中…'
},
group: {
newTitle: '新しいグループチャット',
@@ -374,13 +543,82 @@ const ja: BotsMessages = {
stop: '停止',
stopHint: 'この実行を停止 — ターン中のメンバーを中断し、残りを保留します',
needsYourInput: 'このグループチャットのボットが入力を待っています',
pictureGenerationFailed: 'グループ画像の生成に失敗しました'
pictureGenerationFailed: 'グループ画像の生成に失敗しました',
nameTaken: name => `「${name}」という名前のグループはすでに存在します。`,
memberCount: count => `ボット${count}体`,
settingsHint: group => `グループ設定 — ${group}の名前変更やルーム画像の設定`,
settingsLabel: group => `${group}のグループ設定`,
disbandHint: group => `${group}グループチャットを解散`,
disbandLabel: group => `${group}を解散`,
disbandAction: '解散',
disbanding: '解散中…',
disbandDone: '解散しました',
disbanded: group => `「${group}」を解散しました`,
disbandDescPrefix: '',
disbandDescSuffix: count =>
`のグループ分けをボット${count}体から解除し、共有ルームログを消去します。ボット自体と各グループのセッションは保持されます。`,
stopped: group => `${group}を停止しました — 残りのターンは再開するまで保留されます`,
removeAttachment: '添付を削除',
threadFallback: 'スレッド',
replyCount: replies => `返信${replies}件`,
dropToThread: 'ドロップしてこのスレッド返信に添付',
dropToRoom: 'ドロップして添付 — 応答するすべてのボットが見られます',
waitingForAnswer: 'あなたの回答を待っています…',
memberThinking: name => `${name}が考えています…`,
roomWorking: 'ルームが作業中です…',
messageRoom: group => `${group}にメッセージ`,
newThreadPlaceholder: group => `${group}で新しいスレッド…(@名前で個別、@everyoneで全員)`,
everyoneMeta: 'ルーム内のすべてのボット',
commandApproval: 'コマンドの承認',
answerFailed: (handle, error) => `@${handle}に回答を送信できませんでした: ${error}`,
wantsToRunCommand: handle => `@${handle}がコマンドを実行しようとしています:`,
asks: handle => `@${handle}からの質問:`,
answerTo: member => `@${member}に回答`
},
tools: {
skillsHub: 'Hermes スキルハブ',
filterSkills: 'スキルを絞り込み…',
searchHub: 'ハブを検索(コミュニティと既知のソース)…',
noMcpServers: '設定済みまたはカタログ内の MCP サーバーはありません。'
},
cron: {
filterHint:
'このプロファイルには定期実行ジョブがありますが、このボット向けのタグが付いたものはありません。ジョブ名を「[bot:<名前>] …」にするとここに表示されます。下のCronでも確認できます。',
needsRosterFirst: 'このボットは先に名簿に表示される必要があります。',
staleNotice: '定期実行ジョブを更新できませんでした。最後に取得したリストを表示しています。',
readFailure: 'リストはまだ存在している可能性があります — これは読み取りの失敗で、削除ではありません。',
createDesc: bot => `${bot}がスケジュールに沿って実行する定期タスクです。実行結果は専用のチャット履歴に残ります。`,
instruction: '指示',
whenToRun: '実行するタイミング',
dayOfMonth: '日付',
sendResultsTo: '結果の送信先',
runHistoryOnly: '実行履歴のみ',
botChatTarget: bot => `${bot}のチャット(ボットが応答)`,
continuity: '継続: 各実行が前回の出力を参照します(重複を避け、続きから実行)',
onceIn: when => `1回のみ(${when})`,
everyNDays: days => `${days}日ごと`,
everyNHours: hours => `${hours}時間ごと`,
everyNMinutes: minutes => `${minutes}分ごと`,
freqOnce: '1回のみ、…後',
freqHourly: '毎時',
freqDaily: '毎日',
freqWeekdays: '平日',
freqWeekly: '毎週',
freqMonthly: '毎月',
freqInterval: '間隔',
freqAdvanced: '詳細…',
unitMinutes: '分',
unitHours: '時間',
unitDays: '日',
runsOnce: (count, unit) => `今から${count}${unit}後に1回実行します`,
runsHourly: '毎時0分に実行します',
runsDaily: time => `毎日${time}に実行します`,
runsWeekdays: time => `月曜〜金曜の${time}に実行します`,
runsWeekly: (day, time) => `毎週${day}の${time}に実行します`,
runsMonthly: (day, time) => `毎月${day}日の${time}に実行します`,
runsInterval: (count, unit) => `${count}${unit}ごとに実行します`,
runsRaw: '生のスケジュール — Nm/Nh/Nd または5フィールドのcron',
timesTotal: count => `、合計${count}回`
}
}
@@ -439,7 +677,8 @@ const zh: BotsMessages = {
advancedFailed: '高级配置失败',
openAnotherChatUnsupported: '请更新 Hermes Desktop 以打开另一个机器人聊天。',
remoteConnectionsUnsupported: '请更新 Hermes Desktop 以与其他连接上的机器人聊天。',
chatEmpty: '说点什么开始吧。'
chatEmpty: '说点什么开始吧。',
kickoff: '你好,介绍一下你自己吧!'
},
avatar: {
classicShapes: '经典形状',
@@ -460,7 +699,9 @@ const zh: BotsMessages = {
imageTooLarge: '图片过大(最大 15MB)。',
generationFailed: '头像生成失败',
savedLocally: '外观已保存在本地;远程持久化失败',
savedLocallyDescriptionFailed: '外观已保存在本地;描述更新失败'
savedLocallyDescriptionFailed: '外观已保存在本地;描述更新失败',
generate: '生成',
generating: '生成中…'
},
group: {
newTitle: '新建群聊',
@@ -491,13 +732,82 @@ const zh: BotsMessages = {
stop: '停止',
stopHint: '停止本次运行 — 中断当前回合的成员,并暂停其余成员',
needsYourInput: '此群聊中有机器人需要你输入',
pictureGenerationFailed: '群组图片生成失败'
pictureGenerationFailed: '群组图片生成失败',
nameTaken: name => `已存在名为“${name}”的群聊。`,
memberCount: count => `${count} 个机器人`,
settingsHint: group => `群聊设置 — 重命名 ${group} 或设置房间图片`,
settingsLabel: group => `${group} 的群聊设置`,
disbandHint: group => `解散 ${group} 群聊`,
disbandLabel: group => `解散 ${group}`,
disbandAction: '解散',
disbanding: '正在解散…',
disbandDone: '已解散',
disbanded: group => `已解散“${group}”`,
disbandDescPrefix: '',
disbandDescSuffix: count =>
` 的分组将从 ${count} 个机器人中移除,并清空共享房间日志。机器人本身及其各群聊会话都会保留。`,
stopped: group => `已停止 ${group} — 其余轮次将保留到你恢复为止`,
removeAttachment: '移除附件',
threadFallback: '讨论串',
replyCount: replies => `${replies} 条回复`,
dropToThread: '拖放以附加到此讨论串回复',
dropToRoom: '拖放以附加 — 每个回应的机器人都能看到',
waitingForAnswer: '等待你的回答…',
memberThinking: name => `${name} 正在思考…`,
roomWorking: '房间正在处理…',
messageRoom: group => `发消息给 ${group}`,
newThreadPlaceholder: group => `在 ${group} 中开启新讨论串…(@名称指定,@everyone 全体)`,
everyoneMeta: '房间里的所有机器人',
commandApproval: '命令批准',
answerFailed: (handle, error) => `无法将回答发送给 @${handle}:${error}`,
wantsToRunCommand: handle => `@${handle} 想执行一个命令:`,
asks: handle => `@${handle} 的提问:`,
answerTo: member => `回答 @${member}`
},
tools: {
skillsHub: 'Hermes 技能中心',
filterSkills: '筛选技能…',
searchHub: '搜索技能中心(社区和常见来源)…',
noMcpServers: '未配置 MCP 服务器,目录中也没有。'
},
cron: {
filterHint:
'此配置档案中有定时任务,但没有一个标记给这个机器人。将任务命名为“[bot:<名称>] …”即可显示在这里,也可以在下方的 Cron 中查看。',
needsRosterFirst: '这个机器人需要先出现在名册中。',
staleNotice: '无法刷新定时任务。显示的是上一次获取的列表。',
readFailure: '列表可能仍然存在 — 这是一次读取失败,不是删除。',
createDesc: bot => `由 ${bot} 按计划运行的重复任务。运行结果会保存在它自己的聊天记录中。`,
instruction: '指令',
whenToRun: '运行时间',
dayOfMonth: '每月日期',
sendResultsTo: '结果发送到',
runHistoryOnly: '仅运行历史',
botChatTarget: bot => `${bot} 的聊天(机器人会回应)`,
continuity: '连续性:每次运行都能看到上次的输出(去重,从上次的地方继续)',
onceIn: when => `一次(${when})`,
everyNDays: days => `每 ${days} 天`,
everyNHours: hours => `每 ${hours} 小时`,
everyNMinutes: minutes => `每 ${minutes} 分钟`,
freqOnce: '一次,在…之后',
freqHourly: '每小时',
freqDaily: '每天',
freqWeekdays: '工作日',
freqWeekly: '每周',
freqMonthly: '每月',
freqInterval: '间隔',
freqAdvanced: '高级…',
unitMinutes: '分钟',
unitHours: '小时',
unitDays: '天',
runsOnce: (count, unit) => `从现在起 ${count} ${unit}后运行一次`,
runsHourly: '每小时整点运行',
runsDaily: time => `每天 ${time} 运行`,
runsWeekdays: time => `周一至周五 ${time} 运行`,
runsWeekly: (day, time) => `每${day} ${time} 运行`,
runsMonthly: (day, time) => `每月 ${day} 日 ${time} 运行`,
runsInterval: (count, unit) => `每 ${count} ${unit}运行`,
runsRaw: '原始计划 — every Nm/Nh/Nd 或 5 段 cron',
timesTotal: count => `,共 ${count} 次`
}
}
@@ -556,7 +866,8 @@ const zhHant: BotsMessages = {
advancedFailed: '進階設定失敗',
openAnotherChatUnsupported: '請更新 Hermes Desktop 以開啟另一個機器人聊天。',
remoteConnectionsUnsupported: '請更新 Hermes Desktop 以與其他連線上的機器人聊天。',
chatEmpty: '說點什麼開始吧。'
chatEmpty: '說點什麼開始吧。',
kickoff: '你好,介紹一下你自己吧!'
},
avatar: {
classicShapes: '經典形狀',
@@ -577,7 +888,9 @@ const zhHant: BotsMessages = {
imageTooLarge: '圖片過大(最大 15MB)。',
generationFailed: '頭像產生失敗',
savedLocally: '外觀已儲存在本機;遠端持久化失敗',
savedLocallyDescriptionFailed: '外觀已儲存在本機;描述更新失敗'
savedLocallyDescriptionFailed: '外觀已儲存在本機;描述更新失敗',
generate: '生成',
generating: '生成中…'
},
group: {
newTitle: '新增群組聊天',
@@ -608,13 +921,82 @@ const zhHant: BotsMessages = {
stop: '停止',
stopHint: '停止本次執行 — 中斷目前回合的成員,並暫停其餘成員',
needsYourInput: '此群組聊天中有機器人需要您的輸入',
pictureGenerationFailed: '群組圖片產生失敗'
pictureGenerationFailed: '群組圖片產生失敗',
nameTaken: name => `已存在名為「${name}」的群組聊天。`,
memberCount: count => `${count} 個機器人`,
settingsHint: group => `群組設定 — 重新命名 ${group} 或設定房間圖片`,
settingsLabel: group => `${group} 的群組設定`,
disbandHint: group => `解散 ${group} 群組聊天`,
disbandLabel: group => `解散 ${group}`,
disbandAction: '解散',
disbanding: '正在解散…',
disbandDone: '已解散',
disbanded: group => `已解散「${group}」`,
disbandDescPrefix: '',
disbandDescSuffix: count =>
` 的分組將從 ${count} 個機器人中移除,並清空共享房間日誌。機器人本身及其各群組工作階段都會保留。`,
stopped: group => `已停止 ${group} — 其餘回合將保留到你恢復為止`,
removeAttachment: '移除附件',
threadFallback: '討論串',
replyCount: replies => `${replies} 則回覆`,
dropToThread: '拖放以附加到此討論串回覆',
dropToRoom: '拖放以附加 — 每個回應的機器人都能看到',
waitingForAnswer: '等待你的回答…',
memberThinking: name => `${name} 正在思考…`,
roomWorking: '房間正在處理…',
messageRoom: group => `傳訊息給 ${group}`,
newThreadPlaceholder: group => `在 ${group} 中開啟新討論串…(@名稱指定,@everyone 全體)`,
everyoneMeta: '房間裡的所有機器人',
commandApproval: '命令核准',
answerFailed: (handle, error) => `無法將回答傳送給 @${handle}:${error}`,
wantsToRunCommand: handle => `@${handle} 想執行一個命令:`,
asks: handle => `@${handle} 的提問:`,
answerTo: member => `回覆 @${member}`
},
tools: {
skillsHub: 'Hermes 技能中心',
filterSkills: '篩選技能…',
searchHub: '搜尋技能中心(社群和常見來源)…',
noMcpServers: '未設定 MCP 伺服器,目錄中也沒有。'
},
cron: {
filterHint:
'此設定檔中有排程工作,但沒有任何一個標記給這個機器人。將工作命名為「[bot:<名稱>] …」即可顯示在這裡,也可以在下方的 Cron 中查看。',
needsRosterFirst: '這個機器人需要先出現在名冊中。',
staleNotice: '無法重新整理排程工作。顯示的是上一次取得的清單。',
readFailure: '清單可能仍然存在 — 這是一次讀取失敗,不是刪除。',
createDesc: bot => `由 ${bot} 按排程執行的重複工作。執行結果會保存在它自己的聊天紀錄中。`,
instruction: '指示',
whenToRun: '執行時間',
dayOfMonth: '每月日期',
sendResultsTo: '結果傳送到',
runHistoryOnly: '僅執行紀錄',
botChatTarget: bot => `${bot} 的聊天(機器人會回應)`,
continuity: '連續性:每次執行都能看到上次的輸出(去重,從上次的地方繼續)',
onceIn: when => `一次(${when})`,
everyNDays: days => `每 ${days} 天`,
everyNHours: hours => `每 ${hours} 小時`,
everyNMinutes: minutes => `每 ${minutes} 分鐘`,
freqOnce: '一次,在…之後',
freqHourly: '每小時',
freqDaily: '每天',
freqWeekdays: '工作日',
freqWeekly: '每週',
freqMonthly: '每月',
freqInterval: '間隔',
freqAdvanced: '進階…',
unitMinutes: '分鐘',
unitHours: '小時',
unitDays: '天',
runsOnce: (count, unit) => `從現在起 ${count} ${unit}後執行一次`,
runsHourly: '每小時整點執行',
runsDaily: time => `每天 ${time} 執行`,
runsWeekdays: time => `週一至週五 ${time} 執行`,
runsWeekly: (day, time) => `每${day} ${time} 執行`,
runsMonthly: (day, time) => `每月 ${day} 日 ${time} 執行`,
runsInterval: (count, unit) => `每 ${count} ${unit}執行`,
runsRaw: '原始排程 — every Nm/Nh/Nd 或 5 段 cron',
timesTotal: count => `,共 ${count} 次`
}
}
@@ -655,3 +1037,30 @@ export function useBots(): BotsText {
return useMemo(() => bind(t, en), [t])
}
/** Resolve a dotted path against the English bundle — the floor for a read
* that beats `ctx.i18n` into existence, so an unresolved key never ships as
* the literal `cron.runsHourly`. */
function english(key: string, ...args: unknown[]): string {
const leaf = key.split('.').reduce<unknown>((node, part) => (node as Record<string, unknown>)?.[part], en)
return typeof leaf === 'function' ? (leaf as (...a: unknown[]) => string)(...args) : String(leaf ?? key)
}
let bound: { text: BotsText; translate: PluginTranslate } | null = null
/** `useBots` for the module-level functions a hook can't reach — the schedule
* summarizers and label helpers that render inside components but aren't
* components. Non-reactive on its own; every caller is invoked during a
* render that a core `useI18n()` already subscribes to, so a locale switch
* still repaints. Cached on translator identity: `bind` walks the whole tree,
* and these run per row. */
export function botsText(): BotsText {
const translate = getPluginCtx()?.i18n?.t ?? english
if (bound?.translate !== translate) {
bound = { text: bind(translate, en), translate }
}
return bound.text
}