Skip to content

Give the backend's English prose a code the frontend can translate #1967

Open

Description

@MODSetter

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's unavailable_reason, and the disk-space detail that lib/api.ts wraps 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

What done looks like

  • Each of those surfaces reads in the interface language, through an id the translate skill'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 ChatErrorKind in sse.ts mirrors modules/chat/errors.py with 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 test

Before 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

  1. ybai08 commented on Oct 2, 2026

    @ybai08
    Contributor

    I'd like to take this, one producer per PR as the issue allows, starting with Studio's unavailable_reason.

    Plan for that one: FormatRead gains an unavailable_code beside 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 way license-error-text.ts does, 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.

  2. ybai08 commented on Oct 5, 2026

    @ybai08
    Contributor

    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 in surfsense_local/backend sends the {message, required, available} detail that responseError() in lib/api.ts wraps. Its producer was the Ollama provider's "insufficient disk space" refusal, removed in 9ffe32d, so app_api_insufficient_space_error has had no response to format since. The disk-space sentence a user meets today is refuse_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's error event.

    Plan, as one PR:

    • Every install event carries a code beside its type and message: the constants in install_jobs/jobs.py and steps.py, the engines' after_install steps, and the InstallRefusedError refusals. The disk refusal's event carries needed_bytes and free_bytes raw, and the message formats them with an ICU skeleton.
    • installView() and announce() show the frontend's text for a code and the backend's message for a code they do not know. Today installView() reads event.message || PHASE_LABEL[...], so the translated phase labels only show when the backend sends no message.
    • Codes are a closed set on the backend and a plain string on the wire, as in feat(local): give a Studio format's unavailable reason a code to translate #2146.

    Two things I would leave out unless you say otherwise: a searched model's classification.reason (that is the not_runnable_reason producer, a PR of its own), and the dead wrapper in api.ts with its key, which I would remove in a small separate PR so this one stays one change.

  3. ybai08 commented on Oct 8, 2026

    @ybai08
    Contributor

    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 through notRunnableReason(row) in the search panel. Since #2200 the row sends not_runnable_code: null for them, so they are English in every language:

    • "This repo needs an account to download." (resolve.py)
    • "This repo has no ONNX build SurfSense can run." and "This repo has no tokenizer.json." (pick.py)
    • "Hugging Face's security scan flags this repo." (scan_verdict)
    • "Hugging Face lists no checksum for <file>." (pick.py)

    Plan: the first four get a NotRunnableCode each, carried from pick.py / resolve.py through Resolved to searched_row, a line each in not-runnable-text.ts, and the 9 other languages. Nothing new on the wire: the row already has not_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.

  4. ybai08 commented on Oct 11, 2026

    @ybai08
    Contributor

    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 through search_check after its files land, and verify() in modules/embedding/verify.py turns a miss into "It found 7 of 10 answers first; a search model has to find all 10.". The ONNX Runtime engine yields that as InstallStep("error", refusal) with no code, so installMessage() falls back to the English in every language.

    Plan, as one PR:

    • InstallCode.RETRIEVAL_CHECK_FAILED, next to CHECKING_RETRIEVAL, which is the step it fails.
    • The two counts go raw, as the disk refusal's sizes do: verify() hands back how many it found and how many were asked beside its sentence, InstallStep carries them, and the error event gets found and asked.
    • install-text.ts gets 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, as not_enough_disk does.
    • The gap line loses the item, and the paragraph above it gains the clause.

    The backend's sentence does not change.

  5. ybai08 commented on Oct 12, 2026

    @ybai08
    Contributor

    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_available sends {"message", "code"}), ApiError reads it, and studio-error-text.ts has the five needs_* lines that messageFrom in use-studio.ts puts under the panel's alert. The other refusals in modules/artifacts/service.py are a bare string, so they show in English in every language:

    Refusal Where Code I would give it
    "already generating" regenerate_artifact already_generating
    "nothing is running" cancel_artifact nothing_running
    "pick at least one source" _resolve_sources, both branches no_sources
    "the chosen sources are still indexing" _resolve_sources, a scope that resolves to nothing ready sources_indexing
    "None of this artifact's sources are left." _replayed_sources sources_gone

    Plan, as one PR:

    • Those five get the {"message", "code"} detail _require_available already sends. The backend's sentences do not change.
    • studio-error-text.ts gets 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.
    • The gap line keeps the embedder checksum sentence and loses this half, naming what is left of Studio.

    Left for later producers, each its own PR: the two refusals of the shared load_selected_sources in modules/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_options passes through from validate_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.

  6. ybai08 commented on Oct 13, 2026

    @ybai08
    Contributor

    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_spec in modules/artifacts/studio_documents/service.py refuses with a bare sentence in four cases. The Refine box shows what messageFrom makes of the error, so each is English in every language:

    Refusal Code I would give it
    "already generating" already_generating, the code a job's refusal already carries for the same sentence
    "Only a Word document or PDF made in Studio can be refined." not_refinable
    "The agent made this document: ask for changes in its chat." agent_made
    "This version did not finish: retry it, or refine another version." version_unfinished

    Plan, as one PR:

    • refused() moves out of modules/artifacts/service.py into modules/artifacts/refused.py, since two services use it now. Sentences and status codes stay as they are.
    • Three lines in studio-error-text.ts and in the ten catalogs. refine-box.tsx is not touched: it already goes through messageFrom, which reads the code.
    • The docs in the same change, as on feat(local): give Studio's job refusals codes the interface can translate #2241: docs/architecture/studio.md names the codes in Refine, in the route's row and where it says what the box shows; localization.md lists 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.

  7. ybai08 commented on Oct 30, 2026

    @ybai08
    Contributor

    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 catch RevisedCopyRefusedError in modules/artifacts/router.py and send str(error) as a bare 409 detail. The revised-copy bar and the panel's alert show messageFrom(cause), so these show in English in every language. Four of them can be reached from the panel:

    Refusal Where Code I would give it
    "already generating" rerun.py already_generating, the code a Studio job already sends for the same sentence
    "Only a Word revised copy has tracked changes to accept or reject." decide_all.py not_word_copy
    "Version N has no tracked changes to decide." decide_all.py no_tracked_changes, with version raw
    "Version N of this revised copy is still being made. …" versions.py version_being_made, with version raw

    Plan, as one PR:

    • RevisedCopyRefusedError takes an optional code and values. The router sends refused(409, …) when there is a code and the bare sentence when there is none, and the agent's tool keeps passing str(error) on.
    • Three lines in studio-error-text.ts and the ten catalogs. The two numbered ones fall back to the backend's English without a numeric version, as rewrite_too_long does.
    • studio.md (the two route rows and "In Studio") and localization.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.

  8. ybai08 commented on Oct 30, 2026

    @ybai08
    Contributor

    A question before the next producer. The next example in the gap line in localization.md is "a source named by id that is in another workspace or still indexing", and on dev (36c42457b) the interface can't reach either refusal.

    Where they come from:

    Refusal Where Callers
    "a chosen source is not in this workspace" (422) load_selected_sources in modules/documents/sources.py, and _missing in modules/source_scope/resolve.py Studio's _resolve_sources, the chat's _ground, the agent's turn_sources; the second through resolve_scope
    "sources are still indexing: [ids]" (409) load_selected_sources the same three

    Why the panel never meets them:

    • load_selected_sources only runs when a request sends document_ids with no source_scope. The dashboard always sends a scope, and scopeOf() builds one for every tick state. So Studio's Generate and the chat composer both take the resolve_scope branch, and chat.md already describes the id list as kept "for plugins and older clients".
    • Through a scope, a source that is still indexing is counted, not refused. A scope where nothing is ready already gets sources_indexing.
    • _missing refuses 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?

    1. Leave them as they are, and reword the gap line and step 2 of studio.md to say they only reach API clients that send document_ids, not the interface. I'd fold that into the next producer's PR rather than open a docs-only one.
    2. Give them codes anyway (source_not_in_workspace, and sources_indexing with 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    UXdesktopSurfSense desktop app (surfsense_local)frontendhelp wantedExtra attention is needed

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions