Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion desktop/src/components/layout/ContextBar.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) && (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1.5 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary">
<NotebookPen className="h-3.5 w-3.5" />
Instructions on
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs whitespace-pre-wrap">
{config.translation.instructions}
</TooltipContent>
</Tooltip>
)}

{/* What an LLM run would cost, roughly. Argos costs nothing to call. */}
{originalPath && running && running !== OFFLINE_ENGINE && (
<TokenEstimate filePath={originalPath} targetLang={targetLang} />
Expand Down
10 changes: 8 additions & 2 deletions desktop/src/components/settings/SettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -84,8 +85,9 @@ export function SettingsSheet({

<div className="flex-1 overflow-y-auto px-4">
<Tabs value={tab} onValueChange={(v) => setTab(v as TabValue)}>
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="models">Models</TabsTrigger>
<TabsTrigger value="translation">Translation</TabsTrigger>
<TabsTrigger value="cache">Cache</TabsTrigger>
<TabsTrigger value="chat">Chat</TabsTrigger>
</TabsList>
Expand All @@ -94,6 +96,10 @@ export function SettingsSheet({
<ModelsTab focusProvider={focusProvider} openedFrom={openedFrom} />
</TabsContent>

<TabsContent value="translation" className="mt-4">
<TranslationTab />
</TabsContent>

<TabsContent value="cache" className="mt-4">
<CacheTab open={open && tab === "cache"} />
<div className="mt-6 border-t border-border pt-6">
Expand Down
116 changes: 116 additions & 0 deletions desktop/src/components/settings/TranslationTab.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4 py-2">
<section className="space-y-3">
<div className="flex items-center gap-2">
<Languages className="h-4 w-4 text-primary" />
<Label htmlFor="translation-instructions" className="text-sm font-medium">
Translation instructions
</Label>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
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.
</p>
<Textarea
id="translation-instructions"
value={text}
disabled={!config || update.isPending}
placeholder={
"e.g. Keep programming terms such as index, array and pointer in English."
}
aria-invalid={draft.tooLong}
onChange={(e) => setEdited(e.target.value)}
className="max-h-72 min-h-32"
/>
<div className="flex items-center justify-between gap-3">
<span
className={cn(
"text-xs tabular-nums text-muted-foreground",
draft.tooLong && "text-destructive",
)}
>
{text.trim().length} / {MAX_INSTRUCTIONS_CHARS}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!saved || update.isPending}
onClick={() => save(null)}
>
Clear
</Button>
<Button
size="sm"
disabled={!draft.dirty || draft.tooLong || update.isPending}
onClick={() => save(draft.value)}
>
Save
</Button>
</div>
</div>
{!llm && (
<p className="flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Instructions are sent only to an LLM with a key. Until one
translates, Argos does, and it takes none.
</p>
)}
</section>
</div>
);
}
3 changes: 3 additions & 0 deletions desktop/src/components/translation/TokenEstimate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useConfig } from "@/hooks/useConfig";
import { useTranslationEstimate } from "@/hooks/useTranslationEstimate";
import { parsePageRanges } from "@/lib/page-range";
import { useAppStore } from "@/lib/store";
Expand Down Expand Up @@ -44,11 +45,13 @@ export function TokenEstimate({ filePath, targetLang }: TokenEstimateProps) {
return () => window.clearTimeout(timer);
}, [filePath, text]);

const { data: config } = useConfig();
const parsed = parsePageRanges(pages, pageCount);
const estimate = useTranslationEstimate({
filePath,
ranges: parsed.ok ? parsed.ranges : null,
targetLang,
instructions: config?.translation.instructions ?? null,
enabled: parsed.ok,
});
// `enabled: false` does not hide what is already cached, and an unreadable
Expand Down
13 changes: 9 additions & 4 deletions desktop/src/hooks/useTranslationEstimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ interface EstimateInput {
/** The Pages box, parsed; `null` for every page. */
ranges: PageRange[] | null;
targetLang: string | null;
/** The saved translation instructions. Not sent — the sidecar reads them
* from config — but each paragraph's prompt carries them, so a new set is a
* new question; without it the count went stale for the session. */
instructions: string | null;
/** Only while an LLM will run: Argos costs nothing to call. */
enabled: boolean;
}
Expand All @@ -21,18 +25,19 @@ const FILE_PATH = 2;

/**
* Roughly how many tokens translating these pages would take (#33). The file,
* pages and language are the whole question, so an answer is kept for the
* session; while a new one loads, the previous one for *this document* stays
* on screen.
* pages, language and instructions are the whole question, so an answer is
* kept for the session; while a new one loads, the previous one for *this
* document* stays on screen.
*/
export function useTranslationEstimate({
filePath,
ranges,
targetLang,
instructions,
enabled,
}: EstimateInput) {
return useQuery({
queryKey: ["translate", "estimate", filePath, ranges, targetLang],
queryKey: ["translate", "estimate", filePath, ranges, targetLang, instructions],
queryFn: () => {
const body: EstimateRequest = {
file_path: filePath as string,
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/lib/api-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,8 @@ export interface components {
default_target_lang?: components["schemas"]["LanguageCode"] | null;
/** Max Parallel Chunks */
max_parallel_chunks?: number | null;
/** Translation Instructions */
translation_instructions?: string | null;
translation_model?: components["schemas"]["ModelRef"] | null;
/** Whole Document Tokens */
whole_document_tokens?: number | null;
Expand Down Expand Up @@ -1668,6 +1670,11 @@ export interface components {
* @default vi
*/
default_target_lang: components["schemas"]["LanguageCode"];
/**
* Instructions
* @description Instructions for the model that translates
*/
instructions: string | null;
/**
* Min Text Length
* @description Minimum text length to translate
Expand Down
28 changes: 27 additions & 1 deletion desktop/src/lib/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,18 @@
],
"title": "Max Parallel Chunks"
},
"translation_instructions": {
"anyOf": [
{
"maxLength": 2000,
"type": "string"
},
{
"type": "null"
}
],
"title": "Translation Instructions"
},
"translation_model": {
"anyOf": [
{
Expand Down Expand Up @@ -2368,6 +2380,19 @@
"default": "vi",
"description": "Default target language (Vietnamese priority)"
},
"instructions": {
"anyOf": [
{
"maxLength": 2000,
"type": "string"
},
{
"type": "null"
}
],
"description": "Instructions for the model that translates",
"title": "Instructions"
},
"min_text_length": {
"default": 5,
"description": "Minimum text length to translate",
Expand Down Expand Up @@ -2404,7 +2429,8 @@
"cache_translated_pdfs",
"pdf_cache_max_size_mb",
"preserve_formatting",
"min_text_length"
"min_text_length",
"instructions"
],
"title": "TranslationConfig",
"type": "object"
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ interface AppState {
pageRangeText: string;
setPageRangeText: (text: string) => void;

/** Settings → Translation's instructions box while it differs from what is
* saved; `null` when untouched. Here rather than in the tab because the
* sheet unmounts a tab it leaves, and an unsaved draft went with it. */
instructionsDraft: string | null;
setInstructionsDraft: (draft: string | null) => void;

/** Dimensions of the original PDF's first page (CSS points, scale=1). Used
* to render a blank placeholder in the translated panel before a
* translation exists. */
Expand Down Expand Up @@ -161,6 +167,9 @@ export const useAppStore = create<AppState>((set) => ({
pageRangeText: "",
setPageRangeText: (pageRangeText) => set({ pageRangeText }),

instructionsDraft: null,
setInstructionsDraft: (instructionsDraft) => set({ instructionsDraft }),

originalFirstPageSize: null,
setOriginalFirstPageSize: (size) => set({ originalFirstPageSize: size }),

Expand Down
Loading
Loading