Repository navigation
Description
The gap
docs/architecture/localization.md lists this under Known gaps:
Backend prose without a code stays English in every language: model install messages, fit verdicts,
not_runnable_reason, a Studio format'sunavailable_reason, and the disk-spacedetailthatlib/api.tswraps in a translated sentence.
Why it matters
Someone running the app in Japanese or German meets English at exactly the moments the app is refusing or explaining itself: why a Studio format is greyed out, why a model card is flagged "Won't fit", why a model cannot run, what an install is doing, and why an upload failed for space. Those are the sentences a user most needs to read.
The disk-space one reads worst, because it is half done: lib/api.ts already wraps the backend's English sentence inside a translated one, so a German user gets a German frame around an English clause. The app also already has the answer, in two features — the backend sends a code beside its prose, and the frontend shows its own text for the code and falls back to the backend's English for a code it does not know. Everything in this gap line is prose with no code to key on.
Where
surfsense_local/frontend/src/features/chat/chat-error-text.ts— the pattern to copy, not to reinvent: a record of<feature>_error_<code>messages with the backend's own text as the fallback.license-error-text.tsis the second example.surfsense_local/backend/modules/artifacts/service.py—_availability()and_required()produce a format'sunavailable_reason.surfsense_local/backend/modules/llm/catalog/local/rows.py— thenot_runnable_reasonproperty, falling back to the literal "SurfSense cannot run this model."surfsense_local/backend/modules/llm/fit/copy.py—badge()and_refusal(), every fit verdict and its one line of why.surfsense_local/backend/modules/llm/catalog/local/install_jobs/steps.pyandinstall_jobs/jobs.py— the install step messages, including theQUEUED,CHECKING,CANCELLEDandFAILEDconstants.surfsense_local/frontend/src/lib/api.ts—responseError(), which buildsapp_api_insufficient_space_erroraround the backend's Englishdetail.message.
What done looks like
- Each of those surfaces reads in the interface language, through an id the
translateskill's key shape allows. - The backend still sends its English prose. A code the frontend has not shipped text for must keep showing something, which is what
translatedChatError()'s final fallback relies on. - Decide whether each producer gets an enum mirrored in TypeScript, the way
ChatErrorKindinsse.tsmirrorsmodules/chat/errors.pywith a "keep the two in sync" comment, or a looser string. That is yours to decide, and it is the decision this issue turns on. - The fit verdicts carry numbers. Send them raw and format them in the message with an ICU skeleton —
copy.py's_gb()currently pre-formats "13.6 GB", which no other language can re-space or re-punctuate. - It is fine to land this one producer at a time. Say in each PR which producer it closes, and delete the Known gaps line only with the last.
What not to change
This is not a docs/contracts/ change: those four documents cover the license file, scraper API auth, the export bundle and the sunset flag. The desktop API is one tree talking to itself.
How to verify
surfsense_local/backend/tests/integration/artifacts/test_routes.py already asserts on unavailable_reason, and surfsense_local/frontend/src/lib/api.test.ts on the error body. Write the failing test first on whichever producer you start with: a route test that the response carries the code, then a frontend test that the code renders its own text and an unknown code falls through to the backend's.
cd surfsense_local/backend
uv run pytest -m unit
uv run pytest -m integration
uv run ruff check .
cd ../frontend
pnpm testBefore you start
Read CONTRIBUTING.md and the localization doc, whose Backend text section states the rule this follows. Interface strings in surfsense_local follow the translate skill described in AGENTS.md — it owns the <feature>_error_<code> key shape, the tone per language and the glossary. translations/en.json is generated from the defaultMessage in the code, so never hand-edit it; run pnpm translations and commit the result with the code. Open your PR against dev, not main. Comment here to claim this.
Activity
ybai08 commentedon Oct 2, 2026
I'd like to take this, one producer per PR as the issue allows, starting with Studio's
unavailable_reason.Plan for that one:
FormatReadgains anunavailable_codebeside the prose, one code per set of missing model types (needs_chat,needs_chat_image, …). The frontend keeps a record of whole-sentence messages keyed by code, the waylicense-error-text.tsdoes, and shows the backend's English for a code it does not know. Whole sentences per code and not a joined list, since the translate skill rules out joining keys and the model-type phrases take different forms inside a sentence in several of the languages.On the decision the issue turns on: I'll decide per producer and say why in each PR. For this one the set is closed and small, so the codes are an enum on the backend, and a plain string on the wire that the frontend looks up, so an unknown code still falls through.
Next after that, if this shape is what you want: the disk-space
detail, then the fit verdicts with raw numbers.ybai08 commentedon Oct 5, 2026
Next producer, now that #2146 is in: the install messages. The disk-space one turns out to be one of them.
What I found on
dev: nothing insurfsense_local/backendsends the{message, required, available}detail thatresponseError()inlib/api.tswraps. Its producer was the Ollama provider's "insufficient disk space" refusal, removed in 9ffe32d, soapp_api_insufficient_space_errorhas had no response to format since. The disk-space sentence a user meets today isrefuse_without_room()'s "This download needs 13.6 GB free; this computer has 9.1 GB.", pre-formatted by_gb(), and it reaches the screen as an install job'serrorevent.Plan, as one PR:
codebeside itstypeandmessage: the constants ininstall_jobs/jobs.pyandsteps.py, the engines'after_installsteps, and theInstallRefusedErrorrefusals. The disk refusal's event carriesneeded_bytesandfree_bytesraw, and the message formats them with an ICU skeleton.installView()andannounce()show the frontend's text for a code and the backend'smessagefor a code they do not know. TodayinstallView()readsevent.message || PHASE_LABEL[...], so the translated phase labels only show when the backend sends no message.Two things I would leave out unless you say otherwise: a searched model's
classification.reason(that is thenot_runnable_reasonproducer, a PR of its own), and the dead wrapper inapi.tswith its key, which I would remove in a small separate PR so this one stays one change.feat(local): give install events a code the interface can translate
refactor(local): drop the disk-space error wrapper nothing sends a bo…
feat(local): give a fit badge a code the interface can translate
feat(local): give a model that cannot run a code the interface can tr…
ybai08 commentedon Oct 8, 2026
Next producer, now that #2200 is in: why an opened Hugging Face embedder repo cannot run. It is the first item of the gap line in
localization.md.What is there on
dev(eddb039ad):embedding/huggingface/has five sentences, and a repo with no build shows whichever applies throughnotRunnableReason(row)in the search panel. Since #2200 the row sendsnot_runnable_code: nullfor them, so they are English in every language:resolve.py)pick.py)scan_verdict)<file>." (pick.py)Plan: the first four get a
NotRunnableCodeeach, carried frompick.py/resolve.pythroughResolvedtosearched_row, a line each innot-runnable-text.ts, and the 9 other languages. Nothing new on the wire: the row already hasnot_runnable_code.The fifth names a file, and a code alone cannot carry that. I would leave it as it is, English with the file name and no code, the way an install event without its numbers falls back to the backend's sentence, and keep it named in the gap line. If you would rather have it translated without the file name, say so and I will add a fifth code.
Not in this one: an embedder's failed check at install, the next item of that gap line.
feat(local): give an embedder repo's refusal a code the interface can…
ybai08 commentedon Oct 11, 2026
Next producer, now that #2209 is in: an embedder's failed check at install, the second item of the gap line in
localization.md.What is there on
dev(6b6e21d30): a Hugging Face embedder is run throughsearch_checkafter its files land, andverify()inmodules/embedding/verify.pyturns a miss into "It found 7 of 10 answers first; a search model has to find all 10.". The ONNX Runtime engine yields that asInstallStep("error", refusal)with no code, soinstallMessage()falls back to the English in every language.Plan, as one PR:
InstallCode.RETRIEVAL_CHECK_FAILED, next toCHECKING_RETRIEVAL, which is the step it fails.verify()hands back how many it found and how many were asked beside its sentence,InstallStepcarries them, and theerrorevent getsfoundandasked.install-text.tsgets one line,models_error_install_retrieval_check_failed, with both counts as{…, number}, in all ten catalogs. Without the counts on the event it returns null and the backend's English stays, asnot_enough_diskdoes.The backend's sentence does not change.
feat(local): give an embedder's failed search check a code the interf…
ybai08 commentedon Oct 12, 2026
Next producer, now that #2217 is in: Studio's job refusals, the second half of the gap line in
localization.md.What is there on
dev(f2a021f3f): a refused create or regenerate for a missing model already carries a code (_require_availablesends{"message", "code"}),ApiErrorreads it, andstudio-error-text.tshas the fiveneeds_*lines thatmessageFrominuse-studio.tsputs under the panel's alert. The other refusals inmodules/artifacts/service.pyare a bare string, so they show in English in every language:regenerate_artifactalready_generatingcancel_artifactnothing_running_resolve_sources, both branchesno_sources_resolve_sources, a scope that resolves to nothing readysources_indexing_replayed_sourcessources_gonePlan, as one PR:
{"message", "code"}detail_require_availablealready sends. The backend's sentences do not change.studio-error-text.tsgets five lines,studio_error_<code>, in all ten catalogs. A code it has no line for still falls back to the backend's English, as today.Left for later producers, each its own PR: the two refusals of the shared
load_selected_sourcesinmodules/documents/sources.py("a chosen source is not in this workspace", and "sources are still indexing: [ids]", which also carries ids and is raised for the chat and agent threads too); a format's own option errors, which_resolve_optionspasses through fromvalidate_options; the refine refusals; and the revised copies. "unknown format" I would leave as it is: the panel only sends formats the listing gave it.feat(local): give Studio's job refusals codes the interface can trans…
ybai08 commentedon Oct 13, 2026
Next producer, now that #2241 is in: a document that cannot be refined, the next of Studio's refusals under the gap line in
localization.md.What is there on
dev(28ce7149e):refinable_specinmodules/artifacts/studio_documents/service.pyrefuses with a bare sentence in four cases. The Refine box shows whatmessageFrommakes of the error, so each is English in every language:already_generating, the code a job's refusal already carries for the same sentencenot_refinableagent_madeversion_unfinishedPlan, as one PR:
refused()moves out ofmodules/artifacts/service.pyintomodules/artifacts/refused.py, since two services use it now. Sentences and status codes stay as they are.studio-error-text.tsand in the ten catalogs.refine-box.tsxis not touched: it already goes throughmessageFrom, which reads the code.docs/architecture/studio.mdnames the codes in Refine, in the route's row and where it says what the box shows;localization.mdlists them under Backend text, and its Known gaps line stays general, with a rewrite too long for the model's window as the refine example that still has no code.Not in it: that "too long" refusal from
fits.py. Its sentence carries the model's name and three numbers, so it would send them raw for the message to format, as the disk refusal does; I would take it after this one. A revised copy's refusals after that.feat(local): give a refine's refusals codes the interface can translate
feat(local): send a too-long rewrite's numbers beside its code
ybai08 commentedon Oct 30, 2026
Next producer, now that #2290 is in: a revised copy's refusals, the next item of Studio's refusals under the gap line in
localization.md.What is there on
dev(36c42457b): Retry and Accept all / Reject all on a revised copy catchRevisedCopyRefusedErrorinmodules/artifacts/router.pyand sendstr(error)as a bare409detail. The revised-copy bar and the panel's alert showmessageFrom(cause), so these show in English in every language. Four of them can be reached from the panel:rerun.pyalready_generating, the code a Studio job already sends for the same sentencedecide_all.pynot_word_copydecide_all.pyno_tracked_changes, withversionrawversions.pyversion_being_made, withversionrawPlan, as one PR:
RevisedCopyRefusedErrortakes an optionalcodeand values. The router sendsrefused(409, …)when there is a code and the bare sentence when there is none, and the agent's tool keeps passingstr(error)on.studio-error-text.tsand the ten catalogs. The two numbered ones fall back to the backend's English without a numericversion, asrewrite_too_longdoes.studio.md(the two route rows and "In Studio") andlocalization.md(Backend text and Known gaps) in the same change.Left for later:
revision_base's other refusals (a missing file, no ready version, not a revised copy). The bar does not offer its buttons in those states, and several of those sentences are written for the agent.ybai08 commentedon Oct 30, 2026
A question before the next producer. The next example in the gap line in
localization.mdis "a source named by id that is in another workspace or still indexing", and ondev(36c42457b) the interface can't reach either refusal.Where they come from:
422)load_selected_sourcesinmodules/documents/sources.py, and_missinginmodules/source_scope/resolve.py_resolve_sources, the chat's_ground, the agent'sturn_sources; the second throughresolve_scope409)load_selected_sourcesWhy the panel never meets them:
load_selected_sourcesonly runs when a request sendsdocument_idswith nosource_scope. The dashboard always sends a scope, andscopeOf()builds one for every tick state. So Studio's Generate and the chat composer both take theresolve_scopebranch, andchat.mdalready describes the id list as kept "for plugins and older clients".sources_indexing._missingrefuses an id from another workspace. But the scope is built from the open workspace's own index, the dashboard is keyed by workspace id, and nothing moves a document between workspaces.So by the rule I used on #2374 (code only what the panel can reach), these get no code. There's also no single feature key to put them under, because chat, the agent and Studio share them.
Which would you prefer?
studio.mdto say they only reach API clients that senddocument_ids, not the interface. I'd fold that into the next producer's PR rather than open a docs-only one.source_not_in_workspace, andsources_indexingwith the ids sent raw), with lines in Studio's and the chat's error text, for a client that might send ids later.Unless you'd rather have 2, I'll go with 1 and move on to the next Studio item.