mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
Merge pull request #67303 from NousResearch/bb/desktop-plugin-i18n
feat(desktop): plugin-scoped i18n — ctx.i18n locale bundles (follow-up to #60638)
This commit is contained in:
commit
fe3e5cf8aa
8 changed files with 336 additions and 27 deletions
|
|
@ -13,6 +13,7 @@
|
|||
*/
|
||||
|
||||
import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes'
|
||||
import { createPluginI18n, type PluginI18n } from '@/i18n'
|
||||
import { readKey, writeKey } from '@/lib/storage'
|
||||
|
||||
import { registry } from './registry'
|
||||
|
|
@ -51,6 +52,9 @@ export interface PluginContext {
|
|||
socket: (path: string, onMessage: (data: unknown) => void) => () => void
|
||||
/** Plugin-scoped persistence. */
|
||||
storage: PluginStorage
|
||||
/** Plugin-scoped i18n: ship + register locale bundles under this plugin,
|
||||
* resolved against the app's active locale — no core `en.ts` edit. */
|
||||
i18n: PluginI18n
|
||||
}
|
||||
|
||||
export interface HermesPlugin {
|
||||
|
|
@ -106,6 +110,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () =
|
|||
registerMany: cs => track(registry.registerMany(cs.map(scope))),
|
||||
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
|
||||
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
|
||||
storage: createPluginStorage(pluginId)
|
||||
storage: createPluginStorage(pluginId),
|
||||
i18n: createPluginI18n(pluginId, track)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,5 +16,16 @@ export {
|
|||
localeConfigValue,
|
||||
normalizeLocale
|
||||
} from './languages'
|
||||
export {
|
||||
createPluginI18n,
|
||||
type PluginI18n,
|
||||
type PluginLocaleBundles,
|
||||
type PluginMessages,
|
||||
type PluginMessageValue,
|
||||
type PluginTranslate,
|
||||
registerPluginLocales,
|
||||
translatePlugin,
|
||||
usePluginI18n
|
||||
} from './plugin-i18n'
|
||||
export { setRuntimeI18nLocale, translateNow } from './runtime'
|
||||
export type { Locale, ToolTitleKey, Translations } from './types'
|
||||
|
|
|
|||
126
apps/desktop/src/i18n/plugin-i18n.test.tsx
Normal file
126
apps/desktop/src/i18n/plugin-i18n.test.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { I18nProvider, useI18n } from './context'
|
||||
import { createPluginI18n, registerPluginLocales, translatePlugin, usePluginI18n } from './plugin-i18n'
|
||||
import { setRuntimeI18nLocale } from './runtime'
|
||||
|
||||
const noopTrack = (dispose: () => void) => dispose
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setRuntimeI18nLocale('en')
|
||||
})
|
||||
|
||||
describe('plugin locale registry', () => {
|
||||
it('resolves the active locale, falling back to English then the raw key', () => {
|
||||
const dispose = registerPluginLocales('cost', {
|
||||
en: { panel: { title: 'Cost' }, spent: (n: number) => `$${n} spent` },
|
||||
ja: { panel: { title: 'コスト' } }
|
||||
})
|
||||
|
||||
expect(translatePlugin('cost', 'ja', 'panel.title', [])).toBe('コスト')
|
||||
// Missing in ja → English.
|
||||
expect(translatePlugin('cost', 'ja', 'spent', [7])).toBe('$7 spent')
|
||||
// Missing everywhere → the key itself.
|
||||
expect(translatePlugin('cost', 'ja', 'nope', [])).toBe('nope')
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('scopes bundles per plugin — no cross-read', () => {
|
||||
const a = registerPluginLocales('a', { en: { hi: 'from a' } })
|
||||
const b = registerPluginLocales('b', { en: { hi: 'from b' } })
|
||||
|
||||
expect(translatePlugin('a', 'en', 'hi', [])).toBe('from a')
|
||||
expect(translatePlugin('b', 'en', 'hi', [])).toBe('from b')
|
||||
// An unknown plugin resolves to the key.
|
||||
expect(translatePlugin('c', 'en', 'hi', [])).toBe('hi')
|
||||
|
||||
a()
|
||||
b()
|
||||
})
|
||||
|
||||
it('merges repeated registrations and drops everything on dispose', () => {
|
||||
const one = registerPluginLocales('merge', { en: { a: 'A' } })
|
||||
const two = registerPluginLocales('merge', { en: { b: 'B' }, ja: { a: 'あ' } })
|
||||
|
||||
expect(translatePlugin('merge', 'en', 'a', [])).toBe('A')
|
||||
expect(translatePlugin('merge', 'en', 'b', [])).toBe('B')
|
||||
expect(translatePlugin('merge', 'ja', 'a', [])).toBe('あ')
|
||||
|
||||
one()
|
||||
two()
|
||||
|
||||
expect(translatePlugin('merge', 'en', 'a', [])).toBe('a')
|
||||
})
|
||||
|
||||
it('ctx.i18n.t reads the app runtime locale', () => {
|
||||
const i18n = createPluginI18n('runtime-plugin', noopTrack)
|
||||
i18n.register({ en: { greet: 'hello' }, ja: { greet: 'こんにちは' } })
|
||||
|
||||
expect(i18n.t('greet')).toBe('hello')
|
||||
|
||||
setRuntimeI18nLocale('ja')
|
||||
expect(i18n.t('greet')).toBe('こんにちは')
|
||||
})
|
||||
})
|
||||
|
||||
function Probe({ pluginId }: { pluginId: string }) {
|
||||
const t = usePluginI18n(pluginId)
|
||||
|
||||
return <p data-testid="copy">{t('greet')}</p>
|
||||
}
|
||||
|
||||
function SwitchToJa() {
|
||||
const { setLocale } = useI18n()
|
||||
|
||||
return (
|
||||
<button onClick={() => void setLocale('ja')} type="button">
|
||||
to ja
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe('usePluginI18n', () => {
|
||||
it('re-renders on a locale switch', () => {
|
||||
const dispose = registerPluginLocales('hooked', {
|
||||
en: { greet: 'hello' },
|
||||
ja: { greet: 'こんにちは' }
|
||||
})
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={null}>
|
||||
<SwitchToJa />
|
||||
<Probe pluginId="hooked" />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('copy').textContent).toBe('hello')
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.getByTestId('copy').textContent).toBe('こんにちは')
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('picks up a bundle registered after mount', () => {
|
||||
render(
|
||||
<I18nProvider configClient={null}>
|
||||
<Probe pluginId="late" />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('copy').textContent).toBe('greet')
|
||||
|
||||
let dispose = () => {}
|
||||
act(() => {
|
||||
dispose = registerPluginLocales('late', { en: { greet: 'landed' } })
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('copy').textContent).toBe('landed')
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
113
apps/desktop/src/i18n/plugin-i18n.ts
Normal file
113
apps/desktop/src/i18n/plugin-i18n.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Plugin-scoped i18n — the `ctx.storage` analog for locale bundles. A plugin
|
||||
* ships its own strings and registers them under its id; it never edits core
|
||||
* `en.ts`. Resolution mirrors the app translator: active locale → the plugin's
|
||||
* own `en` bundle → the key itself. The active locale is always the app's
|
||||
* (`display.language`) — plugins follow the user's choice, they don't own it.
|
||||
*
|
||||
* Two consumers, same shape as core (`useI18n` / `translateNow`):
|
||||
* - `usePluginI18n(id)` — reactive translator for React UI (re-renders on a
|
||||
* locale switch or a late bundle registration);
|
||||
* - `ctx.i18n.t` — module-level translator for handlers/stores (non-reactive).
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useI18n } from './context'
|
||||
import { getRuntimeI18nLocale, translateFrom } from './runtime'
|
||||
import type { Locale } from './types'
|
||||
|
||||
/** A leaf message: a literal or an interpolator (`n => `${n} left``). */
|
||||
export type PluginMessageValue = string | ((...args: never[]) => string)
|
||||
|
||||
/** A plugin's messages for one locale — nested trees allowed, addressed by
|
||||
* dot-path (`panel.title`). */
|
||||
export interface PluginMessages {
|
||||
[key: string]: PluginMessages | PluginMessageValue
|
||||
}
|
||||
|
||||
/** Locale → messages. Keyed by the app's locales so autocomplete guides you;
|
||||
* a bundle for a locale the app can't select is simply never resolved. */
|
||||
export type PluginLocaleBundles = Partial<Record<Locale, PluginMessages>>
|
||||
|
||||
/** Resolve `key` for this plugin against `args`; falls back to English, then
|
||||
* the raw key. */
|
||||
export type PluginTranslate = (key: string, ...args: unknown[]) => string
|
||||
|
||||
export interface PluginI18n {
|
||||
/** Merge locale bundles for this plugin (call once at `register`). Returns a
|
||||
* disposer that drops the plugin's bundles on unload/reload. */
|
||||
register: (bundles: PluginLocaleBundles) => () => void
|
||||
/** Module-level translator against the app's active locale (mirrors
|
||||
* `translateNow`). Non-reactive — in React prefer `usePluginI18n`. */
|
||||
t: PluginTranslate
|
||||
}
|
||||
|
||||
const registry = new Map<string, Map<Locale, PluginMessages>>()
|
||||
|
||||
/** Bumps whenever a plugin's bundles change, so React translators re-render on
|
||||
* a registration that lands after first paint. */
|
||||
const $version = atom(0)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function mergeMessages(base: PluginMessages, overrides: PluginMessages): PluginMessages {
|
||||
const result: PluginMessages = { ...base }
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
const prev = result[key]
|
||||
result[key] = isRecord(prev) && isRecord(value) ? mergeMessages(prev, value) : value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function registerPluginLocales(pluginId: string, bundles: PluginLocaleBundles): () => void {
|
||||
const byLocale = registry.get(pluginId) ?? new Map<Locale, PluginMessages>()
|
||||
registry.set(pluginId, byLocale)
|
||||
|
||||
for (const [locale, messages] of Object.entries(bundles) as [Locale, PluginMessages | undefined][]) {
|
||||
if (!messages) {
|
||||
continue
|
||||
}
|
||||
|
||||
const prev = byLocale.get(locale)
|
||||
byLocale.set(locale, prev ? mergeMessages(prev, messages) : messages)
|
||||
}
|
||||
|
||||
$version.set($version.get() + 1)
|
||||
|
||||
return () => {
|
||||
registry.delete(pluginId)
|
||||
$version.set($version.get() + 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function translatePlugin(pluginId: string, locale: Locale, key: string, args: unknown[]): string {
|
||||
return translateFrom(l => registry.get(pluginId)?.get(l), locale, key, args)
|
||||
}
|
||||
|
||||
/** Build the `ctx.i18n` door for a plugin. `track` records the disposer so the
|
||||
* loader tears bundles down on unload (same lifecycle as `register`/`socket`). */
|
||||
export function createPluginI18n(pluginId: string, track: (dispose: () => void) => () => void): PluginI18n {
|
||||
return {
|
||||
register: bundles => track(registerPluginLocales(pluginId, bundles)),
|
||||
t: (key, ...args) => translatePlugin(pluginId, getRuntimeI18nLocale(), key, args)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reactive scoped translator for React UI. Re-renders on a locale switch or a
|
||||
* late bundle registration. Pass your plugin id (your default export's `id`). */
|
||||
export function usePluginI18n(pluginId: string): PluginTranslate {
|
||||
const { locale } = useI18n()
|
||||
|
||||
// Subscribe so a bundle registered after mount repaints; `translatePlugin`
|
||||
// reads the live registry, so the memoized closure needs only id + locale.
|
||||
useStore($version)
|
||||
|
||||
return useCallback((key: string, ...args: unknown[]) => translatePlugin(pluginId, locale, key, args), [pluginId, locale])
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { TRANSLATIONS } from './catalog'
|
||||
import { DEFAULT_LOCALE } from './languages'
|
||||
import type { Locale, Translations } from './types'
|
||||
import type { Locale } from './types'
|
||||
|
||||
let runtimeLocale: Locale = DEFAULT_LOCALE
|
||||
|
||||
|
|
@ -8,17 +8,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function resolvePath(catalog: Translations, key: string): unknown {
|
||||
return key.split('.').reduce<unknown>((current, part) => {
|
||||
if (!isRecord(current)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return current[part]
|
||||
}, catalog)
|
||||
/** Walk a dot-path (`a.b.c`) into a nested message tree. */
|
||||
function resolvePath(source: unknown, key: string): unknown {
|
||||
return key.split('.').reduce<unknown>((current, part) => (isRecord(current) ? current[part] : undefined), source)
|
||||
}
|
||||
|
||||
function renderTranslation(value: unknown, args: unknown[]): string | null {
|
||||
/** A string is returned as-is, a function is called with `args`, else `null`. */
|
||||
function render(value: unknown, args: unknown[]): null | string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
|
@ -30,19 +26,22 @@ function renderTranslation(value: unknown, args: unknown[]): string | null {
|
|||
return null
|
||||
}
|
||||
|
||||
export function setRuntimeI18nLocale(locale: Locale) {
|
||||
runtimeLocale = locale
|
||||
}
|
||||
|
||||
export function translateNow(key: string, ...args: unknown[]): string {
|
||||
const active = renderTranslation(resolvePath(TRANSLATIONS[runtimeLocale], key), args)
|
||||
/** The active → DEFAULT → key resolution every translator shares. `source`
|
||||
* yields a message tree per locale — the app catalog, or a plugin's bundles. */
|
||||
export function translateFrom(
|
||||
source: (locale: Locale) => unknown,
|
||||
locale: Locale,
|
||||
key: string,
|
||||
args: unknown[]
|
||||
): string {
|
||||
const active = render(resolvePath(source(locale), key), args)
|
||||
|
||||
if (active !== null) {
|
||||
return active
|
||||
}
|
||||
|
||||
if (runtimeLocale !== DEFAULT_LOCALE) {
|
||||
const fallback = renderTranslation(resolvePath(TRANSLATIONS[DEFAULT_LOCALE], key), args)
|
||||
if (locale !== DEFAULT_LOCALE) {
|
||||
const fallback = render(resolvePath(source(DEFAULT_LOCALE), key), args)
|
||||
|
||||
if (fallback !== null) {
|
||||
return fallback
|
||||
|
|
@ -51,3 +50,17 @@ export function translateNow(key: string, ...args: unknown[]): string {
|
|||
|
||||
return key
|
||||
}
|
||||
|
||||
export function setRuntimeI18nLocale(locale: Locale) {
|
||||
runtimeLocale = locale
|
||||
}
|
||||
|
||||
/** The locale module-level translators resolve against (the app's active
|
||||
* `display.language`). Plugin `ctx.i18n.t` reads this too. */
|
||||
export function getRuntimeI18nLocale(): Locale {
|
||||
return runtimeLocale
|
||||
}
|
||||
|
||||
export function translateNow(key: string, ...args: unknown[]): string {
|
||||
return translateFrom(locale => TRANSLATIONS[locale], runtimeLocale, key, args)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,8 +194,19 @@ export type {
|
|||
* id with your plugin slug (`kanban:board-switcher`). */
|
||||
export { Contribute, type ContributeProps } from '@/contrib/react/contribute'
|
||||
export type { Contribution } from '@/contrib/types'
|
||||
/** Localized copy — plugins reuse the app's strings (and stay translatable). */
|
||||
export { useI18n } from '@/i18n'
|
||||
/** Localized copy. `useI18n` reuses the app's strings; `usePluginI18n(id)` +
|
||||
* `ctx.i18n.register` let a plugin ship its OWN locale bundles, scoped like
|
||||
* `ctx.storage` and resolved against the app's active locale — no core edit. */
|
||||
export {
|
||||
type Locale,
|
||||
type PluginI18n,
|
||||
type PluginLocaleBundles,
|
||||
type PluginMessages,
|
||||
type PluginMessageValue,
|
||||
type PluginTranslate,
|
||||
useI18n,
|
||||
usePluginI18n
|
||||
} from '@/i18n'
|
||||
export { triggerHaptic as haptic } from '@/lib/haptics'
|
||||
/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */
|
||||
export * as icons from '@/lib/icons'
|
||||
|
|
|
|||
|
|
@ -83,6 +83,12 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
|
|||
(renders below Artifacts, lights up at the route) — and/or a
|
||||
`PALETTE_AREA` command calling `host.navigate('/my-page')`.
|
||||
- `ctx.storage.get/set/remove` — persistence namespaced to your plugin.
|
||||
- `ctx.i18n.register({ en, ja, ... })` — ship your OWN locale bundles, scoped
|
||||
to your plugin (never edit core `en.ts`). Values are literal strings or
|
||||
interpolator functions; nested trees are addressed by dot-path. Read them
|
||||
reactively in components with `usePluginI18n(id)` returning `t('key', ...args)`
|
||||
(re-renders on a locale switch), or via `ctx.i18n.t` in handlers/stores.
|
||||
Resolution follows the app's active locale, then your `en`, then the raw key.
|
||||
- Data: `useQuery`/`useMutation`/`useQueryClient`/`queryClient` (the app's ONE
|
||||
React Query client — cache, dedupe, `refetchInterval`, invalidate like core;
|
||||
never hand-roll a poll loop), plus `atom`/`computed` for plugin-local state.
|
||||
|
|
|
|||
|
|
@ -10,27 +10,34 @@
|
|||
* Only these imports resolve: @hermes/plugin-sdk, react, react/jsx-runtime.
|
||||
*/
|
||||
|
||||
import { cn, haptic, host, Tip, useValue } from '@hermes/plugin-sdk'
|
||||
import { cn, haptic, host, Tip, usePluginI18n, useValue } from '@hermes/plugin-sdk'
|
||||
import { jsx, jsxs } from 'react/jsx-runtime'
|
||||
|
||||
// Ship your OWN strings (never edit core en.ts). `usePluginI18n` resolves them
|
||||
// against the app's active locale, falling back to `en`, then the raw key.
|
||||
const ID = 'my-plugin'
|
||||
|
||||
function MyPane() {
|
||||
const t = usePluginI18n(ID)
|
||||
const gateway = useValue(host.state.gateway)
|
||||
|
||||
return jsxs('div', {
|
||||
className: 'flex h-full flex-col gap-2 p-3 text-sm',
|
||||
children: [
|
||||
jsx('div', { className: 'font-medium', children: 'My Plugin Pane' }),
|
||||
jsx('div', { className: 'font-medium', children: t('paneTitle') }),
|
||||
jsx('div', {
|
||||
className: 'text-(--ui-text-tertiary)',
|
||||
children: `gateway: ${gateway}`
|
||||
children: t('gateway', gateway)
|
||||
})
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function MyChip() {
|
||||
const t = usePluginI18n(ID)
|
||||
|
||||
return jsx(Tip, {
|
||||
label: 'My plugin — click me',
|
||||
label: t('chipTip'),
|
||||
children: jsx('button', {
|
||||
className: cn(
|
||||
'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] transition-colors',
|
||||
|
|
@ -39,7 +46,7 @@ function MyChip() {
|
|||
type: 'button',
|
||||
onClick: () => {
|
||||
haptic('tap')
|
||||
host.notify({ kind: 'info', message: 'Hello from my plugin!' })
|
||||
host.notify({ kind: 'info', message: t('hello') })
|
||||
},
|
||||
children: 'my-plugin'
|
||||
})
|
||||
|
|
@ -47,9 +54,26 @@ function MyChip() {
|
|||
}
|
||||
|
||||
export default {
|
||||
id: 'my-plugin', // must match the folder name
|
||||
id: ID, // must match the folder name
|
||||
name: 'My Plugin',
|
||||
register(ctx) {
|
||||
// Register locale bundles — scoped to this plugin, torn down on reload.
|
||||
// Values are literals or interpolators (`arg => `…${arg}…``).
|
||||
ctx.i18n.register({
|
||||
en: {
|
||||
paneTitle: 'My Plugin Pane',
|
||||
gateway: state => `gateway: ${state}`,
|
||||
chipTip: 'My plugin — click me',
|
||||
hello: 'Hello from my plugin!'
|
||||
},
|
||||
ja: {
|
||||
paneTitle: 'マイプラグイン',
|
||||
gateway: state => `ゲートウェイ: ${state}`,
|
||||
chipTip: 'マイプラグイン — クリック',
|
||||
hello: 'マイプラグインからこんにちは!'
|
||||
}
|
||||
})
|
||||
|
||||
// A layout pane — auto-placed by the placement hint; user can drag it.
|
||||
// To land on a specific edge instead of stacking, add a dock gesture,
|
||||
// e.g. below the conversation:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue