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:
@@ -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
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user