Translating the panel
Every reader-facing string is replaceable, one string at a time.
The shape
docPilot = {
i18n: {
// Applies to every locale.
translations: {
empty: { heading: 'How can I help?' },
},
// Keyed by VitePress locale.
locales: {
ru: {
translations: {
empty: { heading: 'Чем помочь?' },
composer: { placeholderNoProduct: 'Спросите про документацию' },
},
},
},
},
}The inside is byte-for-byte VitePress's own local-search i18n, so their example transfers here unchanged. The outer key is i18n rather than locales because a VitePress config already has a top-level locales meaning something else, and two keys with one name in one file is a support question waiting to happen.
Overriding one string keeps the other hundred. The merge is per leaf, never per block.
Two selectors, one key space
There are two different "reader's language" in this panel, and conflating them regresses behaviour that already ships.
| driven by | covers | |
|---|---|---|
| UI locale | the page's locale (lang) | every button, label, aria name, status line and refusal |
| Reply locale | the language the reader typed | reply.credential.* and reply.social.* — the two copies the host writes with no model call |
Which selector a key uses is a property of the key and is not configurable: everything under reply. follows the typed language, everything else follows the page.
One selector for both would have to pick a regression: either a Russian greeting answered in English, or Russian written into an English page's chrome. Both were behaviours that worked before the i18n layer existed, and neither is worth trading.
Both selectors are subtags, which is the point. A site that already declares locales: { ru: … } in VitePress writes one ru block here and covers its chrome and its replies.
Fallback, per leaf
i18n.locales[selector].translations.<path>i18n.translations.<path>- for
reply.*only:i18n.locales[<page locale>].translations.<path>— a reader typing an unrecognised language on a Russian site is better served Russian than English - the shipped default for that locale
- the shipped default for
en
A regional tag is normalised to its subtag: ru-RU finds ru, en-US finds en. A locale nobody ships and nobody configured falls back to English rather than rendering blank.
The keys
185 leaves, in twenty-six groups. The panel chrome ships in English only; reply.* ships in eighteen languages.
| group | n | what it is |
|---|---|---|
panel | 10 | the drawer title, its buttons, the aria names of its regions, jump to latest |
empty | 7 | the greeting on an empty thread, the pages a narrow scope is asking about, follow-up questions, and the first-visit hint |
status | 10 | the live line while a turn runs — searching, thinking, readingPage, tool while a site tool runs, and the two waiting steps |
turn | 4 | Stopped., the tentative note, the reasoning timer |
citation | 4 | the passage disclosure under a source row, and the heading over a copied answer's sources |
refusal | 9 | Searched {scope}, I couldn't find this in {scope}., the widen button, the pages a refused turn read |
results | 4 | search-only mode: the lead over the passages, the "nothing matches closely" form of it, the list's aria name, and the empty case |
error | 8 | the transport failure and its two buttons, plus the spent daily limit, when it lifts, the two lines about what is left of it, and the note that this site has no embedder |
hybrid | 2 | the last rung of the answer ladder: the sentence over the passages when no model could be reached, and its quieter form under a spent daily limit |
actions | 11 | copy and edit on a question; copy, ask again, thumbs and the reasons group on an answer; the reopen link |
reasons | 4 | the four not-helpful labels. The values are stable; only the labels translate |
feedback | 8 | the comment box: its label, placeholder, hint, counter, and two buttons |
picker | 9 | the scope picker and its three presets |
history | 9 | the conversation switcher and its two-step Delete all |
promptDoc | 15 | the prompt disclosure's headings and its edit controls |
composer | 8 | the placeholder, send/stop, the counter, the screen-reader hint, the bar's button that brings the conversation back |
quote | 3 | the popover on a selected passage, and the chip it puts in the composer |
disclaimer | 3 | no endpoint · down-votes only · any rating sent |
credit | 2 | the DocPilot link that closes the footnote, and its tooltip — a name, so translating it is not the point; renaming it is |
degraded | 2 | when AI answers are off in this environment |
scope | 2 | All docs and {n} pages, as the reader sees them |
trigger | 4 | the nav button, the words on the floating one, and the dot that says an answer arrived behind a closed panel |
tools | 10 | an answer's actions — the group's name, running, done, failed, unavailable, cancel, required, a field to check, the arguments a pill describes — and "from the site" on a tool's source row. The button text is each tool's own label |
cta | 1 | the end-of-article link |
announce | 21 | the polite live region — screen-reader only |
reply | 15 | the credential warning and the four social replies |
A key that does not exist is dropped and named on stdout:
[docpilot] i18n.translations.empty.headng is not a key this panel has — dropped
[docpilot] i18n root + 1 locale (ru) · 1 unknown key ignoredFailing a docs build over a typo in copy would be out of proportion — but so would swallowing it, which is why the drop is loud.
Interpolation
{name} for a value, {n, singular|plural} for a two-form plural:
refusal: {
searchedAndRead: 'Searched {scope} and read {n} {n, page|pages}',
}Two forms only, deliberately. This is a UI string table, not an ICU runtime, and the four strings that count anything count pages or sources. A language whose plural rules need more than two forms overrides the whole sentence — which is what the override mechanism is for.
What is not translatable
The system instruction. promptHash names every eval report, and eval refuses to diff two reports built from different instructions. A translatable prompt would give two readers of the same build two different hashes and break every cross-report comparison there is.
Change the instruction with prompt.override / prompt.extend, which are build-time and singular, and remember that languageDirective already makes the model answer in the reader's language — a translated system prompt buys nothing and costs the hash.
The prompt disclosure's headings are chrome and are in the table (promptDoc.heading*). Its bodies are the exact bytes sent to the model; translating those would make the disclosure a description of something other than what was sent.
Page and section titles. The scope picker shows corpus text. Translating a page title would name a page that does not exist.
Finding the answer, before there is one to translate
The panel answers in the reader's language whenever it answers at all — but a question has to reach the model first, and that is a retrieval problem rather than a translation one.
A dense channel handles it: the question is embedded verbatim, no translation step anywhere, and a multilingual embedder puts a Russian question and an English paragraph near each other by meaning. A lexical channel cannot. BM25 matches words, and a Russian question shares no word with an English corpus — so on a site with no embedder (embed: false, an embedder that stopped answering, an index built with another model) the score is zero, for a question the corpus answers perfectly well.
Two things close that, and neither is i18n:
vocabularydeclares the words readers use for what your documentation calls something — including in their language.npx docpilot vocabularyproposes the map; the rewrite runs over the index and the query through one tokenizer, so both sides meet on the same token.guard.mode: 'off', which ships, stops a verdict computed from lexical coverage alone — or from anything else — from ending the turn, on this shape and on every other one a per-language threshold cannot reach. The words a map does not carry are exactly the ones nobody thought to declare, so the model — which is shown the passages and can read them — decides instead.
A follow-up has a third thing behind it. «а я могу его стилизировать?» retrieves nothing on its own in any language — the pronoun is the question — so the gate also scores it composed with the previous question. The check that keeps that composition honest asks whether a word of the follow-up appears in the retrieved evidence, which no Russian word can do over an English corpus, so it stands aside for a tail whose words and letters are both foreign to the corpus and lets the dense channel decide. Same script, different language — French over English docs — is vocabulary's job, as above. See The refusal gate.
The refusal copy itself follows the page's locale rather than the language the question was typed in, like the rest of the panel chrome; only reply.* follows the reader. That is the split Two selectors, one key space describes.
One thing this cannot check
themeDocPilot validates key paths, but it does not see VitePress's own locales — so an override for a language your site does not publish validates cleanly and never renders. If a block appears to do nothing, check that the locale key matches one your site actually has.
Where the merge happens
Validation is in Node, at build time. The merge is in the browser.
themeConfig is inlined into the hydration payload of every page, so merging server-side would ship the whole default tree — a hundred UI strings plus eighteen languages of reply copy — on every page of the site. Only your delta crosses; the defaults stay inside the panel's own lazily loaded chunk, where they already were.