diff --git a/messages/de.json b/messages/de.json index 66cc4a50a..64d976c3c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5884,9 +5884,9 @@ "questionsOpenCoach": "Im Coach beantworten" }, "responseTimeoutHeading": "Antwort-Timeout", - "responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.", + "responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht. Ohne Wert darf eine Antwort {seconds} s dauern, das Tagesbriefing bis zu {briefingSeconds} s.", "responseTimeoutLabel": "Antwort-Timeout (Sekunden)", - "responseTimeoutPlaceholder": "Standard (~120)", + "responseTimeoutPlaceholder": "Standard ({seconds} s)", "responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.", "autoRead": { "title": "Dokumente automatisch mit KI lesen", diff --git a/messages/en.json b/messages/en.json index 8bfe26d62..72b7b6c01 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5884,9 +5884,9 @@ "questionsOpenCoach": "Answer them in the Coach" }, "responseTimeoutHeading": "Response timeout", - "responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.", + "responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request. Left empty, a reply may take {seconds} s; the daily briefing allows up to {briefingSeconds} s.", "responseTimeoutLabel": "Response timeout (seconds)", - "responseTimeoutPlaceholder": "Default (~120)", + "responseTimeoutPlaceholder": "Default ({seconds} s)", "responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.", "autoRead": { "title": "Read documents automatically with AI", diff --git a/messages/es.json b/messages/es.json index a3e109389..f6710ede7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5884,9 +5884,9 @@ "questionsOpenCoach": "Responderlas en el Coach" }, "responseTimeoutHeading": "Tiempo de espera de la respuesta", - "responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.", + "responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud. Si lo dejas vacío, una respuesta puede tardar {seconds} s; el resumen diario admite hasta {briefingSeconds} s.", "responseTimeoutLabel": "Tiempo de espera de la respuesta (segundos)", - "responseTimeoutPlaceholder": "Predeterminado (~120)", + "responseTimeoutPlaceholder": "Predeterminado ({seconds} s)", "responseTimeoutInvalid": "Introduce un número entre 10 y 600 segundos.", "autoRead": { "title": "Leer documentos automáticamente con IA", diff --git a/messages/fr.json b/messages/fr.json index d9e6a5017..90cb85fce 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5884,9 +5884,9 @@ "questionsOpenCoach": "Y répondre dans le Coach" }, "responseTimeoutHeading": "Délai de réponse", - "responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.", + "responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête. Sans valeur, une réponse peut prendre {seconds} s ; le briefing quotidien jusqu'à {briefingSeconds} s.", "responseTimeoutLabel": "Délai de réponse (secondes)", - "responseTimeoutPlaceholder": "Par défaut (~120)", + "responseTimeoutPlaceholder": "Par défaut ({seconds} s)", "responseTimeoutInvalid": "Saisissez un nombre entre 10 et 600 secondes.", "autoRead": { "title": "Lire les documents automatiquement avec l'IA", diff --git a/messages/it.json b/messages/it.json index 639c0a7a6..b2f1590cd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5884,9 +5884,9 @@ "questionsOpenCoach": "Rispondi nel Coach" }, "responseTimeoutHeading": "Timeout della risposta", - "responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.", + "responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta. Se lasci vuoto, una risposta può richiedere {seconds} s; il briefing giornaliero fino a {briefingSeconds} s.", "responseTimeoutLabel": "Timeout della risposta (secondi)", - "responseTimeoutPlaceholder": "Predefinito (~120)", + "responseTimeoutPlaceholder": "Predefinito ({seconds} s)", "responseTimeoutInvalid": "Inserisci un numero tra 10 e 600 secondi.", "autoRead": { "title": "Leggi i documenti automaticamente con l'IA", diff --git a/messages/ko.json b/messages/ko.json index bab8a4d71..3b00e3bd9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5884,9 +5884,9 @@ "questionsOpenCoach": "코치에서 답하기" }, "responseTimeoutHeading": "응답 제한 시간", - "responseTimeoutBody": "AI 응답을 얼마나 기다린 뒤 포기할지 정해요. 첫 요청에서 모델을 불러오는 데 시간이 걸리는 로컬·셀프 호스팅 모델이라면 늘리세요.", + "responseTimeoutBody": "AI 응답을 얼마나 기다린 뒤 포기할지 정해요. 첫 요청에서 모델을 불러오는 데 시간이 걸리는 로컬·셀프 호스팅 모델이라면 늘리세요. 비워 두면 응답은 {seconds}초까지 기다리고, 일일 브리핑은 최대 {briefingSeconds}초까지 기다려요.", "responseTimeoutLabel": "응답 제한 시간 (초)", - "responseTimeoutPlaceholder": "기본값 (약 120)", + "responseTimeoutPlaceholder": "기본값 ({seconds}초)", "responseTimeoutInvalid": "10에서 600초 사이의 숫자를 입력해 주세요.", "autoRead": { "title": "AI로 문서 자동 읽기", diff --git a/messages/pl.json b/messages/pl.json index 1ecc73cc5..a39f615c5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5884,9 +5884,9 @@ "questionsOpenCoach": "Odpowiedz w Coachu" }, "responseTimeoutHeading": "Limit czasu odpowiedzi", - "responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.", + "responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu. Bez wartości odpowiedź może trwać {seconds} s, a dzienne podsumowanie do {briefingSeconds} s.", "responseTimeoutLabel": "Limit czasu odpowiedzi (sekundy)", - "responseTimeoutPlaceholder": "Domyślnie (~120)", + "responseTimeoutPlaceholder": "Domyślnie ({seconds} s)", "responseTimeoutInvalid": "Podaj liczbę od 10 do 600 sekund.", "autoRead": { "title": "Automatyczne czytanie dokumentów przez AI", diff --git a/src/components/settings/ai/__tests__/response-timeout-copy.test.ts b/src/components/settings/ai/__tests__/response-timeout-copy.test.ts new file mode 100644 index 000000000..da2c87b54 --- /dev/null +++ b/src/components/settings/ai/__tests__/response-timeout-copy.test.ts @@ -0,0 +1,70 @@ +/** + * The response-timeout copy names the defaults the server applies. + * + * The placeholder said "Default (~120)" while most surfaces fell back to 60 s + * and the briefing to 180 s. The numbers now come from the server constants + * through interpolation; this pins that every locale interpolates them instead + * of writing a number of its own, and that the rendered text carries them. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout"; + +import { RESPONSE_TIMEOUT_COPY_PARAMS } from "../response-timeout-card"; + +const LOCALES = ["de", "en", "es", "fr", "it", "ko", "pl"] as const; + +function aiCopy(locale: string): Record { + const bundle = JSON.parse( + readFileSync(join(process.cwd(), "messages", `${locale}.json`), "utf8"), + ) as { settings: { ai: Record } }; + return bundle.settings.ai; +} + +function render(template: string): string { + return template.replace(/\{(\w+)\}/g, (_, k: string) => + String( + RESPONSE_TIMEOUT_COPY_PARAMS[ + k as keyof typeof RESPONSE_TIMEOUT_COPY_PARAMS + ], + ), + ); +} + +describe("response-timeout copy", () => { + it("reads its numbers from the server constants", () => { + expect(RESPONSE_TIMEOUT_COPY_PARAMS.seconds).toBe( + PROVIDER_DEFAULT_TIMEOUT_MS / 1000, + ); + expect(RESPONSE_TIMEOUT_COPY_PARAMS.briefingSeconds).toBe( + AI_BUDGETS.comprehensive.timeoutMs! / 1000, + ); + expect(RESPONSE_TIMEOUT_COPY_PARAMS.briefingSeconds).toBeGreaterThan(0); + }); + + it.each(LOCALES)( + "%s interpolates the defaults and writes none itself", + (locale) => { + const copy = aiCopy(locale); + const placeholder = copy.responseTimeoutPlaceholder; + const body = copy.responseTimeoutBody; + expect(placeholder).toContain("{seconds}"); + expect(body).toContain("{seconds}"); + expect(body).toContain("{briefingSeconds}"); + // No literal number outside the placeholders: a hardcoded one would drift. + expect(placeholder.replace(/\{\w+\}/g, "")).not.toMatch(/\d/); + expect(body.replace(/\{\w+\}/g, "")).not.toMatch(/\d/); + + expect(render(placeholder)).toContain( + String(PROVIDER_DEFAULT_TIMEOUT_MS / 1000), + ); + expect(render(body)).toContain( + String(AI_BUDGETS.comprehensive.timeoutMs! / 1000), + ); + }, + ); +}); diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx index 386522ec8..628e9c0e4 100644 --- a/src/components/settings/ai/response-timeout-card.tsx +++ b/src/components/settings/ai/response-timeout-card.tsx @@ -18,12 +18,24 @@ import { Loader2, Save } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import type { UserAIProvider } from "./shared"; +/** + * The defaults the copy names, read from the constants the server applies, so + * the placeholder cannot drift from the real fallback again (it said "~120" + * while most surfaces waited 60 s). + */ +export const RESPONSE_TIMEOUT_COPY_PARAMS = { + seconds: PROVIDER_DEFAULT_TIMEOUT_MS / 1000, + briefingSeconds: (AI_BUDGETS.comprehensive.timeoutMs ?? 0) / 1000, +} as const; + export function ResponseTimeoutCard({ userProvider, }: { @@ -98,7 +110,7 @@ export function ResponseTimeoutCard({ {t("settings.ai.responseTimeoutHeading")}

- {t("settings.ai.responseTimeoutBody")} + {t("settings.ai.responseTimeoutBody", RESPONSE_TIMEOUT_COPY_PARAMS)}

@@ -113,7 +125,10 @@ export function ResponseTimeoutCard({ max={600} value={value} onChange={(e) => setValue(e.target.value)} - placeholder={t("settings.ai.responseTimeoutPlaceholder")} + placeholder={t( + "settings.ai.responseTimeoutPlaceholder", + RESPONSE_TIMEOUT_COPY_PARAMS, + )} className="mt-1 sm:max-w-xs" />