diff --git a/CLAUDE.md b/CLAUDE.md index f49c0f2..7e52f0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,8 @@ Non-obvious bits of the contract: per-job temp dir. - Keys, endpoints and a provider's models change only through `/providers`; `PUT /config` takes the models chosen (`translation_model`, `answer_model`) - and refuses the per-service blocks and `preferred_service` retired in #88. + and `translation_instructions` (omitted is unchanged, `null` or blank + clears), and refuses the per-service blocks and `preferred_service` retired in #88. A provider id is a plain string on the wire, checked against the registry. ## Invariants @@ -251,6 +252,12 @@ rule has, unless noted, a test. They are the things most easily undone by `providers.md`'s "Adding a provider" and fix the design, not the checklist, if it needs more. `default_base_url` is *sent* when no endpoint is saved (`registry.endpoint_for`), which is what lets OpenRouter reuse OpenAI's SDK. +- **Translation instructions go in our prompt, after the fixed rules** — + BabelDOC's `custom_system_prompt` is inert without `do_llm_translate`. They + are part of both cache keys (appended only when set), reach LLM translation + only (`capabilities.translation_instructions`, asked of the effective + service), and never chat. (The processor's wiring — one value to the + translator and both ends of the PDF cache — is checked by hand.) - Translation has **no page or file-size limit** (`max_pages` / `max_file_size_mb` are gone; a `config.toml` that still holds them is ignored and loses them on the next save). The Pages box only chooses *which* pages; the output is always diff --git a/desktop/src/components/layout/ContextBar.tsx b/desktop/src/components/layout/ContextBar.tsx index d892628..db19ad3 100644 --- a/desktop/src/components/layout/ContextBar.tsx +++ b/desktop/src/components/layout/ContextBar.tsx @@ -1,4 +1,13 @@ -import { ArrowRight, FilePlus, Loader2, Lock, MessageSquare, RefreshCw, Sparkles } from "lucide-react"; +import { + ArrowRight, + FilePlus, + Loader2, + Lock, + MessageSquare, + NotebookPen, + RefreshCw, + Sparkles, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -38,6 +47,7 @@ import { isSourceSupported, } from "@/lib/translate-request"; import { useAppStore } from "@/lib/store"; +import { instructionsApply } from "@/lib/translation-instructions"; import { cn } from "@/lib/utils"; /** Warm the backend the user just selected. The value is passed in rather than @@ -291,6 +301,22 @@ export function ContextBar({ /> )} + {/* Instructions live in Settings, out of sight of the button they change + the output of, so the toolbar says when they will be sent. */} + {config && providers && instructionsApply(config, providers) && ( + + + + + Instructions on + + + + {config.translation.instructions} + + + )} + {/* What an LLM run would cost, roughly. Argos costs nothing to call. */} {originalPath && running && running !== OFFLINE_ENGINE && ( diff --git a/desktop/src/components/settings/SettingsSheet.tsx b/desktop/src/components/settings/SettingsSheet.tsx index 4138406..282b567 100644 --- a/desktop/src/components/settings/SettingsSheet.tsx +++ b/desktop/src/components/settings/SettingsSheet.tsx @@ -4,6 +4,7 @@ import { Database, FileText, Loader2, RefreshCw, Sparkles, Trash2 } from "lucide import { ChatIndexTab } from "@/components/settings/ChatIndexTab"; import { ModelsTab } from "@/components/settings/ModelsTab"; +import { TranslationTab } from "@/components/settings/TranslationTab"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -45,7 +46,7 @@ import { useConfig, useUpdateConfig } from "@/hooks/useConfig"; import type { OpenedFrom } from "@/lib/provider-draft"; import { cn } from "@/lib/utils"; -type TabValue = "models" | "cache" | "chat"; +type TabValue = "models" | "translation" | "cache" | "chat"; interface SettingsSheetProps { open: boolean; @@ -84,8 +85,9 @@ export function SettingsSheet({
setTab(v as TabValue)}> - + Models + Translation Cache Chat @@ -94,6 +96,10 @@ export function SettingsSheet({ + + + +
diff --git a/desktop/src/components/settings/TranslationTab.tsx b/desktop/src/components/settings/TranslationTab.tsx new file mode 100644 index 0000000..48e7b5a --- /dev/null +++ b/desktop/src/components/settings/TranslationTab.tsx @@ -0,0 +1,116 @@ +import { Info, Languages } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { useConfig, useUpdateConfig } from "@/hooks/useConfig"; +import { useProviders } from "@/hooks/useProviders"; +import { useAppStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { + MAX_INSTRUCTIONS_CHARS, + instructionsDraft, + translatesWithLlm, +} from "@/lib/translation-instructions"; + +/** + * Settings → Translation: instructions added to every paragraph's prompt. + * Saved with a button rather than as the user types — each `PUT /config` + * rewrites `config.toml`, and a half-typed instruction is not one to run with. + */ +export function TranslationTab() { + const { data: config } = useConfig(); + const { data: providers } = useProviders(); + const update = useUpdateConfig(); + + // `null` until edited, so the box shows whatever is saved — including a + // value saved elsewhere while the sheet was closed — without an effect to + // copy it in. + const edited = useAppStore((s) => s.instructionsDraft); + const setEdited = useAppStore((s) => s.setInstructionsDraft); + const saved = config?.translation.instructions ?? null; + const text = edited ?? saved ?? ""; + const draft = instructionsDraft(text, saved); + const llm = config && providers ? translatesWithLlm(config, providers) : true; + + const save = (value: string | null) => + update.mutate( + { translation_instructions: value }, + { + onSuccess: () => { + // Safe to drop: the box is locked while a save is in flight, so + // nothing was typed after what was sent. + setEdited(null); + toast.success(value ? "Instructions saved" : "Instructions cleared"); + }, + onError: (e) => + toast.error("Could not save the instructions", { + description: (e as Error).message, + }), + }, + ); + + return ( +
+
+
+ + +
+

+ Tell the model how to translate: tone, terms to keep, how to render + names. Sent with every paragraph, so a long instruction costs tokens + on each one. Applies to translation only, not to chat. +

+