feat(settings): choose the AI usage level, and set its bounds as an operator

Settings, AI gains one card with the three levels, each with what it does
and the server's estimate of tokens per day, labelled as one. Levels above
the operator's ceiling are shown disabled with the reason; a capped choice
is named and kept; a stored choice can return to the server default. The
automatic document read says when the frugal level rests it. Admin, Coach
gains the ceiling, the default and the sync gap, and the provider readout
the accounts per level. Strings in all seven languages.

The admin card's save confirms with a success toast like its neighbour
assistant-section; it is a settings write without counts, so it is pinned
in the success-affordance guard at one.

Refs #1217
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:55:40 +02:00
1 parent 74dcc67ef7
commit ec67663def
17 files changed
+905 -32

No files matched your search

+40 -4
View File
@@ -6255,7 +6255,8 @@
"subLabel": "Dein KI-Anbieter liest und indiziert neue Dokumente, ohne dass du jedes einzeln bestätigst.",
"honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden, ohne Bestätigung pro Dokument. Das Einschalten wirkt außerdem rückwirkend: Bereits hochgeladene Dokumente ohne Zusammenfassung werden ebenfalls gesendet, bis zu 200 pro Einschaltvorgang. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen: Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für deine Dokumente akzeptierst.",
"confirm": "Verstanden, aktivieren",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"restingFrugal": "Pausiert auf der sparsamen KI-Stufe: Dokumente werden nur auf Knopfdruck gelesen."
},
"centralCodex": {
"title": "Gemeinsamen KI-Zugang des Servers nutzen",
@@ -6313,7 +6314,32 @@
"coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen."
},
"providerCardDescription": "Der Anbieter, den HealthLog zuerst fragt, mit Schlüssel und Modell.",
"runtimeTitle": "Prüfen und aktualisieren"
"runtimeTitle": "Prüfen und aktualisieren",
"usage": {
"title": "KI-Einsatz",
"description": "Wie viel KI-Arbeit im Hintergrund für dich läuft, während der Coach auf jeder Stufe gleich bleibt.",
"levels": {
"frugal": {
"label": "Sparsam",
"hint": "Karten erneuern sich nach einem Sync höchstens einmal am Tag, ein kurzes Tagesbriefing, Dokumente werden nur auf Knopfdruck gelesen."
},
"standard": {
"label": "Standard",
"hint": "Karten erneuern sich nach einem Sync alle paar Stunden, das volle Tagesbriefing."
},
"full": {
"label": "Voll",
"hint": "Karten erneuern sich nach neuen Daten bis zu stündlich, wie vor dieser Einstellung."
}
},
"limited": "Vom Betreiber begrenzt.",
"estimate": "etwa {tokens} Tokens am Tag, geschätzt",
"measured": "Gemessen: {tokens} Tokens am Tag in den letzten {days} Tagen, Coach eingeschlossen. Die Werte je Stufe sind Schätzungen.",
"noEstimate": "Eine Schätzung je Stufe erscheint nach drei Tagen KI-Nutzung.",
"capped": "Du hast {choice} gewählt, das der Server gerade nicht erlaubt, deshalb gilt {level}. Deine Wahl bleibt gespeichert.",
"followsDefault": "Du folgst dem Standard des Servers ({level}).",
"useDefault": "Standard des Servers verwenden ({level})"
}
},
"dashboard": {
"units": {
@@ -7343,7 +7369,8 @@
"spendToday": "Heute, jeweils am eigenen Kalendertag jedes Kontos: {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers (Konten mit Verbrauch: {accounts}).",
"statusPaused": "Pausiert",
"pausedHint": "Nach wiederholten Fehlern für {count} Nutzer pausiert bis {at}. Danach wird er automatisch wieder versucht.",
"modelNotServed": "Der Endpunkt bietet das Modell {model} nicht mehr an. Wähle in den KI-Einstellungen ein Modell, das er anbietet."
"modelNotServed": "Der Endpunkt bietet das Modell {model} nicht mehr an. Wähle in den KI-Einstellungen ein Modell, das er anbietet.",
"usageLevels": "Konten je KI-Stufe: {frugal} sparsam, {standard} Standard, {full} voll."
},
"aiQuality": {
"title": "Auswertungs-Qualität",
@@ -8120,7 +8147,16 @@
"codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}."
},
"loginOverviewCardTitle": "Anmeldeereignisse",
"userColumn": "Nutzer"
"userColumn": "Nutzer",
"aiUsage": {
"title": "KI-Einsatz-Stufen",
"description": "Die höchste Stufe, die Konten wählen dürfen, und die Stufe, mit der sie starten.",
"maxLabel": "Höchste Stufe",
"defaultLabel": "Standardstufe",
"defaultAboveCeiling": "Die Standardstufe darf nicht über der höchsten Stufe liegen.",
"gapLabel": "Stunden zwischen Karten-Erneuerungen nach einem Sync (Standard)",
"gapHint": "Ganze Stunden von 1 bis 48. Leer lassen für AI_SYNC_REGEN_GAP_HOURS, sonst 6. Gerade wirksam: {hours} h."
}
},
"achievements": {
"title": "Erfolge",
+40 -4
View File
@@ -6255,7 +6255,8 @@
"subLabel": "Your AI provider reads and indexes each new document without a tap per document.",
"honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search, with no per-document confirmation. Turning it on also reaches backwards: documents you have already uploaded that carry no summary yet are sent too, up to 200 each time you switch it on. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply: subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for your documents.",
"confirm": "I understand, turn on",
"cancel": "Cancel"
"cancel": "Cancel",
"restingFrugal": "Paused at the frugal AI usage level: documents are read only when you ask."
},
"centralCodex": {
"title": "Use the server's shared AI access",
@@ -6313,7 +6314,32 @@
"coachNote": "In the Coach, the thinking depth from the Coach settings applies."
},
"providerCardDescription": "The provider HealthLog asks first, and its key and model.",
"runtimeTitle": "Check and refresh"
"runtimeTitle": "Check and refresh",
"usage": {
"title": "AI usage",
"description": "How much AI work runs in the background for you, while the Coach stays the same at every level.",
"levels": {
"frugal": {
"label": "Frugal",
"hint": "Cards refresh at most once a day after a sync, a short daily briefing, documents are read only when you ask."
},
"standard": {
"label": "Standard",
"hint": "Cards refresh every few hours after a sync, the full daily briefing."
},
"full": {
"label": "Full",
"hint": "Cards refresh up to every hour after new data, as before this setting existed."
}
},
"limited": "Limited by the server operator.",
"estimate": "about {tokens} tokens a day, estimated",
"measured": "Measured: {tokens} tokens a day over the last {days} days, the Coach included. The figures per level are estimates.",
"noEstimate": "An estimate per level appears after three days of AI use.",
"capped": "You chose {choice}, which the server does not allow at the moment, so {level} applies. Your choice is kept.",
"followsDefault": "You follow the server default ({level}).",
"useDefault": "Use the server default ({level})"
}
},
"dashboard": {
"units": {
@@ -7343,7 +7369,8 @@
"spendToday": "Today, on each account's own calendar day: {total} tokens, {operator} of them on the operator's own key (accounts with spend: {accounts}).",
"statusPaused": "Paused",
"pausedHint": "Paused after repeated failures for {count} users until {at}. It is tried again automatically then.",
"modelNotServed": "The endpoint no longer offers the model {model}. Choose a model it lists in the AI settings."
"modelNotServed": "The endpoint no longer offers the model {model}. Choose a model it lists in the AI settings.",
"usageLevels": "Accounts per AI usage level: {frugal} frugal, {standard} standard, {full} full."
},
"aiQuality": {
"title": "Insights Quality",
@@ -8120,7 +8147,16 @@
"codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}."
},
"loginOverviewCardTitle": "Sign-in events",
"userColumn": "User"
"userColumn": "User",
"aiUsage": {
"title": "AI usage levels",
"description": "The highest level accounts may choose and the level they start at.",
"maxLabel": "Highest level",
"defaultLabel": "Default level",
"defaultAboveCeiling": "The default level cannot be above the highest level.",
"gapLabel": "Hours between card refreshes after a sync (Standard)",
"gapHint": "Whole hours from 1 to 48. Leave empty to use AI_SYNC_REGEN_GAP_HOURS, else 6. In effect now: {hours} h."
}
},
"achievements": {
"title": "Achievements",
+40 -4
View File
@@ -6255,7 +6255,8 @@
"subLabel": "Los documentos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.",
"honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Activarlo también actúa de forma retroactiva: los documentos que ya has subido y que aún no tienen resumen también se envían, hasta 200 cada vez que lo activas. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para tus documentos.",
"confirm": "Entendido: activar",
"cancel": "Cancelar"
"cancel": "Cancelar",
"restingFrugal": "En pausa en el nivel de ahorro de IA: los documentos solo se leen cuando lo pides."
},
"centralCodex": {
"title": "Usar el acceso de IA compartido del servidor",
@@ -6313,7 +6314,32 @@
"coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach."
},
"providerCardDescription": "El proveedor al que HealthLog pregunta primero, con su clave y su modelo.",
"runtimeTitle": "Comprobar y actualizar"
"runtimeTitle": "Comprobar y actualizar",
"usage": {
"title": "Uso de la IA",
"description": "Cuánto trabajo de IA se hace en segundo plano para ti, mientras el Coach sigue igual en todos los niveles.",
"levels": {
"frugal": {
"label": "Ahorro",
"hint": "Las tarjetas se actualizan como mucho una vez al día tras una sincronización, un resumen diario breve, los documentos solo se leen cuando lo pides."
},
"standard": {
"label": "Estándar",
"hint": "Las tarjetas se actualizan cada pocas horas tras una sincronización, el resumen diario completo."
},
"full": {
"label": "Completo",
"hint": "Las tarjetas se actualizan hasta cada hora con datos nuevos, como antes de existir este ajuste."
}
},
"limited": "Limitado por quien administra el servidor.",
"estimate": "unos {tokens} tokens al día, estimado",
"measured": "Medido: {tokens} tokens al día en los últimos {days} días, Coach incluido. Las cifras por nivel son estimaciones.",
"noEstimate": "La estimación por nivel aparece tras tres días de uso de la IA.",
"capped": "Elegiste {choice}, que el servidor no permite ahora mismo, así que se aplica {level}. Tu elección se conserva.",
"followsDefault": "Sigues el valor predeterminado del servidor ({level}).",
"useDefault": "Usar el valor predeterminado del servidor ({level})"
}
},
"dashboard": {
"units": {
@@ -7343,7 +7369,8 @@
"spendToday": "Hoy, en el día natural de cada cuenta: {total} tokens, {operator} de ellos en la clave del operador (cuentas con consumo: {accounts}).",
"statusPaused": "En pausa",
"pausedHint": "En pausa tras fallos repetidos para {count} usuarios hasta {at}. Después se vuelve a intentar automáticamente.",
"modelNotServed": "El endpoint ya no ofrece el modelo {model}. Elige en los ajustes de IA un modelo que ofrezca."
"modelNotServed": "El endpoint ya no ofrece el modelo {model}. Elige en los ajustes de IA un modelo que ofrezca.",
"usageLevels": "Cuentas por nivel de uso de la IA: {frugal} ahorro, {standard} estándar, {full} completo."
},
"aiQuality": {
"title": "Calidad de los análisis",
@@ -8120,7 +8147,16 @@
"codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}."
},
"loginOverviewCardTitle": "Eventos de inicio de sesión",
"userColumn": "Usuario"
"userColumn": "Usuario",
"aiUsage": {
"title": "Niveles de uso de la IA",
"description": "El nivel más alto que pueden elegir las cuentas y el nivel con el que empiezan.",
"maxLabel": "Nivel más alto",
"defaultLabel": "Nivel predeterminado",
"defaultAboveCeiling": "El nivel predeterminado no puede estar por encima del nivel más alto.",
"gapLabel": "Horas entre actualizaciones de tarjetas tras una sincronización (Estándar)",
"gapHint": "Horas enteras de 1 a 48. Déjalo vacío para usar AI_SYNC_REGEN_GAP_HOURS, si no 6. Vigente ahora: {hours} h."
}
},
"achievements": {
"title": "Logros",
+40 -4
View File
@@ -6255,7 +6255,8 @@
"subLabel": "Les documents sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.",
"honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. L'activation agit aussi rétroactivement : les documents que vous avez déjà téléversés et qui n'ont pas encore de résumé sont envoyés eux aussi, jusqu'à 200 à chaque activation. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour vos documents.",
"confirm": "Compris, activer",
"cancel": "Annuler"
"cancel": "Annuler",
"restingFrugal": "En pause au niveau d'IA économe : les documents ne sont lus qu'à votre demande."
},
"centralCodex": {
"title": "Utiliser l'accès IA partagé du serveur",
@@ -6313,7 +6314,32 @@
"coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique."
},
"providerCardDescription": "Le fournisseur que HealthLog sollicite en premier, avec sa clé et son modèle.",
"runtimeTitle": "Vérifier et actualiser"
"runtimeTitle": "Vérifier et actualiser",
"usage": {
"title": "Usage de l'IA",
"description": "La quantité de travail d'IA effectuée en arrière-plan pour vous, le Coach restant le même à chaque niveau.",
"levels": {
"frugal": {
"label": "Économe",
"hint": "Les cartes se mettent à jour au plus une fois par jour après une synchronisation, un bilan quotidien court, les documents ne sont lus qu'à votre demande."
},
"standard": {
"label": "Standard",
"hint": "Les cartes se mettent à jour toutes les quelques heures après une synchronisation, le bilan quotidien complet."
},
"full": {
"label": "Complet",
"hint": "Les cartes se mettent à jour jusqu'à chaque heure avec de nouvelles données, comme avant ce réglage."
}
},
"limited": "Limité par l'administrateur du serveur.",
"estimate": "environ {tokens} jetons par jour, estimation",
"measured": "Mesuré : {tokens} jetons par jour sur les {days} derniers jours, Coach compris. Les chiffres par niveau sont des estimations.",
"noEstimate": "Une estimation par niveau apparaît après trois jours d'utilisation de l'IA.",
"capped": "Vous avez choisi {choice}, que le serveur n'autorise pas pour le moment, donc {level} s'applique. Votre choix est conservé.",
"followsDefault": "Vous suivez le réglage par défaut du serveur ({level}).",
"useDefault": "Utiliser le réglage par défaut du serveur ({level})"
}
},
"dashboard": {
"units": {
@@ -7343,7 +7369,8 @@
"spendToday": "Aujourd'hui, selon le jour civil de chaque compte : {total} jetons, dont {operator} sur la clé de l'opérateur (comptes avec consommation : {accounts}).",
"statusPaused": "En pause",
"pausedHint": "En pause après des échecs répétés pour {count} utilisateurs jusqu’au {at}. Il sera ensuite réessayé automatiquement.",
"modelNotServed": "Le point d’accès ne propose plus le modèle {model}. Choisissez dans les réglages IA un modèle qu’il propose."
"modelNotServed": "Le point d’accès ne propose plus le modèle {model}. Choisissez dans les réglages IA un modèle qu’il propose.",
"usageLevels": "Comptes par niveau d'usage de l'IA : {frugal} économe, {standard} standard, {full} complet."
},
"aiQuality": {
"title": "Qualité des analyses",
@@ -8120,7 +8147,16 @@
"codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}."
},
"loginOverviewCardTitle": "Événements de connexion",
"userColumn": "Utilisateur"
"userColumn": "Utilisateur",
"aiUsage": {
"title": "Niveaux d'usage de l'IA",
"description": "Le niveau le plus élevé que les comptes peuvent choisir et le niveau de départ.",
"maxLabel": "Niveau le plus élevé",
"defaultLabel": "Niveau par défaut",
"defaultAboveCeiling": "Le niveau par défaut ne peut pas dépasser le niveau le plus élevé.",
"gapLabel": "Heures entre deux mises à jour des cartes après une synchronisation (Standard)",
"gapHint": "Heures entières de 1 à 48. Laissez vide pour utiliser AI_SYNC_REGEN_GAP_HOURS, sinon 6. En vigueur : {hours} h."
}
},
"achievements": {
"title": "Réussites",
+40 -4
View File
@@ -6255,7 +6255,8 @@
"subLabel": "I documenti vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.",
"honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Attivarlo agisce anche retroattivamente: i documenti che hai già caricato e che non hanno ancora un riassunto vengono inviati anch'essi, fino a 200 per ogni attivazione. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i tuoi documenti.",
"confirm": "Ho capito, attiva",
"cancel": "Annulla"
"cancel": "Annulla",
"restingFrugal": "In pausa al livello di IA a risparmio: i documenti vengono letti solo su richiesta."
},
"centralCodex": {
"title": "Usa l'accesso IA condiviso del server",
@@ -6313,7 +6314,32 @@
"coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach."
},
"providerCardDescription": "Il fornitore che HealthLog interpella per primo, con chiave e modello.",
"runtimeTitle": "Verifica e aggiorna"
"runtimeTitle": "Verifica e aggiorna",
"usage": {
"title": "Uso dell'IA",
"description": "Quanto lavoro di IA viene svolto in background per te, mentre il Coach resta uguale a ogni livello.",
"levels": {
"frugal": {
"label": "Risparmio",
"hint": "Le schede si aggiornano al massimo una volta al giorno dopo una sincronizzazione, un riepilogo giornaliero breve, i documenti vengono letti solo su richiesta."
},
"standard": {
"label": "Standard",
"hint": "Le schede si aggiornano ogni poche ore dopo una sincronizzazione, il riepilogo giornaliero completo."
},
"full": {
"label": "Completo",
"hint": "Le schede si aggiornano fino a ogni ora con nuovi dati, come prima di questa impostazione."
}
},
"limited": "Limitato dall'amministratore del server.",
"estimate": "circa {tokens} token al giorno, stima",
"measured": "Misurato: {tokens} token al giorno negli ultimi {days} giorni, Coach compreso. I valori per livello sono stime.",
"noEstimate": "La stima per livello compare dopo tre giorni di uso dell'IA.",
"capped": "Hai scelto {choice}, che il server al momento non consente, quindi vale {level}. La tua scelta resta salvata.",
"followsDefault": "Segui l'impostazione predefinita del server ({level}).",
"useDefault": "Usa l'impostazione predefinita del server ({level})"
}
},
"dashboard": {
"units": {
@@ -7343,7 +7369,8 @@
"spendToday": "Oggi, secondo il giorno di calendario di ciascun account: {total} token, di cui {operator} sulla chiave dell'operatore (account con consumo: {accounts}).",
"statusPaused": "In pausa",
"pausedHint": "In pausa dopo errori ripetuti per {count} utenti fino al {at}. Poi viene ritentato automaticamente.",
"modelNotServed": "L’endpoint non offre più il modello {model}. Scegli nelle impostazioni IA un modello che offre."
"modelNotServed": "L’endpoint non offre più il modello {model}. Scegli nelle impostazioni IA un modello che offre.",
"usageLevels": "Account per livello di uso dell'IA: {frugal} risparmio, {standard} standard, {full} completo."
},
"aiQuality": {
"title": "Qualità delle analisi",
@@ -8120,7 +8147,16 @@
"codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}."
},
"loginOverviewCardTitle": "Eventi di accesso",
"userColumn": "Utente"
"userColumn": "Utente",
"aiUsage": {
"title": "Livelli di uso dell'IA",
"description": "Il livello più alto che gli account possono scegliere e il livello di partenza.",
"maxLabel": "Livello più alto",
"defaultLabel": "Livello predefinito",
"defaultAboveCeiling": "Il livello predefinito non può superare il livello più alto.",
"gapLabel": "Ore tra due aggiornamenti delle schede dopo una sincronizzazione (Standard)",
"gapHint": "Ore intere da 1 a 48. Lascia vuoto per usare AI_SYNC_REGEN_GAP_HOURS, altrimenti 6. In vigore ora: {hours} h."
}
},
"achievements": {
"title": "Obiettivi",
+40 -4
View File
@@ -6255,7 +6255,8 @@
"subLabel": "문서마다 따로 누르지 않아도 설정한 AI 제공자가 문서를 읽고 색인해요.",
"honesty": "켜 두면 올리는 문서마다 설정한 AI 제공자에게 전송되어 읽히고, 설명이 붙고, 검색용으로 색인돼요. 문서별 확인 절차는 없어요. 켜는 순간 과거 문서에도 적용돼요. 이미 올렸지만 아직 요약이 없는 문서도 켤 때마다 최대 200개까지 함께 전송돼요. 이 작업은 서버에서 이뤄지고 문서 내용은 이 기기를 떠나 그 제공자에게 전달돼요. 설정한 제공자가 구독 연결(API 키가 아니라 로그인한 AI 계정)이라면 그 제공자의 데이터 정책이 적용돼요. 구독 제공자는 내용을 모델 개선에 쓸 수 있고, 이를 보호하는 데이터 처리 계약도 없어요. 셀프 호스팅 로컬 모델이 읽은 문서는 서버 밖으로 나가지 않아요. 기본값은 꺼짐이고, 이 조건을 받아들일 때만 켜세요.",
"confirm": "이해했어요, 켜기",
"cancel": "취소"
"cancel": "취소",
"restingFrugal": "AI 절약 단계에서는 일시 중지돼요. 문서는 요청할 때만 읽어요."
},
"centralCodex": {
"title": "서버의 공용 AI 연결 사용",
@@ -6313,7 +6314,32 @@
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
},
"providerCardDescription": "HealthLog가 가장 먼저 쓰는 제공자와 그 키, 모델이에요.",
"runtimeTitle": "확인 및 새로고침"
"runtimeTitle": "확인 및 새로고침",
"usage": {
"title": "AI 사용량",
"description": "백그라운드에서 실행되는 AI 작업의 양이며, 코치는 모든 단계에서 똑같아요.",
"levels": {
"frugal": {
"label": "절약",
"hint": "동기화 후 카드는 하루에 최대 한 번 새로 고쳐지고, 일일 브리핑은 짧아지며, 문서는 요청할 때만 읽어요."
},
"standard": {
"label": "표준",
"hint": "동기화 후 카드는 몇 시간마다 새로 고쳐지고, 일일 브리핑은 전체 형태예요."
},
"full": {
"label": "전체",
"hint": "새 데이터가 들어오면 카드가 최대 한 시간마다 새로 고쳐져요. 이 설정이 생기기 전과 같아요."
}
},
"limited": "서버 운영자가 제한했어요.",
"estimate": "하루 약 {tokens} 토큰, 추정치",
"measured": "측정값: 최근 {days}일 동안 하루 {tokens} 토큰이에요. 코치 사용량도 포함돼요. 단계별 수치는 추정치예요.",
"noEstimate": "AI를 3일 사용하면 단계별 추정치가 표시돼요.",
"capped": "{choice} 단계를 선택했지만 지금은 서버에서 허용하지 않아 {level} 단계가 적용돼요. 선택은 그대로 저장돼요.",
"followsDefault": "서버 기본값({level})을 따르고 있어요.",
"useDefault": "서버 기본값 사용({level})"
}
},
"dashboard": {
"units": {
@@ -8061,7 +8087,8 @@
"spendToday": "오늘(각 계정의 현지 날짜 기준): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다(사용량이 있는 계정: {accounts}개).",
"statusPaused": "일시 중지됨",
"pausedHint": "반복된 오류로 사용자 {count}명에 대해 {at}까지 일시 중지되었습니다. 그 후 자동으로 다시 시도합니다.",
"modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요."
"modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요.",
"usageLevels": "AI 사용량 단계별 계정 수: 절약 {frugal}, 표준 {standard}, 전체 {full}."
},
"cardError": {
"title": "페이지의 이 부분을 표시할 수 없습니다",
@@ -8120,7 +8147,16 @@
"codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다."
},
"loginOverviewCardTitle": "로그인 이벤트",
"userColumn": "사용자"
"userColumn": "사용자",
"aiUsage": {
"title": "AI 사용량 단계",
"description": "계정이 선택할 수 있는 최고 단계와 처음 시작하는 단계예요.",
"maxLabel": "최고 단계",
"defaultLabel": "기본 단계",
"defaultAboveCeiling": "기본 단계는 최고 단계보다 높을 수 없어요.",
"gapLabel": "동기화 후 카드 새로 고침 간격(시간, 표준 단계)",
"gapHint": "1에서 48 사이의 정수 시간이에요. 비워 두면 AI_SYNC_REGEN_GAP_HOURS를, 없으면 6을 사용해요. 현재 적용 값: {hours}시간."
}
},
"achievements": {
"title": "업적",
+40 -4
View File
@@ -6255,7 +6255,8 @@
"subLabel": "Dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.",
"honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania, bez potwierdzania każdego dokumentu. Włączenie działa też wstecz: dokumenty już przesłane, które nie mają jeszcze streszczenia, również zostaną wysłane (do 200 przy każdym włączeniu). Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych: dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla swoich dokumentów.",
"confirm": "Rozumiem, włącz",
"cancel": "Anuluj"
"cancel": "Anuluj",
"restingFrugal": "Wstrzymane na oszczędnym poziomie AI: dokumenty są czytane tylko na żądanie."
},
"centralCodex": {
"title": "Użyj wspólnego dostępu AI serwera",
@@ -6313,7 +6314,32 @@
"coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha."
},
"providerCardDescription": "Dostawca, którego HealthLog pyta najpierw, wraz z kluczem i modelem.",
"runtimeTitle": "Sprawdź i odśwież"
"runtimeTitle": "Sprawdź i odśwież",
"usage": {
"title": "Użycie AI",
"description": "Ile pracy AI wykonuje się dla Ciebie w tle, przy czym Coach działa tak samo na każdym poziomie.",
"levels": {
"frugal": {
"label": "Oszczędny",
"hint": "Karty odświeżają się po synchronizacji najwyżej raz dziennie, krótkie podsumowanie dnia, dokumenty są czytane tylko na żądanie."
},
"standard": {
"label": "Standardowy",
"hint": "Karty odświeżają się po synchronizacji co kilka godzin, pełne podsumowanie dnia."
},
"full": {
"label": "Pełny",
"hint": "Karty odświeżają się po nowych danych nawet co godzinę, jak przed wprowadzeniem tego ustawienia."
}
},
"limited": "Ograniczone przez administratora serwera.",
"estimate": "około {tokens} tokenów dziennie, szacunkowo",
"measured": "Zmierzono: {tokens} tokenów dziennie w ostatnich {days} dniach, łącznie z Coachem. Wartości dla poziomów są szacunkami.",
"noEstimate": "Szacunek dla każdego poziomu pojawi się po trzech dniach korzystania z AI.",
"capped": "Wybrano poziom {choice}, na który serwer obecnie nie pozwala, więc obowiązuje {level}. Twój wybór zostaje zapisany.",
"followsDefault": "Korzystasz z ustawienia domyślnego serwera ({level}).",
"useDefault": "Użyj ustawienia domyślnego serwera ({level})"
}
},
"dashboard": {
"units": {
@@ -7343,7 +7369,8 @@
"spendToday": "Dzisiaj, według dnia kalendarzowego każdego konta: {total} tokenów, w tym {operator} na kluczu operatora (konta ze zużyciem: {accounts}).",
"statusPaused": "Wstrzymany",
"pausedHint": "Wstrzymany po powtarzających się błędach dla {count} użytkowników do {at}. Potem zostanie automatycznie ponowiony.",
"modelNotServed": "Punkt końcowy nie oferuje już modelu {model}. Wybierz w ustawieniach AI model, który oferuje."
"modelNotServed": "Punkt końcowy nie oferuje już modelu {model}. Wybierz w ustawieniach AI model, który oferuje.",
"usageLevels": "Konta według poziomu użycia AI: oszczędny {frugal}, standardowy {standard}, pełny {full}."
},
"aiQuality": {
"title": "Jakość analiz",
@@ -8120,7 +8147,16 @@
"codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}."
},
"loginOverviewCardTitle": "Zdarzenia logowania",
"userColumn": "Użytkownik"
"userColumn": "Użytkownik",
"aiUsage": {
"title": "Poziomy użycia AI",
"description": "Najwyższy poziom, jaki mogą wybrać konta, i poziom, od którego zaczynają.",
"maxLabel": "Najwyższy poziom",
"defaultLabel": "Poziom domyślny",
"defaultAboveCeiling": "Poziom domyślny nie może być wyższy niż najwyższy poziom.",
"gapLabel": "Godziny między odświeżeniami kart po synchronizacji (Standardowy)",
"gapHint": "Pełne godziny od 1 do 48. Zostaw puste, aby użyć AI_SYNC_REGEN_GAP_HOURS, w przeciwnym razie 6. Obecnie obowiązuje: {hours} h."
}
},
"achievements": {
"title": "Osiągnięcia",
@@ -92,6 +92,8 @@ const PINNED_AFFORDANCES: Record<
"text-success": 1,
},
"src/components/admin/assistant-section.tsx": { "toast.success": 1 },
// v1.43 — the AI usage bounds: a settings save, not a write with counts.
"src/components/admin/ai-usage-section.tsx": { "toast.success": 1 },
"src/components/admin/backups-section.tsx": { "toast.success": 4 },
"src/components/admin/central-codex-section.tsx": { "toast.success": 2 },
"src/components/admin/danger-zone-section.tsx": { "text-success": 1 },
+2
View File
@@ -22,6 +22,7 @@ import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
import { ProviderHealthSection } from "@/components/admin/provider-health-section";
import { CentralCodexSection } from "@/components/admin/central-codex-section";
import { AssistantSection } from "@/components/admin/assistant-section";
import { AiUsageSection } from "@/components/admin/ai-usage-section";
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
@@ -102,6 +103,7 @@ export function AdminSectionRenderer({
return (
<SectionFrame>
<AssistantSection />
<AiUsageSection />
<AiServerKeySection />
<CentralCodexSection />
<ProviderHealthSection />
+228
View File
@@ -0,0 +1,228 @@
"use client";
/**
* v1.43 (#1217) — the operator's AI usage bounds.
*
* The operator pays for the shared key, so the operator sets the highest
* level an account may choose and the level an account without a choice runs
* at; each person picks within that under Settings > AI. Beside them, the
* gap between two status-card regenerations after a sync at `standard`
* (whole hours, 1 to 48; empty follows `AI_SYNC_REGEN_GAP_HOURS`, else six).
*
* Reads and writes the `aiUsage` block of the assistant-flags route, the same
* query the assistant switches use. Lowering the ceiling caps every account
* above it at once; their stored choice is kept.
*/
import { useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Gauge } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
AI_USAGE_LEVELS,
SYNC_REGEN_GAP_HOURS_MAX,
SYNC_REGEN_GAP_HOURS_MIN,
usageLevelRank,
type AiUsageLevel,
} from "@/lib/ai/usage-level";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
interface AiUsageBounds {
maxLevel: AiUsageLevel;
defaultLevel: AiUsageLevel;
syncRegenGapHours: number | null;
effectiveSyncRegenGapHours: number;
}
const LEVEL_LABEL_KEYS = {
frugal: "settings.ai.usage.levels.frugal.label",
standard: "settings.ai.usage.levels.standard.label",
full: "settings.ai.usage.levels.full.label",
} as const satisfies Record<AiUsageLevel, string>;
export function AiUsageSection() {
const { t } = useTranslations();
const { data } = useQuery({
queryKey: queryKeys.adminAssistantFlags(),
queryFn: () =>
apiGet<{ aiUsage?: AiUsageBounds }>(
"/api/admin/settings/assistant-flags",
),
});
const bounds = data?.aiUsage;
if (!bounds) {
return (
<SettingsCard>
<SettingsCardHeader
icon={Gauge}
title={t("admin.aiUsage.title")}
description={t("admin.aiUsage.description")}
/>
</SettingsCard>
);
}
// Keyed on the stored values, so a save (or another operator's) re-seeds
// the form instead of leaving it on stale edits.
return (
<AiUsageForm
key={`${bounds.maxLevel}:${bounds.defaultLevel}:${bounds.syncRegenGapHours}`}
bounds={bounds}
/>
);
}
function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) {
const { t } = useTranslations();
const client = useQueryClient();
const [maxLevel, setMaxLevel] = useState<AiUsageLevel>(bounds.maxLevel);
const [defaultLevel, setDefaultLevel] = useState<AiUsageLevel>(
bounds.defaultLevel,
);
const [gap, setGap] = useState(
bounds.syncRegenGapHours === null ? "" : String(bounds.syncRegenGapHours),
);
const gapValue = gap.trim() === "" ? null : Number(gap);
const gapValid =
gapValue === null ||
(Number.isInteger(gapValue) &&
gapValue >= SYNC_REGEN_GAP_HOURS_MIN &&
gapValue <= SYNC_REGEN_GAP_HOURS_MAX);
const defaultAbove = usageLevelRank(defaultLevel) > usageLevelRank(maxLevel);
const save = useMutation({
mutationFn: () =>
apiPut<{ aiUsage: AiUsageBounds }>(
"/api/admin/settings/assistant-flags",
{
aiUsage: { maxLevel, defaultLevel, syncRegenGapHours: gapValue },
},
),
onSuccess: (result) => {
client.setQueryData(queryKeys.adminAssistantFlags(), result);
void client.invalidateQueries({
queryKey: queryKeys.adminProviderHealth(),
});
void invalidateKeys(client, aiInputDependentKeys);
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
);
},
});
function onSubmit(event: FormEvent) {
event.preventDefault();
if (!gapValid || defaultAbove) return;
save.mutate();
}
return (
<SettingsCard data-slot="admin-ai-usage">
<SettingsCardHeader
icon={Gauge}
title={t("admin.aiUsage.title")}
description={t("admin.aiUsage.description")}
/>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-ai-usage-max">
{t("admin.aiUsage.maxLabel")}
</Label>
<NativeSelect
id="admin-ai-usage-max"
value={maxLevel}
disabled={save.isPending}
onChange={(e) => setMaxLevel(e.target.value as AiUsageLevel)}
>
{AI_USAGE_LEVELS.map((level) => (
<option key={level} value={level}>
{t(LEVEL_LABEL_KEYS[level])}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-ai-usage-default">
{t("admin.aiUsage.defaultLabel")}
</Label>
<NativeSelect
id="admin-ai-usage-default"
value={defaultLevel}
disabled={save.isPending}
aria-invalid={defaultAbove || undefined}
onChange={(e) => setDefaultLevel(e.target.value as AiUsageLevel)}
>
{AI_USAGE_LEVELS.map((level) => (
<option key={level} value={level}>
{t(LEVEL_LABEL_KEYS[level])}
</option>
))}
</NativeSelect>
</div>
</div>
{defaultAbove ? (
<p role="alert" className="text-destructive text-xs">
{t("admin.aiUsage.defaultAboveCeiling")}
</p>
) : null}
<div className="space-y-1.5">
<Label htmlFor="admin-ai-usage-gap">
{t("admin.aiUsage.gapLabel")}
</Label>
<Input
id="admin-ai-usage-gap"
type="number"
inputMode="numeric"
min={SYNC_REGEN_GAP_HOURS_MIN}
max={SYNC_REGEN_GAP_HOURS_MAX}
step={1}
className="sm:max-w-40"
value={gap}
placeholder={String(bounds.effectiveSyncRegenGapHours)}
disabled={save.isPending}
aria-invalid={!gapValid || undefined}
aria-describedby="admin-ai-usage-gap-hint"
onChange={(e) => setGap(e.target.value)}
/>
<p
id="admin-ai-usage-gap-hint"
className="text-muted-foreground text-xs"
>
{t("admin.aiUsage.gapHint", {
hours: bounds.effectiveSyncRegenGapHours,
})}
</p>
</div>
<SettingsCardActions>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={save.isPending || !gapValid || defaultAbove}
>
{t("common.save")}
</Button>
</SettingsCardActions>
</form>
</SettingsCard>
);
}
@@ -69,9 +69,11 @@ export function ProviderHealthSection() {
const { data, isPending, isError } = useQuery({
queryKey: queryKeys.adminProviderHealth(),
queryFn: () =>
apiGet<{ providers: ProviderHealthRow[]; spendToday: SpendToday }>(
"/api/admin/provider-health",
),
apiGet<{
providers: ProviderHealthRow[];
spendToday: SpendToday;
usageLevels?: { frugal: number; standard: number; full: number };
}>("/api/admin/provider-health"),
});
const typeLabel = (type: string): string => {
@@ -97,6 +99,7 @@ export function ProviderHealthSection() {
const providers = data?.providers ?? [];
const spendToday = data?.spendToday ?? null;
const usageLevels = data?.usageLevels ?? null;
return (
<SettingsCard>
@@ -114,6 +117,18 @@ export function ProviderHealthSection() {
})}
</p>
) : null}
{usageLevels ? (
<p
className="text-muted-foreground text-sm"
data-slot="ai-usage-levels"
>
{t("admin.providerHealth.usageLevels", {
frugal: groupDigits(usageLevels.frugal),
standard: groupDigits(usageLevels.standard),
full: groupDigits(usageLevels.full),
})}
</p>
) : null}
{isPending ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.loading")}
@@ -54,6 +54,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
onDeviceAllowed: true,
}),
useAiCapabilityMap: () => null,
// v1.43 — no usage level published: the setting stays out of the way.
useAiUsage: () => null,
useAiProviderState: () => ({
configured: true,
managedBy: "user",
@@ -54,6 +54,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
onDeviceAllowed: true,
}),
useAiCapabilityMap: () => null,
// v1.43 — no usage level published: the setting stays out of the way.
useAiUsage: () => null,
useAiProviderState: () => ({
configured: true,
managedBy: "user",
@@ -0,0 +1,120 @@
/**
* #1217 — the AI usage setting renders what the server resolved: three
* options, the ones above the operator's ceiling disabled with their reason,
* each estimate labelled as one, and nothing computed on the client.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AiUsageState } from "@/lib/ai/usage-level";
const state = vi.hoisted(() => ({
usage: null as AiUsageState | null,
canConfigure: true,
}));
vi.mock("@/hooks/use-ai-capability", () => ({
useAiUsage: () => state.usage,
useAiProviderState: () => ({
configured: true,
managedBy: "user",
canConfigure: state.canConfigure,
responseTimeoutMs: 60_000,
}),
}));
import { AiUsageCard } from "../ai-usage-card";
function render(usage: AiUsageState | null, canConfigure = true) {
state.usage = usage;
state.canConfigure = canConfigure;
const client = new QueryClient();
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiUsageCard />
</I18nProvider>
</QueryClientProvider>,
);
}
const CAPPED: AiUsageState = {
level: "standard",
preference: "full",
defaultLevel: "standard",
maxLevel: "standard",
source: "admin_cap",
options: [
{ level: "frugal", allowed: true, estimatedTokensPerDay: 48_000 },
{ level: "standard", allowed: true, estimatedTokensPerDay: 100_000 },
{ level: "full", allowed: false, estimatedTokensPerDay: 400_000 },
],
measuredTokensPerDay: 100_000,
measuredDays: 7,
};
describe("AiUsageCard", () => {
it("renders nothing on a server that publishes no level", () => {
expect(render(null)).toBe("");
});
it("checks the level in effect and disables the ones above the ceiling", () => {
const html = render(CAPPED);
expect(html.match(/role="radio"/g)).toHaveLength(3);
expect(html).toMatch(/aria-checked="true"[^>]*data-level="standard"/);
expect(html.match(/aria-checked="true"/g)).toHaveLength(1);
expect(html).toContain('data-level="full" data-allowed="false"');
expect(html).toContain("Limited by the server operator.");
// The capped choice is named and kept.
expect(html).toContain('data-slot="ai-usage-capped"');
expect(html).toContain("You chose Full");
});
it("shows the server's estimates, labelled, in the reader's number format", () => {
const html = render(CAPPED);
expect(html).toContain("about 48,000 tokens a day, estimated");
expect(html).toContain("about 400,000 tokens a day, estimated");
expect(html).toContain(
"Measured: 100,000 tokens a day over the last 7 days",
);
});
it("says when there is no estimate yet instead of inventing one", () => {
const html = render({
...CAPPED,
source: "default",
preference: null,
options: CAPPED.options.map((o) => ({
...o,
estimatedTokensPerDay: null,
})),
measuredTokensPerDay: null,
measuredDays: 0,
});
expect(html).not.toContain("tokens a day, estimated");
expect(html).toContain("An estimate per level appears after three days");
expect(html).toContain("You follow the server default (Standard).");
// No stored choice: nothing to return to.
expect(html).not.toContain('data-slot="ai-usage-use-default"');
});
it("offers the way back to the default when a choice is stored", () => {
const html = render({
...CAPPED,
source: "user",
preference: "frugal",
level: "frugal",
});
expect(html).toContain('data-slot="ai-usage-use-default"');
expect(html).toContain("Use the server default (Standard)");
});
it("locks the options where the account cannot set its own level", () => {
const html = render(CAPPED, false);
expect(html).toContain(
'role="radiogroup" aria-label="AI usage" aria-disabled="true"',
);
});
});
@@ -36,6 +36,7 @@ import {
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
import { AiConsentCard } from "./ai-consent-card";
import { AiUsageCard } from "./ai-usage-card";
import { AnthropicProviderForm } from "./anthropic-provider-form";
import { AutoReadCard } from "./auto-read-card";
import { CentralCodexSwitch } from "./central-codex-switch";
@@ -233,6 +234,10 @@ export function AiInsightsCard({
onSelect={pickProvider}
/>
{/* v1.43 (#1217) — how much background AI work the account
asks for: one setting, three levels. */}
<AiUsageCard />
{/* v1.22 (#89) — per-user response timeout (mainly for slow
local/self-hosted backends). */}
<ResponseTimeoutCard userProvider={userProvider} />
@@ -0,0 +1,233 @@
"use client";
/* ────────────────────────────────────────────────────────────────
* v1.43 (#1217) — AI usage: one setting, three levels.
*
* The level decides how much background AI work the account asks for: how
* often status cards regenerate after a sync, the snapshot size, background
* reasoning, the length of the daily briefing, the automatic document read
* and the label photo. The Coach is the same at every level.
*
* Everything shown comes off `ai.usage` on `/api/auth/me`, resolved on the
* server: which options the operator allows, the estimate per option (only
* ever labelled as one), the measured use. The card computes nothing. A pick
* is written at once through `PUT /api/auth/me/ai-usage`; the account payload
* is refetched so every surface that reads a capability sees the new level.
* ──────────────────────────────────────────────────────────────── */
import { useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Gauge } from "lucide-react";
import { Button } from "@/components/ui/button";
import { nextChoiceIndex } from "@/components/documents/sources/segmented-choice";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAiProviderState, useAiUsage } from "@/hooks/use-ai-capability";
import { apiPut } from "@/lib/api/api-fetch";
import type { AiUsageLevel, AiUsageState } from "@/lib/ai/usage-level";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
const LEVEL_KEYS = {
frugal: {
label: "settings.ai.usage.levels.frugal.label",
hint: "settings.ai.usage.levels.frugal.hint",
},
standard: {
label: "settings.ai.usage.levels.standard.label",
hint: "settings.ai.usage.levels.standard.hint",
},
full: {
label: "settings.ai.usage.levels.full.label",
hint: "settings.ai.usage.levels.full.hint",
},
} as const satisfies Record<AiUsageLevel, { label: string; hint: string }>;
export function AiUsageCard() {
const { t, locale } = useTranslations();
const queryClient = useQueryClient();
const usage = useAiUsage();
const provider = useAiProviderState();
const refs = useRef<Array<HTMLButtonElement | null>>([]);
const save = useMutation<AiUsageState, Error, AiUsageLevel | null>({
mutationFn: (level) =>
apiPut<AiUsageState>("/api/auth/me/ai-usage", { level }),
onSuccess: () => {
void invalidateKeys(queryClient, aiInputDependentKeys);
},
});
// An older server publishes no level: there is nothing to set.
if (!usage) return null;
// Only the account itself sets its level, and only while the operator's
// master switch is on (`canConfigure` is false inside somebody else's
// record and while AI is off on the server).
const editable = provider.canConfigure && !save.isPending;
const tokens = (value: number) => new Intl.NumberFormat(locale).format(value);
const levelName = (level: AiUsageLevel) => t(LEVEL_KEYS[level].label);
const allowedLevels = usage.options
.filter((option) => option.allowed)
.map((option) => option.level);
function pick(level: AiUsageLevel) {
if (!editable || level === usage?.level) return;
save.mutate(level);
}
function onKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>,
level: AiUsageLevel,
) {
const from = allowedLevels.indexOf(level);
const next = nextChoiceIndex(event.key, from, allowedLevels.length);
if (next === null) return;
event.preventDefault();
const target = allowedLevels[next]!;
pick(target);
refs.current[usage!.options.findIndex((o) => o.level === target)]?.focus();
}
return (
<SettingsCard data-slot="ai-usage-card">
<SettingsCardHeader
anchor="ai-usage"
icon={Gauge}
title={t("settings.ai.usage.title")}
description={t("settings.ai.usage.description")}
/>
<div
role="radiogroup"
aria-label={t("settings.ai.usage.title")}
aria-disabled={!editable || undefined}
className="border-border divide-border divide-y rounded-lg border"
>
{usage.options.map((option, i) => {
const checked = option.level === usage.level;
const disabled = !option.allowed || !editable;
return (
<button
key={option.level}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
aria-disabled={disabled || undefined}
tabIndex={checked ? 0 : -1}
data-slot="ai-usage-option"
data-level={option.level}
data-allowed={option.allowed}
onClick={() => (option.allowed ? pick(option.level) : undefined)}
onKeyDown={(event) => onKeyDown(event, option.level)}
className={cn(
"flex min-h-11 w-full items-start gap-3 px-3 py-2.5 text-left first:rounded-t-lg last:rounded-b-lg",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
checked && "bg-primary/10",
!option.allowed && "cursor-not-allowed opacity-50",
)}
>
<span
aria-hidden
className={cn(
"border-border mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
checked && "border-primary",
)}
>
{checked ? (
<span className="bg-primary size-2 rounded-full" />
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{levelName(option.level)}
</span>
<span className="text-muted-foreground block text-xs">
{t(LEVEL_KEYS[option.level].hint)}
</span>
{!option.allowed ? (
<span
data-slot="ai-usage-limited"
className="text-muted-foreground block text-xs"
>
{t("settings.ai.usage.limited")}
</span>
) : null}
</span>
<span
data-slot="ai-usage-estimate"
className="text-muted-foreground shrink-0 text-right text-xs tabular-nums"
>
{option.estimatedTokensPerDay === null
? null
: t("settings.ai.usage.estimate", {
tokens: tokens(option.estimatedTokensPerDay),
})}
</span>
</button>
);
})}
</div>
<p
data-slot="ai-usage-measured"
className="text-muted-foreground text-xs"
>
{usage.measuredTokensPerDay === null || usage.measuredDays < 3
? t("settings.ai.usage.noEstimate")
: t("settings.ai.usage.measured", {
tokens: tokens(usage.measuredTokensPerDay),
days: usage.measuredDays,
})}
</p>
{usage.source === "admin_cap" && usage.preference ? (
<p data-slot="ai-usage-capped" className="text-sm">
{t("settings.ai.usage.capped", {
choice: levelName(usage.preference),
level: levelName(usage.level),
})}
</p>
) : usage.source === "default" ? (
<p
data-slot="ai-usage-default"
className="text-muted-foreground text-xs"
>
{t("settings.ai.usage.followsDefault", {
level: levelName(usage.defaultLevel),
})}
</p>
) : null}
{save.isError ? (
<p role="alert" className="text-destructive text-xs">
{t("settings.ai.errorGeneric")}
</p>
) : null}
{usage.preference !== null ? (
<SettingsCardActions>
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={!editable}
onClick={() => save.mutate(null)}
data-slot="ai-usage-use-default"
>
{t("settings.ai.usage.useDefault", {
level: levelName(usage.defaultLevel),
})}
</Button>
</SettingsCardActions>
) : null}
</SettingsCard>
);
}
+13 -1
View File
@@ -24,7 +24,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Switch } from "@/components/ui/switch";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useAiCapability, useAiUsage } from "@/hooks/use-ai-capability";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -41,6 +41,9 @@ export function AutoReadCard() {
// already-on preference off stays possible.
const documentAi = useAiCapability("documentAi");
const operatorOff = documentAi.reason === "operator_disabled";
// v1.43 (#1217) — on the frugal AI usage level the switch rests: documents
// are read only on request. The choice itself is kept.
const restingFrugal = useAiUsage()?.level === "frugal";
// Whether the honesty confirm is currently shown (user flipped the switch ON
// but has not yet acknowledged the trade). Off→on reveals it; the write only
@@ -115,6 +118,15 @@ export function AutoReadCard() {
</p>
) : null}
{restingFrugal && enabled && !operatorOff ? (
<p
data-slot="documents-auto-read-resting"
className="text-muted-foreground text-xs"
>
{t("settings.ai.autoRead.restingFrugal")}
</p>
) : null}
{pendingEnable ? (
<div
data-slot="documents-auto-read-confirm"