fix(settings): name the real response-timeout defaults

The placeholder said "Default (~120)" while most surfaces fall back to
60 s and the briefing to 180 s. The placeholder and the help line now
interpolate both numbers from the constants the server applies
(PROVIDER_DEFAULT_TIMEOUT_MS, AI_BUDGETS.comprehensive.timeoutMs), in
all seven locales, and a test fails if a locale writes a number of its
own.

Refs #1090
This commit is contained in:
Marc-André Bombeck committed 2026-10-02 23:17:37 +02:00
1 parent 6207101006
commit ae0bdd0390
9 files changed
+101 -16

No files matched your search

+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -5884,9 +5884,9 @@
"questionsOpenCoach": "코치에서 답하기"
},
"responseTimeoutHeading": "응답 제한 시간",
"responseTimeoutBody": "AI 응답을 얼마나 기다린 뒤 포기할지 정해요. 첫 요청에서 모델을 불러오는 데 시간이 걸리는 로컬·셀프 호스팅 모델이라면 늘리세요.",
"responseTimeoutBody": "AI 응답을 얼마나 기다린 뒤 포기할지 정해요. 첫 요청에서 모델을 불러오는 데 시간이 걸리는 로컬·셀프 호스팅 모델이라면 늘리세요. 비워 두면 응답은 {seconds}초까지 기다리고, 일일 브리핑은 최대 {briefingSeconds}초까지 기다려요.",
"responseTimeoutLabel": "응답 제한 시간 (초)",
"responseTimeoutPlaceholder": "기본값 (약 120)",
"responseTimeoutPlaceholder": "기본값 ({seconds}초)",
"responseTimeoutInvalid": "10에서 600초 사이의 숫자를 입력해 주세요.",
"autoRead": {
"title": "AI로 문서 자동 읽기",
+2 -2
View File
@@ -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",
@@ -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<string, string> {
const bundle = JSON.parse(
readFileSync(join(process.cwd(), "messages", `${locale}.json`), "utf8"),
) as { settings: { ai: Record<string, string> } };
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),
);
},
);
});
@@ -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")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.ai.responseTimeoutBody")}
{t("settings.ai.responseTimeoutBody", RESPONSE_TIMEOUT_COPY_PARAMS)}
</p>
</div>
<div>
@@ -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"
/>
</div>