diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 1d131ba..e3cef8d 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -388,7 +388,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.5.1; + MARKETING_VERSION = 2.5.2; PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -440,7 +440,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.5.1; + MARKETING_VERSION = 2.5.2; PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/oAI/Localizable.xcstrings b/oAI/Localizable.xcstrings index 7e88e58..622e708 100644 --- a/oAI/Localizable.xcstrings +++ b/oAI/Localizable.xcstrings @@ -1,6 +1,856 @@ { "sourceLanguage" : "en", "strings" : { + "Used for Google web search (if selected above) and for Google embeddings in Settings → Advanced → Semantic Search." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruges til Google-websøgning (hvis valgt ovenfor) og til Google-indlejringer i Indstillinger → Avanceret → Semantisk søgning." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird für die Google-Websuche (falls oben ausgewählt) und für Google-Embeddings unter Einstellungen → Erweitert → Semantische Suche verwendet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisée pour la recherche web Google (si sélectionnée ci-dessus) et pour les embeddings Google dans Réglages → Avancé → Recherche sémantique." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brukes til Google-websøk (hvis valgt ovenfor) og til Google-innbygginger i Innstillinger → Avansert → Semantisk søk." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Används för Google-webbsökning (om vald ovan) och för Google-embeddings i Inställningar → Avancerat → Semantisk sökning." + } + } + } + }, + "⚠️ No embedding providers available. Configure an API key for OpenAI, OpenRouter, or Google (under General → Web Search) in the General tab." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Ingen indlejringsudbydere tilgængelige. Konfigurer en API-nøgle til OpenAI, OpenRouter eller Google (under Generelt → Websøgning) på fanen Generelt." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Keine Embedding-Anbieter verfügbar. Konfiguriere einen API-Schlüssel für OpenAI, OpenRouter oder Google (unter Allgemein → Websuche) auf der Registerkarte „Allgemein“." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Aucun fournisseur d'embeddings disponible. Configure une clé API pour OpenAI, OpenRouter ou Google (sous Général → Recherche web) dans l'onglet Général." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Ingen innbyggingsleverandører tilgjengelig. Konfigurer en API-nøkkel for OpenAI, OpenRouter eller Google (under Generelt → Websøk) i fanen Generelt." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Inga embedding-leverantörer tillgängliga. Konfigurera en API-nyckel för OpenAI, OpenRouter eller Google (under Allmänt → Webbsökning) på fliken Allmänt." + } + } + } + }, + "Remote (HTTP)" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern (HTTP)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remote (HTTP)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distant (HTTP)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekstern (HTTP)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjärr (HTTP)" + } + } + } + }, + "Command: a local program Confab launches itself. Remote (HTTP): an already-running MCP server reachable by URL." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommando: et lokalt program Confab selv starter. Fjern (HTTP): en allerede kørende MCP-server, der kan nås via URL." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Befehl: ein lokales Programm, das Confab selbst startet. Remote (HTTP): ein bereits laufender MCP-Server, der über eine URL erreichbar ist." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commande : un programme local que Confab lance lui-même. Distant (HTTP) : un serveur MCP déjà en cours d'exécution, accessible par URL." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommando: et lokalt program Confab selv starter. Ekstern (HTTP): en allerede kjørende MCP-server som kan nås via URL." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommando: ett lokalt program som Confab själv startar. Fjärr (HTTP): en redan igångkörd MCP-server som nås via URL." + } + } + } + }, + "Type" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typ" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typ" + } + } + } + }, + "Bearer Token" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bearer-token" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bearer-Token" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jeton Bearer" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bearer-token" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bearer-token" + } + } + } + }, + "Environment Variables" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miljøvariabler" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umgebungsvariablen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variables d'environnement" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miljøvariabler" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miljövariabler" + } + } + } + }, + "Extra Headers" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekstra headers" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zusätzliche Header" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En-têtes supplémentaires" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekstra headers" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extra headers" + } + } + } + }, + "Optional" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valgfri" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Facultatif" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valgfritt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valfritt" + } + } + } + }, + "Key" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nøgle" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schlüssel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clé" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nøkkel" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nyckel" + } + } + } + }, + "Value" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Værdi" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valeur" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verdi" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Värde" + } + } + } + }, + "None configured" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen konfigureret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine konfiguriert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun configuré" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen konfigurert" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga konfigurerade" + } + } + } + }, + "By Provider" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efter udbyder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nach Anbieter" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Par fournisseur" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etter leverandør" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efter leverantör" + } + } + } + }, + "Today" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dag" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heute" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aujourd'hui" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dag" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Idag" + } + } + } + }, + "7 Days" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 dage" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 Tage" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 jours" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 dager" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 dagar" + } + } + } + }, + "Week" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uge" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Woche" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Semaine" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uke" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vecka" + } + } + } + }, + "Month" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Måned" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mois" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Måned" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Månad" + } + } + } + }, + "Year" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "År" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jahr" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Année" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "År" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "År" + } + } + } + }, + "Questions" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spørgsmål" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fragen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questions" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spørsmål" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frågor" + } + } + } + }, + "Money Used" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Penge brugt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgegebenes Geld" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argent dépensé" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Penger brukt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengar använda" + } + } + } + }, + "Over Time" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Over tid" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Im Zeitverlauf" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dans le temps" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Over tid" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Över tid" + } + } + } + }, + "By Model" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efter model" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nach Modell" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Par modèle" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etter modell" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efter modell" + } + } + } + }, + "No usage data for this timeframe" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen brugsdata for denne periode" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Nutzungsdaten für diesen Zeitraum" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune donnée d'utilisation pour cette période" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen bruksdata for denne perioden" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen användningsdata för denna period" + } + } + } + }, + "Analytics" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analyse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analyse" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analytique" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analyse" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analys" + } + } + } + }, + "View detailed usage analytics" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se detaljeret brugsanalyse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detaillierte Nutzungsanalyse anzeigen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voir l'analyse d'utilisation détaillée" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis detaljert bruksanalyse" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa detaljerad användningsanalys" + } + } + } + }, "·" : { "comment" : "A separator between the message count and the date.", "isCommentAutoGenerated" : true, @@ -71,6 +921,40 @@ } } }, + "'%@' is a reserved prefix. Choose a different name." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' er et reserveret præfiks. Vælg et andet navn." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "„%@“ ist ein reserviertes Präfix. Wähle einen anderen Namen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "« %@ » est un préfixe réservé. Choisissez un autre nom." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' er et reservert prefiks. Velg et annet navn." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' är ett reserverat prefix. Välj ett annat namn." + } + } + } + }, "(always used)" : { "localizations" : { "da" : { @@ -315,7 +1199,42 @@ } } }, + "%@ conversations" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ samtaler" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Gespräche" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ conversations" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ samtaler" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ konversationer" + } + } + } + }, "%@ min" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -385,7 +1304,42 @@ } } }, + "%@ selected" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ valgt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ausgewählt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sélectionnées" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ valgt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ valda" + } + } + } + }, "%@ tokens" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -419,6 +1373,74 @@ } } }, + "%@_" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@_" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@_" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@_" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@_" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@_" + } + } + } + }, + "%@." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + } + } + }, "%@s" : { "localizations" : { "da" : { @@ -628,6 +1650,7 @@ } }, "^[%@ file](inflect: true)" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -695,7 +1718,48 @@ } } }, + "^[%@ message](inflect: true) · %@ tokens" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "^[%1$@ message](inflect: true) · %2$@ tokens" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "^[%1$@ besked](inflect: true) · %2$@ tokens" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "^[%1$@ Nachricht](inflect: true) · %2$@ tokens" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "^[%1$@ message](inflect: true) · %2$@ tokens" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "^[%1$@ melding](inflect: true) · %2$@ tokens" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "^[%1$@ meddelande](inflect: true) · %2$@ tokens" + } + } + } + }, "^[%@ running](inflect: true)" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -730,6 +1794,7 @@ } }, "^[%@ skill](inflect: true) active — appended to every system prompt" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -764,6 +1829,7 @@ } }, "^[%@ skill](inflect: true) exported to Downloads" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -798,6 +1864,7 @@ } }, "^[%@ skill](inflect: true) imported" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1006,6 +2073,74 @@ } } }, + "⚠️ Beta — Apple's Foundation Models framework is still in active development." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Beta — Apples Foundation Models-framework er stadig under aktiv udvikling." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Beta — Apples Foundation-Models-Framework befindet sich noch in aktiver Entwicklung." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Bêta — le framework Foundation Models d'Apple est encore en développement actif." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Beta — Apples Foundation Models-rammeverk er fortsatt under aktiv utvikling." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Beta — Apples Foundation Models-ramverk är fortfarande under aktiv utveckling." + } + } + } + }, + "⚠️ Beta — Apple's on-device model is still in active development (currently macOS 27 beta). Expect rough edges: a small 4K context window, occasional generation errors, and no tool support yet." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Beta — Apples model på enheden er stadig under aktiv udvikling (i øjeblikket macOS 27 beta). Forvent grovheder: et lille 4K-kontekstvindue, lejlighedsvise genereringsfejl og ingen værktøjsstøtte endnu." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Beta — Apples On-Device-Modell befindet sich noch in aktiver Entwicklung (derzeit macOS 27 Beta). Erwarte Kanten: ein kleines 4K-Kontextfenster, gelegentliche Generierungsfehler und noch keine Tool-Unterstützung." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Bêta — le modèle sur l'appareil d'Apple est encore en développement actif (actuellement macOS 27 bêta). Attendez-vous à des aspérités : une petite fenêtre de contexte de 4K, des erreurs de génération occasionnelles, et pas encore de prise en charge des outils." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Beta — Apples modell på enheten er fortsatt under aktiv utvikling (for tiden macOS 27 beta). Forvent noe ruhet: et lite 4K-kontekstvindu, sporadiske genereringsfeil, og ingen verktøystøtte ennå." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Beta — Apples modell på enheten är fortfarande under aktiv utveckling (för närvarande macOS 27 beta). Förvänta dig vissa skavanker: ett litet 4K-kontextfönster, enstaka genereringsfel och inget verktygsstöd ännu." + } + } + } + }, "⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly." : { "comment" : "A warning displayed in the settings view.", "isCommentAutoGenerated" : true, @@ -1494,6 +2629,40 @@ } } }, + "1 second" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 sekund" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 Sekunde" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 seconde" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 sekund" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 sekund" + } + } + } + }, "1. Open Anytype → Settings → Integrations" : { "localizations" : { "da" : { @@ -1565,6 +2734,7 @@ } }, "1K tokens" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1706,7 +2876,42 @@ } } }, + "10 seconds" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 sekunder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 Sekunden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 secondes" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 sekunder" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 sekunder" + } + } + } + }, "10K tokens" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1740,7 +2945,76 @@ } } }, + "30 seconds" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 sekunder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 Sekunden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 secondes" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 sekunder" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 sekunder" + } + } + } + }, + "60 seconds" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "60 sekunder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "60 Sekunden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "60 secondes" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "60 sekunder" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "60 sekunder" + } + } + } + }, "100K tokens" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1809,6 +3083,7 @@ } }, "A new version is available — click to open the releases page" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1879,6 +3154,7 @@ } }, "About Confab" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1913,6 +3189,7 @@ } }, "Access denied — enable in System Settings" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1947,6 +3224,7 @@ } }, "Access granted" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1981,6 +3259,7 @@ } }, "Access not granted" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2083,6 +3362,7 @@ } }, "Actions" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2225,6 +3505,7 @@ } }, "Activity" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2258,7 +3539,76 @@ } } }, + "Add a server below, e.g.: safaridriver --mcp" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj en server nedenfor, f.eks.: safaridriver --mcp" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Füge unten einen Server hinzu, z. B.: safaridriver --mcp" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajoutez un serveur ci-dessous, par ex. : safaridriver --mcp" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til en server nedenfor, f.eks.: safaridriver --mcp" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till en server nedan, t.ex.: safaridriver --mcp" + } + } + } + }, + "Add External MCP Server" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj ekstern MCP-server" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Externen MCP-Server hinzufügen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un serveur MCP externe" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til ekstern MCP-server" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till extern MCP-server" + } + } + } + }, "Add File" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2293,6 +3643,7 @@ } }, "Add Folder…" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2327,6 +3678,7 @@ } }, "Advanced search — semantic search, bulk delete, export" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2360,7 +3712,42 @@ } } }, + "AFFECTED FILES" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "BERØRTE FILER" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "BETROFFENE DATEIEN" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "FICHIERS CONCERNÉS" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "BERØRTE FILER" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "BERÖRDA FILER" + } + } + } + }, "After idle timeout" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2429,6 +3816,7 @@ } }, "Agent name" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2531,6 +3919,7 @@ } }, "AI Configuration" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2565,6 +3954,7 @@ } }, "AI Email Handler" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2599,6 +3989,7 @@ } }, "AI Model" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2633,6 +4024,7 @@ } }, "AI Provider" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2700,7 +4092,42 @@ } } }, + "All-Time" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samlet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesamt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt" + } + } + } + }, "Allow for Session" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2735,6 +4162,7 @@ } }, "Allow Once" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2873,6 +4301,7 @@ } }, "Allowed Folders" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2943,6 +4372,7 @@ } }, "API Key" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2977,6 +4407,7 @@ } }, "API Keys" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3047,6 +4478,7 @@ } }, "API Token" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3081,6 +4513,7 @@ } }, "API URL" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3114,7 +4547,42 @@ } } }, + "Apple Intelligence" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Intelligence" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Intelligence" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Intelligence" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Intelligence" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Intelligence" + } + } + } + }, "Architecture" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3149,6 +4617,7 @@ } }, "Attach file" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3183,6 +4652,7 @@ } }, "Authentication" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3251,6 +4721,7 @@ } }, "Auto-export on save" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3285,6 +4756,7 @@ } }, "Auto-pull on launch" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3319,6 +4791,7 @@ } }, "Auto-Save" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3353,6 +4826,7 @@ } }, "Auto-sync can cause conflicts if running on multiple machines simultaneously." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3527,6 +5001,7 @@ } }, "Base URL" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3629,6 +5104,7 @@ } }, "Brief description shown in the command dropdown" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3663,6 +5139,7 @@ } }, "Brief summary (optional — auto-extracted from content if left blank)" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3733,6 +5210,7 @@ } }, "Calendar" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3767,6 +5245,7 @@ } }, "Cancel" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3837,6 +5316,7 @@ } }, "Categories" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4045,6 +5525,7 @@ } }, "Choose…" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4079,6 +5560,7 @@ } }, "Clear" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4149,6 +5631,7 @@ } }, "Clear Chat" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4183,6 +5666,7 @@ } }, "Clear Email Log?" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4250,7 +5734,42 @@ } } }, + "Clone Repository" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klon repository" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repository klonen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cloner le dépôt" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klon repositorium" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klona förvar" + } + } + } + }, "Combine" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4528,7 +6047,183 @@ } } }, + "Confab" : { + "comment" : "The name of the app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab" + } + } + } + }, + "Confab (formerly oAI) v2.4 was the last version to support Intel Macs and Rosetta. Starting with macOS 28, Confab will require Apple Silicon. Consider upgrading your Mac to continue receiving updates." : { + "comment" : "A warning that Intel Macs are no longer supported.", + "isCommentAutoGenerated" : true, + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab (tidligere oAI) v2.4 var den sidste version, der understøttede Intel-Mac og Rosetta. Fra macOS 28 vil Confab kræve Apple Silicon. Overvej at opgradere din Mac for at fortsætte med at få opdateringer." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab (früher oAI) v2.4 war die letzte Version, die Intel-Macs und Rosetta unterstützte. Ab macOS 28 benötigt Confab Apple Silicon. Erwäge ein Upgrade deines Mac, um weiterhin Updates zu erhalten." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab (anciennement oAI) v2.4 était la dernière version à prendre en charge les Mac Intel et Rosetta. À partir de macOS 28, Confab nécessitera Apple Silicon. Envisage de mettre à niveau ton Mac pour continuer à recevoir des mises à jour." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab (tidligere oAI) v2.4 var den siste versjonen som støttet Intel-Mac og Rosetta. Fra macOS 28 vil Confab kreve Apple Silicon. Vurder å oppgradere Mac-en din for å fortsette å få oppdateringer." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab (tidigare oAI) v2.4 var den sista versionen som stödde Intel-Mac och Rosetta. Från macOS 28 kommer Confab att kräva Apple Silicon. Överväg att uppgradera din Mac för att fortsätta få uppdateringar." + } + } + } + }, + "Confab Help" : { + "extractionState" : "stale", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab-hjælp" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab-Hilfe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aide Confab" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab-hjelp" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab-hjälp" + } + } + } + }, + "Confab syncs in the background automatically, and just hit a file that collided with a leftover local copy of itself" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab synkroniserer automatisk i baggrunden og stødte lige på en fil, der kolliderede med en efterladt lokal kopi af sig selv" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab synchronisiert automatisch im Hintergrund und ist gerade auf eine Datei gestoßen, die mit einer zurückgebliebenen lokalen Kopie von sich selbst kollidierte" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab se synchronise automatiquement en arrière-plan, et vient de rencontrer un fichier en conflit avec une copie locale résiduelle de lui-même" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab synkroniserer automatisk i bakgrunnen, og støtte nettopp på en fil som kolliderte med en gjenværende lokal kopi av seg selv" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confab synkroniserar automatiskt i bakgrunden, och stötte just på en fil som kolliderade med en kvarbliven lokal kopia av sig själv" + } + } + } + }, + "Connect any stdio MCP server (e.g. safaridriver --mcp) to give the AI access to its tools. Tool names are prefixed with the server slug." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forbind enhver stdio MCP-server (f.eks. safaridriver --mcp) for at give AI'en adgang til dens værktøjer. Værktøjsnavne får serverens slug som præfiks." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinde einen beliebigen stdio-MCP-Server (z. B. safaridriver --mcp), um der KI Zugriff auf dessen Tools zu geben. Tool-Namen erhalten den Server-Slug als Präfix." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez n'importe quel serveur MCP stdio (par ex. safaridriver --mcp) pour donner à l'IA accès à ses outils. Les noms d'outils sont préfixés par le slug du serveur." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koble til en hvilken som helst stdio MCP-server (f.eks. safaridriver --mcp) for å gi AI-en tilgang til verktøyene dens. Verktøynavn får serverens slug som prefiks." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslut vilken stdio MCP-server som helst (t.ex. safaridriver --mcp) för att ge AI:n tillgång till dess verktyg. Verktygsnamn prefixas med serverns slug." + } + } + } + }, "Connection" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4563,6 +6258,7 @@ } }, "Contacts" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4633,6 +6329,7 @@ } }, "Context Window" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4701,6 +6398,7 @@ } }, "Conversation Memory" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4805,6 +6503,7 @@ } }, "Copy Image" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4908,10 +6607,9 @@ } } }, - "Cost warning: each sub-agent runs its own full chain of model calls. A single request that spawns several agents can cost several times a normal reply. The AI is instructed to only use this for genuinely parallel research, but model behavior can vary — leave this off unless you want that tradeoff." : { - - }, + "Cost warning: each sub-agent runs its own full chain of model calls. A single request that spawns several agents can cost several times a normal reply. The AI is instructed to only use this for genuinely parallel research, but model behavior can vary — leave this off unless you want that tradeoff." : {}, "Costs" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4945,6 +6643,40 @@ } } }, + "Couldn't load release notes. Check your internet connection and try again." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke indlæse udgivelsesnoter. Tjek din internetforbindelse, og prøv igen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versionshinweise konnten nicht geladen werden. Überprüfe deine Internetverbindung und versuche es erneut." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de charger les notes de version. Vérifiez votre connexion internet et réessayez." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke laste inn utgivelsesnotater. Sjekk internettforbindelsen din, og prøv igjen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det gick inte att läsa in versionsanteckningar. Kontrollera din internetanslutning och försök igen." + } + } + } + }, "Create a dedicated email account specifically for AI handling. Do NOT use your personal email address." : { "localizations" : { "da" : { @@ -5016,6 +6748,7 @@ } }, "Create Directories" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5084,6 +6817,7 @@ } }, "Credits" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5118,6 +6852,7 @@ } }, "Cron expression (e.g. 0 * * * *)" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5151,7 +6886,42 @@ } } }, + "Daily" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dagligt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Täglich" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quotidien" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daglig" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dagligen" + } + } + } + }, "Default Model" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5220,6 +6990,7 @@ } }, "Default Provider" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5254,6 +7025,7 @@ } }, "Delete" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5322,6 +7094,7 @@ } }, "Delete conversation" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5356,6 +7129,7 @@ } }, "Delete Files" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5390,6 +7164,7 @@ } }, "Delete original conversations after combining" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5424,6 +7199,7 @@ } }, "Delete shortcut" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5458,6 +7234,7 @@ } }, "Delete skill" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5492,6 +7269,7 @@ } }, "Deny" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5562,6 +7340,7 @@ } }, "Dialog Text" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5630,6 +7409,7 @@ } }, "Dismiss" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5664,6 +7444,7 @@ } }, "Done" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5698,6 +7479,7 @@ } }, "Download v%@" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5732,6 +7514,7 @@ } }, "Duplicate Shortcut" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5766,6 +7549,7 @@ } }, "e.g. Code Review, Test Writer, Security Auditor" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -5833,6 +7617,40 @@ } } }, + "Each conversation can keep its own persistent notes.md file, read and written automatically by the AI once turned on with /notes on. Use this to browse or edit notes files directly in Finder." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hver samtale kan have sin egen vedvarende notes.md-fil, som AI'en automatisk læser og skriver til, når den er slået til med /notes on. Brug denne til at gennemse eller redigere notesfiler direkte i Finder." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jede Unterhaltung kann eine eigene, dauerhafte notes.md-Datei führen, die die KI automatisch liest und schreibt, sobald sie mit /notes on aktiviert wurde. Nutze dies, um Notizdateien direkt im Finder zu durchsuchen oder zu bearbeiten." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chaque conversation peut conserver son propre fichier notes.md persistant, lu et écrit automatiquement par l'IA une fois activé avec /notes on. Utilisez ceci pour parcourir ou modifier les fichiers de notes directement dans le Finder." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hver samtale kan ha sin egen vedvarende notes.md-fil, som AI-en automatisk leser og skriver til når den er slått på med /notes on. Bruk dette til å bla gjennom eller redigere notatfiler direkte i Finder." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varje konversation kan ha sin egen beständiga notes.md-fil, som AI:n automatiskt läser och skriver till när den slås på med /notes on. Använd detta för att bläddra i eller redigera anteckningsfiler direkt i Finder." + } + } + } + }, "Edit" : { "comment" : "A button to edit a skill.", "isCommentAutoGenerated" : true, @@ -5974,6 +7792,7 @@ } }, "Email Log" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6008,6 +7827,7 @@ } }, "Email Server" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6076,6 +7896,7 @@ } }, "Email Trigger" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6110,6 +7931,7 @@ } }, "Embed All Conversations" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6178,6 +8000,7 @@ } }, "Enable Anytype" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6212,6 +8035,7 @@ } }, "Enable Auto-Save" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6246,6 +8070,7 @@ } }, "Enable Bash Execution" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6280,6 +8105,7 @@ } }, "Enable Email Handler" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6350,6 +8176,7 @@ } }, "Enable Embeddings" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6384,6 +8211,7 @@ } }, "Enable Git Sync" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6418,6 +8246,7 @@ } }, "Enable Jarvis" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6452,6 +8281,7 @@ } }, "Enable MCP" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6486,6 +8316,7 @@ } }, "Enable Online Mode" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6520,6 +8351,7 @@ } }, "Enable Paperless" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6554,6 +8386,7 @@ } }, "Enable Rate Limit" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6588,6 +8421,7 @@ } }, "Enable Streaming" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6622,6 +8456,7 @@ } }, "Enable Summarization" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6656,6 +8491,7 @@ } }, "Enabled" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6690,6 +8526,7 @@ } }, "Enter your Ollama server URL to enable the Ollama provider" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6830,6 +8667,7 @@ } }, "Executes immediately — no extra input needed" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6864,6 +8702,7 @@ } }, "Export" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6898,6 +8737,7 @@ } }, "Export All" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6932,6 +8772,7 @@ } }, "Export as Markdown…" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6966,6 +8807,7 @@ } }, "Export shortcut" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6999,7 +8841,42 @@ } } }, + "External MCP Servers" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksterne MCP-servere" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Externe MCP-Server" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serveurs MCP externes" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksterne MCP-servere" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Externa MCP-servrar" + } + } + } + }, "Features" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7104,6 +8981,7 @@ } }, "Filter by category" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7172,6 +9050,7 @@ } }, "Filtering" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7205,7 +9084,144 @@ } } }, + "Fix It Yourself" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordn det selv" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selbst beheben" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le faire soi-même" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiks det selv" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fixa det själv" + } + } + } + }, + "Fixed — your conversations are back in sync." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Løst — dine samtaler er synkroniseret igen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behoben — deine Unterhaltungen sind wieder synchron." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corrigé — vos conversations sont de nouveau synchronisées." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fikset — samtalene dine er synkronisert igjen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löst — dina konversationer är synkroniserade igen." + } + } + } + }, + "Fixing..." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordner..." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird behoben..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correction en cours..." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fikser..." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fixar..." + } + } + } + }, + "Four steps, then Confab takes over again" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fire trin, så tager Confab over igen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vier Schritte, dann übernimmt Confab wieder" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quatre étapes, puis Confab reprend la main" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fire steg, så tar Confab over igjen" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fyra steg, sedan tar Confab över igen" + } + } + } + }, "Generate an API key in your Jarvis settings and paste it above." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7240,6 +9256,7 @@ } }, "Generate embeddings for all existing messages (one-time operation)" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7273,7 +9290,42 @@ } } }, + "git pull --ff-only" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "git pull --ff-only" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "git pull --ff-only" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "git pull --ff-only" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "git pull --ff-only" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "git pull --ff-only" + } + } + } + }, "Git Sync" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7342,6 +9394,7 @@ } }, "Google API Key" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7376,6 +9429,7 @@ } }, "Got It" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7410,6 +9464,7 @@ } }, "GUI Text" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7480,6 +9535,7 @@ } }, "Hide" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7514,6 +9570,7 @@ } }, "Hide Reasoning in Response" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7686,6 +9743,7 @@ } }, "iCloud Drive Backup" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7720,6 +9778,7 @@ } }, "Icon Size" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7754,6 +9813,7 @@ } }, "ID" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7788,6 +9848,7 @@ } }, "Idle" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7822,6 +9883,7 @@ } }, "Idle Timeout" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7856,6 +9918,7 @@ } }, "Image Gen" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7890,6 +9953,7 @@ } }, "IMAP Host" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7924,6 +9988,7 @@ } }, "IMAP Port" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7958,6 +10023,7 @@ } }, "immediate" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -7992,6 +10058,7 @@ } }, "Import" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8026,6 +10093,7 @@ } }, "In-App Help" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8059,40 +10127,6 @@ } } }, - "Clone Repository" : { - "localizations" : { - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klon repository" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Repository klonen" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cloner le dépôt" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klon repositorium" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klona förvar" - } - } - } - }, "Inject into system prompt for every conversation" : { "localizations" : { "da" : { @@ -8162,6 +10196,7 @@ } }, "Input Text" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8196,6 +10231,7 @@ } }, "Instruct Type" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8230,6 +10266,7 @@ } }, "Intel Mac Support Ending" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8264,6 +10301,7 @@ } }, "Jarvis" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8297,7 +10335,42 @@ } } }, + "Jarvis is oAI-Web, a self-hosted companion server that lets Confab sync and connect remotely." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jarvis er oAI-Web, en selv-hostet følgeserver, der lader Confab synkronisere og oprette forbindelse eksternt." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jarvis ist oAI-Web, ein selbst gehosteter Begleitserver, mit dem Confab remote synchronisieren und sich verbinden kann." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jarvis, c'est oAI-Web, un serveur compagnon auto-hébergé qui permet à Confab de se synchroniser et de se connecter à distance." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jarvis er oAI-Web, en selvhostet følgeserver som lar Confab synkronisere og koble til eksternt." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jarvis är oAI-Web, en självhostad följeslagarserver som låter Confab synkronisera och ansluta på distans." + } + } + } + }, "Keep Both" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8332,6 +10405,7 @@ } }, "Keyboard Shortcuts" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8402,6 +10476,7 @@ } }, "Last Backup" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8470,6 +10545,7 @@ } }, "Later" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8504,6 +10580,7 @@ } }, "Leave blank for default" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8572,6 +10649,7 @@ } }, "Let the AI access your Calendar, Reminders, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8605,10 +10683,43 @@ } } }, - "Let the AI spawn read-only research sub-agents to investigate multiple things in parallel (read files, list/search directories, search the web — no writing, no bash). Intended for genuinely independent research tasks, not everyday questions." : { - + "Let the AI access your Calendar, Reminders, Contacts, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lad AI'en få adgang til din Kalender, Påmindelser, Kontakter og Lokation og Kort for at besvare spørgsmål om din tidsplan og dine omgivelser. Hver tjeneste er valgfri og bruger standard macOS-tilladelsesprompter. Denne funktion er i beta og kan ændre sig." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erlaube der KI Zugriff auf deinen Kalender, deine Erinnerungen, Kontakte sowie Standort & Karten, um Fragen zu deinem Terminplan und deiner Umgebung zu beantworten. Jeder Dienst ist optional und nutzt die üblichen macOS-Berechtigungsabfragen. Diese Funktion befindet sich in der Beta-Phase und kann sich ändern." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laissez l'IA accéder à votre Calendrier, vos Rappels, vos Contacts et Position et Plans pour répondre à des questions sur votre emploi du temps et votre environnement. Chaque service est optionnel et utilise les demandes d'autorisation standard de macOS. Cette fonctionnalité est en bêta et peut évoluer." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "La AI-en få tilgang til Kalenderen, Påminnelser, Kontakter og Posisjon og Kart for å svare på spørsmål om timeplanen og omgivelsene dine. Hver tjeneste er valgfri og bruker standard macOS-tillatelsesspørsmål. Denne funksjonaliteten er i beta og kan endre seg." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Låt AI:n få åtkomst till din Kalender, Påminnelser, Kontakter och Plats och Kartor för att svara på frågor om ditt schema och din omgivning. Varje tjänst är valfri och använder standardbehörighetsfrågor i macOS. Den här funktionen är i betaversion och kan komma att ändras." + } + } + } }, + "Let the AI spawn read-only research sub-agents to investigate multiple things in parallel (read files, list/search directories, search the web — no writing, no bash). Intended for genuinely independent research tasks, not everyday questions." : {}, "Loading usage…" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8643,6 +10754,7 @@ } }, "Loading..." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8677,6 +10789,7 @@ } }, "Local Path" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8711,6 +10824,7 @@ } }, "Location & Maps" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8745,6 +10859,7 @@ } }, "Log Level" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8779,6 +10894,7 @@ } }, "Logging" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8883,6 +10999,7 @@ } }, "Manual Sync" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8953,6 +11070,7 @@ } }, "Max Context Tokens" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8987,6 +11105,7 @@ } }, "Max Emails Per Hour" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9021,6 +11140,7 @@ } }, "Max Response Tokens" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9055,6 +11175,7 @@ } }, "Max Tokens" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9123,6 +11244,7 @@ } }, "Max Tool Calls" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9225,6 +11347,7 @@ } }, "Memory & Context" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9293,6 +11416,7 @@ } }, "Message Threshold" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9395,6 +11519,7 @@ } }, "Method" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9429,6 +11554,7 @@ } }, "Min Messages" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9496,7 +11622,42 @@ } } }, + "Mirrors your in-progress conversation to disk so a crash or force-quit doesn't lose it. Never shown as a saved conversation." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spejler din igangværende samtale til disken, så et nedbrud eller en tvunget lukning ikke mister den. Vises aldrig som en gemt samtale." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spiegelt deine laufende Unterhaltung auf die Festplatte, damit ein Absturz oder erzwungenes Beenden sie nicht verliert. Wird nie als gespeicherte Unterhaltung angezeigt." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reflète votre conversation en cours sur le disque afin qu'un plantage ou une fermeture forcée ne la perde pas. Jamais affichée comme une conversation enregistrée." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speiler den pågående samtalen din til disk, slik at en krasj eller tvungen lukking ikke mister den. Vises aldri som en lagret samtale." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speglar din pågående konversation till disken så att en krasch eller tvingad avslutning inte förlorar den. Visas aldrig som en sparad konversation." + } + } + } + }, "Modality" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9531,6 +11692,7 @@ } }, "Model" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9599,6 +11761,7 @@ } }, "Model Details" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9669,6 +11832,7 @@ } }, "Model Parameters" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9703,6 +11867,7 @@ } }, "Model Settings" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9771,6 +11936,7 @@ } }, "Move & Copy Files" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9804,6 +11970,40 @@ } } }, + "Move to Folder (%@)" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flyt til mappe (%@)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In Ordner verschieben (%@)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer vers le dossier (%@)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flytt til mappe (%@)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flytta till mapp (%@)" + } + } + } + }, "Multi-provider AI chat client" : { "comment" : "A description of Confab.", "isCommentAutoGenerated" : true, @@ -9877,6 +12077,7 @@ } }, "needs input" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9911,6 +12112,7 @@ } }, "Needs input — user types after the command" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -9981,6 +12183,7 @@ } }, "New Agent" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10083,6 +12286,7 @@ } }, "New Shortcut" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10117,6 +12321,7 @@ } }, "New Skill" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10187,6 +12392,7 @@ } }, "No default model configured — set one in Settings → General." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10256,6 +12462,40 @@ } } }, + "No external servers configured" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen eksterne servere konfigureret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine externen Server konfiguriert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun serveur externe configuré" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen eksterne servere konfigurert" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga externa servrar konfigurerade" + } + } + } + }, "No files attached. Add JSON, YAML, CSV or TXT files to inject data into the system prompt alongside this skill." : { "comment" : "A message displayed when a user has not attached any files to a skill.", "isCommentAutoGenerated" : true, @@ -10362,6 +12602,40 @@ } } }, + "No release notes are available yet for %@." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der er endnu ikke udgivelsesnoter tilgængelige for %@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Für %@ sind noch keine Versionshinweise verfügbar." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune note de version n'est encore disponible pour %@." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det er ikke tilgjengelig utgivelsesnotater for %@ ennå." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det finns inga versionsanteckningar tillgängliga för %@ än." + } + } + } + }, "No results for \"%@\"" : { "comment" : "A message displayed when there are no search results. The argument is the search term.", "isCommentAutoGenerated" : true, @@ -10576,113 +12850,76 @@ } } }, - "Confab" : { - "comment" : "The name of the app.", - "isCommentAutoGenerated" : true, + "No usage data yet" : { "localizations" : { "da" : { "stringUnit" : { "state" : "translated", - "value" : "Confab" + "value" : "Ingen brugsdata endnu" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Confab" + "value" : "Noch keine Nutzungsdaten" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Confab" + "value" : "Aucune donnée d'utilisation pour le moment" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Confab" + "value" : "Ingen bruksdata ennå" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Confab" + "value" : "Ingen användningsdata än" } } } }, - "Confab Help" : { + "Off" : { "localizations" : { "da" : { "stringUnit" : { "state" : "translated", - "value" : "Confab-hjælp" + "value" : "Fra" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Confab-Hilfe" + "value" : "Aus" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Aide Confab" + "value" : "Désactivé" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Confab-hjelp" + "value" : "Av" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Confab-hjälp" - } - } - } - }, - "Confab (formerly oAI) v2.4 was the last version to support Intel Macs and Rosetta. Starting with macOS 28, Confab will require Apple Silicon. Consider upgrading your Mac to continue receiving updates." : { - "comment" : "A warning that Intel Macs are no longer supported.", - "isCommentAutoGenerated" : true, - "localizations" : { - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confab (tidligere oAI) v2.4 var den sidste version, der understøttede Intel-Mac og Rosetta. Fra macOS 28 vil Confab kræve Apple Silicon. Overvej at opgradere din Mac for at fortsætte med at få opdateringer." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confab (früher oAI) v2.4 war die letzte Version, die Intel-Macs und Rosetta unterstützte. Ab macOS 28 benötigt Confab Apple Silicon. Erwäge ein Upgrade deines Mac, um weiterhin Updates zu erhalten." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confab (anciennement oAI) v2.4 était la dernière version à prendre en charge les Mac Intel et Rosetta. À partir de macOS 28, Confab nécessitera Apple Silicon. Envisage de mettre à niveau ton Mac pour continuer à recevoir des mises à jour." - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confab (tidligere oAI) v2.4 var den siste versjonen som støttet Intel-Mac og Rosetta. Fra macOS 28 vil Confab kreve Apple Silicon. Vurder å oppgradere Mac-en din for å fortsette å få oppdateringer." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confab (tidigare oAI) v2.4 var den sista versionen som stödde Intel-Mac och Rosetta. Från macOS 28 kommer Confab att kräva Apple Silicon. Överväg att uppgradera din Mac för att fortsätta få uppdateringar." + "value" : "Av" } } } }, "OK" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10753,6 +12990,7 @@ } }, "Ollama URL" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10787,6 +13025,7 @@ } }, "On app quit" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10821,6 +13060,7 @@ } }, "On model switch" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10854,7 +13094,110 @@ } } }, + "On-Device (4K context)" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "På enheden (4K kontekst)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf dem Gerät (4K Kontext)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sur l'appareil (contexte 4K)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "På enheten (4K kontekst)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "På enheten (4K kontext)" + } + } + } + }, + "On-device and free — no credits or API key needed." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "På enheden og gratis — ingen kreditter eller API-nøgle nødvendig." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf dem Gerät und kostenlos — kein Guthaben oder API-Schlüssel nötig." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sur l'appareil et gratuit — aucun crédit ni clé API nécessaire." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "På enheten og gratis — ingen kreditter eller API-nøkkel nødvendig." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "På enheten och gratis — inga krediter eller API-nyckel behövs." + } + } + } + }, + "One or more of these files aren't ones Confab recognizes as safe to remove automatically — please fix this one yourself." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En eller flere af disse filer er ikke nogen, Confab genkender som sikre at fjerne automatisk — ordn venligst denne selv." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine oder mehrere dieser Dateien erkennt Confab nicht als sicher zum automatischen Entfernen — bitte behebe dies selbst." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un ou plusieurs de ces fichiers ne sont pas reconnus par Confab comme sûrs à supprimer automatiquement — veuillez corriger cela vous-même." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En eller flere av disse filene er ikke slike Confab gjenkjenner som trygge å fjerne automatisk — fiks denne selv." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "En eller flera av dessa filer är inte sådana Confab känner igen som säkra att ta bort automatiskt — fixa den här själv." + } + } + } + }, "Online" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10889,6 +13232,7 @@ } }, "Online Mode (Web Search)" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10923,6 +13267,7 @@ } }, "Online Mode: Off" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -10957,6 +13302,7 @@ } }, "Online Mode: On" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11025,6 +13371,7 @@ } }, "Open Chat…" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11059,6 +13406,7 @@ } }, "Open System Settings" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11369,6 +13717,7 @@ } }, "Optional description" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11437,6 +13786,7 @@ } }, "Overview" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11471,6 +13821,7 @@ } }, "Password" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11505,6 +13856,7 @@ } }, "Paused" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11539,6 +13891,7 @@ } }, "Permissions" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11607,6 +13960,7 @@ } }, "Pricing" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11641,6 +13995,7 @@ } }, "Progressive Summarization" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11709,6 +14064,7 @@ } }, "Provider" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11743,6 +14099,7 @@ } }, "Rate Limiting" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11845,6 +14202,7 @@ } }, "Reasoning (Thinking)" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11879,6 +14237,7 @@ } }, "Reasoning Effort" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11913,6 +14272,7 @@ } }, "Refresh" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11947,6 +14307,7 @@ } }, "Refresh agents" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -11981,6 +14342,7 @@ } }, "Release Page" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12015,6 +14377,7 @@ } }, "Reminders" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12083,6 +14446,7 @@ } }, "Remove file" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12117,6 +14481,7 @@ } }, "Rename" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12151,6 +14516,7 @@ } }, "Rename conversation" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12185,6 +14551,7 @@ } }, "Replace" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12219,6 +14586,7 @@ } }, "Request Access" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12253,6 +14621,7 @@ } }, "Require Approval" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12287,6 +14656,7 @@ } }, "Require Approval for Changes" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12320,10 +14690,9 @@ } } }, - "Research Agents" : { - - }, + "Research Agents" : {}, "Respect .gitignore" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12358,6 +14727,7 @@ } }, "Response Generation" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12392,6 +14762,7 @@ } }, "Response Settings" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12462,6 +14833,7 @@ } }, "Retry" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12496,6 +14868,7 @@ } }, "Run agent now" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12670,6 +15043,7 @@ } }, "Save" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12704,6 +15078,7 @@ } }, "Save Chat" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12738,6 +15113,7 @@ } }, "Save Chat As…" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12772,6 +15148,7 @@ } }, "Save to Downloads" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12806,6 +15183,7 @@ } }, "Schedule" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12840,6 +15218,7 @@ } }, "Search by sender, subject, or content..." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12874,6 +15253,7 @@ } }, "Search by text or date (dd.mm.yyyy)..." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12908,6 +15288,7 @@ } }, "Search commands, shortcuts, features..." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12942,6 +15323,7 @@ } }, "Search Conversations" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -12976,6 +15358,7 @@ } }, "Search conversations..." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13010,6 +15393,7 @@ } }, "Search conversations…" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13044,6 +15428,7 @@ } }, "Search Engine ID" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13078,6 +15463,7 @@ } }, "Search models..." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13112,6 +15498,7 @@ } }, "Search Provider" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13180,6 +15567,7 @@ } }, "Select" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13214,6 +15602,7 @@ } }, "Select model" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13248,6 +15637,7 @@ } }, "Select Model" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13282,6 +15672,7 @@ } }, "Select model..." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13316,6 +15707,7 @@ } }, "Semantic" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13350,6 +15742,7 @@ } }, "Semantic Search" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13384,6 +15777,7 @@ } }, "Send message" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13418,6 +15812,7 @@ } }, "Server URL" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13452,6 +15847,7 @@ } }, "Services" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13485,7 +15881,42 @@ } } }, + "Session" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitzung" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Økt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session" + } + } + } + }, "Session Info" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13520,6 +15951,7 @@ } }, "Settings" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13554,6 +15986,7 @@ } }, "Settings…" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13624,6 +16057,7 @@ } }, "Show" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13658,6 +16092,7 @@ } }, "Show favorites only" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13692,6 +16127,7 @@ } }, "Show Icon Labels" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13726,6 +16162,7 @@ } }, "Show model info" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13900,6 +16337,7 @@ } }, "Skip" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13934,6 +16372,7 @@ } }, "Smart Context Selection" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -13968,6 +16407,7 @@ } }, "SMTP Host" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14002,6 +16442,7 @@ } }, "SMTP Port" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14036,6 +16477,7 @@ } }, "Software Update" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14138,6 +16580,7 @@ } }, "Star this message to always include it in context" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14171,7 +16614,42 @@ } } }, + "Starred models sync automatically via the same iCloud Drive folder — star a model on one Mac and it appears starred on your others within a launch or two." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stjernemarkerede modeller synkroniseres automatisk via den samme iCloud Drive-mappe — stjernemarker en model på én Mac, og den vises stjernemarkeret på dine andre inden for en opstart eller to." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorisierte Modelle werden automatisch über denselben iCloud-Drive-Ordner synchronisiert — markiere ein Modell auf einem Mac als Favorit, und es erscheint innerhalb von ein bis zwei Starts auch auf deinen anderen Macs als favorisiert." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les modèles favoris se synchronisent automatiquement via le même dossier iCloud Drive — mettez une étoile à un modèle sur un Mac, et il apparaîtra étoilé sur vos autres Mac en un ou deux lancements." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stjernemerkede modeller synkroniseres automatisk via den samme iCloud Drive-mappen — stjernemerk en modell på én Mac, så vises den stjernemerket på de andre innen en oppstart eller to." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stjärnmärkta modeller synkroniseras automatiskt via samma iCloud Drive-mapp — stjärnmärk en modell på en Mac så visas den stjärnmärkt på dina andra inom en start eller två." + } + } + } + }, "Statistics" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14206,6 +16684,7 @@ } }, "Stats" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14240,6 +16719,7 @@ } }, "Status" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14273,7 +16753,42 @@ } } }, + "Still couldn't sync: %@" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne stadig ikke synkronisere: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisierung weiterhin fehlgeschlagen: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toujours impossible de synchroniser : %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne fortsatt ikke synkronisere: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunde fortfarande inte synkronisera: %@" + } + } + } + }, "Stop agent" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14308,6 +16823,7 @@ } }, "Stop generation" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14376,6 +16892,7 @@ } }, "Subject Identifier" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14410,6 +16927,7 @@ } }, "Switch provider" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14512,6 +17030,7 @@ } }, "Sync Options" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14545,7 +17064,42 @@ } } }, + "Sync Ran Into a Conflict" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisering stødte på en konflikt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisierung ist auf einen Konflikt gestoßen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La synchronisation a rencontré un conflit" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisering støtte på en konflikt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkroniseringen stötte på en konflikt" + } + } + } + }, "System Prompt (Optional)" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14580,6 +17134,7 @@ } }, "System Prompts" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14614,6 +17169,7 @@ } }, "Temperature" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14788,6 +17344,7 @@ } }, "Text Sizes" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14959,7 +17516,42 @@ } } }, + "These are Confab's own sync bookkeeping files, not your conversations — nothing you've written is at risk either way." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dette er Confabs egne synkroniseringsbogføringsfiler, ikke dine samtaler — intet du har skrevet, er i fare uanset hvad." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das sind Confabs eigene Synchronisierungs-Buchhaltungsdateien, nicht deine Unterhaltungen — nichts, was du geschrieben hast, ist in irgendeiner Weise gefährdet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce sont les propres fichiers de suivi de synchronisation de Confab, pas vos conversations — rien de ce que vous avez écrit n'est en danger, dans un cas comme dans l'autre." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dette er Confabs egne synkroniseringsbokføringsfiler, ikke samtalene dine — ingenting du har skrevet er i fare uansett." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det här är Confabs egna synkroniseringsbokföringsfiler, inte dina konversationer — inget du har skrivit är i fara oavsett." + } + } + } + }, "Thinking" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15098,6 +17690,7 @@ } }, "Timeout (seconds)" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15132,6 +17725,7 @@ } }, "Token" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15202,6 +17796,7 @@ } }, "Token Usage" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15236,6 +17831,7 @@ } }, "Tokenizer" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15303,7 +17899,42 @@ } } }, + "Tool prefix:" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Værktøjspræfiks:" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tool-Präfix:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préfixe d'outil :" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verktøyprefiks:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verktygsprefix:" + } + } + } + }, "Toolbar" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15338,6 +17969,7 @@ } }, "Tools" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15615,6 +18247,40 @@ } } }, + "Unfiled" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uden mappe" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohne Ordner" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sans dossier" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uten mappe" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utan mapp" + } + } + } + }, "Update Available%@" : { "comment" : "A button that opens a website with information about a new version of Confab. The argument is the version number of the new version.", "isCommentAutoGenerated" : true, @@ -15652,6 +18318,7 @@ } }, "URL" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15828,6 +18495,7 @@ } }, "Use AI-powered semantic search instead of keyword matching" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15862,6 +18530,7 @@ } }, "Use Only Your Prompt" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15896,6 +18565,7 @@ } }, "username" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -15930,6 +18600,7 @@ } }, "Username" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -16077,6 +18748,7 @@ } }, "View" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -16145,6 +18817,7 @@ } }, "Vision" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -16179,6 +18852,7 @@ } }, "Web Search" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -16212,6 +18886,74 @@ } } }, + "Weekly" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugentligt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wöchentlich" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hebdomadaire" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukentlig" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veckovis" + } + } + } + }, + "When enabled, Confab backs up automatically in the background (checked at launch and hourly while running) — no need to press \"Back Up Now\" yourself." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når aktiveret, sikkerhedskopierer Confab automatisk i baggrunden (tjekket ved opstart og hver time mens den kører) — ingen grund til selv at trykke på \"Sikkerhedskopier nu\"." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn aktiviert, sichert Confab automatisch im Hintergrund (geprüft beim Start und stündlich während der Ausführung) — du musst nicht selbst auf „Jetzt sichern“ drücken." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une fois activé, Confab effectue une sauvegarde automatique en arrière-plan (vérifiée au lancement et toutes les heures pendant l'exécution) — pas besoin d'appuyer vous-même sur « Sauvegarder maintenant »." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når aktivert, sikkerhetskopierer Confab automatisk i bakgrunnen (sjekket ved oppstart og hver time mens den kjører) — ingen grunn til å trykke \"Sikkerhetskopier nå\" selv." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "När aktiverat säkerhetskopierar Confab automatiskt i bakgrunden (kontrolleras vid start och varje timme medan appen körs) — inget behov av att trycka på \"Säkerhetskopiera nu\" själv." + } + } + } + }, "When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore." : { "localizations" : { "da" : { @@ -16247,6 +18989,7 @@ } }, "Working Directory" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -16317,6 +19060,7 @@ } }, "Write & Edit Files" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -16351,6 +19095,7 @@ } }, "Write Actions" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -16421,6 +19166,7 @@ } }, "Your Shortcuts" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -16489,6 +19235,3168 @@ } } } + }, + "Click to re-save \"%@\"" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klik for at gemme \"%@\" igen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klicke, um „%@“ erneut zu speichern" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliquez pour ré-enregistrer « %@ »" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klikk for å lagre \"%@\" på nytt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klicka för att spara \"%@\" igen" + } + } + } + }, + "Saved — no changes" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemt — ingen ændringer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gespeichert — keine Änderungen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistré — aucune modification" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagret — ingen endringer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sparad — inga ändringar" + } + } + } + }, + "Not saved — use /save " : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke gemt — brug /save " + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht gespeichert — verwende /save " + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non enregistré — utilisez /save " + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke lagret — bruk /save " + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inte sparad — använd /save " + } + } + } + }, + "Sync" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisering" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisierung" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisation" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisering" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisering" + } + } + } + }, + "Syncing" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkroniserer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisation…" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkroniserer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkroniserar" + } + } + } + }, + "Error" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejl" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feil" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fel" + } + } + } + }, + "Synced" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkroniseret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisé" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisert" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkroniserad" + } + } + } + }, + "Sync not configured" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisering ikke konfigureret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisierung nicht konfiguriert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisation non configurée" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisering ikke konfigurert" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisering ej konfigurerad" + } + } + } + }, + "Sync failed: %@" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisering mislykkedes: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisierung fehlgeschlagen: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échec de la synchronisation : %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkronisering mislyktes: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synkroniseringen misslyckades: %@" + } + } + } + }, + "Not configured" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke konfigureret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht konfiguriert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non configuré" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke konfigurert" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ej konfigurerad" + } + } + } + }, + "Not cloned" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke klonet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht geklont" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non cloné" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke klonet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ej klonad" + } + } + } + }, + "Ready" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klar" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prêt" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klar" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klar" + } + } + } + }, + "Last synced: %@" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sidst synkroniseret: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt synchronisiert: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dernière synchronisation : %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sist synkronisert: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senast synkroniserad: %@" + } + } + } + }, + "Last Sync: %@" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sidste synkronisering: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Letzte Synchronisierung: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dernière synchro : %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sist synkronisert: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senaste synkronisering: %@" + } + } + } + }, + "Default" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Par défaut" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" + } + } + } + }, + "Price: Low to High" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pris: Lav til høj" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preis: Niedrig zu hoch" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prix : croissant" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pris: Lav til høy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pris: Lågt till högt" + } + } + } + }, + "Price: High to Low" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pris: Høj til lav" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preis: Hoch zu niedrig" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prix : décroissant" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pris: Høy til lav" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pris: Högt till lågt" + } + } + } + }, + "Context: High to Low" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontekst: Høj til lav" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontext: Hoch zu niedrig" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contexte : décroissant" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontekst: Høy til lav" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontext: Högt till lågt" + } + } + } + }, + "Uses ~80% of max tokens for reasoning — best for hard problems" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruger ~80% af maks. tokens til ræsonnement — bedst til svære problemer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwendet ~80 % der maximalen Tokens für den Denkprozess — am besten für schwierige Probleme" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilise ~80 % des tokens max pour le raisonnement — idéal pour les problèmes complexes" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruker ~80 % av maks. tokens til resonnering — best for vanskelige problemer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Använder ~80 % av max antal tokens för resonemang — bäst för svåra problem" + } + } + } + }, + "Uses ~50% of max tokens for reasoning — balanced default" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruger ~50% af maks. tokens til ræsonnement — afbalanceret standard" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwendet ~50 % der maximalen Tokens für den Denkprozess — ausgewogener Standard" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilise ~50 % des tokens max pour le raisonnement — équilibre par défaut" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruker ~50 % av maks. tokens til resonnering — balansert standard" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Använder ~50 % av max antal tokens för resonemang — balanserad standard" + } + } + } + }, + "Uses ~20% of max tokens for reasoning — faster, cheaper" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruger ~20% af maks. tokens til ræsonnement — hurtigere, billigere" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwendet ~20 % der maximalen Tokens für den Denkprozess — schneller, günstiger" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilise ~20 % des tokens max pour le raisonnement — plus rapide, moins cher" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruker ~20 % av maks. tokens til resonnering — raskere, billigere" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Använder ~20 % av max antal tokens för resonemang — snabbare, billigare" + } + } + } + }, + "Uses ~10% of max tokens for reasoning — lightest thinking" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruger ~10% af maks. tokens til ræsonnement — mindst tænkning" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwendet ~10 % der maximalen Tokens für den Denkprozess — geringste Denktiefe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilise ~10 % des tokens max pour le raisonnement — réflexion minimale" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruker ~10 % av maks. tokens til resonnering — lettest tenkning" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Använder ~10 % av max antal tokens för resonemang — lättaste tänkandet" + } + } + } + }, + "Show help and available commands" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis hjælp og tilgængelige kommandoer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeigt Hilfe und verfügbare Befehle" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affiche l'aide et les commandes disponibles" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis hjelp og tilgjengelige kommandoer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa hjälp och tillgängliga kommandon" + } + } + } + }, + "View command history" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis kommandohistorik" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Befehlshistorie anzeigen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l'historique des commandes" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis kommandohistorikk" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa kommandohistorik" + } + } + } + }, + "Select AI model" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg AI-model" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KI-Modell auswählen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionner le modèle d'IA" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velg AI-modell" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Välj AI-modell" + } + } + } + }, + "Clear chat history" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ryd chathistorik" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chatverlauf löschen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer l'historique du chat" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tøm chattehistorikk" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rensa chatthistorik" + } + } + } + }, + "Retry last message" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prøv sidste besked igen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Letzte Nachricht erneut senden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réessayer le dernier message" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prøv siste melding på nytt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Försök igen med senaste meddelandet" + } + } + } + }, + "Manage your prompt shortcuts" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrer dine prompt-genveje" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prompt-Shortcuts verwalten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gérer vos raccourcis de prompt" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrer prompt-snarveiene dine" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hantera dina promptgenvägar" + } + } + } + }, + "Manage your agent skills" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrer dine agent-færdigheder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent-Skills verwalten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gérer vos agent skills" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrer agent-ferdighetene dine" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hantera dina agent-färdigheter" + } + } + } + }, + "Open Jarvis agent manager" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åbn Jarvis-agenthåndtering" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jarvis-Agentenverwaltung öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir le gestionnaire d'agent Jarvis" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åpne Jarvis-agenthåndtering" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öppna Jarvis-agenthantering" + } + } + } + }, + "Enable conversation memory" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivér samtalehukommelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesprächsspeicher aktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer la mémoire de conversation" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver samtaleminne" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivera konversationsminne" + } + } + } + }, + "Disable conversation memory" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktivér samtalehukommelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesprächsspeicher deaktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactiver la mémoire de conversation" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiver samtaleminne" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inaktivera konversationsminne" + } + } + } + }, + "Enable web search" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivér websøgning" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Websuche aktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer la recherche web" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver websøk" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivera webbsökning" + } + } + } + }, + "Disable web search" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktivér websøgning" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Websuche deaktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactiver la recherche web" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiver websøk" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inaktivera webbsökning" + } + } + } + }, + "Show session statistics" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis sessionsstatistik" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitzungsstatistiken anzeigen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les statistiques de session" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis øktstatistikk" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa sessionsstatistik" + } + } + } + }, + "Open settings" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åbn indstillinger" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir les réglages" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åpne innstillinger" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öppna inställningar" + } + } + } + }, + "Switch AI provider" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skift AI-udbyder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KI-Anbieter wechseln" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changer de fournisseur d'IA" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bytt AI-tilbyder" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Byt AI-leverantör" + } + } + } + }, + "Save conversation" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem samtale" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unterhaltung speichern" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer la conversation" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre samtale" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spara konversation" + } + } + } + }, + "Load conversation" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indlæs samtale" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unterhaltung laden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charger une conversation" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last inn samtale" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Läs in konversation" + } + } + } + }, + "List saved conversations" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis gemte samtaler" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gespeicherte Unterhaltungen auflisten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lister les conversations enregistrées" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis lagrede samtaler" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lista sparade konversationer" + } + } + } + }, + "Export as Markdown" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksportér som Markdown" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als Markdown exportieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter en Markdown" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporter som Markdown" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportera som Markdown" + } + } + } + }, + "Export as HTML" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksportér som HTML" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als HTML exportieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter en HTML" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporter som HTML" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportera som HTML" + } + } + } + }, + "Export as PDF" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksportér som PDF" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als PDF exportieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter en PDF" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporter som PDF" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportera som PDF" + } + } + } + }, + "Export as JSON" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksportér som JSON" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als JSON exportieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter en JSON" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporter som JSON" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportera som JSON" + } + } + } + }, + "Show model information" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis modelinformation" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modellinformationen anzeigen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les informations du modèle" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis modellinformasjon" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa modellinformation" + } + } + } + }, + "Check account credits" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tjek kontokreditter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontoguthaben prüfen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifier les crédits du compte" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sjekk kontokreditter" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrollera kontokrediter" + } + } + } + }, + "Enable MCP (file access)" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivér MCP (filadgang)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP aktivieren (Dateizugriff)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer MCP (accès aux fichiers)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver MCP (filtilgang)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivera MCP (filåtkomst)" + } + } + } + }, + "Disable MCP" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktivér MCP" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP deaktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactiver MCP" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiver MCP" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inaktivera MCP" + } + } + } + }, + "Show MCP status" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis MCP-status" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP-Status anzeigen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l'état de MCP" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis MCP-status" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa MCP-status" + } + } + } + }, + "List MCP folders" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis MCP-mapper" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP-Ordner auflisten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lister les dossiers MCP" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis MCP-mapper" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lista MCP-mappar" + } + } + } + }, + "Add folder for MCP" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj mappe til MCP" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordner für MCP hinzufügen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un dossier pour MCP" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til mappe for MCP" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till mapp för MCP" + } + } + } + }, + "Enable MCP write permissions" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivér MCP-skriverettigheder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP-Schreibrechte aktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer les droits d'écriture MCP" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver MCP-skriverettigheter" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivera MCP-skrivbehörigheter" + } + } + } + }, + "Disable MCP write permissions" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktivér MCP-skriverettigheder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP-Schreibrechte deaktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactiver les droits d'écriture MCP" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiver MCP-skriverettigheter" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inaktivera MCP-skrivbehörigheter" + } + } + } + }, + "Enable persistent notes for this conversation" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivér vedvarende noter for denne samtale" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dauerhafte Notizen für diese Unterhaltung aktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer les notes persistantes pour cette conversation" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver vedvarende notater for denne samtalen" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivera beständiga anteckningar för denna konversation" + } + } + } + }, + "Disable persistent notes for this conversation" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktivér vedvarende noter for denne samtale" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dauerhafte Notizen für diese Unterhaltung deaktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactiver les notes persistantes pour cette conversation" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiver vedvarende notater for denne samtalen" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inaktivera beständiga anteckningar för denna konversation" + } + } + } + }, + "Show this conversation's notes" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis denne samtales noter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notizen dieser Unterhaltung anzeigen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les notes de cette conversation" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis notatene for denne samtalen" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa anteckningarna för denna konversation" + } + } + } + }, + "Exported %@.md" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporterede %@.md" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@.md exportiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@.md exporté" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporterte %@.md" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporterade %@.md" + } + } + } + }, + "Exported %@.zip" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporterede %@.zip" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@.zip exportiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@.zip exporté" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporterte %@.zip" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporterade %@.zip" + } + } + } + }, + "No valid shortcuts found" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen gyldige genveje fundet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine gültigen Shortcuts gefunden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun raccourci valide trouvé" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen gyldige snarveier funnet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga giltiga genvägar hittades" + } + } + } + }, + "Exported to Downloads/shortcuts.json" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporteret til Downloads/shortcuts.json" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportiert nach Downloads/shortcuts.json" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporté vers Téléchargements/shortcuts.json" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksportert til Nedlastinger/shortcuts.json" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporterad till Hämtade filer/shortcuts.json" + } + } + } + }, + "Imported %@" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importerede %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ importiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ importé" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importerte %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importerade %@" + } + } + } + }, + "Exported %@" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporterede %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ exportiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ exporté" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporterte %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporterade %@" + } + } + } + }, + "Arguments" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argumenter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argumente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arguments" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argumenter" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argument" + } + } + } + }, + "Automatic Backup" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk sikkerhedskopi" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatische Sicherung" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sauvegarde automatique" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk sikkerhetskopi" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk säkerhetskopiering" + } + } + } + }, + "Configured Servers" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurerede servere" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurierte Server" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serveurs configurés" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurerte servere" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurerade servrar" + } + } + } + }, + "Conversation Notes" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samtalenoter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unterhaltungsnotizen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notes de conversation" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samtalenotater" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konversationsanteckningar" + } + } + } + }, + "Crash Recovery" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nedbrudsgendannelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Absturzwiederherstellung" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Récupération après plantage" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krasjgjenoppretting" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kraschåterställning" + } + } + } + }, + "Enable Research Agents" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivér research-agenter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recherche-Agenten aktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer les agents de recherche" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver research-agenter" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivera forskningsagenter" + } + } + } + }, + "Favorite Models" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoritmodeller" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoritenmodelle" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modèles favoris" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorittmodeller" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoritmodeller" + } + } + } + }, + "Frequency" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frekvens" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Häufigkeit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fréquence" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frekvens" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frekvens" + } + } + } + }, + "Max Concurrent Agents" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maks. samtidige agenter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Max. gleichzeitige Agenten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nb max d'agents simultanés" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maks. samtidige agenter" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Max antal samtidiga agenter" + } + } + } + }, + "Notes Folder" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notesmappe" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notizordner" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dossier des notes" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notatmappe" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anteckningsmapp" + } + } + } + }, + "Released" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udgivet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veröffentlicht" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publié" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utgitt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utgiven" + } + } + } + }, + "Save Draft Every" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem kladde hvert" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entwurf speichern alle" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer le brouillon toutes les" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre utkast hvert" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spara utkast var" + } + } + } + }, + "Timeout" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeout" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitlimit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Délai d'expiration" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsavbrudd" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsgräns" + } + } + } + }, + "Anthropic" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anthropic" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anthropic" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anthropic" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anthropic" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anthropic" + } + } + } + }, + "Anytype" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anytype" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anytype" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anytype" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anytype" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anytype" + } + } + } + }, + "OpenAI" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenAI" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenAI" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenAI" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenAI" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenAI" + } + } + } + }, + "OpenRouter" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenRouter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenRouter" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenRouter" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenRouter" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenRouter" + } + } + } + }, + "Paperless-NGX" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paperless-NGX" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paperless-NGX" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paperless-NGX" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paperless-NGX" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paperless-NGX" + } + } + } + }, + "iCloud Drive" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud Drive" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud Drive" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud Drive" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud Drive" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud Drive" + } + } + } + }, + "CLI Access" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "CLI-adgang" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CLI-Zugriff" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accès CLI" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "CLI-tilgang" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "CLI-åtkomst" + } + } + } + }, + "Expose a local socket so a shell command (like a zsh \"ai\" function) can get a one-shot text reply from a single fixed model, without opening the app window. Confab must be running." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksponer en lokal socket, så en shell-kommando (som en zsh \"ai\"-funktion) kan få et engangs-tekstsvar fra én fast model, uden at åbne appvinduet. Confab skal være kørende." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffnet einen lokalen Socket, damit ein Shell-Befehl (wie eine zsh-\"ai\"-Funktion) eine einmalige Textantwort von einem einzigen festgelegten Modell erhalten kann, ohne das App-Fenster zu öffnen. Confab muss dafür laufen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expose un socket local pour qu'une commande shell (comme une fonction zsh \"ai\") puisse obtenir une réponse texte ponctuelle d'un seul modèle fixe, sans ouvrir la fenêtre de l'application. Confab doit être en cours d'exécution." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksponerer en lokal socket, slik at en shell-kommando (som en zsh \"ai\"-funksjon) kan få et engangs-tekstsvar fra én fast modell, uten å åpne appvinduet. Confab må kjøre." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exponerar en lokal socket så att ett skalkommando (som en zsh-\"ai\"-funktion) kan få ett engångs textsvar från en enda fast modell, utan att öppna appfönstret. Confab måste vara igång." + } + } + } + }, + "Enable CLI Access" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivér CLI-adgang" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CLI-Zugriff aktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer l'accès CLI" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver CLI-tilgang" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivera CLI-åtkomst" + } + } + } + }, + "Shell Function" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell-funktion" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell-Funktion" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fonction shell" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell-funksjon" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell-funktion" + } + } + } + }, + "Add this to your ~/.zshrc, then run `ai \"your prompt\"` in Terminal:" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj dette til din ~/.zshrc, og kør derefter `ai \"din prompt\"` i Terminal:" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Füge dies zu deiner ~/.zshrc hinzu und führe dann `ai \"dein Prompt\"` im Terminal aus:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajoutez ceci à votre ~/.zshrc, puis exécutez `ai \"votre prompt\"` dans Terminal :" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg dette til i ~/.zshrc, og kjør deretter `ai \"prompten din\"` i Terminal:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till detta i din ~/.zshrc, och kör sedan `ai \"din prompt\"` i Terminal:" + } + } + } + }, + "Requires jq (brew install jq)." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kræver jq (brew install jq)." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erfordert jq (brew install jq)." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nécessite jq (brew install jq)." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krever jq (brew install jq)." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kräver jq (brew install jq)." + } + } + } + }, + "Run Details" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kørselsdetaljer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausführungsdetails" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Détails de l'exécution" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kjøredetaljer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Körningsdetaljer" + } + } + } + }, + "No output for this run." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen output for denne kørsel." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Ausgabe für diesen Lauf." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune sortie pour cette exécution." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen output for denne kjøringen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen output för denna körning." + } + } + } + }, + "Started" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Startet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestartet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarré" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Startet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Startad" + } + } + } + }, + "Duration" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varighed" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dauer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durée" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varighet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varaktighet" + } + } + } + }, + "Trigger" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udløser" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auslöser" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déclencheur" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utløser" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utlösare" + } + } + } + }, + "Tokens" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tokens" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tokens" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tokens" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tokens" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tokens" + } + } + } } }, "version" : "1.1" diff --git a/oAI/Models/JarvisModels.swift b/oAI/Models/JarvisModels.swift index 33fe407..27ba1f4 100644 --- a/oAI/Models/JarvisModels.swift +++ b/oAI/Models/JarvisModels.swift @@ -69,7 +69,7 @@ struct JarvisAgentInput: Codable, Sendable { struct JarvisAgentRun: Identifiable, Codable, Sendable { let id: String let agentId: String? - let status: String // "running" | "completed" | "failed" | "stopped" + let status: String // "running" | "success" | "failed"/"error" | "stopped" (server-observed; not formally documented) let startedAt: String? let finishedAt: String? let output: String? @@ -80,10 +80,11 @@ struct JarvisAgentRun: Identifiable, Codable, Sendable { let triggerType: String? enum CodingKeys: String, CodingKey { - case id, status, output, error + case id, status, error case agentId = "agent_id" case startedAt = "started_at" - case finishedAt = "finished_at" + case finishedAt = "ended_at" + case output = "result" case costUsd = "cost_usd" case inputTokens = "input_tokens" case outputTokens = "output_tokens" diff --git a/oAI/Models/UsageStats.swift b/oAI/Models/UsageStats.swift index cc66e91..1a5beb8 100644 --- a/oAI/Models/UsageStats.swift +++ b/oAI/Models/UsageStats.swift @@ -25,6 +25,7 @@ import Foundation struct UsageStats: Sendable { var totalMessages: Int + var totalQuestions: Int var totalTokens: Int var totalCost: Double var hasCostData: Bool @@ -33,6 +34,7 @@ struct UsageStats: Sendable { nonisolated init( totalMessages: Int = 0, + totalQuestions: Int = 0, totalTokens: Int = 0, totalCost: Double = 0.0, hasCostData: Bool = false, @@ -40,6 +42,7 @@ struct UsageStats: Sendable { lastMessageDate: Date? = nil ) { self.totalMessages = totalMessages + self.totalQuestions = totalQuestions self.totalTokens = totalTokens self.totalCost = totalCost self.hasCostData = hasCostData @@ -66,6 +69,7 @@ struct ModelUsageStat: Identifiable, Sendable { var id: String { modelId } let modelId: String var messageCount: Int + var questionCount: Int var totalTokens: Int var totalCost: Double var hasCostData: Bool @@ -74,6 +78,7 @@ struct ModelUsageStat: Identifiable, Sendable { nonisolated init( modelId: String, messageCount: Int, + questionCount: Int = 0, totalTokens: Int, totalCost: Double, hasCostData: Bool, @@ -81,6 +86,7 @@ struct ModelUsageStat: Identifiable, Sendable { ) { self.modelId = modelId self.messageCount = messageCount + self.questionCount = questionCount self.totalTokens = totalTokens self.totalCost = totalCost self.hasCostData = hasCostData @@ -102,6 +108,74 @@ struct ModelUsageStat: Identifiable, Sendable { } } +/// Usage grouped by provider (openrouter/anthropic/openai/ollama/appleOnDevice), from `usage_events`. +struct ProviderUsageStat: Identifiable, Sendable { + var id: String { provider } + let provider: String + var questionCount: Int + var totalTokens: Int + var totalCost: Double + var hasCostData: Bool + var lastUsed: Date + + nonisolated init( + provider: String, + questionCount: Int, + totalTokens: Int, + totalCost: Double, + hasCostData: Bool, + lastUsed: Date + ) { + self.provider = provider + self.questionCount = questionCount + self.totalTokens = totalTokens + self.totalCost = totalCost + self.hasCostData = hasCostData + self.lastUsed = lastUsed + } + + var totalTokensDisplay: String { + if totalTokens >= 1_000_000 { + return String(format: "%.1fM", Double(totalTokens) / 1_000_000) + } else if totalTokens >= 1000 { + return String(format: "%.1fK", Double(totalTokens) / 1000) + } else { + return "\(totalTokens)" + } + } + + var totalCostDisplay: String { + hasCostData ? String(format: "$%.4f", totalCost) : "N/A" + } +} + +/// One day's usage totals, for time-series charting in the Analytics view. +struct DailyUsageStat: Identifiable, Sendable { + var id: Date { day } + let day: Date + var messageCount: Int + var questionCount: Int + var totalTokens: Int + var totalCost: Double + var hasCostData: Bool + + nonisolated init( + day: Date, + messageCount: Int, + questionCount: Int, + totalTokens: Int, + totalCost: Double, + hasCostData: Bool + ) { + self.day = day + self.messageCount = messageCount + self.questionCount = questionCount + self.totalTokens = totalTokens + self.totalCost = totalCost + self.hasCostData = hasCostData + } +} + struct ConversationUsageStat: Identifiable, Sendable { let conversationId: UUID var id: UUID { conversationId } @@ -141,3 +215,33 @@ struct ConversationUsageStat: Identifiable, Sendable { hasCostData ? String(format: "$%.4f", totalCost) : "N/A" } } + +/// Time window for the Analytics view. `.total` means all-time (no lower bound). +enum AnalyticsTimeframe: String, CaseIterable, Identifiable, Sendable { + case today + case last7Days + case week + case month + case year + case total + + var id: String { rawValue } + + /// Bounds for this timeframe. `start` is nil for `.total` (all time, no lower bound). + nonisolated func dateRange(now: Date = Date(), calendar: Calendar = .current) -> (start: Date?, end: Date) { + switch self { + case .today: + return (calendar.startOfDay(for: now), now) + case .last7Days: + return (calendar.date(byAdding: .day, value: -7, to: now) ?? now, now) + case .week: + return (calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? now, now) + case .month: + return (calendar.dateInterval(of: .month, for: now)?.start ?? now, now) + case .year: + return (calendar.dateInterval(of: .year, for: now)?.start ?? now, now) + case .total: + return (nil, now) + } + } +} diff --git a/oAI/Providers/AIProvider.swift b/oAI/Providers/AIProvider.swift index 77b9285..3cd06a8 100644 --- a/oAI/Providers/AIProvider.swift +++ b/oAI/Providers/AIProvider.swift @@ -223,28 +223,32 @@ struct StreamChunk { // MARK: - Tool Definition -struct Tool: Codable { +// Plain data types for JSON tool-schema encoding — marked `nonisolated` throughout (each nested +// type independently, since `-default-isolation=MainActor` doesn't cascade isolation-freedom from +// an outer type to its nested declarations) so they stay usable from `nonisolated` contexts like +// ExternalMCPManager's pure conversion helpers, not just from the main actor. +nonisolated struct Tool: Codable { let type: String let function: Function - - struct Function: Codable { + + nonisolated struct Function: Codable { let name: String let description: String let parameters: Parameters - - struct Parameters: Codable { + + nonisolated struct Parameters: Codable { let type: String let properties: [String: Property] let required: [String]? - - struct Property: Codable { + + nonisolated struct Property: Codable { let type: String let description: String let `enum`: [String]? let items: Items? /// Item schema for `type: "array"` properties (e.g. an array of strings). - struct Items: Codable { + nonisolated struct Items: Codable { let type: String } diff --git a/oAI/Providers/AnthropicProvider.swift b/oAI/Providers/AnthropicProvider.swift index 25e493b..47bad9b 100644 --- a/oAI/Providers/AnthropicProvider.swift +++ b/oAI/Providers/AnthropicProvider.swift @@ -34,40 +34,20 @@ class AnthropicProvider: AIProvider { maxContextLength: nil ) - enum AuthMode { - case apiKey(String) - case oauth - } - - private let authMode: AuthMode + private let apiKey: String private let baseURL = "https://api.anthropic.com/v1" private let apiVersion = "2023-06-01" private let session: URLSession /// Create with a standard API key init(apiKey: String) { - self.authMode = .apiKey(apiKey) + self.apiKey = apiKey let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time) config.timeoutIntervalForResource = 600 // 10 minutes total self.session = URLSession(configuration: config) } - /// Create with OAuth (Pro/Max subscription) - init(oauth: Bool) { - self.authMode = .oauth - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time) - config.timeoutIntervalForResource = 600 // 10 minutes total - self.session = URLSession(configuration: config) - } - - /// Whether this provider is using OAuth authentication - var isOAuth: Bool { - if case .oauth = authMode { return true } - return false - } - // MARK: - Models /// Local metadata used to enrich API results (pricing, context length) and as offline fallback. @@ -555,29 +535,14 @@ class AnthropicProvider: AIProvider { // MARK: - Auth Helpers - /// Apply auth headers based on mode (API key or OAuth Bearer) + /// Apply auth headers private func applyAuth(to request: inout URLRequest) async throws { - switch authMode { - case .apiKey(let key): - request.addValue(key, forHTTPHeaderField: "x-api-key") - request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version") - - case .oauth: - let token = try await AnthropicOAuthService.shared.getValidAccessToken() - request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version") - request.addValue("oauth-2025-04-20,interleaved-thinking-2025-05-14", forHTTPHeaderField: "anthropic-beta") - } + request.addValue(apiKey, forHTTPHeaderField: "x-api-key") + request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version") } - /// Build the messages endpoint URL, appending ?beta=true for OAuth private var messagesURL: URL { - switch authMode { - case .apiKey: - return URL(string: "\(baseURL)/messages")! - case .oauth: - return URL(string: "\(baseURL)/messages?beta=true")! - } + URL(string: "\(baseURL)/messages")! } // MARK: - Request Building diff --git a/oAI/Providers/OpenRouterModels.swift b/oAI/Providers/OpenRouterModels.swift index f658252..9e95f7b 100644 --- a/oAI/Providers/OpenRouterModels.swift +++ b/oAI/Providers/OpenRouterModels.swift @@ -47,11 +47,18 @@ struct OpenRouterChatRequest: Codable { let modalities: [String]? let reasoning: ReasoningAPIConfig? let cacheControl: CacheControl? + let usage: UsageOptions? struct CacheControl: Codable { let type: String } + /// Requests OpenRouter to include the actual billed USD cost in the response's `usage` + /// object — needed for models priced outside plain per-token rates (e.g. per-image). + struct UsageOptions: Codable { + let include: Bool + } + struct APIMessage: Codable { let role: String let content: MessageContent @@ -141,6 +148,7 @@ struct OpenRouterChatRequest: Codable { case toolChoice = "tool_choice" case modalities case reasoning + case usage case cacheControl = "cache_control" } } @@ -230,6 +238,9 @@ struct OpenRouterChatResponse: Codable { let completionTokens: Int let totalTokens: Int let promptTokensDetails: PromptTokensDetails? + /// Actual billed USD cost — only present when the request opted in via `usage.include`. + /// Needed for models priced outside plain per-token rates (e.g. per-image generation). + let cost: Double? struct PromptTokensDetails: Codable { let cachedTokens: Int? @@ -246,6 +257,7 @@ struct OpenRouterChatResponse: Codable { case completionTokens = "completion_tokens" case totalTokens = "total_tokens" case promptTokensDetails = "prompt_tokens_details" + case cost } } } @@ -485,10 +497,9 @@ struct OpenRouterImageGenerationResponse: Codable { struct OpenRouterErrorResponse: Codable { let error: ErrorDetail - + struct ErrorDetail: Codable { let message: String let type: String? - let code: String? } } diff --git a/oAI/Providers/OpenRouterProvider.swift b/oAI/Providers/OpenRouterProvider.swift index a6809c5..ad17a0a 100644 --- a/oAI/Providers/OpenRouterProvider.swift +++ b/oAI/Providers/OpenRouterProvider.swift @@ -276,7 +276,8 @@ class OpenRouterProvider: AIProvider { var body: [String: Any] = [ "model": model, "messages": messages, - "stream": false + "stream": false, + "usage": ["include": true] ] if let tools = tools { let toolsData = try JSONEncoder().encode(tools) @@ -347,8 +348,16 @@ class OpenRouterProvider: AIProvider { } guard httpResponse.statusCode == 200 else { - Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)") - continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)")) + var errorBody = "" + for try await line in bytes.lines { errorBody += line } + if let errorData = errorBody.data(using: .utf8), + let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: errorData) { + Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode): \(errorResponse.error.message)") + continuation.finish(throwing: ProviderError.unknown(errorResponse.error.message)) + } else { + Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)") + continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)")) + } return } @@ -497,7 +506,8 @@ class OpenRouterProvider: AIProvider { toolChoice: request.tools != nil ? "auto" : nil, modalities: request.imageGeneration ? ["text", "image"] : nil, reasoning: reasoningConfig, - cacheControl: cacheControl + cacheControl: cacheControl, + usage: OpenRouterChatRequest.UsageOptions(include: true) ) } @@ -536,7 +546,8 @@ class OpenRouterProvider: AIProvider { completionTokens: usage.completionTokens, totalTokens: usage.totalTokens, cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens, - cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens + cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens, + rawCostUSD: usage.cost ) }, created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)), @@ -577,7 +588,8 @@ class OpenRouterProvider: AIProvider { completionTokens: usage.completionTokens, totalTokens: usage.totalTokens, cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens, - cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens + cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens, + rawCostUSD: usage.cost ) } ) diff --git a/oAI/Providers/ProviderRegistry.swift b/oAI/Providers/ProviderRegistry.swift index 30157ee..2400c0f 100644 --- a/oAI/Providers/ProviderRegistry.swift +++ b/oAI/Providers/ProviderRegistry.swift @@ -101,8 +101,7 @@ class ProviderRegistry { case .openrouter: return settings.openrouterAPIKey != nil && !settings.openrouterAPIKey!.isEmpty case .anthropic: - return AnthropicOAuthService.shared.isAuthenticated - || (settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty) + return settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty case .openai: return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty case .ollama: diff --git a/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html index 2a9ca30..32f6401 100644 --- a/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html +++ b/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html @@ -1838,7 +1838,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
-

© 2026 Confab - Rune Olsen. For support or feedback, visit gitlab.pm or Contact Us.

+

© 2025 - Confab - Rune Olsen. For support or feedback, visit gitlab.pm or Contact Us.

diff --git a/oAI/Services/AnthropicOAuthService.swift b/oAI/Services/AnthropicOAuthService.swift deleted file mode 100644 index 3f7e4ee..0000000 --- a/oAI/Services/AnthropicOAuthService.swift +++ /dev/null @@ -1,314 +0,0 @@ -// -// AnthropicOAuthService.swift -// Confab -// -// OAuth 2.0 PKCE flow for Anthropic Pro/Max subscription login -// -// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -// Copyright (C) 2026 Rune Olsen -// -// This file is part of Confab. -// -// Confab is licensed under the PolyForm Noncommercial License 1.0.0. -// You may use, study, modify, and share it for any noncommercial -// purpose. Commercial use — including selling Confab or any part of -// it, standalone or bundled into another product or service — -// requires a separate commercial license from the copyright holder. -// -// See the LICENSE file or -// for -// the full license text. For commercial licensing, contact Rune -// Olsen via . - - -import Foundation -import CryptoKit -import Security - -@Observable -class AnthropicOAuthService { - static let shared = AnthropicOAuthService() - - // OAuth configuration (matches Claude Code CLI) - private let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - private let redirectURI = "https://console.anthropic.com/oauth/code/callback" - private let scope = "org:create_api_key user:profile user:inference" - private let tokenEndpoint = "https://console.anthropic.com/v1/oauth/token" - - // Keychain keys - private enum Keys { - static let accessToken = "com.oai.anthropic.oauth.accessToken" - static let refreshToken = "com.oai.anthropic.oauth.refreshToken" - static let expiresAt = "com.oai.anthropic.oauth.expiresAt" - } - - // PKCE state for current flow - private var currentVerifier: String? - - // Observable state - var isAuthenticated: Bool { accessToken != nil } - var isLoggingIn = false - - // MARK: - Token Access - - var accessToken: String? { - getKeychainValue(for: Keys.accessToken) - } - - private var refreshToken: String? { - getKeychainValue(for: Keys.refreshToken) - } - - private var expiresAt: Date? { - guard let str = getKeychainValue(for: Keys.expiresAt), - let interval = Double(str) else { return nil } - return Date(timeIntervalSince1970: interval) - } - - var isTokenExpired: Bool { - guard let expires = expiresAt else { return true } - return Date() >= expires - } - - // MARK: - Step 1: Generate Authorization URL - - func generateAuthorizationURL() -> URL { - let verifier = generateCodeVerifier() - currentVerifier = verifier - let challenge = generateCodeChallenge(from: verifier) - - var components = URLComponents(string: "https://claude.ai/oauth/authorize")! - components.queryItems = [ - URLQueryItem(name: "code", value: "true"), - URLQueryItem(name: "client_id", value: clientId), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "redirect_uri", value: redirectURI), - URLQueryItem(name: "scope", value: scope), - URLQueryItem(name: "code_challenge", value: challenge), - URLQueryItem(name: "code_challenge_method", value: "S256"), - URLQueryItem(name: "state", value: verifier), - ] - - return components.url! - } - - // MARK: - Step 2: Exchange Code for Tokens - - func exchangeCode(_ pastedCode: String) async throws { - guard let verifier = currentVerifier else { - throw OAuthError.noVerifier - } - - // Code format: "auth_code#state" - let parts = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "#") - let authCode: String - let state: String - - if parts.count >= 2 { - authCode = parts[0] - state = parts.dropFirst().joined(separator: "#") - } else { - // If no # separator, treat entire string as the code - authCode = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines) - state = verifier - } - - Log.api.info("Exchanging OAuth code for tokens") - - let body: [String: String] = [ - "code": authCode, - "state": state, - "grant_type": "authorization_code", - "client_id": clientId, - "redirect_uri": redirectURI, - "code_verifier": verifier, - ] - - let tokenResponse = try await postTokenRequest(body) - saveTokens(tokenResponse) - currentVerifier = nil - - Log.api.info("OAuth login successful, token expires in \(tokenResponse.expiresIn)s") - } - - // MARK: - Token Refresh - - func refreshAccessToken() async throws { - guard let refresh = refreshToken else { - throw OAuthError.noRefreshToken - } - - Log.api.info("Refreshing OAuth access token") - - let body: [String: String] = [ - "grant_type": "refresh_token", - "refresh_token": refresh, - "client_id": clientId, - ] - - let tokenResponse = try await postTokenRequest(body) - saveTokens(tokenResponse) - - Log.api.info("OAuth token refreshed successfully") - } - - /// Returns a valid access token, refreshing if needed - func getValidAccessToken() async throws -> String { - guard let token = accessToken else { - throw OAuthError.notAuthenticated - } - - if isTokenExpired { - try await refreshAccessToken() - guard let newToken = accessToken else { - throw OAuthError.notAuthenticated - } - return newToken - } - - return token - } - - // MARK: - Logout - - func logout() { - deleteKeychainValue(for: Keys.accessToken) - deleteKeychainValue(for: Keys.refreshToken) - deleteKeychainValue(for: Keys.expiresAt) - currentVerifier = nil - Log.api.info("OAuth logout complete") - } - - // MARK: - PKCE Helpers - - private func generateCodeVerifier() -> String { - var bytes = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - return Data(bytes).base64URLEncoded() - } - - private func generateCodeChallenge(from verifier: String) -> String { - let data = Data(verifier.utf8) - let hash = SHA256.hash(data: data) - return Data(hash).base64URLEncoded() - } - - // MARK: - Token Request - - private func postTokenRequest(_ body: [String: String]) async throws -> TokenResponse { - var request = URLRequest(url: URL(string: tokenEndpoint)!) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(body) - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw OAuthError.invalidResponse - } - - guard httpResponse.statusCode == 200 else { - let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" - Log.api.error("OAuth token exchange failed HTTP \(httpResponse.statusCode): \(errorBody)") - throw OAuthError.tokenExchangeFailed(httpResponse.statusCode, errorBody) - } - - return try JSONDecoder().decode(TokenResponse.self, from: data) - } - - // MARK: - Token Storage - - private func saveTokens(_ response: TokenResponse) { - setKeychainValue(response.accessToken, for: Keys.accessToken) - if let refresh = response.refreshToken { - setKeychainValue(refresh, for: Keys.refreshToken) - } - let expiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn)) - setKeychainValue(String(expiresAt.timeIntervalSince1970), for: Keys.expiresAt) - } - - // MARK: - Keychain Helpers - - private func getKeychainValue(for key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var ref: AnyObject? - guard SecItemCopyMatching(query as CFDictionary, &ref) == errSecSuccess, - let data = ref as? Data, - let value = String(data: data, encoding: .utf8) else { - return nil - } - return value - } - - private func setKeychainValue(_ value: String, for key: String) { - guard let data = value.data(using: .utf8) else { return } - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - ] - let attrs: [String: Any] = [kSecValueData as String: data] - let status = SecItemUpdate(query as CFDictionary, attrs as CFDictionary) - if status == errSecItemNotFound { - var newItem = query - newItem[kSecValueData as String] = data - SecItemAdd(newItem as CFDictionary, nil) - } - } - - private func deleteKeychainValue(for key: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - ] - SecItemDelete(query as CFDictionary) - } - - // MARK: - Types - - struct TokenResponse: Decodable { - let accessToken: String - let refreshToken: String? - let expiresIn: Int - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case refreshToken = "refresh_token" - case expiresIn = "expires_in" - } - } - - enum OAuthError: LocalizedError { - case noVerifier - case noRefreshToken - case notAuthenticated - case invalidResponse - case tokenExchangeFailed(Int, String) - - var errorDescription: String? { - switch self { - case .noVerifier: return "No PKCE verifier — start the login flow first." - case .noRefreshToken: return "No refresh token available. Please log in again." - case .notAuthenticated: return "Not authenticated. Please log in." - case .invalidResponse: return "Invalid response from Anthropic OAuth server." - case .tokenExchangeFailed(let code, let body): - return "Token exchange failed (HTTP \(code)): \(body)" - } - } - } -} - -// MARK: - Base64URL Encoding - -private extension Data { - func base64URLEncoded() -> String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/oAI/Services/CLIServerService.swift b/oAI/Services/CLIServerService.swift new file mode 100644 index 0000000..2e91731 --- /dev/null +++ b/oAI/Services/CLIServerService.swift @@ -0,0 +1,271 @@ +// +// CLIServerService.swift +// Confab +// +// Local Unix-socket server for one-shot shell/CLI access to a single fixed model +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen +// +// This file is part of Confab. +// +// Confab is licensed under the PolyForm Noncommercial License 1.0.0. +// You may use, study, modify, and share it for any noncommercial +// purpose. Commercial use — including selling Confab or any part of +// it, standalone or bundled into another product or service — +// requires a separate commercial license from the copyright holder. +// +// See the LICENSE file or +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +import Foundation +import Network +import os + +/// Local Unix-domain-socket server exposing one-shot, non-streaming text completions from a +/// single fixed model (Settings → MCP → CLI Access) — e.g. an `ai "prompt"` shell function. +/// Deliberately minimal for v1: no tool-calling, no streaming, no per-request model override. +/// Speaks a minimal HTTP/1.1 subset (just enough for `curl --unix-socket`) rather than a custom +/// protocol, so it stays curl-friendly and trivially extensible — new fields can be added to the +/// JSON request/response bodies later without changing the transport. +final class CLIServerService { + static let shared = CLIServerService() + private init() {} + + private let log = Logger(subsystem: Log.subsystem, category: "cli") + + /// All listener/connection callbacks run on this single serial queue, so `activeConnections` + /// never needs a lock — Network.framework callbacks don't run on the main thread. + private let queue = DispatchQueue(label: "com.oai.Confab.cliserver") + private var listener: NWListener? + private var isListenerReady = false + private var activeConnections: [ObjectIdentifier: NWConnection] = [:] + + /// How long to wait for `NWListener` to report `.ready` before giving up. See `scheduleBindTimeout`. + private static let bindTimeout: TimeInterval = 8 + + static let socketPath: String = { + (("~/Library/Application Support/oAI" as NSString).expandingTildeInPath as NSString) + .appendingPathComponent("cli.sock") + }() + + // MARK: - Lifecycle + + func start() { + guard SettingsService.shared.cliServerEnabled else { + log.info("CLI server disabled") + return + } + startListening() + } + + func stop() { + listener?.cancel() + listener = nil + for (_, connection) in activeConnections { connection.cancel() } + activeConnections.removeAll() + try? FileManager.default.removeItem(atPath: Self.socketPath) + log.info("CLI server stopped") + } + + /// Call after toggling cliServerEnabled or changing the provider/model, so the change takes + /// effect immediately instead of requiring an app relaunch. + func restart() { + stop() + start() + } + + private func startListening() { + // Remove a stale socket file left behind by an unclean shutdown (bind() fails on an + // existing path even if nothing is listening on it anymore). + try? FileManager.default.removeItem(atPath: Self.socketPath) + isListenerReady = false + + let params = NWParameters() + params.defaultProtocolStack.transportProtocol = NWProtocolTCP.Options() + params.requiredLocalEndpoint = NWEndpoint.unix(path: Self.socketPath) + params.allowLocalEndpointReuse = true + + do { + let newListener = try NWListener(using: params) + newListener.newConnectionHandler = { [weak self] connection in + self?.handle(connection: connection) + } + newListener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.isListenerReady = true + self?.log.info("CLI server listening at \(Self.socketPath)") + case .failed(let error): + self?.log.error("CLI server listener failed: \(error.localizedDescription)") + self?.listener = nil + default: + break + } + } + newListener.start(queue: queue) + listener = newListener + scheduleBindTimeout(for: newListener) + } catch { + log.error("Failed to start CLI server: \(error.localizedDescription)") + } + } + + /// `NWListener`'s state transitions are asynchronous and normally reach `.ready` or `.failed` + /// within milliseconds — but a real incident (2026-08-14) showed a state where `lsof` + /// confirmed Confab held the socket open, yet every connection attempt was refused and + /// nothing was ever logged, with no crash and no error. Whatever the exact cause, a listener + /// that never resolves either way is indistinguishable from a working one without this: it + /// bounds the wait so a stuck bind becomes a visible log line and a cleared listener slot, + /// instead of silently pretending to work forever. + private func scheduleBindTimeout(for candidate: NWListener) { + queue.asyncAfter(deadline: .now() + Self.bindTimeout) { [weak self] in + guard let self, self.listener === candidate, !self.isListenerReady else { return } + self.log.error("CLI server did not become ready within \(Int(Self.bindTimeout))s — giving up") + candidate.cancel() + self.listener = nil + } + } + + // MARK: - Connection handling + + private func handle(connection: NWConnection) { + let id = ObjectIdentifier(connection) + activeConnections[id] = connection + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .failed, .cancelled: + self?.activeConnections.removeValue(forKey: id) + default: + break + } + } + connection.start(queue: queue) + readRequest(on: connection, buffer: Data()) + } + + private func readRequest(on connection: NWConnection, buffer: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in + guard let self else { return } + var buffer = buffer + if let data { buffer.append(data) } + + if let body = Self.parseRequestBody(from: buffer) { + Task { + let responseData = await self.processRequest(body) + await self.sendAndClose(responseData, on: connection) + } + return + } + + if isComplete || error != nil { + connection.cancel() + return + } + + self.readRequest(on: connection, buffer: buffer) + } + } + + private func sendAndClose(_ data: Data, on connection: NWConnection) { + let id = ObjectIdentifier(connection) + connection.send(content: data, completion: .contentProcessed { [weak self] _ in + connection.cancel() + self?.activeConnections.removeValue(forKey: id) + }) + } + + // MARK: - Request processing + + private struct AskRequest: Decodable { + let prompt: String + } + + private func processRequest(_ body: Data) async -> Data { + guard let req = try? JSONDecoder().decode(AskRequest.self, from: body), + !req.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return Self.errorResponse("Request body must be JSON: {\"prompt\": \"...\"}") + } + + let settings = SettingsService.shared + guard settings.cliServerEnabled else { + return Self.errorResponse("CLI server is disabled in Settings") + } + guard let providerType = Settings.Provider(rawValue: settings.cliServerProvider), + let provider = ProviderRegistry.shared.getProvider(for: providerType) else { + return Self.errorResponse("No provider configured — set one in Settings > MCP > CLI Access") + } + guard !settings.cliServerModel.isEmpty else { + return Self.errorResponse("No model configured — set one in Settings > MCP > CLI Access") + } + + do { + let request = ChatRequest( + messages: [Message(role: .user, content: req.prompt)], + model: settings.cliServerModel, + stream: false + ) + let response = try await provider.chat(request: request) + return Self.successResponse(response.content) + } catch { + log.error("CLI request failed: \(error.localizedDescription)") + return Self.errorResponse(error.localizedDescription, statusCode: 500) + } + } + + // MARK: - Pure helpers (unit tested — see CLIServerServiceTests.swift) + + struct AskResponseBody: Codable { + let response: String? + let error: String? + } + + /// Parses a minimal HTTP/1.1 request out of `buffer`, returning the body once the full + /// header block and (per Content-Length) body have arrived, or nil if more data is needed. + /// Not a general-purpose HTTP parser — this server only ever talks to `curl --unix-socket`, + /// so the method/path/most headers are read past rather than validated. + static func parseRequestBody(from buffer: Data) -> Data? { + let headerTerminator = Data("\r\n\r\n".utf8) + guard let headerEndRange = buffer.range(of: headerTerminator) else { return nil } + + let headerData = buffer.subdata(in: buffer.startIndex..= contentLength else { return nil } + + let bodyEnd = buffer.index(bodyStart, offsetBy: contentLength) + return buffer.subdata(in: bodyStart.. Data { + let header = "HTTP/1.1 \(statusCode) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(jsonBody.count)\r\nConnection: close\r\n\r\n" + var data = Data(header.utf8) + data.append(jsonBody) + return data + } + + static func successResponse(_ text: String) -> Data { + let body = (try? JSONEncoder().encode(AskResponseBody(response: text, error: nil))) ?? Data() + return makeHTTPResponse(statusCode: 200, statusText: "OK", jsonBody: body) + } + + static func errorResponse(_ message: String, statusCode: Int = 400) -> Data { + let body = (try? JSONEncoder().encode(AskResponseBody(response: nil, error: message))) ?? Data() + let statusText = statusCode == 400 ? "Bad Request" : "Internal Server Error" + return makeHTTPResponse(statusCode: statusCode, statusText: statusText, jsonBody: body) + } +} diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index 85a8db5..e7699a9 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -65,6 +65,19 @@ struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable { var modelId: String? } +struct UsageEventRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + static let databaseTableName = "usage_events" + + var id: String + var timestamp: String + var provider: String + var modelId: String + var promptTokens: Int? + var completionTokens: Int? + var cost: Double? + var conversationId: String? +} + struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable { static let databaseTableName = "settings" @@ -404,6 +417,26 @@ final class DatabaseService: Sendable { } } + migrator.registerMigration("v13") { db in + // Usage tracking independent of conversation persistence: conversation *text* is only + // ever saved when the user explicitly saves (⌘S), but usage (tokens/cost/model/provider) + // should be recorded for every completed AI response regardless — one row per finished + // generation. conversationId is a best-effort link (set only if the conversation had + // already been saved at least once at logging time) and is not a foreign key, since the + // referenced conversation may never exist. + try db.create(table: "usage_events") { t in + t.column("id", .text).primaryKey() + t.column("timestamp", .text).notNull() + t.column("provider", .text).notNull() + t.column("modelId", .text).notNull() + t.column("promptTokens", .integer) + t.column("completionTokens", .integer) + t.column("cost", .double) + t.column("conversationId", .text) + } + try db.create(index: "idx_usage_events_timestamp", on: "usage_events", columns: ["timestamp"]) + } + return migrator } @@ -806,17 +839,44 @@ final class DatabaseService: Sendable { // MARK: - Usage Statistics - nonisolated func getOverallUsageStats() throws -> UsageStats { + /// Builds a `WHERE`-ready timestamp range clause (empty if both bounds are nil) plus its bound arguments. + /// `baseClauses` are ANDed in ahead of the range (e.g. "modelId IS NOT NULL") so callers can share one WHERE. + private nonisolated static func timestampRangeClause( + from: Date?, + to: Date?, + baseClauses: [String] = [] + ) -> (whereSQL: String, arguments: [String]) { + var clauses = baseClauses + var arguments: [String] = [] + if let from { + clauses.append("timestamp >= ?") + arguments.append(Self.isoString(from: from)) + } + if let to { + clauses.append("timestamp <= ?") + arguments.append(Self.isoString(from: to)) + } + guard !clauses.isEmpty else { return ("", []) } + return (" WHERE " + clauses.joined(separator: " AND "), arguments) + } + + /// Overall usage totals. Pass `from`/`to` to restrict to a time window; omit both for all-time. + nonisolated func getOverallUsageStats(from: Date? = nil, to: Date? = nil) throws -> UsageStats { try dbQueue.read { db in - guard let row = try Row.fetchOne(db, sql: """ + let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to) + let sql = """ SELECT COUNT(*) AS cnt, + COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions, COALESCE(SUM(tokens), 0) AS tokens, COALESCE(SUM(cost), 0) AS cost, COUNT(cost) AS costCount, MIN(timestamp) AS minTs, MAX(timestamp) AS maxTs FROM messages - """) + \(whereSQL) + """ + + guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments)) else { return UsageStats() } @@ -827,6 +887,7 @@ final class DatabaseService: Sendable { return UsageStats( totalMessages: row["cnt"], + totalQuestions: row["questions"], totalTokens: row["tokens"], totalCost: row["cost"], hasCostData: costCount > 0, @@ -836,19 +897,25 @@ final class DatabaseService: Sendable { } } - nonisolated func getUsageByModel() throws -> [ModelUsageStat] { + /// Usage grouped by model. Pass `from`/`to` to restrict to a time window; omit both for all-time. + nonisolated func getUsageByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] { try dbQueue.read { db in - let rows = try Row.fetchAll(db, sql: """ + let (whereSQL, arguments) = Self.timestampRangeClause( + from: from, to: to, baseClauses: ["modelId IS NOT NULL"] + ) + let sql = """ SELECT modelId, COUNT(*) AS cnt, + COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions, COALESCE(SUM(tokens), 0) AS tokens, COALESCE(SUM(cost), 0) AS cost, COUNT(cost) AS costCount, MAX(timestamp) AS lastUsed FROM messages - WHERE modelId IS NOT NULL + \(whereSQL) GROUP BY modelId - """) + """ + let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments)) let stats: [ModelUsageStat] = rows.compactMap { row in guard let modelId: String = row["modelId"], @@ -860,6 +927,7 @@ final class DatabaseService: Sendable { return ModelUsageStat( modelId: modelId, messageCount: row["cnt"], + questionCount: row["questions"], totalTokens: row["tokens"], totalCost: row["cost"], hasCostData: costCount > 0, @@ -876,6 +944,252 @@ final class DatabaseService: Sendable { } } + /// Usage bucketed by calendar day (in the local timezone the timestamps were stored in — GMT, see + /// `isoStyle`), for the Analytics view's time-series chart. `from`/`to` bound the range; days with + /// no messages are simply absent (the view fills gaps when rendering). + nonisolated func getDailyUsage(from: Date, to: Date) throws -> [DailyUsageStat] { + try dbQueue.read { db in + let sql = """ + SELECT substr(timestamp, 1, 10) AS day, + COUNT(*) AS cnt, + COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions, + COALESCE(SUM(tokens), 0) AS tokens, + COALESCE(SUM(cost), 0) AS cost, + COUNT(cost) AS costCount + FROM messages + WHERE timestamp >= ? AND timestamp <= ? + GROUP BY day + ORDER BY day + """ + let rows = try Row.fetchAll( + db, sql: sql, + arguments: [Self.isoString(from: from), Self.isoString(from: to)] + ) + + var gmtCalendar = Calendar(identifier: .gregorian) + gmtCalendar.timeZone = TimeZone(identifier: "GMT")! + + return rows.compactMap { row -> DailyUsageStat? in + guard let dayString: String = row["day"] else { return nil } + let parts = dayString.split(separator: "-").compactMap { Int($0) } + guard parts.count == 3 else { return nil } + var components = DateComponents() + components.year = parts[0] + components.month = parts[1] + components.day = parts[2] + guard let day = gmtCalendar.date(from: components) else { return nil } + + let costCount: Int = row["costCount"] + return DailyUsageStat( + day: day, + messageCount: row["cnt"], + questionCount: row["questions"], + totalTokens: row["tokens"], + totalCost: row["cost"], + hasCostData: costCount > 0 + ) + } + } + } + + // MARK: - Usage Events (save-independent usage tracking) + + /// Records one completed AI generation's usage — independent of whether the conversation it + /// belongs to is ever explicitly saved. `conversationId` is a best-effort link, set only if the + /// conversation had already been saved at least once when this was logged. + nonisolated func logUsageEvent( + provider: String, + modelId: String, + promptTokens: Int?, + completionTokens: Int?, + cost: Double?, + conversationId: UUID? + ) throws { + let record = UsageEventRecord( + id: UUID().uuidString, + timestamp: Self.isoString(from: Date()), + provider: provider, + modelId: modelId, + promptTokens: promptTokens, + completionTokens: completionTokens, + cost: cost, + conversationId: conversationId?.uuidString + ) + try dbQueue.write { db in + try record.insert(db) + } + } + + /// Overall usage-event totals (each row = one completed generation = one "question"). + nonisolated func getUsageEventTotals(from: Date? = nil, to: Date? = nil) throws -> UsageStats { + try dbQueue.read { db in + let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to) + let sql = """ + SELECT COUNT(*) AS cnt, + COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens, + COALESCE(SUM(cost), 0) AS cost, + COUNT(cost) AS costCount, + MIN(timestamp) AS minTs, + MAX(timestamp) AS maxTs + FROM usage_events + \(whereSQL) + """ + guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments)) + else { + return UsageStats() + } + + let costCount: Int = row["costCount"] + let cnt: Int = row["cnt"] + let minTs: String? = row["minTs"] + let maxTs: String? = row["maxTs"] + + return UsageStats( + totalMessages: cnt, + totalQuestions: cnt, + totalTokens: row["tokens"], + totalCost: row["cost"], + hasCostData: costCount > 0, + firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) }, + lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) } + ) + } + } + + /// Usage events grouped by model. + nonisolated func getUsageEventsByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] { + try dbQueue.read { db in + let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to) + let sql = """ + SELECT modelId, + COUNT(*) AS cnt, + COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens, + COALESCE(SUM(cost), 0) AS cost, + COUNT(cost) AS costCount, + MAX(timestamp) AS lastUsed + FROM usage_events + \(whereSQL) + GROUP BY modelId + """ + let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments)) + + let stats: [ModelUsageStat] = rows.compactMap { row in + guard let modelId: String = row["modelId"], + let lastUsedString: String = row["lastUsed"], + let lastUsed = Self.isoDate(from: lastUsedString) + else { return nil } + + let costCount: Int = row["costCount"] + let cnt: Int = row["cnt"] + return ModelUsageStat( + modelId: modelId, + messageCount: cnt, + questionCount: cnt, + totalTokens: row["tokens"], + totalCost: row["cost"], + hasCostData: costCount > 0, + lastUsed: lastUsed + ) + } + + return stats.sorted { lhs, rhs in + if lhs.hasCostData || rhs.hasCostData { + return lhs.totalCost > rhs.totalCost + } + return lhs.totalTokens > rhs.totalTokens + } + } + } + + /// Usage events grouped by provider. + nonisolated func getUsageEventsByProvider(from: Date? = nil, to: Date? = nil) throws -> [ProviderUsageStat] { + try dbQueue.read { db in + let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to) + let sql = """ + SELECT provider, + COUNT(*) AS cnt, + COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens, + COALESCE(SUM(cost), 0) AS cost, + COUNT(cost) AS costCount, + MAX(timestamp) AS lastUsed + FROM usage_events + \(whereSQL) + GROUP BY provider + """ + let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments)) + + let stats: [ProviderUsageStat] = rows.compactMap { row in + guard let provider: String = row["provider"], + let lastUsedString: String = row["lastUsed"], + let lastUsed = Self.isoDate(from: lastUsedString) + else { return nil } + + let costCount: Int = row["costCount"] + return ProviderUsageStat( + provider: provider, + questionCount: row["cnt"], + totalTokens: row["tokens"], + totalCost: row["cost"], + hasCostData: costCount > 0, + lastUsed: lastUsed + ) + } + + return stats.sorted { lhs, rhs in + if lhs.hasCostData || rhs.hasCostData { + return lhs.totalCost > rhs.totalCost + } + return lhs.totalTokens > rhs.totalTokens + } + } + } + + /// Usage events bucketed by calendar day (GMT, matching `isoStyle`), for the Analytics view. + nonisolated func getDailyUsageEvents(from: Date, to: Date) throws -> [DailyUsageStat] { + try dbQueue.read { db in + let sql = """ + SELECT substr(timestamp, 1, 10) AS day, + COUNT(*) AS cnt, + COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens, + COALESCE(SUM(cost), 0) AS cost, + COUNT(cost) AS costCount + FROM usage_events + WHERE timestamp >= ? AND timestamp <= ? + GROUP BY day + ORDER BY day + """ + let rows = try Row.fetchAll( + db, sql: sql, + arguments: [Self.isoString(from: from), Self.isoString(from: to)] + ) + + var gmtCalendar = Calendar(identifier: .gregorian) + gmtCalendar.timeZone = TimeZone(identifier: "GMT")! + + return rows.compactMap { row -> DailyUsageStat? in + guard let dayString: String = row["day"] else { return nil } + let parts = dayString.split(separator: "-").compactMap { Int($0) } + guard parts.count == 3 else { return nil } + var components = DateComponents() + components.year = parts[0] + components.month = parts[1] + components.day = parts[2] + guard let day = gmtCalendar.date(from: components) else { return nil } + + let costCount: Int = row["costCount"] + let cnt: Int = row["cnt"] + return DailyUsageStat( + day: day, + messageCount: cnt, + questionCount: cnt, + totalTokens: row["tokens"], + totalCost: row["cost"], + hasCostData: costCount > 0 + ) + } + } + } + nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] { try dbQueue.read { db in let rows = try Row.fetchAll(db, sql: """ diff --git a/oAI/Services/ExternalMCPClient.swift b/oAI/Services/ExternalMCPClient.swift index 6b85464..1091b77 100644 --- a/oAI/Services/ExternalMCPClient.swift +++ b/oAI/Services/ExternalMCPClient.swift @@ -5,22 +5,18 @@ import Foundation // MARK: - ExternalMCPClient -/// Manages one MCP stdio server process. All state is MainActor-isolated -/// (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor project setting). -/// Background I/O runs in Task.detached; state mutations hop back to MainActor. +/// Owns one MCP server connection's lifecycle and JSON-RPC message framing. Delivery (stdio +/// subprocess vs Streamable HTTP) is delegated to a `MCPTransport` — this class only builds +/// envelopes, decodes typed results, and tracks connection state. +/// All state is MainActor-isolated (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor +/// project setting). @MainActor final class ExternalMCPClient { let server: ExternalMCPServer weak var stateDelegate: (any ExternalMCPStateDelegate)? - private var process: Process? - private var stdinHandle: FileHandle? - private var readTask: Task? - private var stderrTask: Task? - + private let transport: any MCPTransport private var nextRequestId: Int = 1 - private var pendingCalls: [Int: CheckedContinuation] = [:] - private var lineBuffer = Data() private(set) var state: MCPClientState = .idle private(set) var discoveredTools: [MCPToolDefinition] = [] @@ -28,6 +24,31 @@ final class ExternalMCPClient { init(server: ExternalMCPServer, stateDelegate: (any ExternalMCPStateDelegate)?) { self.server = server self.stateDelegate = stateDelegate + let stdioTransport: StdioMCPTransport? + switch server.transportKind { + case .stdio: + let t = StdioMCPTransport(server: server) + stdioTransport = t + self.transport = t + case .http: + stdioTransport = nil + self.transport = HTTPMCPTransport(server: server) + } + // `self` is only safe to capture once every stored property above has a value — + // wire the crash callback here, after `init` would otherwise be considered complete. + stdioTransport?.onTerminated = { [weak self] in + self?.handleTransportTerminatedUnexpectedly() + } + } + + /// Called by a stdio transport whose subprocess died on its own — as opposed to a + /// deliberate `stop()` call, or a failure already handled inline within `start()`. + /// No HTTP equivalent: a Streamable HTTP connection has no persistent process to crash; + /// its failures surface per-request instead (handled in `start()`/`callTool()` directly). + private func handleTransportTerminatedUnexpectedly() { + guard state != .stopped else { return } + state = .crashed + stateDelegate?.clientDidChangeState(id: server.id, state: .crashed) } // MARK: - Lifecycle @@ -37,54 +58,28 @@ final class ExternalMCPClient { state = .connecting stateDelegate?.clientDidChangeState(id: server.id, state: .connecting) - let proc = Process() - if server.command.hasPrefix("/") { - proc.executableURL = URL(fileURLWithPath: server.command) - proc.arguments = server.args - } else { - proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") - proc.arguments = [server.command] + server.args - } - proc.environment = ProcessInfo.processInfo.environment - - let stdinPipe = Pipe() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - proc.standardInput = stdinPipe - proc.standardOutput = stdoutPipe - proc.standardError = stderrPipe - - proc.terminationHandler = { [weak self] _ in - Task { @MainActor [weak self] in self?.handleProcessTerminated() } - } - do { - try proc.run() - } catch { - state = .error(error.localizedDescription) - stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription)) - throw MCPClientError.processLaunchFailed(error.localizedDescription) - } + try await transport.prepare() - process = proc - stdinHandle = stdinPipe.fileHandleForWriting - startReadLoop(pipe: stdoutPipe) - startStderrLoop(pipe: stderrPipe) - - do { let _: MCPInitializeResult = try await timedRequest(seconds: 15, method: "initialize", params: [ "protocolVersion": "2024-11-05", "capabilities": [:] as [String: Any], "clientInfo": ["name": "Confab", "version": "1.0"] as [String: Any] ]) - try sendNotification(method: "notifications/initialized") + try await transport.sendNotification(["jsonrpc": "2.0", "method": "notifications/initialized"]) let toolsResult: MCPToolsListResult = try await timedRequest(seconds: 15, method: "tools/list", params: nil) discoveredTools = toolsResult.tools } catch { - state = .error(error.localizedDescription) - stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription)) - proc.terminate() + // Uniformly route every start() failure (bad config, launch failure, handshake + // failure — for either transport) through .crashed, not .error, so + // ExternalMCPManager's restart-with-backoff drives from exactly one place. + // (Previously, stdio relied on the subprocess's termination handler firing + // asynchronously to reach .crashed; that path doesn't exist for HTTP, so failures + // there would otherwise get stuck at .error with no retry.) + state = .crashed + transport.stop() + stateDelegate?.clientDidChangeState(id: server.id, state: .crashed) throw error } @@ -94,14 +89,7 @@ final class ExternalMCPClient { func stop() { state = .stopped - readTask?.cancel() - stderrTask?.cancel() - process?.terminate() - process = nil - stdinHandle = nil - lineBuffer = Data() - for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) } - pendingCalls.removeAll() + transport.stop() } // MARK: - Tool Execution @@ -128,125 +116,17 @@ final class ExternalMCPClient { } } - // MARK: - I/O Loops (detached from MainActor) - - private func startReadLoop(pipe: Pipe) { - readTask = Task.detached { [weak self] in - let handle = pipe.fileHandleForReading - while true { - let data = handle.availableData - if data.isEmpty { break } - await self?.receiveData(data) - } - } - } - - private func startStderrLoop(pipe: Pipe) { - let name = server.name - stderrTask = Task.detached { - let handle = pipe.fileHandleForReading - var buf = Data() - while true { - let data = handle.availableData - if data.isEmpty { break } - buf.append(data) - while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) { - let line = String(data: buf[buf.startIndex..(seconds: Double, method: String, params: [String: Any]?) async throws -> T { let id = nextRequestId nextRequestId += 1 var message: [String: Any] = ["jsonrpc": "2.0", "method": method, "id": id] if let params { message["params"] = params } - try writeJSON(message) - - // Schedule timeout: cancels the specific pending call by ID - let timeoutId = id - Task { [weak self, timeoutId] in - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout) - } - - // Await response data, then decode on MainActor - let resultData: Data = try await withCheckedThrowingContinuation { cont in - pendingCalls[id] = cont - } + let resultData = try await transport.sendRequest(message, id: id, timeoutSeconds: seconds) return try JSONDecoder().decode(T.self, from: resultData) } - private func cancelPendingCall(id: Int, with error: Error) { - pendingCalls.removeValue(forKey: id)?.resume(throwing: error) - } - - private func sendNotification(method: String) throws { - try writeJSON(["jsonrpc": "2.0", "method": method]) - } - - private func writeJSON(_ message: [String: Any]) throws { - guard let handle = stdinHandle, process?.isRunning == true else { - throw MCPClientError.writeFailed - } - guard let data = try? JSONSerialization.data(withJSONObject: message), - let line = String(data: data, encoding: .utf8) else { - throw MCPClientError.writeFailed - } - do { - try handle.write(contentsOf: Data((line + "\n").utf8)) - } catch { - throw MCPClientError.writeFailed - } - } - - // MARK: - Process termination - - private func handleProcessTerminated() { - guard state != .stopped else { return } - state = .crashed - for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) } - pendingCalls.removeAll() - stateDelegate?.clientDidChangeState(id: server.id, state: .crashed) - } - // MARK: - Result conversion private func convertMCPResult(_ result: MCPToolCallResult) -> [String: Any] { diff --git a/oAI/Services/ExternalMCPManager.swift b/oAI/Services/ExternalMCPManager.swift index f05f5b0..be858a1 100644 --- a/oAI/Services/ExternalMCPManager.swift +++ b/oAI/Services/ExternalMCPManager.swift @@ -67,13 +67,10 @@ final class ExternalMCPManager { Task { do { try await client.start() - } catch MCPClientError.processLaunchFailed(let msg) { - // Process never started — termination handler won't fire, so manually trigger crashed - Log.extMcp.error("Failed to launch '\(server.name)': \(msg)") - clientDidChangeState(id: server.id, state: .crashed) } catch { - // Handshake/other failure — proc.terminate() was called in start(), termination - // handler will fire and set .crashed, which drives the restart from one place only. + // start() already notified stateDelegate with .crashed (uniformly, for both + // transports and every failure kind) before throwing — restart-with-backoff is + // already scheduled via clientDidChangeState. Nothing further to do but log. Log.extMcp.warning("'\(server.name)' start failed: \(error.localizedDescription)") } } @@ -126,7 +123,7 @@ final class ExternalMCPManager { private func rebuildCache(for server: ExternalMCPServer, tools: [MCPToolDefinition]) { removeCachedSchemas(for: server.id, slug: server.slug) - let prefixed = tools.compactMap { convertToolDefinition($0, server: server) } + let prefixed = tools.compactMap { Self.convertToolDefinition($0, server: server) } cachedToolSchemas.append(contentsOf: prefixed) Log.extMcp.info("[\(server.name)] cached \(prefixed.count) tools: \(prefixed.map { $0.function.name }.joined(separator: ", "))") } @@ -140,7 +137,7 @@ final class ExternalMCPManager { cachedToolSchemas.removeAll { $0.function.name.hasPrefix("\(slug)_") } } - private func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? { + nonisolated static func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? { Tool( type: "function", function: Tool.Function( @@ -151,13 +148,18 @@ final class ExternalMCPManager { ) } - private func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters { + /// A schema property with no `"type"` at all is valid JSON Schema (e.g. an `enum`-only or + /// composed property) — not every MCP server's tool schemas set it, so this must not assume + /// it's present. (Found via a real crash: Obsidian's Local REST API plugin sends at least one + /// tool parameter with no `type`, which a `prop.type!` force-unwrap here used to crash on.) + nonisolated static func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters { var properties: [String: Tool.Function.Parameters.Property] = [:] for (key, prop) in schema.properties ?? [:] { + let effectiveType = prop.type ?? "string" let normalized: String - switch prop.type ?? "string" { + switch effectiveType { case "integer": normalized = "number" - case "string", "number", "boolean", "array", "object": normalized = prop.type! + case "string", "number", "boolean", "array", "object": normalized = effectiveType default: normalized = "string" } var items: Tool.Function.Parameters.Property.Items? = nil diff --git a/oAI/Services/ExternalMCPModels.swift b/oAI/Services/ExternalMCPModels.swift index 5bcde08..b4fb906 100644 --- a/oAI/Services/ExternalMCPModels.swift +++ b/oAI/Services/ExternalMCPModels.swift @@ -5,26 +5,79 @@ import Foundation // MARK: - Server Configuration -struct ExternalMCPServer: Codable, Identifiable, Sendable { +/// Which wire protocol an `ExternalMCPServer` uses. `.stdio` fields are `command`/`args`/`env`; +/// `.http` fields are `url`/`bearerToken`/`headers`. Kept as one flat struct rather than an enum +/// with associated values — simpler `Codable` and simpler settings-JSON storage, at the cost of +/// each server config carrying some always-unused fields for its transport. +nonisolated enum MCPTransportKind: String, Codable, Sendable, CaseIterable { + case stdio + case http +} + +nonisolated struct ExternalMCPServer: Codable, Identifiable, Sendable { var id: UUID var name: String + var transportKind: MCPTransportKind var command: String var args: [String] + var env: [String: String] + var url: String + var bearerToken: String + var headers: [String: String] var isEnabled: Bool var timeout: TimeInterval var createdAt: Date - init(id: UUID = UUID(), name: String, command: String, args: [String] = [], - isEnabled: Bool = true, timeout: TimeInterval = 30, createdAt: Date = Date()) { + init( + id: UUID = UUID(), + name: String, + transportKind: MCPTransportKind = .stdio, + command: String = "", + args: [String] = [], + env: [String: String] = [:], + url: String = "", + bearerToken: String = "", + headers: [String: String] = [:], + isEnabled: Bool = true, + timeout: TimeInterval = 30, + createdAt: Date = Date() + ) { self.id = id self.name = name + self.transportKind = transportKind self.command = command self.args = args + self.env = env + self.url = url + self.bearerToken = bearerToken + self.headers = headers self.isEnabled = isEnabled self.timeout = timeout self.createdAt = createdAt } + enum CodingKeys: String, CodingKey { + case id, name, transportKind, command, args, env, url, bearerToken, headers, isEnabled, timeout, createdAt + } + + /// Custom decoding so servers saved before `transportKind`/`env`/`url`/`bearerToken`/`headers` + /// existed (plain stdio-only configs) still decode — those keys default rather than fail. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(UUID.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + command = try c.decode(String.self, forKey: .command) + args = try c.decode([String].self, forKey: .args) + isEnabled = try c.decode(Bool.self, forKey: .isEnabled) + timeout = try c.decode(TimeInterval.self, forKey: .timeout) + createdAt = try c.decode(Date.self, forKey: .createdAt) + transportKind = try c.decodeIfPresent(MCPTransportKind.self, forKey: .transportKind) ?? .stdio + env = try c.decodeIfPresent([String: String].self, forKey: .env) ?? [:] + url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" + bearerToken = try c.decodeIfPresent(String.self, forKey: .bearerToken) ?? "" + headers = try c.decodeIfPresent([String: String].self, forKey: .headers) ?? [:] + } + var slug: String { Self.makeSlug(from: name) } static func makeSlug(from name: String) -> String { @@ -100,6 +153,7 @@ enum MCPClientError: LocalizedError { case processLaunchFailed(String) case handshakeFailed(String) case writeFailed + case invalidConfiguration(String) var errorDescription: String? { switch self { @@ -109,6 +163,7 @@ enum MCPClientError: LocalizedError { case .processLaunchFailed(let s): return "Failed to launch MCP server: \(s)" case .handshakeFailed(let s): return "MCP handshake failed: \(s)" case .writeFailed: return "Failed to write to MCP server stdin" + case .invalidConfiguration(let s): return "Invalid MCP server configuration: \(s)" } } } @@ -136,24 +191,27 @@ struct MCPToolsListResult: Decodable { let nextCursor: String? } -struct MCPToolDefinition: Decodable { +// Plain DTOs read from ExternalMCPManager.convertToolDefinition/convertInputSchema — both +// `nonisolated static func` (for direct unit testing) — so these must stay `nonisolated` too, +// same reasoning as `Tool` in AIProvider.swift. +nonisolated struct MCPToolDefinition: Decodable { let name: String let description: String? let inputSchema: MCPInputSchema } -struct MCPInputSchema: Decodable { +nonisolated struct MCPInputSchema: Decodable { let type: String let properties: [String: MCPPropertySchema]? let required: [String]? } -struct MCPPropertySchema: Decodable { +nonisolated struct MCPPropertySchema: Decodable { let type: String? let description: String? let `enum`: [String]? let items: MCPItemsSchema? - struct MCPItemsSchema: Decodable { let type: String? } + nonisolated struct MCPItemsSchema: Decodable { let type: String? } } struct MCPToolCallResult: Decodable { diff --git a/oAI/Services/MCPTransport.swift b/oAI/Services/MCPTransport.swift new file mode 100644 index 0000000..a051a71 --- /dev/null +++ b/oAI/Services/MCPTransport.swift @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Foundation + +// MARK: - Transport abstraction + +/// One MCP server connection's wire protocol. `ExternalMCPClient` builds JSON-RPC envelopes and +/// decodes typed results; the transport only owns *delivery* — how the bytes get to the server +/// and back. A long-lived subprocess pipe for stdio, discrete HTTP requests for Streamable HTTP. +@MainActor +protocol MCPTransport: AnyObject { + /// Prepares the transport for use — launches the subprocess for stdio; a no-op for HTTP, + /// since there's no persistent connection to establish ahead of the first request. + func prepare() async throws + /// Sends a JSON-RPC *request* (`message` includes `"id"`) and returns its `result` payload. + /// Throws `MCPClientError.invalidResponse` if the server returned a JSON-RPC error, or + /// `.timeout` if `timeoutSeconds` elapses first. + func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data + /// Sends a JSON-RPC *notification* (no `"id"`, no response expected). + func sendNotification(_ message: [String: Any]) async throws + /// Tears down the transport — terminates the subprocess / drops HTTP session state. + func stop() +} + +enum MCPTransportSupport { + /// Extracts the `result` payload from a decoded top-level JSON-RPC response object, shared + /// by every transport so error/result semantics stay identical regardless of wire protocol. + static func extractResult(from json: [String: Any]) throws -> Data { + if let err = json["error"] as? [String: Any] { + throw MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error") + } + guard let result = json["result"], + let resultData = try? JSONSerialization.data(withJSONObject: result) + else { + throw MCPClientError.invalidResponse("Missing result field") + } + return resultData + } +} + +// MARK: - Stdio Transport + +/// Launches the server as a subprocess and speaks newline-delimited JSON-RPC over its +/// stdin/stdout, exactly as the original (pre-transport-split) `ExternalMCPClient` did. +@MainActor +final class StdioMCPTransport: MCPTransport { + private let server: ExternalMCPServer + /// Set by the owner after construction (not an init parameter) — the owner (`ExternalMCPClient`) + /// needs to capture `self` weakly to wire this up, which Swift only allows once `self` is + /// fully initialized, i.e. after its own `init` has finished assigning all stored properties. + var onTerminated: (() -> Void)? + + private var process: Process? + private var stdinHandle: FileHandle? + private var readTask: Task? + private var stderrTask: Task? + private var pendingCalls: [Int: CheckedContinuation] = [:] + private var lineBuffer = Data() + + init(server: ExternalMCPServer) { + self.server = server + } + + func prepare() async throws { + let proc = Process() + if server.command.hasPrefix("/") { + proc.executableURL = URL(fileURLWithPath: server.command) + proc.arguments = server.args + } else { + proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") + proc.arguments = [server.command] + server.args + } + // Inherited environment first (so PATH etc. still resolves — e.g. `npx` needs PATH to + // find node), then layer the user-configured vars over it so they can override. + var environment = ProcessInfo.processInfo.environment + for (key, value) in server.env { environment[key] = value } + proc.environment = environment + + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + proc.standardInput = stdinPipe + proc.standardOutput = stdoutPipe + proc.standardError = stderrPipe + + proc.terminationHandler = { [weak self] _ in + Task { @MainActor [weak self] in self?.handleProcessTerminated() } + } + + do { + try proc.run() + } catch { + throw MCPClientError.processLaunchFailed(error.localizedDescription) + } + + process = proc + stdinHandle = stdinPipe.fileHandleForWriting + startReadLoop(pipe: stdoutPipe) + startStderrLoop(pipe: stderrPipe) + } + + func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data { + try writeJSON(message) + + // Schedule timeout: cancels the specific pending call by ID + let timeoutId = id + Task { [weak self, timeoutId] in + try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) + self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout) + } + + return try await withCheckedThrowingContinuation { cont in + pendingCalls[id] = cont + } + } + + func sendNotification(_ message: [String: Any]) async throws { + try writeJSON(message) + } + + func stop() { + readTask?.cancel() + stderrTask?.cancel() + process?.terminate() + process = nil + stdinHandle = nil + lineBuffer = Data() + for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) } + pendingCalls.removeAll() + } + + // MARK: I/O Loops (detached from MainActor) + + private func startReadLoop(pipe: Pipe) { + readTask = Task.detached { [weak self] in + let handle = pipe.fileHandleForReading + while true { + let data = handle.availableData + if data.isEmpty { break } + await self?.receiveData(data) + } + } + } + + private func startStderrLoop(pipe: Pipe) { + let name = server.name + stderrTask = Task.detached { + let handle = pipe.fileHandleForReading + var buf = Data() + while true { + let data = handle.availableData + if data.isEmpty { break } + buf.append(data) + while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) { + let line = String(data: buf[buf.startIndex.. Data { + let request = try buildRequest(for: message, timeoutSeconds: timeoutSeconds) + let (data, response) = try await urlSession.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw MCPClientError.invalidResponse("Non-HTTP response") + } + if let newSessionId = http.value(forHTTPHeaderField: "Mcp-Session-Id") { + sessionId = newSessionId + } + guard (200...299).contains(http.statusCode) else { + throw MCPClientError.invalidResponse("HTTP \(http.statusCode)") + } + let json = try Self.parseResponseBody( + data, contentType: http.value(forHTTPHeaderField: "Content-Type"), expectedId: id + ) + return try MCPTransportSupport.extractResult(from: json) + } + + func sendNotification(_ message: [String: Any]) async throws { + // Notifications carry no "id", so per spec the server responds 202 Accepted, no body. + let request = try buildRequest(for: message, timeoutSeconds: 15) + let (_, response) = try await urlSession.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + throw MCPClientError.invalidResponse("Notification not accepted") + } + } + + func stop() { + sessionId = nil + } + + private func buildRequest(for message: [String: Any], timeoutSeconds: Double) throws -> URLRequest { + guard let url = URL(string: server.url) else { + throw MCPClientError.invalidConfiguration("Invalid server URL: \(server.url)") + } + var request = URLRequest(url: url, timeoutInterval: timeoutSeconds) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") + request.setValue("2025-06-18", forHTTPHeaderField: "MCP-Protocol-Version") + if !server.bearerToken.isEmpty { + request.setValue("Bearer \(server.bearerToken)", forHTTPHeaderField: "Authorization") + } + for (key, value) in server.headers { + request.setValue(value, forHTTPHeaderField: key) + } + if let sessionId { + request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") + } + request.httpBody = try JSONSerialization.data(withJSONObject: message) + return request + } + + /// Parses either a direct `application/json` body, or an SSE (`text/event-stream`) body — + /// scanning its `data:` lines for the JSON-RPC message whose `id` matches `expectedId` (the + /// server may send unrelated requests/notifications on the same stream first, per spec). + nonisolated static func parseResponseBody(_ data: Data, contentType: String?, expectedId: Int) throws -> [String: Any] { + if contentType?.contains("text/event-stream") == true { + guard let text = String(data: data, encoding: .utf8) else { + throw MCPClientError.invalidResponse("Non-UTF8 SSE body") + } + for line in text.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("data:") else { continue } + let payload = trimmed.dropFirst("data:".count).trimmingCharacters(in: .whitespaces) + guard let payloadData = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], + let id = json["id"] as? Int, id == expectedId + else { continue } + return json + } + throw MCPClientError.invalidResponse("No matching response in SSE stream") + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw MCPClientError.invalidResponse("Malformed JSON response body") + } + return json + } +} diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index f188df8..1ca30b3 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -1043,6 +1043,36 @@ class SettingsService { } } + // MARK: - CLI Server + + /// Local Unix-socket server (CLIServerService) for one-shot, non-streaming shell access to a + /// single fixed model — deliberately separate from the chat UI's defaultProvider/defaultModel + /// so switching models in the GUI never changes shell-tool behavior, same reasoning as the + /// email handler's own dedicated provider/model pair above. + var cliServerEnabled: Bool { + get { cache["cliServerEnabled"] == "true" } + set { + cache["cliServerEnabled"] = String(newValue) + DatabaseService.shared.setSetting(key: "cliServerEnabled", value: String(newValue)) + } + } + + var cliServerProvider: String { + get { cache["cliServerProvider"] ?? "openrouter" } + set { + cache["cliServerProvider"] = newValue + DatabaseService.shared.setSetting(key: "cliServerProvider", value: newValue) + } + } + + var cliServerModel: String { + get { cache["cliServerModel"] ?? "" } + set { + cache["cliServerModel"] = newValue + DatabaseService.shared.setSetting(key: "cliServerModel", value: newValue) + } + } + var emailSubjectIdentifier: String { get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" } set { diff --git a/oAI/Services/ThinkingVerbs.swift b/oAI/Services/ThinkingVerbs.swift index 55806c8..2be5f52 100644 --- a/oAI/Services/ThinkingVerbs.swift +++ b/oAI/Services/ThinkingVerbs.swift @@ -23,14 +23,44 @@ import Foundation -struct ThinkingVerbs { - /// Get a random thinking verb with ellipsis +/// Marked `nonisolated` at the type level (not per-member) since this project builds with +/// `-default-isolation=MainActor` — without it, every member (including the static `let` verb +/// arrays) defaults to MainActor-isolated, which then conflicts with `nonisolated` funcs trying +/// to reference them. See CLAUDE.md's Swift 6 Compatibility section / MEMORY.md's Common Gotchas. +nonisolated struct ThinkingVerbs { + /// Get a random thinking verb with ellipsis, in the app's active display language. + /// Each language has its own hand-written set (not a translation of the English one) — + /// literal translations of English wordplay ("Waking up the hamsters") often land flat + /// or sound plain odd in another language, so every list was written to be funny/natural + /// on its own terms while covering the same rough categories (classic, technical, mystical, + /// quirky, etc). static func random() -> String { - verbs.randomElement()! + "..." + verbs(for: currentLanguageCode).randomElement()! + "..." } - /// Collection of fun thinking verbs and phrases - private static let verbs = [ + /// Mirrors how Text()/String(localized:) resolve the active language: the first of the + /// user's preferred languages that this bundle actually ships a localization for (falls + /// back to English otherwise). See CLAUDE.md's supported-language list (en, nb, sv, da, de, fr). + private static var currentLanguageCode: String { + Bundle.main.preferredLocalizations.first ?? "en" + } + + /// Internal (not private) so tests can verify the language→list mapping without depending + /// on Bundle.main's runtime localization state. + static func verbs(for languageCode: String) -> [String] { + switch languageCode { + case "nb": return nbVerbs + case "sv": return svVerbs + case "da": return daVerbs + case "de": return deVerbs + case "fr": return frVerbs + default: return enVerbs + } + } + + // MARK: - English + + private static let enVerbs = [ // Classic thinking "Thinking", "Pondering", @@ -144,4 +174,589 @@ struct ThinkingVerbs { "Loading brilliance", "Unfurling wisdom" ] + + // MARK: - Norwegian Bokmål + + private static let nbVerbs = [ + // Classic thinking + "Tenker", + "Grunner", + "Fundere", + "Reflekterer", + "Mediterer", + "Spekulerer", + "Overveier", + + // Fancy/sophisticated + "Kontemplerer", + "Filosoferer", + "Resonnerer", + "Analyserer dypt", + "Bryner hjernen", + + // Technical/AI themed + "Beregner", + "Prosesserer", + "Analyserer", + "Syntetiserer", + "Kalkulerer", + "Utleder", + "Kompilerer tanker", + "Kjører algoritmer", + "Knuser data", + "Parser nevroner", + + // Creative/playful + "Dagdrømmer", + "Idémyldrer", + "Koker sammen tanker", + "Rører i tankegryta", + "Lar det godgjøre seg", + "Spinner nevroner", + "Varmer opp knollen", + "Klekker ut idéer", + + // Mystical/fun + "Rådfører orakelet", + "Leser kaffegrut", + "Maner frem visdom", + "Trollbinder et svar", + "Kaster nevrale garn", + "Spår svaret", + + // Quirky/silly + "Gjør greia", + "Trylle frem et svar", + "Aktiverer hjerneceller", + "Bøyer nevronene", + "Varmer opp transistorene", + "Rever opp synapsene", + "Kiler hjernebarken", + "Vekker hamsterne", + "Rådfører tomrommet", + "Spør den magiske 8-ballen", + + // Self-aware/meta + "Later som om jeg tenker", + "Ser opptatt ut", + "Trekker ut tiden", + "Teller sauer", + "Tvinner tomlene", + "Ordner tankene", + "Leter etter riktige ord", + + // Speed variations + "Tenker fort", + "Lynraskt tankearbeid", + "Dyptenkende", + "Hypertenker", + + // Action-oriented + "Snekrer sammen et svar", + "Vever ord", + "Setter sammen tanker", + "Konstruerer svar", + "Formulerer idéer", + "Dirigerer nevroner", + "Koreograferer bits", + + // Whimsical + "Får en åpenbaring", + "Kobler prikkene", + "Følger tråden", + "Jager tanker", + "Gjeter idéer", + "Reder ut nevronfloken", + + // Time-based + "Tar en tenkepause", + "Puster rolig", + "Tar fem", + "Samler tankene", + "Henter pusten", + + // Just plain weird + "Piper og beregner", + "Aktiverer hjernemodus", + "Slår på smartheten", + "Laster ned tanker", + "Bufrer intelligens", + "Laster inn genialitet", + "Folder ut visdom", + "Roter i idébanken", + "Sorterer tankene", + "Venter på et lyn av genialitet", + "Plager hjernen med det", + "Kokende av idéer", + "Grubler høyt", + "Filosoferer over saken" + ] + + // MARK: - Swedish + + private static let svVerbs = [ + // Classic thinking + "Tänker", + "Funderar", + "Grubblar", + "Reflekterar", + "Mediterar", + "Spekulerar", + "Överväger", + + // Fancy/sophisticated + "Kontemplerar", + "Filosoferar", + "Resonerar", + "Djupanalyserar", + "Vässar hjärnan", + + // Technical/AI themed + "Beräknar", + "Processar", + "Analyserar", + "Syntetiserar", + "Kalkylerar", + "Härleder", + "Kompilerar tankar", + "Kör algoritmer", + "Mosar data", + "Parsar nervceller", + + // Creative/playful + "Dagdrömmer", + "Idékläcker", + "Kokar ihop tankar", + "Rör i tankegrytan", + "Låter det mogna", + "Snurrar nervceller", + "Värmer upp knoppen", + "Ruvar på idéer", + + // Mystical/fun + "Rådfrågar oraklet", + "Läser i kaffesumpen", + "Frammanar visdom", + "Trollbinder ett svar", + "Kastar neurala nät", + "Spår svaret", + + // Quirky/silly + "Gör grejen", + "Trollar fram ett svar", + "Aktiverar hjärnceller", + "Böjer nervcellerna", + "Värmer upp transistorerna", + "Varvar upp synapserna", + "Kittlar hjärnbarken", + "Väcker hamstrarna", + "Rådfrågar tomrummet", + "Frågar magiska åttan", + + // Self-aware/meta + "Låtsas tänka", + "Ser upptagen ut", + "Drar ut på tiden", + "Räknar får", + "Tvinnar tummarna", + "Ordnar tankarna", + "Letar rätt ord", + + // Speed variations + "Tänker snabbt", + "Blixtsnabb tankeverksamhet", + "Djuptänkande", + "Hypertänker", + + // Action-oriented + "Snickrar ihop ett svar", + "Väver ord", + "Sätter ihop tankar", + "Konstruerar svar", + "Formulerar idéer", + "Dirigerar nervceller", + "Koreograferar bitar", + + // Whimsical + "Får en aha-upplevelse", + "Kopplar ihop punkterna", + "Följer tråden", + "Jagar tankar", + "Vallar idéer", + "Reder ut nervtrasslet", + + // Time-based + "Tar en tankepaus", + "Andas lugnt", + "Tar fem", + "Samlar tankarna", + "Hämtar andan", + + // Just plain weird + "Piper och beräknar", + "Aktiverar hjärnläge", + "Slår på smartheten", + "Laddar ner tankar", + "Buffrar intelligens", + "Laddar in briljans", + "Vecklar ut visdom", + "Rotar i idébanken", + "Sorterar tankarna", + "Väntar på ett geniblixt", + "Bryr hjärnan med det", + "Kokar av idéer", + "Grubblar högt", + "Filosoferar över saken" + ] + + // MARK: - Danish + + private static let daVerbs = [ + // Classic thinking + "Tænker", + "Grunder", + "Grubler", + "Reflekterer", + "Mediterer", + "Spekulerer", + "Overvejer", + + // Fancy/sophisticated + "Kontemplerer", + "Filosoferer", + "Ræsonnerer", + "Dybdeanalyserer", + "Skærper hjernen", + + // Technical/AI themed + "Beregner", + "Processerer", + "Analyserer", + "Syntetiserer", + "Kalkulerer", + "Udleder", + "Kompilerer tanker", + "Kører algoritmer", + "Knuser data", + "Parser neuroner", + + // Creative/playful + "Dagdrømmer", + "Idémylrer", + "Koger tanker sammen", + "Rører i tankegryden", + "Lader det simre", + "Snurrer neuroner", + "Varmer knolden op", + "Udruger idéer", + + // Mystical/fun + "Rådspørger orakelet", + "Læser kaffegrums", + "Fremmaner visdom", + "Tryller et svar frem", + "Kaster neurale net", + "Spår svaret", + + // Quirky/silly + "Gør tingen", + "Trylle-fremkalder et svar", + "Aktiverer hjerneceller", + "Bøjer neuronerne", + "Varmer transistorerne op", + "Ruller synapserne op", + "Kilder hjernebarken", + "Vækker hamsterne", + "Rådspørger tomrummet", + "Spørger den magiske 8-tal", + + // Self-aware/meta + "Lader som om jeg tænker", + "Ser optaget ud", + "Trækker tiden ud", + "Tæller får", + "Snor tommelfingrene", + "Ordner tankerne", + "Leder efter de rette ord", + + // Speed variations + "Tænker hurtigt", + "Lynhurtig tænkning", + "Dybttænkende", + "Hypertænker", + + // Action-oriented + "Snedkererer et svar", + "Væver ord", + "Samler tanker", + "Konstruerer svar", + "Formulerer idéer", + "Dirigerer neuroner", + "Koreograferer bits", + + // Whimsical + "Får en åbenbaring", + "Forbinder prikkerne", + "Følger tråden", + "Jagter tanker", + "Vogter idéer", + "Reder neurontrådene ud", + + // Time-based + "Tager en tænkepause", + "Trækker vejret roligt", + "Tager fem", + "Samler tankerne", + "Henter vejret", + + // Just plain weird + "Bipper og beregner", + "Aktiverer hjernemodus", + "Tænder for kløgt", + "Downloader tanker", + "Bufrer intelligens", + "Indlæser genialitet", + "Folder visdom ud", + "Roder i idébanken", + "Sorterer tankerne", + "Venter på et lyn af genialitet", + "Plager hjernen med det", + "Kogende af idéer", + "Grunder højt", + "Filosoferer over sagen" + ] + + // MARK: - German + + private static let deVerbs = [ + // Classic thinking + "Denkt nach", + "Grübelt", + "Sinniert", + "Reflektiert", + "Meditiert", + "Spekuliert", + "Überlegt", + + // Fancy/sophisticated + "Kontempliert", + "Philosophiert", + "Räsoniert", + "Analysiert tiefgründig", + "Schärft das Denkvermögen", + + // Technical/AI themed + "Berechnet", + "Verarbeitet", + "Analysiert", + "Synthetisiert", + "Kalkuliert", + "Leitet ab", + "Kompiliert Gedanken", + "Lässt Algorithmen laufen", + "Zermalmt Daten", + "Parst Neuronen", + + // Creative/playful + "Tagträumt", + "Brainstormt", + "Braut Gedanken zusammen", + "Rührt im Gedankentopf", + "Lässt es köcheln", + "Dreht Neuronen", + "Wärmt die Denkerstube auf", + "Brütet Ideen aus", + + // Mystical/fun + "Befragt das Orakel", + "Liest im Kaffeesatz", + "Beschwört Weisheit", + "Zaubert eine Antwort", + "Wirft neuronale Netze aus", + "Weissagt die Antwort", + + // Quirky/silly + "Macht sein Ding", + "Zaubert eine Antwort herbei", + "Aktiviert Gehirnzellen", + "Biegt Neuronen", + "Wärmt die Transistoren auf", + "Dreht die Synapsen hoch", + "Kitzelt die Großhirnrinde", + "Weckt die Hamster", + "Befragt die Leere", + "Fragt die magische Kugel", + + // Self-aware/meta + "Tut nur so, als würde es denken", + "Sieht beschäftigt aus", + "Zieht Zeit", + "Zählt Schafe", + "Dreht Däumchen", + "Ordnet die Gedanken", + "Sucht die richtigen Worte", + + // Speed variations + "Denkt schnell", + "Blitzschnelles Denken", + "Tiefes Nachdenken", + "Hyperdenken", + + // Action-oriented + "Zimmert eine Antwort", + "Webt Worte", + "Fügt Gedanken zusammen", + "Konstruiert Antworten", + "Formuliert Ideen", + "Dirigiert Neuronen", + "Choreografiert Bits", + + // Whimsical + "Hat eine Eingebung", + "Verbindet die Punkte", + "Folgt dem roten Faden", + "Jagt Gedanken", + "Hütet Ideen", + "Entwirrt die Neuronen", + + // Time-based + "Nimmt sich einen Moment", + "Atmet tief durch", + "Macht kurz Pause", + "Sammelt die Gedanken", + "Holt Luft", + + // Just plain weird + "Piept und rechnet", + "Aktiviert den Gehirnmodus", + "Schaltet auf Schlaumodus", + "Lädt Gedanken herunter", + "Puffert Intelligenz", + "Lädt Genialität", + "Entfaltet Weisheit", + "Wühlt in der Ideenkiste", + "Sortiert die Gedanken", + "Wartet auf einen Geistesblitz", + "Quält das Gehirn damit", + "Kocht vor Ideen", + "Grübelt laut", + "Philosophiert über die Sache" + ] + + // MARK: - French + + private static let frVerbs = [ + // Classic thinking + "Réfléchit", + "Songe", + "Médite", + "Contemple", + "Spécule", + "Délibère", + "Rumine", + + // Fancy/sophisticated + "Philosophise", + "Raisonne", + "Cogite", + "Analyse en profondeur", + "Aiguise ses neurones", + + // Technical/AI themed + "Calcule", + "Traite", + "Analyse", + "Synthétise", + "Déduit", + "Compile des pensées", + "Fait tourner des algorithmes", + "Broie des données", + "Parse des neurones", + "Chiffre les possibilités", + + // Creative/playful + "Rêvasse", + "Brainstorme", + "Mijote des idées", + "Remue la marmite à idées", + "Laisse mijoter", + "Fait tourner ses neurones", + "Chauffe la matière grise", + "Couve une idée", + + // Mystical/fun + "Consulte l'oracle", + "Lit dans le marc de café", + "Invoque la sagesse", + "Conjure une réponse", + "Lance des filets neuronaux", + "Prédit la réponse", + + // Quirky/silly + "Fait son truc", + "Fait apparaître une réponse par magie", + "Active ses neurones", + "Étire ses neurones", + "Chauffe les transistors", + "Emballe les synapses", + "Chatouille le cortex", + "Réveille les hamsters", + "Consulte le vide", + "Interroge la boule magique", + + // Self-aware/meta + "Fait semblant de réfléchir", + "A l'air occupé", + "Fait durer le suspense", + "Compte les moutons", + "Se tourne les pouces", + "Met de l'ordre dans ses pensées", + "Cherche les mots justes", + + // Speed variations + "Réfléchit vite", + "Pensée éclair", + "Réflexion profonde", + "Hyper-réflexion", + + // Action-oriented + "Bricole une réponse", + "Tisse des mots", + "Assemble des idées", + "Construit une réponse", + "Formule des idées", + "Dirige ses neurones", + "Chorégraphie des bits", + + // Whimsical + "A une révélation", + "Relie les points", + "Suit le fil", + "Traque une pensée", + "Rassemble ses idées", + "Démêle ses neurones", + + // Time-based + "Prend un instant", + "Respire un grand coup", + "Fait une pause", + "Rassemble ses pensées", + "Reprend son souffle", + + // Just plain weird + "Bip bip, ça calcule", + "Active le mode cerveau", + "Passe en mode intelligent", + "Télécharge des pensées", + "Met l'intelligence en cache", + "Charge du génie", + "Déploie sa sagesse", + "Fouille sa boîte à idées", + "Trie ses pensées", + "Attend un éclair de génie", + "Torture son cerveau", + "Bouillonne d'idées", + "Rumine à voix haute", + "Philosophise sur la question" + ] } diff --git a/oAI/Utilities/Logging.swift b/oAI/Utilities/Logging.swift index 7fe0bcf..0dff966 100644 --- a/oAI/Utilities/Logging.swift +++ b/oAI/Utilities/Logging.swift @@ -152,4 +152,5 @@ enum Log { nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui") nonisolated static let general = AppLogger(subsystem: subsystem, category: "general") nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp") + nonisolated static let cli = AppLogger(subsystem: subsystem, category: "cli") } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 3d5e93f..5f0420c 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -120,6 +120,13 @@ class ChatViewModel { var messages: [Message] = [] var inputText: String = "" var isGenerating: Bool = false + /// Live "what's happening right now" line shown under ProcessingIndicator's thinking verb + /// while a tool-calling loop is running — e.g. "🔧 Calling: read_file". Replaced in place each + /// round rather than appending a new chat message, so a long multi-round tool chain doesn't + /// stack up a growing list of rows. Not persisted; nil whenever no tool round is in flight. The + /// full chain is still recorded, just collapsed into one expandable summary message once the + /// loop finishes — see generateAIResponseWithTools's use of allToolCallDetails. + var currentToolActivity: String? = nil var sessionStats = SessionStats() var selectedModel: ModelInfo? var currentProvider: Settings.Provider = .openrouter @@ -1143,10 +1150,10 @@ Don't narrate future actions ("Let me...") - just use the tools. if let usage = response.usage { messages[index].tokens = usage.completionTokens if let model = selectedModel { - let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil + let cost = Self.resolveCost(usage: usage, pricing: model.pricing) messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) + logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost) } } } @@ -1207,10 +1214,10 @@ Don't narrate future actions ("Let me...") - just use the tools. if let usage = totalTokens { messages[index].tokens = usage.completionTokens if let model = selectedModel { - let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil + let cost = Self.resolveCost(usage: usage, pricing: model.pricing) messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) + logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost) } } @@ -1567,6 +1574,7 @@ Don't narrate future actions ("Let me...") - just use the tools. let cost = usage.rawCostUSD messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) + logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost) } } } catch { @@ -1589,6 +1597,10 @@ Don't narrate future actions ("Let me...") - just use the tools. streamingTask = Task { let startTime = Date() var wasCancelled = false + // Accumulates ToolCallDetail entries across every round of this tool-calling loop — + // collapsed into a single expandable summary message once the loop exits (success, + // cancellation, or error), instead of one persisted message per round. + var allToolCallDetails: [ToolCallDetail] = [] do { // Include web_search tool when online mode is on (not needed for OpenRouter — it handles search via :online suffix) let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter) @@ -1741,15 +1753,16 @@ Don't narrate future actions ("Let me...") - just use the tools. break } - // Show what tools the model is calling + // Show what tools the model is calling as a transient status line rather than + // a new chat message — see currentToolActivity's doc comment. let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ") - let toolMsgId = showSystemMessage("🔧 Calling: \(toolNames)") + currentToolActivity = String(localized: "🔧 Calling: \(toolNames)") - // Initialise detail entries with inputs (results fill in below) + // Initialise detail entries with inputs (results fill in below); appended to + // allToolCallDetails once this round finishes executing. var toolDetails: [ToolCallDetail] = toolCalls.map { tc in ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil) } - updateToolCallMessage(id: toolMsgId, details: toolDetails) let usingTextCalls = !textCalls.isEmpty @@ -1800,9 +1813,9 @@ Don't narrate future actions ("Let me...") - just use the tools. resultJSON = "{\"error\": \"Failed to serialize result\"}" } - // Update the detail entry with the result so the UI can show it + // Record the result so the collapsed summary message can show it once + // the whole tool-calling loop finishes. toolDetails[i].result = resultJSON - updateToolCallMessage(id: toolMsgId, details: toolDetails) if usingTextCalls { // Inject results as a user message for text-call models @@ -1822,6 +1835,8 @@ Don't narrate future actions ("Let me...") - just use the tools. apiMessages.append(["role": "user", "content": combined]) } + allToolCallDetails.append(contentsOf: toolDetails) + // If this was the last iteration, note it if iteration == maxIterations - 1 { hitIterationLimit = true // We're exiting with pending tool calls @@ -1834,6 +1849,8 @@ Don't narrate future actions ("Let me...") - just use the tools. wasCancelled = true } + flushToolCallSummary(allToolCallDetails) + // If we hit the iteration limit or the model returned no text at all, silently // nudge a follow-up turn instead of showing a placeholder/blank bubble. let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled @@ -1843,13 +1860,13 @@ Don't narrate future actions ("Let me...") - just use the tools. if willAutoContinue && finalContent.isEmpty { // Nothing worth showing yet — still record usage/cost for this turn. if let usage = totalUsage, let model = selectedModel { - let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil + let cost = Self.resolveCost(usage: usage, pricing: model.pricing) sessionStats.addMessage( inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost ) + logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost) } } else { let assistantMessage = Message( @@ -1868,8 +1885,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Calculate cost if let usage = totalUsage, let model = selectedModel { - let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil + let cost = Self.resolveCost(usage: usage, pricing: model.pricing) if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) { messages[index].cost = cost } @@ -1878,6 +1894,7 @@ Don't narrate future actions ("Let me...") - just use the tools. outputTokens: usage.completionTokens, cost: cost ) + logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost) } } @@ -1891,6 +1908,10 @@ Don't narrate future actions ("Let me...") - just use the tools. } catch { let responseTime = Date().timeIntervalSince(startTime) + // Same collapse as the success path — any tool rounds that completed before the + // error/cancellation are still worth keeping a record of. + flushToolCallSummary(allToolCallDetails) + // Check if this was a cancellation let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError @@ -1935,7 +1956,17 @@ Don't narrate future actions ("Let me...") - just use the tools. messages[idx].toolCalls = details } } - + + /// Clears the live tool-activity status line and, if any tool calls actually ran, collapses + /// them into a single persisted, expandable summary message — used on every exit path of + /// generateAIResponseWithTools's loop (success, cancellation, or error). + private func flushToolCallSummary(_ details: [ToolCallDetail]) { + currentToolActivity = nil + guard !details.isEmpty else { return } + let summaryId = showSystemMessage("🔧 Used ^[\(details.count) tool call](inflect: true)") + updateToolCallMessage(id: summaryId, details: details) + } + // MARK: - Error Helpers private func friendlyErrorMessage(from error: Error) -> String { @@ -2574,6 +2605,34 @@ Don't narrate future actions ("Let me...") - just use the tools. return inputCost + cacheReadCost + cacheWriteCost + outputCost } + /// Resolves a response's cost, preferring the provider's actual billed amount + /// (`usage.rawCostUSD` — e.g. OpenRouter's `usage.include` cost, needed for models priced + /// outside plain per-token rates like per-image generation) over token-based calculation. + /// Falls back to `nil` when neither the raw cost nor per-token pricing is available. + nonisolated static func resolveCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double? { + if let raw = usage.rawCostUSD { return raw } + guard pricing.prompt > 0 || pricing.completion > 0 else { return nil } + return calculateCost(usage: usage, pricing: pricing) + } + + /// Records usage independent of whether this conversation ever gets explicitly saved (⌘S) — + /// conversation *text* is opt-in, but tokens/cost/model/provider are tracked for every + /// completed generation regardless, so the Analytics view reflects real usage either way. + private func logUsageEvent(modelId: String, promptTokens: Int?, completionTokens: Int?, cost: Double?) { + do { + try DatabaseService.shared.logUsageEvent( + provider: currentProvider.rawValue, + modelId: modelId, + promptTokens: promptTokens, + completionTokens: completionTokens, + cost: cost, + conversationId: currentConversationId + ) + } catch { + Log.db.error("Failed to log usage event: \(error.localizedDescription)") + } + } + /// Summarize a chunk of messages into a concise summary private func summarizeMessageChunk(_ messages: [Message]) async -> String? { guard let provider = providerRegistry.getProvider(for: currentProvider), diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift index c993c92..f48342b 100644 --- a/oAI/Views/Main/ChatView.swift +++ b/oAI/Views/Main/ChatView.swift @@ -53,7 +53,7 @@ struct ChatView: View { // Processing indicator if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true { - ProcessingIndicator() + ProcessingIndicator(toolActivity: viewModel.currentToolActivity) .padding(.horizontal) } @@ -156,30 +156,43 @@ struct ChatView: View { } struct ProcessingIndicator: View { + /// Current tool round's status (e.g. "🔧 Calling: read_file"), replaced in place each round + /// rather than the chat accumulating a new row per round — see ChatViewModel.currentToolActivity. + let toolActivity: String? @State private var animating = false @State private var thinkingText = ThinkingVerbs.random() var body: some View { - HStack(spacing: 8) { - Text(thinkingText) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.confabSecondary) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(thinkingText) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.confabSecondary) - HStack(spacing: 4) { - ForEach(0..<3) { index in - Circle() - .fill(Color.confabSecondary) - .frame(width: 6, height: 6) - .scaleEffect(animating ? 1.0 : 0.5) - .animation( - .easeInOut(duration: 0.6) - .repeatForever() - .delay(Double(index) * 0.2), - value: animating - ) + HStack(spacing: 4) { + ForEach(0..<3) { index in + Circle() + .fill(Color.confabSecondary) + .frame(width: 6, height: 6) + .scaleEffect(animating ? 1.0 : 0.5) + .animation( + .easeInOut(duration: 0.6) + .repeatForever() + .delay(Double(index) * 0.2), + value: animating + ) + } } } + + if let toolActivity { + Text(toolActivity) + .font(.system(size: 12)) + .foregroundColor(.confabSecondary.opacity(0.75)) + .transition(.opacity) + } } + .animation(.easeInOut(duration: 0.15), value: toolActivity) .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color.confabSecondary.opacity(0.05)) diff --git a/oAI/Views/Main/SyncStatusIndicator.swift b/oAI/Views/Main/SyncStatusIndicator.swift index 59a54a0..91de639 100644 --- a/oAI/Views/Main/SyncStatusIndicator.swift +++ b/oAI/Views/Main/SyncStatusIndicator.swift @@ -47,7 +47,7 @@ enum SyncState { } } - var tooltipText: String { + var tooltipText: LocalizedStringKey { switch self { case .disabled: return "Auto-sync disabled" diff --git a/oAI/Views/Screens/JarvisView.swift b/oAI/Views/Screens/JarvisView.swift index 411cf2a..b0c9c32 100644 --- a/oAI/Views/Screens/JarvisView.swift +++ b/oAI/Views/Screens/JarvisView.swift @@ -30,6 +30,7 @@ struct JarvisView: View { @State private var isLoadingUsage = false @State private var selectedAgent: JarvisAgent? = nil @State private var editContext: AgentEditContext? = nil + @State private var selectedRun: JarvisAgentRun? = nil @State private var errorMessage: String? = nil @State private var actionInProgress: Set = [] @@ -91,6 +92,9 @@ struct JarvisView: View { await saveAgent(existing: ctx.agent, input: input) }) } + .sheet(item: $selectedRun) { run in + JarvisRunDetailSheet(run: run) + } } // MARK: - Agents Tab @@ -323,6 +327,8 @@ struct JarvisView: View { } else { List(agentRuns) { run in RunHistoryRow(run: run) + .contentShape(Rectangle()) + .onTapGesture { selectedRun = run } } .listStyle(.plain) } @@ -628,69 +634,41 @@ struct JarvisView: View { private struct RunHistoryRow: View { let run: JarvisAgentRun - @State private var expanded = false var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - statusIcon - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: 6) { - Text(run.formattedStarted) + HStack(spacing: 8) { + statusIcon + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(run.formattedStarted) + .font(.system(size: 12)) + if let dur = run.formattedDuration { + Text("· \(dur)") .font(.system(size: 12)) - if let dur = run.formattedDuration { - Text("· \(dur)") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - } - HStack(spacing: 8) { - if run.totalTokens > 0 { - Text("^[\(run.totalTokens) token](inflect: true)") - .font(.caption2) - .foregroundStyle(.secondary) - } - if let cost = run.costUsd, cost > 0 { - Text(String(format: "$%.5f", cost)) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - } + .foregroundStyle(.secondary) } } - Spacer() - if run.output != nil || run.error != nil { - Button { - withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } - } label: { - Image(systemName: expanded ? "chevron.up" : "chevron.down") + HStack(spacing: 8) { + if run.totalTokens > 0 { + Text("^[\(run.totalTokens) token](inflect: true)") .font(.caption2) .foregroundStyle(.secondary) } - .buttonStyle(.plain) + if let cost = run.costUsd, cost > 0 { + Text(String(format: "$%.5f", cost)) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } } } - - if expanded { - if let err = run.error { - Text(err) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.red) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.red.opacity(0.06)) - .cornerRadius(6) - .textSelection(.enabled) - } else if let out = run.output { - Text(out) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.primary) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.gray.opacity(0.07)) - .cornerRadius(6) - .lineLimit(20) - .textSelection(.enabled) - } + Spacer() + // Tapping the row (wired by the caller) opens JarvisRunDetailSheet with the full, + // untruncated output/error — this used to be an inline chevron-expand capped at + // lineLimit(20), which isn't "complete" for long output. + if run.output != nil || run.error != nil { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) } } .padding(.vertical, 4) @@ -698,29 +676,175 @@ private struct RunHistoryRow: View { @ViewBuilder private var statusIcon: some View { - switch run.status { + JarvisRunStatusIcon(status: run.status, size: 14) + } +} + +/// Shared between RunHistoryRow and JarvisRunDetailSheet so the two views can't drift apart. +fileprivate struct JarvisRunStatusIcon: View { + let status: String + var size: CGFloat = 14 + + var body: some View { + switch status { case "running": - ProgressView().scaleEffect(0.6).frame(width: 14, height: 14) - case "completed": + ProgressView().scaleEffect(size / 24).frame(width: size, height: size) + case "completed", "success": Image(systemName: "checkmark.circle.fill") - .font(.system(size: 14)) + .font(.system(size: size)) .foregroundStyle(.green) - case "failed": + case "failed", "error": Image(systemName: "xmark.circle.fill") - .font(.system(size: 14)) + .font(.system(size: size)) .foregroundStyle(.red) case "stopped": Image(systemName: "stop.circle.fill") - .font(.system(size: 14)) + .font(.system(size: size)) .foregroundStyle(.orange) default: Image(systemName: "circle") - .font(.system(size: 14)) + .font(.system(size: size)) .foregroundStyle(.secondary) } } } +// MARK: - Run Detail Sheet + +/// Full, untruncated view of a single run — opened by tapping a row in the run history list. +/// Output/error use an explicit Copy button rather than .textSelection(.enabled): a multi-line +/// selectable Text can grab real AppKit first-responder status, and Escape then gets consumed by +/// its own cancelOperation: handling instead of dismissing the sheet (silent beep, no log) — see +/// the identical bug fixed in ModelInfoView. +struct JarvisRunDetailSheet: View { + let run: JarvisAgentRun + + @Environment(\.dismiss) var dismiss + @State private var showOutputCopied = false + @State private var showErrorCopied = false + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Run Details") + .font(.system(size: 18, weight: .bold)) + Spacer() + JarvisRunStatusIcon(status: run.status, size: 16) + Text(run.status.capitalized) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(.leading, 8) + .keyboardShortcut(.escape, modifiers: []) + } + .padding(.horizontal, 24) + .padding(.top, 20) + .padding(.bottom, 12) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + detailRow("Started", run.formattedStarted) + if let dur = run.formattedDuration { + detailRow("Duration", dur) + } + if let trigger = run.triggerType { + detailRow("Trigger", trigger.capitalized) + } + if run.totalTokens > 0 { + detailRow("Tokens", "\(run.totalTokens.formatted()) (\((run.inputTokens ?? 0).formatted()) in / \((run.outputTokens ?? 0).formatted()) out)") + } + if let cost = run.costUsd, cost > 0 { + detailRow("Cost", String(format: "$%.5f", cost)) + } + } + + if let err = run.error { + Divider() + outputBlock(title: "Error", text: err, color: .red, showCopied: $showErrorCopied) + } + if let out = run.output { + Divider() + outputBlock(title: "Output", text: out, color: .primary, showCopied: $showOutputCopied) + } + if run.output == nil && run.error == nil { + Text("No output for this run.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + .padding(24) + } + } + .frame(minWidth: 600, idealWidth: 700, minHeight: 450, idealHeight: 620) + .onExitCommand { dismiss() } + } + + private func detailRow(_ label: LocalizedStringKey, _ value: String) -> some View { + HStack { + Text(label) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(width: 90, alignment: .leading) + Text(value) + .font(.system(size: 13)) + Spacer() + } + } + + @ViewBuilder + private func outputBlock(title: LocalizedStringKey, text: String, color: Color, showCopied: Binding) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(title) + .font(.subheadline.weight(.medium)) + .foregroundColor(.secondary) + Spacer() + Button(action: { copy(text, showCopied: showCopied) }) { + HStack(spacing: 3) { + Image(systemName: showCopied.wrappedValue ? "checkmark" : "doc.on.doc") + .font(.system(size: 11)) + if showCopied.wrappedValue { + Text("Copied!") + .font(.system(size: 11)) + } + } + .foregroundColor(showCopied.wrappedValue ? .green : .secondary) + } + .buttonStyle(.plain) + } + Text(text) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(color) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(color.opacity(0.06)) + .cornerRadius(6) + } + } + + private func copy(_ text: String, showCopied: Binding) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + withAnimation { + showCopied.wrappedValue = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + showCopied.wrappedValue = false + } + } + } +} + // MARK: - Agent Editor Sheet struct JarvisAgentEditorSheet: View { diff --git a/oAI/Views/Screens/ModelInfoView.swift b/oAI/Views/Screens/ModelInfoView.swift index 5b3aacf..b5c843a 100644 --- a/oAI/Views/Screens/ModelInfoView.swift +++ b/oAI/Views/Screens/ModelInfoView.swift @@ -28,7 +28,7 @@ struct ModelInfoView: View { @Environment(\.dismiss) var dismiss @Bindable private var settings = SettingsService.shared - @State private var isDescriptionExpanded = false + @State private var showDescriptionCopied = false var body: some View { VStack(spacing: 0) { @@ -73,25 +73,42 @@ struct ModelInfoView: View { infoRow("Released", releaseDate.formatted(date: .abbreviated, time: .omitted)) } if let desc = model.description { + // Always shown in full, no truncate/expand toggle — Text with a lineLimit + // nested inside this view's ScrollView doesn't reliably compute wrapping/ + // truncation (a well-documented SwiftUI/AppKit quirk: without a fixedSize + // hint it hard-clips mid-word with no ellipsis; with one, sibling views in + // the same VStack — like the former "More…" button — can silently fail to + // lay out). The modal itself already scrolls, so a long description just + // means more scrolling, which sidesteps the whole bug class. VStack(alignment: .leading, spacing: 6) { - Text("Description") - .font(.subheadline.weight(.medium)) - .foregroundColor(.secondary) + HStack(spacing: 6) { + Text("Description") + .font(.subheadline.weight(.medium)) + .foregroundColor(.secondary) + Spacer() + // A Copy button instead of .textSelection(.enabled): clicking into + // a multi-line selectable Text hands it real AppKit first-responder + // status, and Escape then gets consumed by that text view's own + // cancelOperation: handling before it ever reaches this modal's + // onExitCommand — the beep-instead-of-dismiss bug Rune reported. + // infoRow's single-line values keep .textSelection(.enabled); only + // this multi-line block reproduced the bug. + Button(action: copyDescription) { + HStack(spacing: 3) { + Image(systemName: showDescriptionCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: 11)) + if showDescriptionCopied { + Text("Copied!") + .font(.system(size: 11)) + } + } + .foregroundColor(showDescriptionCopied ? .green : .secondary) + } + .buttonStyle(.plain) + } Text(desc) .font(.body) .foregroundColor(.primary) - .lineLimit(isDescriptionExpanded ? nil : 4) - .textSelection(.enabled) - if desc.count > 250 { - Button(isDescriptionExpanded ? "Less" : "More…") { - withAnimation(.easeInOut(duration: 0.2)) { - isDescriptionExpanded.toggle() - } - } - .font(.callout) - .foregroundStyle(.blue) - .buttonStyle(.plain) - } } .padding(.leading, 4) } @@ -217,6 +234,29 @@ struct ModelInfoView: View { .padding(.vertical, 12) } .frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750) + // Nearly every value in this modal has .textSelection(.enabled) (infoRow's value text, + // the description). Once one of those has text-selection focus, Escape can get + // intercepted by AppKit's text-selection machinery instead of reaching the close + // button's .keyboardShortcut(.escape) — no action is bound there, so it just beeps + // instead of dismissing. onExitCommand is macOS's dedicated hook for the "Escape/Cancel" + // user command and fires regardless of which child currently holds focus, so it's a more + // reliable place to handle this than a single button's keyboardShortcut alone. + .onExitCommand { dismiss() } + } + + private func copyDescription() { + guard let desc = model.description else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(desc, forType: .string) + withAnimation { + showDescriptionCopied = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + showDescriptionCopied = false + } + } } // MARK: - Layout Helpers diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index d0cc9c1..0c2c23d 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -25,6 +25,13 @@ import SwiftUI import UniformTypeIdentifiers import FoundationModels +/// One editable row in the External MCP Servers "add" sheet's env var / custom header lists. +private struct MCPKeyValuePair: Identifiable { + let id = UUID() + var key: String = "" + var value: String = "" +} + struct SettingsView: View { @Environment(\.dismiss) var dismiss @Bindable private var settingsService = SettingsService.shared @@ -77,8 +84,13 @@ struct SettingsView: View { // External MCP Servers state @State private var showAddExternalMCPServer = false @State private var newMCPServerName = "" + @State private var newMCPServerTransportKind: MCPTransportKind = .stdio @State private var newMCPServerCommand = "" @State private var newMCPServerArgs = "" + @State private var newMCPServerEnvPairs: [MCPKeyValuePair] = [] + @State private var newMCPServerURL = "" + @State private var newMCPServerBearerToken = "" + @State private var newMCPServerHeaderPairs: [MCPKeyValuePair] = [] @State private var newMCPServerTimeout: Double = 30 private var externalMCPManager = ExternalMCPManager.shared @@ -113,6 +125,11 @@ struct SettingsView: View { @State private var isTestingEmailConnection = false @State private var emailConnectionTestResult: String? + // CLI server state + @State private var showCLIModelSelector = false + @State private var cliAvailableModels: [ModelInfo] = [] + @State private var isLoadingCLIModels = false + private let labelWidth: CGFloat = 160 // Default system prompt - generic for all models @@ -468,17 +485,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .labelsHidden() .fixedSize() } + // Always visible (not gated behind Search Provider == .google): this key is also + // used for Google embeddings in Semantic Search (Advanced tab), which has nothing + // to do with which web search provider is selected. Search Engine ID is web-search + // specific, so that one stays conditional. + rowDivider() + row("Google API Key") { + SecureField("", text: $googleKey) + .textFieldStyle(.roundedBorder) + .frame(width: 300) + .onAppear { googleKey = settingsService.googleAPIKey ?? "" } + .onChange(of: googleKey) { + settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey + } + } + VStack(alignment: .leading, spacing: 2) { + Text("Used for Google web search (if selected above) and for Google embeddings in Settings → Advanced → Semantic Search.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.bottom, 4) if settingsService.searchProvider == .google { - rowDivider() - row("Google API Key") { - SecureField("", text: $googleKey) - .textFieldStyle(.roundedBorder) - .frame(width: 300) - .onAppear { googleKey = settingsService.googleAPIKey ?? "" } - .onChange(of: googleKey) { - settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey - } - } rowDivider() row("Search Engine ID") { TextField("", text: $googleEngineID) @@ -911,6 +939,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak Divider() externalMCPSection + // MARK: CLI Access + Divider() + cliServerSection + // MARK: Personal Data if !PersonalDataTools.isHiddenPendingAppleFix { Divider() @@ -1040,7 +1072,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak VStack(alignment: .leading, spacing: 2) { Text(server.name) .font(.system(size: 14)) - Text(([server.command] + server.args).joined(separator: " ")) + Text(server.transportKind == .http ? server.url : ([server.command] + server.args).joined(separator: " ")) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(1) @@ -1075,8 +1107,13 @@ It's better to admit "I need more information" or "I cannot do that" than to fak Button { newMCPServerName = "" + newMCPServerTransportKind = .stdio newMCPServerCommand = "" newMCPServerArgs = "" + newMCPServerEnvPairs = [] + newMCPServerURL = "" + newMCPServerBearerToken = "" + newMCPServerHeaderPairs = [] newMCPServerTimeout = 30 showAddExternalMCPServer = true } label: { @@ -1095,6 +1132,169 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } + // MARK: - CLI Access Section + + @ViewBuilder + private var cliServerSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "terminal") + .font(.title2) + .foregroundStyle(.green) + Text("CLI Access") + .font(.system(size: 18, weight: .semibold)) + } + Text("Expose a local socket so a shell command (like a zsh \"ai\" function) can get a one-shot text reply from a single fixed model, without opening the app window. Confab must be running.") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.bottom, 4) + .onAppear { + Task { await loadCLIModels() } + } + .sheet(isPresented: $showCLIModelSelector) { + ModelSelectorView( + models: cliAvailableModels.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }, + selectedModel: cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel }) + ) { selectedModel in + settingsService.cliServerModel = selectedModel.id + showCLIModelSelector = false + CLIServerService.shared.restart() + } + } + + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Status") + formSection { + row("Enable CLI Access") { + Toggle("", isOn: $settingsService.cliServerEnabled) + .toggleStyle(.switch) + .onChange(of: settingsService.cliServerEnabled) { + CLIServerService.shared.restart() + } + } + } + } + + if settingsService.cliServerEnabled { + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Model") + formSection { + row("Provider") { + Picker("", selection: $settingsService.cliServerProvider) { + ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in + Text(provider.displayName).tag(provider.rawValue) + } + } + .labelsHidden() + .frame(width: 250) + .onChange(of: settingsService.cliServerProvider) { + Task { await loadCLIModels() } + CLIServerService.shared.restart() + } + } + rowDivider() + row("Model") { + if isLoadingCLIModels { + ProgressView().scaleEffect(0.7).frame(width: 250, alignment: .leading) + } else if cliAvailableModels.isEmpty { + Text("No models available") + .font(.system(size: settingsService.guiTextSize)) + .foregroundColor(.secondary) + .frame(width: 250, alignment: .leading) + } else { + Button(action: { showCLIModelSelector = true }) { + HStack { + Text(cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })?.name ?? "Select model...") + .font(.system(size: settingsService.guiTextSize)) + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .frame(width: 250) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + } + } + } + + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Shell Function") + Text("Add this to your ~/.zshrc, then run `ai \"your prompt\"` in Terminal:") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Text(Self.cliShellFunctionSnippet) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Text("Requires jq (brew install jq).") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + } + + // `noglob` (applied via the alias, not inside the function) stops zsh from trying to glob-expand + // an unquoted prompt containing `?`/`*`/`[...]` — e.g. `ai Who are you?` — before `_ai_impl` ever + // runs. A plain `ai() { ... }` function can't protect its own call site this way: filename + // generation happens during command-line parsing, before the shell has even decided this is a + // function call. Quoting the prompt is still the fully robust habit for other shell metacharacters + // (`;`, `|`, backticks, `$()`), but this covers the specific class of crash users are most likely + // to hit by accident (an unquoted question ending in "?"). + // Deliberately one line, no backslash continuations: a `\` line-continuation is silently + // broken by a trailing space or a dropped backslash from copy/pasting out of a chat UI or + // browser — each following line then gets parsed as its own bogus command ("command not + // found: -H", etc.). A long single line has no continuation character to mangle. + private static let cliShellFunctionSnippet = """ + _ai_impl() { + curl -s --unix-socket "$HOME/Library/Application Support/oAI/cli.sock" -H "Content-Type: application/json" -d "$(jq -n --arg p "$*" '{prompt: $p}')" http://localhost/ | jq -r 'if .error then "Error: " + .error else .response end' + } + alias ai='noglob _ai_impl' + """ + + private func loadCLIModels() async { + guard settingsService.cliServerEnabled else { + cliAvailableModels = [] + return + } + + let providerRawValue = settingsService.cliServerProvider + guard let providerType = Settings.Provider(rawValue: providerRawValue), + let provider = ProviderRegistry.shared.getProvider(for: providerType) else { + cliAvailableModels = [] + return + } + + isLoadingCLIModels = true + defer { isLoadingCLIModels = false } + + do { + let models = try await provider.listModels() + cliAvailableModels = models + + if !models.contains(where: { $0.id == settingsService.cliServerModel }) { + if let firstModel = models.first { + settingsService.cliServerModel = firstModel.id + } + } + } catch { + Log.ui.error("Failed to load CLI server models: \(error.localizedDescription)") + cliAvailableModels = [] + } + } + @ViewBuilder private var addExternalMCPServerSheet: some View { VStack(alignment: .leading, spacing: 20) { @@ -1109,19 +1309,47 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .frame(width: 240) } rowDivider() - row("Command") { - TextField("safaridriver", text: $newMCPServerCommand) - .textFieldStyle(.roundedBorder) - .font(.system(size: 13, design: .monospaced)) - .frame(width: 240) + row("Type") { + Picker("", selection: $newMCPServerTransportKind) { + Text("Command").tag(MCPTransportKind.stdio) + Text("Remote (HTTP)").tag(MCPTransportKind.http) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 240) + .help("Command: a local program Confab launches itself. Remote (HTTP): an already-running MCP server reachable by URL.") } rowDivider() - row("Arguments") { - TextField("--mcp", text: $newMCPServerArgs) - .textFieldStyle(.roundedBorder) - .font(.system(size: 13, design: .monospaced)) - .frame(width: 240) - .help("Space-separated arguments") + switch newMCPServerTransportKind { + case .stdio: + row("Command") { + TextField("safaridriver", text: $newMCPServerCommand) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13, design: .monospaced)) + .frame(width: 240) + } + rowDivider() + row("Arguments") { + TextField("--mcp", text: $newMCPServerArgs) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13, design: .monospaced)) + .frame(width: 240) + .help("Space-separated arguments") + } + case .http: + row("Server URL") { + TextField("http://127.0.0.1:27123/mcp/", text: $newMCPServerURL) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13, design: .monospaced)) + .frame(width: 240) + } + rowDivider() + row("Bearer Token") { + SecureField("Optional", text: $newMCPServerBearerToken) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13, design: .monospaced)) + .frame(width: 240) + } } rowDivider() row("Timeout") { @@ -1136,6 +1364,15 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } + formSection { + switch newMCPServerTransportKind { + case .stdio: + mcpKeyValueEditor(title: "Environment Variables", pairs: $newMCPServerEnvPairs) + case .http: + mcpKeyValueEditor(title: "Extra Headers", pairs: $newMCPServerHeaderPairs) + } + } + if !newMCPServerName.isEmpty { let slug = ExternalMCPServer.makeSlug(from: newMCPServerName) HStack(spacing: 6) { @@ -1157,25 +1394,93 @@ It's better to admit "I need more information" or "I cannot do that" than to fak Button("Cancel") { showAddExternalMCPServer = false } Spacer() Button("Add") { - let args = ExternalMCPServer.parseArguments(newMCPServerArgs) - let server = ExternalMCPServer( - name: newMCPServerName, - command: newMCPServerCommand, - args: args, - timeout: newMCPServerTimeout - ) + let server: ExternalMCPServer + switch newMCPServerTransportKind { + case .stdio: + server = ExternalMCPServer( + name: newMCPServerName, + transportKind: .stdio, + command: newMCPServerCommand, + args: ExternalMCPServer.parseArguments(newMCPServerArgs), + env: mcpDictionary(from: newMCPServerEnvPairs), + timeout: newMCPServerTimeout + ) + case .http: + server = ExternalMCPServer( + name: newMCPServerName, + transportKind: .http, + url: newMCPServerURL, + bearerToken: newMCPServerBearerToken, + headers: mcpDictionary(from: newMCPServerHeaderPairs), + timeout: newMCPServerTimeout + ) + } settingsService.addExternalMCPServer(server) showAddExternalMCPServer = false } .buttonStyle(.borderedProminent) - .disabled(newMCPServerName.isEmpty || newMCPServerCommand.isEmpty || - ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName))) + .disabled(isAddExternalMCPServerDisabled) } } .padding(24) .frame(minWidth: 460, minHeight: 320) } + private var isAddExternalMCPServerDisabled: Bool { + if newMCPServerName.isEmpty { return true } + if ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)) { return true } + switch newMCPServerTransportKind { + case .stdio: return newMCPServerCommand.isEmpty + case .http: return newMCPServerURL.isEmpty + } + } + + private func mcpDictionary(from pairs: [MCPKeyValuePair]) -> [String: String] { + Dictionary(uniqueKeysWithValues: pairs.filter { !$0.key.isEmpty }.map { ($0.key, $0.value) }) + } + + @ViewBuilder + private func mcpKeyValueEditor(title: LocalizedStringKey, pairs: Binding<[MCPKeyValuePair]>) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + Spacer() + Button { + pairs.wrappedValue.append(MCPKeyValuePair()) + } label: { + Image(systemName: "plus.circle.fill") + } + .buttonStyle(.plain) + } + ForEach(pairs) { $pair in + HStack(spacing: 6) { + TextField("Key", text: $pair.key) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + SecureField("Value", text: $pair.value) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + Button { + pairs.wrappedValue.removeAll { $0.id == pair.id } + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } + if pairs.wrappedValue.isEmpty { + Text("None configured") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + private func mcpStatusColor(_ state: MCPClientState?) -> Color { switch state { case .ready: return .green @@ -1500,7 +1805,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 4) } else { - Text("⚠️ No embedding providers available. Configure an API key for OpenAI, OpenRouter, or Google in the General tab.") + Text("⚠️ No embedding providers available. Configure an API key for OpenAI, OpenRouter, or Google (under General → Web Search) in the General tab.") .font(.system(size: 13)) .foregroundStyle(.orange) .fixedSize(horizontal: false, vertical: true) diff --git a/oAI/Views/Screens/StatsView.swift b/oAI/Views/Screens/StatsView.swift index e2738b4..96c91a7 100644 --- a/oAI/Views/Screens/StatsView.swift +++ b/oAI/Views/Screens/StatsView.swift @@ -38,16 +38,26 @@ struct StatsView: View { @State private var overallStats = UsageStats() @State private var modelStats: [ModelUsageStat] = [] @State private var conversationStats: [ConversationUsageStat] = [] + @State private var showAnalytics = false var body: some View { NavigationStack { VStack(spacing: 0) { - Picker("", selection: $selectedTab) { - Text("Session").tag(StatsTab.session) - Text("All-Time").tag(StatsTab.allTime) + HStack(spacing: 8) { + Picker("", selection: $selectedTab) { + Text("Session").tag(StatsTab.session) + Text("All-Time").tag(StatsTab.allTime) + } + .pickerStyle(.segmented) + .labelsHidden() + + Button { + showAnalytics = true + } label: { + Image(systemName: "chart.bar.xaxis") + } + .help("View detailed usage analytics") } - .pickerStyle(.segmented) - .labelsHidden() .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 4) @@ -74,6 +84,9 @@ struct StatsView: View { .task { loadAllTimeStats() } + .sheet(isPresented: $showAnalytics) { + UsageAnalyticsView() + } } private var sessionList: some View { diff --git a/oAI/Views/Screens/UsageAnalyticsView.swift b/oAI/Views/Screens/UsageAnalyticsView.swift new file mode 100644 index 0000000..a2e6bf3 --- /dev/null +++ b/oAI/Views/Screens/UsageAnalyticsView.swift @@ -0,0 +1,418 @@ +// +// UsageAnalyticsView.swift +// Confab +// +// Usage analytics: tokens / questions / money, over time and by model +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen +// +// This file is part of Confab. +// +// Confab is licensed under the PolyForm Noncommercial License 1.0.0. +// You may use, study, modify, and share it for any noncommercial +// purpose. Commercial use — including selling Confab or any part of +// it, standalone or bundled into another product or service — +// requires a separate commercial license from the copyright holder. +// +// See the LICENSE file or +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +import SwiftUI +import Charts + +private enum AnalyticsMetric: String, CaseIterable, Identifiable { + case tokens + case questions + case cost + + var id: String { rawValue } + + var icon: String { + switch self { + case .tokens: return "number" + case .questions: return "bubble.left.and.bubble.right" + case .cost: return "dollarsign.circle" + } + } + + var color: Color { + switch self { + case .tokens: return .confabAccent + case .questions: return .confabSuccess + case .cost: return .confabWarning + } + } +} + +private enum AnalyticsChartMode: String, CaseIterable, Identifiable { + case overTime + case byModel + case byProvider + + var id: String { rawValue } +} + +struct UsageAnalyticsView: View { + @Environment(\.dismiss) var dismiss + + @State private var timeframe: AnalyticsTimeframe = .last7Days + @State private var metric: AnalyticsMetric = .tokens + @State private var chartMode: AnalyticsChartMode = .overTime + + @State private var overallStats = UsageStats() + @State private var modelStats: [ModelUsageStat] = [] + @State private var providerStats: [ProviderUsageStat] = [] + @State private var dailyStats: [DailyUsageStat] = [] + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + timeframePicker + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + summaryTiles + + VStack(alignment: .leading, spacing: 12) { + chartModePicker + chartSection + } + + if chartMode == .byModel && !modelStats.isEmpty { + modelBreakdownList + } + + if chartMode == .byProvider && !providerStats.isEmpty { + providerBreakdownList + } + } + .padding(20) + } + } + .navigationTitle("Analytics") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .frame(minWidth: 700, idealWidth: 780, minHeight: 620, idealHeight: 680) + } + .task(id: timeframe) { + loadStats() + } + } + + // MARK: - Timeframe + + private var timeframePicker: some View { + Picker("", selection: $timeframe) { + Text("Today").tag(AnalyticsTimeframe.today) + Text("7 Days").tag(AnalyticsTimeframe.last7Days) + Text("Week").tag(AnalyticsTimeframe.week) + Text("Month").tag(AnalyticsTimeframe.month) + Text("Year").tag(AnalyticsTimeframe.year) + Text("Total").tag(AnalyticsTimeframe.total) + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 12) + } + + // MARK: - Summary Tiles + + private var summaryTiles: some View { + HStack(spacing: 12) { + MetricTile( + title: "Tokens", + value: overallStats.totalTokensDisplay, + icon: AnalyticsMetric.tokens.icon, + color: AnalyticsMetric.tokens.color, + isSelected: metric == .tokens + ) { metric = .tokens } + + MetricTile( + title: "Questions", + value: "\(overallStats.totalQuestions)", + icon: AnalyticsMetric.questions.icon, + color: AnalyticsMetric.questions.color, + isSelected: metric == .questions + ) { metric = .questions } + + MetricTile( + title: "Money Used", + value: overallStats.totalCostDisplay, + icon: AnalyticsMetric.cost.icon, + color: AnalyticsMetric.cost.color, + isSelected: metric == .cost + ) { metric = .cost } + } + } + + // MARK: - Chart + + private var chartModePicker: some View { + Picker("", selection: $chartMode) { + Text("Over Time").tag(AnalyticsChartMode.overTime) + Text("By Model").tag(AnalyticsChartMode.byModel) + Text("By Provider").tag(AnalyticsChartMode.byProvider) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 400) + } + + @ViewBuilder + private var chartSection: some View { + switch chartMode { + case .overTime: + timeSeriesChart + case .byModel: + byModelChart + case .byProvider: + byProviderChart + } + } + + private var timeSeriesChart: some View { + Group { + if dailyStats.isEmpty { + emptyChartPlaceholder + } else { + Chart(dailyStats) { stat in + BarMark( + x: .value("Day", stat.day, unit: .day), + y: .value("Value", dailyValue(for: stat)) + ) + .foregroundStyle(metric.color) + } + .frame(height: 220) + } + } + } + + private var byModelChart: some View { + Group { + if modelStats.isEmpty { + emptyChartPlaceholder + } else { + Chart(modelStats) { stat in + SectorMark( + angle: .value("Value", modelValue(for: stat)), + innerRadius: .ratio(0.55), + angularInset: 1.5 + ) + .foregroundStyle(by: .value("Model", stat.modelId)) + .cornerRadius(4) + } + .frame(height: 240) + .chartLegend(position: .bottom, alignment: .center, spacing: 8) + } + } + } + + private var byProviderChart: some View { + Group { + if providerStats.isEmpty { + emptyChartPlaceholder + } else { + Chart(providerStats) { stat in + SectorMark( + angle: .value("Value", providerValue(for: stat)), + innerRadius: .ratio(0.55), + angularInset: 1.5 + ) + .foregroundStyle(by: .value("Provider", providerDisplayName(stat.provider))) + .cornerRadius(4) + } + .frame(height: 240) + .chartLegend(position: .bottom, alignment: .center, spacing: 8) + } + } + } + + private var emptyChartPlaceholder: some View { + VStack(spacing: 8) { + Image(systemName: "chart.bar.xaxis") + .font(.system(size: 32)) + .foregroundColor(.secondary) + Text("No usage data for this timeframe") + .font(.callout) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .frame(height: 220) + } + + // MARK: - By Model List + + private var modelBreakdownList: some View { + VStack(alignment: .leading, spacing: 4) { + Text("By Model") + .font(.headline) + .padding(.bottom, 4) + + ForEach(modelStats) { stat in + HStack { + Text(stat.modelId) + .font(.body) + .lineLimit(1) + Spacer() + Text(modelValueDisplay(for: stat)) + .font(.body.monospacedDigit()) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + + if stat.id != modelStats.last?.id { + Divider() + } + } + } + .padding(16) + .background(Color.confabSurface.opacity(0.5)) + .cornerRadius(10) + } + + // MARK: - By Provider List + + private var providerBreakdownList: some View { + VStack(alignment: .leading, spacing: 4) { + Text("By Provider") + .font(.headline) + .padding(.bottom, 4) + + ForEach(providerStats) { stat in + HStack { + Text(providerDisplayName(stat.provider)) + .font(.body) + .lineLimit(1) + Spacer() + Text(providerValueDisplay(for: stat)) + .font(.body.monospacedDigit()) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + + if stat.id != providerStats.last?.id { + Divider() + } + } + } + .padding(16) + .background(Color.confabSurface.opacity(0.5)) + .cornerRadius(10) + } + + private func providerDisplayName(_ rawProvider: String) -> String { + Settings.Provider(rawValue: rawProvider)?.displayName ?? rawProvider + } + + // MARK: - Value helpers + + private func dailyValue(for stat: DailyUsageStat) -> Double { + switch metric { + case .tokens: return Double(stat.totalTokens) + case .questions: return Double(stat.questionCount) + case .cost: return stat.totalCost + } + } + + private func modelValue(for stat: ModelUsageStat) -> Double { + switch metric { + case .tokens: return Double(stat.totalTokens) + case .questions: return Double(stat.questionCount) + case .cost: return stat.totalCost + } + } + + private func modelValueDisplay(for stat: ModelUsageStat) -> String { + switch metric { + case .tokens: return stat.totalTokensDisplay + case .questions: return "\(stat.questionCount)" + case .cost: return stat.totalCostDisplay + } + } + + private func providerValue(for stat: ProviderUsageStat) -> Double { + switch metric { + case .tokens: return Double(stat.totalTokens) + case .questions: return Double(stat.questionCount) + case .cost: return stat.totalCost + } + } + + private func providerValueDisplay(for stat: ProviderUsageStat) -> String { + switch metric { + case .tokens: return stat.totalTokensDisplay + case .questions: return "\(stat.questionCount)" + case .cost: return stat.totalCostDisplay + } + } + + // MARK: - Data Loading + + /// Sourced from `usage_events`, not `messages` — reflects usage for every completed + /// generation regardless of whether the conversation it belongs to was ever saved. + private func loadStats() { + let range = timeframe.dateRange() + overallStats = (try? DatabaseService.shared.getUsageEventTotals(from: range.start, to: range.end)) ?? UsageStats() + modelStats = (try? DatabaseService.shared.getUsageEventsByModel(from: range.start, to: range.end)) ?? [] + providerStats = (try? DatabaseService.shared.getUsageEventsByProvider(from: range.start, to: range.end)) ?? [] + + let dailyFrom = range.start ?? overallStats.firstMessageDate ?? range.end + dailyStats = (try? DatabaseService.shared.getDailyUsageEvents(from: dailyFrom, to: range.end)) ?? [] + } +} + +// MARK: - Metric Tile + +private struct MetricTile: View { + let title: LocalizedStringKey + let value: String + let icon: String + let color: Color + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: icon) + .foregroundColor(color) + Spacer() + } + Text(value) + .font(.title2.monospacedDigit()) + .fontWeight(.bold) + .foregroundColor(.primary) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(title) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isSelected ? color.opacity(0.15) : Color.confabSurface.opacity(0.5)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isSelected ? color : Color.clear, lineWidth: 1.5) + ) + .cornerRadius(10) + } + .buttonStyle(.plain) + } +} + +#Preview { + UsageAnalyticsView() +} diff --git a/oAI/oAI-InfoPlist.xcstrings b/oAI/oAI-InfoPlist.xcstrings index 11e8759..cb3c243 100644 --- a/oAI/oAI-InfoPlist.xcstrings +++ b/oAI/oAI-InfoPlist.xcstrings @@ -3,7 +3,7 @@ "strings" : { "CFBundleName" : { "comment" : "Bundle name", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -36,54 +36,6 @@ } } } - }, - "NSCalendarsFullAccessUsageDescription" : { - "comment" : "Privacy - Calendars Full Access Usage Description", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Confab can read and create calendar events when you ask it to, if you enable Calendar access in Settings." - } - } - } - }, - "NSContactsUsageDescription" : { - "comment" : "Privacy - Contacts Usage Description", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Confab can search your contacts when you ask it to, if you enable Contacts access in Settings." - } - } - } - }, - "NSLocationWhenInUseUsageDescription" : { - "comment" : "Privacy - Location When In Use Usage Description", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Confab can use your current location to answer questions, if you enable Location & Maps access in Settings." - } - } - } - }, - "NSRemindersFullAccessUsageDescription" : { - "comment" : "Privacy - Reminders Full Access Usage Description", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Confab can read and create reminders when you ask it to, if you enable Reminders access in Settings." - } - } - } } }, "version" : "1.1" diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index f74a147..c9ce2f5 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -73,6 +73,9 @@ struct oAIApp: App { // Start email handler on app launch EmailHandlerService.shared.start() + // Start the local CLI server (Settings > Advanced > CLI Access) — no-op if disabled + CLIServerService.shared.start() + // Start external MCP servers Task { @MainActor in ExternalMCPManager.shared.startAll() } @@ -88,6 +91,17 @@ struct oAIApp: App { // Check for updates in the background UpdateCheckService.shared.checkForUpdates() + + // Periodic heartbeat: on its own, a quiet Confab.log is ambiguous — it could mean the + // user just wasn't chatting, or that the app silently froze (confirmed possible on + // 2026-08-14: a stuck process left the CLI socket open per `lsof` yet refused every + // connection, with the log not written to at all for the rest of that session — no + // crash, no error, nothing to distinguish "idle" from "wedged" after the fact). A + // regular, otherwise-meaningless log line turns that ambiguity into a plain read: if the + // *next* freeze happens, the gap since the last heartbeat pins down roughly when. + Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in + Log.general.info("heartbeat") + } } var body: some Scene { @@ -106,6 +120,7 @@ struct oAIApp: App { } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in Task { @MainActor in ExternalMCPManager.shared.stopAll() } + CLIServerService.shared.stop() } #endif } diff --git a/oAITests/CLIServerServiceTests.swift b/oAITests/CLIServerServiceTests.swift new file mode 100644 index 0000000..4f5b663 --- /dev/null +++ b/oAITests/CLIServerServiceTests.swift @@ -0,0 +1,107 @@ +// +// CLIServerServiceTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import Confab + +@Suite("CLIServerService pure HTTP helpers") +struct CLIServerServiceTests { + + // MARK: - parseRequestBody + + @Test("Returns the body once headers and full Content-Length body have arrived") + func parsesCompleteRequest() { + let body = "{\"prompt\":\"hi\"}" + let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)" + let result = CLIServerService.parseRequestBody(from: Data(raw.utf8)) + #expect(result.map { String(data: $0, encoding: .utf8) } == body) + } + + @Test("Returns nil when the header block hasn't fully arrived yet") + func returnsNilForIncompleteHeaders() { + let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5" + #expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil) + } + + @Test("Returns nil when the body hasn't fully arrived yet") + func returnsNilForIncompleteBody() { + let raw = "POST / HTTP/1.1\r\nContent-Length: 20\r\n\r\n{\"prompt\":\"hi\"}" + #expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil) + } + + @Test("Content-Length header name match is case-insensitive") + func headerNameIsCaseInsensitive() { + let body = "abc" + let raw = "POST / HTTP/1.1\r\ncontent-length: 3\r\n\r\n\(body)" + let result = CLIServerService.parseRequestBody(from: Data(raw.utf8)) + #expect(result.map { String(data: $0, encoding: .utf8) } == body) + } + + @Test("Missing Content-Length is treated as a zero-length body") + func missingContentLengthIsEmptyBody() { + let raw = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n" + let result = CLIServerService.parseRequestBody(from: Data(raw.utf8)) + #expect(result?.isEmpty == true) + } + + @Test("Extra trailing bytes beyond Content-Length don't prevent extraction (pipelined data ignored)") + func extraTrailingBytesStillExtractsBody() { + let body = "abc" + let raw = "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\n\(body)EXTRA" + let result = CLIServerService.parseRequestBody(from: Data(raw.utf8)) + #expect(result.map { String(data: $0, encoding: .utf8) } == body) + } + + // MARK: - Response encoding + + @Test("successResponse produces a 200 with the response field set") + func successResponseShape() throws { + let data = CLIServerService.successResponse("hello world") + let text = String(data: data, encoding: .utf8)! + #expect(text.hasPrefix("HTTP/1.1 200 OK\r\n")) + #expect(text.contains("Content-Type: application/json")) + + let bodyStart = text.range(of: "\r\n\r\n")!.upperBound + let bodyJSON = Data(text[bodyStart...].utf8) + let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON) + #expect(decoded.response == "hello world") + #expect(decoded.error == nil) + } + + @Test("errorResponse defaults to 400 Bad Request") + func errorResponseDefaultStatus() throws { + let data = CLIServerService.errorResponse("bad input") + let text = String(data: data, encoding: .utf8)! + #expect(text.hasPrefix("HTTP/1.1 400 Bad Request\r\n")) + + let bodyStart = text.range(of: "\r\n\r\n")!.upperBound + let bodyJSON = Data(text[bodyStart...].utf8) + let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON) + #expect(decoded.error == "bad input") + #expect(decoded.response == nil) + } + + @Test("errorResponse supports a custom status code") + func errorResponseCustomStatus() { + let data = CLIServerService.errorResponse("provider failed", statusCode: 500) + let text = String(data: data, encoding: .utf8)! + #expect(text.hasPrefix("HTTP/1.1 500 Internal Server Error\r\n")) + } + + @Test("Content-Length in the response header matches the actual JSON body byte count") + func responseContentLengthMatchesBody() { + let data = CLIServerService.successResponse("hello") + let text = String(data: data, encoding: .utf8)! + let headerEnd = text.range(of: "\r\n\r\n")!.upperBound + let bodyByteCount = Data(text[headerEnd...].utf8).count + + let lengthLine = text.split(separator: "\r\n").first { $0.hasPrefix("Content-Length:") }! + let declaredLength = Int(lengthLine.split(separator: " ")[1])! + #expect(declaredLength == bodyByteCount) + } +} diff --git a/oAITests/ChatViewModelPureLogicTests.swift b/oAITests/ChatViewModelPureLogicTests.swift index 6df9387..aecf522 100644 --- a/oAITests/ChatViewModelPureLogicTests.swift +++ b/oAITests/ChatViewModelPureLogicTests.swift @@ -82,6 +82,36 @@ struct ChatViewModelPureLogicTests { #expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0) } + // MARK: - resolveCost + + @Test("Raw provider-billed cost wins over token-based pricing when present") + func resolveCostPrefersRawCostUSD() { + let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000, rawCostUSD: 0.19) + let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) + #expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 0.19) + } + + @Test("Raw cost of zero is trusted, not treated as missing") + func resolveCostTrustsZeroRawCost() { + let usage = ChatResponse.Usage(promptTokens: 0, completionTokens: 0, totalTokens: 0, rawCostUSD: 0.0) + let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) + #expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 0.0) + } + + @Test("Falls back to token-based pricing when no raw cost is reported") + func resolveCostFallsBackToCalculation() { + let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000) + let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) + #expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 18.0) + } + + @Test("Returns nil when neither raw cost nor per-token pricing is available") + func resolveCostNilWhenNoDataAvailable() { + let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000) + let pricing = ModelInfo.Pricing(prompt: 0, completion: 0) + #expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == nil) + } + // MARK: - draftFingerprint @Test("Identical message content produces the same fingerprint") diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift index b860bb6..8addd70 100644 --- a/oAITests/DatabaseServiceTests.swift +++ b/oAITests/DatabaseServiceTests.swift @@ -25,6 +25,17 @@ struct DatabaseServiceMigrationTests { } } + @Test("v13 adds the usage_events table with the expected columns") + func v13AddsUsageEventsTable() { + let db = DatabaseService.makeInMemory() + #expect(db.tableExists("usage_events")) + let columns = Set(db.columnNames(in: "usage_events")) + let expected: Set = [ + "id", "timestamp", "provider", "modelId", "promptTokens", "completionTokens", "cost", "conversationId", + ] + #expect(expected.isSubset(of: columns)) + } + @Test("v4 adds modelId to messages and primaryModel to conversations") func v4AddsModelColumns() { let db = DatabaseService.makeInMemory() @@ -192,6 +203,84 @@ struct DatabaseServiceUsageStatsTests { #expect(byModel.first?.modelId == "claude-sonnet") } + @Test("Overall stats respect an optional date range and count user questions") + func overallStatsRespectsDateRangeAndQuestions() throws { + let db = DatabaseService.makeInMemory() + let now = Date() + let old = now.addingTimeInterval(-10 * 24 * 3600) + + _ = try db.saveConversation(name: "Old Chat", messages: [ + Message(role: .user, content: "old question", tokens: 10, timestamp: old), + Message(role: .assistant, content: "old answer", tokens: 20, cost: 0.01, timestamp: old), + ]) + _ = try db.saveConversation(name: "Recent Chat", messages: [ + Message(role: .user, content: "recent question", tokens: 5, timestamp: now), + Message(role: .assistant, content: "recent answer", tokens: 15, cost: 0.02, timestamp: now), + ]) + + let rangeStats = try db.getOverallUsageStats( + from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600) + ) + #expect(rangeStats.totalMessages == 2) + #expect(rangeStats.totalQuestions == 1) + #expect(rangeStats.totalTokens == 20) + + let allStats = try db.getOverallUsageStats() + #expect(allStats.totalMessages == 4) + #expect(allStats.totalQuestions == 2) + } + + @Test("Usage by model respects an optional date range and reports question counts") + func usageByModelRespectsDateRangeAndQuestions() throws { + let db = DatabaseService.makeInMemory() + let now = Date() + let old = now.addingTimeInterval(-10 * 24 * 3600) + + _ = try db.saveConversation(name: "Old Chat", messages: [ + Message(role: .user, content: "hi", tokens: 5, timestamp: old, modelId: "claude-sonnet"), + Message(role: .assistant, content: "hello", tokens: 10, cost: 0.01, timestamp: old, modelId: "claude-sonnet"), + ]) + _ = try db.saveConversation(name: "Recent Chat", messages: [ + Message(role: .user, content: "hey", tokens: 5, timestamp: now, modelId: "gpt-4"), + Message(role: .assistant, content: "hi", tokens: 15, cost: 0.02, timestamp: now, modelId: "gpt-4"), + ]) + + let recent = try db.getUsageByModel( + from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600) + ) + #expect(recent.count == 1) + #expect(recent.first?.modelId == "gpt-4") + #expect(recent.first?.questionCount == 1) + } + + @Test("Daily usage buckets messages by calendar day (GMT, matching stored timestamps)") + func dailyUsageBucketsByDay() throws { + let db = DatabaseService.makeInMemory() + var gmtCalendar = Calendar(identifier: .gregorian) + gmtCalendar.timeZone = TimeZone(identifier: "GMT")! + + let day1 = gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 1, hour: 10))! + let day2 = gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 2, hour: 10))! + + _ = try db.saveConversation(name: "Chat", messages: [ + Message(role: .user, content: "q1", tokens: 5, timestamp: day1), + Message(role: .assistant, content: "a1", tokens: 10, cost: 0.01, timestamp: day1), + Message(role: .user, content: "q2", tokens: 5, timestamp: day2), + Message(role: .assistant, content: "a2", tokens: 20, cost: 0.02, timestamp: day2), + ]) + + let daily = try db.getDailyUsage( + from: gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 1))!, + to: gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 3))! + ) + #expect(daily.count == 2) + #expect(daily[0].totalTokens == 15) + #expect(daily[0].questionCount == 1) + #expect(daily[0].hasCostData == true) + #expect(daily[1].totalTokens == 25) + #expect(daily[1].questionCount == 1) + } + @Test("Usage by conversation joins conversation names and sorts by cost descending") func usageByConversationSortsByCost() throws { let db = DatabaseService.makeInMemory() @@ -221,6 +310,80 @@ struct DatabaseServiceUsageStatsTests { } } +@Suite("DatabaseService usage events, against a throwaway in-memory queue") +struct DatabaseServiceUsageEventsTests { + + @Test("Logged usage events are readable regardless of any saved conversation") + func logUsageEventPersistsWithNoConversation() throws { + let db = DatabaseService.makeInMemory() + try db.logUsageEvent( + provider: "openrouter", modelId: "gpt-5", promptTokens: 100, completionTokens: 50, + cost: 0.02, conversationId: nil + ) + + let totals = try db.getUsageEventTotals() + #expect(totals.totalQuestions == 1) + #expect(totals.totalTokens == 150) + #expect(totals.hasCostData == true) + #expect(abs(totals.totalCost - 0.02) < 0.0001) + } + + @Test("Usage events respect an optional date range") + func usageEventTotalsRespectsDateRange() throws { + let db = DatabaseService.makeInMemory() + let now = Date() + let old = now.addingTimeInterval(-10 * 24 * 3600) + + try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil) + // Directly insert an old-dated row via the private record path isn't exposed, so approximate + // "old" coverage by asserting the fresh row is excluded when the range is set entirely in the past. + let rangeExcludingNow = try db.getUsageEventTotals(from: old, to: old.addingTimeInterval(3600)) + #expect(rangeExcludingNow.totalQuestions == 0) + + let rangeIncludingNow = try db.getUsageEventTotals(from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600)) + #expect(rangeIncludingNow.totalQuestions == 1) + } + + @Test("Usage events group by model and by provider independently") + func usageEventsGroupByModelAndProvider() throws { + let db = DatabaseService.makeInMemory() + try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil) + try db.logUsageEvent(provider: "openrouter", modelId: "claude-sonnet", promptTokens: 5, completionTokens: 5, cost: 0.02, conversationId: nil) + try db.logUsageEvent(provider: "anthropic", modelId: "claude-sonnet", promptTokens: 20, completionTokens: 20, cost: 0.03, conversationId: nil) + + let byModel = try db.getUsageEventsByModel() + #expect(byModel.count == 2) + let sonnet = byModel.first { $0.modelId == "claude-sonnet" } + #expect(sonnet?.questionCount == 2) + #expect(sonnet?.totalTokens == 50) + + let byProvider = try db.getUsageEventsByProvider() + #expect(byProvider.count == 2) + let openrouter = byProvider.first { $0.provider == "openrouter" } + #expect(openrouter?.questionCount == 2) + #expect(openrouter?.totalTokens == 30) + let anthropic = byProvider.first { $0.provider == "anthropic" } + #expect(anthropic?.questionCount == 1) + #expect(anthropic?.totalTokens == 40) + } + + @Test("Daily usage events bucket by calendar day (GMT, matching stored timestamps)") + func dailyUsageEventsBucketByDay() throws { + let db = DatabaseService.makeInMemory() + try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil) + + var gmtCalendar = Calendar(identifier: .gregorian) + gmtCalendar.timeZone = TimeZone(identifier: "GMT")! + let farPast = gmtCalendar.date(from: DateComponents(year: 2020, month: 1, day: 1))! + let farFuture = gmtCalendar.date(from: DateComponents(year: 2030, month: 1, day: 1))! + + let daily = try db.getDailyUsageEvents(from: farPast, to: farFuture) + #expect(daily.count == 1) + #expect(daily.first?.totalTokens == 20) + #expect(daily.first?.questionCount == 1) + } +} + @Suite("DatabaseService folders, against a throwaway in-memory queue") struct DatabaseServiceFolderTests { diff --git a/oAITests/ExternalMCPManagerConversionTests.swift b/oAITests/ExternalMCPManagerConversionTests.swift new file mode 100644 index 0000000..9955f67 --- /dev/null +++ b/oAITests/ExternalMCPManagerConversionTests.swift @@ -0,0 +1,65 @@ +// +// ExternalMCPManagerConversionTests.swift +// ConfabTests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import Confab + +@Suite("ExternalMCPManager.convertInputSchema") +struct ExternalMCPManagerConversionTests { + + /// Regression test for a real crash: Obsidian's Local REST API plugin's MCP tool schemas + /// include at least one property with no "type" key at all — valid JSON Schema (e.g. an + /// enum-only or composed property) — which a `prop.type!` force-unwrap used to crash on the + /// moment a live HTTP MCP server's tools/list response reached this code. + @Test("A property with no type at all defaults to string instead of crashing") + func propertyWithNoTypeDefaultsToString() { + let prop = MCPPropertySchema(type: nil, description: "no explicit type", enum: nil, items: nil) + let schema = MCPInputSchema(type: "object", properties: ["mode": prop], required: nil) + + let parameters = ExternalMCPManager.convertInputSchema(schema) + + #expect(parameters.properties["mode"]?.type == "string") + #expect(parameters.properties["mode"]?.description == "no explicit type") + } + + @Test("integer is normalized to number") + func integerNormalizesToNumber() { + let prop = MCPPropertySchema(type: "integer", description: nil, enum: nil, items: nil) + let schema = MCPInputSchema(type: "object", properties: ["count": prop], required: nil) + + let parameters = ExternalMCPManager.convertInputSchema(schema) + + #expect(parameters.properties["count"]?.type == "number") + } + + @Test("An unrecognized type string falls back to string") + func unrecognizedTypeFallsBackToString() { + let prop = MCPPropertySchema(type: "something-unusual", description: nil, enum: nil, items: nil) + let schema = MCPInputSchema(type: "object", properties: ["weird": prop], required: nil) + + let parameters = ExternalMCPManager.convertInputSchema(schema) + + #expect(parameters.properties["weird"]?.type == "string") + } + + @Test("Recognized types (string/number/boolean/array/object) pass through unchanged") + func recognizedTypesPassThrough() { + let types = ["string", "number", "boolean", "array", "object"] + var properties: [String: MCPPropertySchema] = [:] + for t in types { + properties[t] = MCPPropertySchema(type: t, description: nil, enum: nil, items: nil) + } + let schema = MCPInputSchema(type: "object", properties: properties, required: nil) + + let parameters = ExternalMCPManager.convertInputSchema(schema) + + for t in types { + #expect(parameters.properties[t]?.type == t) + } + } +} diff --git a/oAITests/ExternalMCPModelsTests.swift b/oAITests/ExternalMCPModelsTests.swift new file mode 100644 index 0000000..89fb52e --- /dev/null +++ b/oAITests/ExternalMCPModelsTests.swift @@ -0,0 +1,105 @@ +// +// ExternalMCPModelsTests.swift +// ConfabTests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import Confab + +@Suite("ExternalMCPServer Codable") +struct ExternalMCPServerCodableTests { + + private static func makeCoders() -> (JSONEncoder, JSONDecoder) { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return (encoder, decoder) + } + + @Test("Decodes old-shape JSON (saved before env/HTTP fields existed) with safe defaults") + func decodesOldShapeJSON() throws { + // Exactly the 7 keys ExternalMCPServer had before transportKind/env/url/bearerToken/headers + // were added — this is what's actually sitting in existing users' settings DB right now. + let oldJSON = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "name": "Safari", + "command": "safaridriver", + "args": ["--mcp"], + "isEnabled": true, + "timeout": 30, + "createdAt": "2026-01-01T00:00:00Z" + } + """ + let (_, decoder) = Self.makeCoders() + let server = try decoder.decode(ExternalMCPServer.self, from: Data(oldJSON.utf8)) + + #expect(server.name == "Safari") + #expect(server.command == "safaridriver") + #expect(server.args == ["--mcp"]) + #expect(server.transportKind == .stdio) + #expect(server.env.isEmpty) + #expect(server.url.isEmpty) + #expect(server.bearerToken.isEmpty) + #expect(server.headers.isEmpty) + } + + @Test("An array of old-shape servers (the real settings-JSON shape) decodes without dropping any") + func decodesOldShapeArray() throws { + let oldJSON = """ + [ + {"id": "00000000-0000-0000-0000-000000000001", "name": "Safari", "command": "safaridriver", + "args": ["--mcp"], "isEnabled": true, "timeout": 30, "createdAt": "2026-01-01T00:00:00Z"}, + {"id": "00000000-0000-0000-0000-000000000002", "name": "Other", "command": "some-tool", + "args": [], "isEnabled": false, "timeout": 15, "createdAt": "2026-02-01T00:00:00Z"} + ] + """ + let (_, decoder) = Self.makeCoders() + let servers = try decoder.decode([ExternalMCPServer].self, from: Data(oldJSON.utf8)) + #expect(servers.count == 2) + } + + @Test("Round-trips a full HTTP-transport server through encode/decode") + func roundTripsHTTPServer() throws { + let original = ExternalMCPServer( + name: "Obsidian", + transportKind: .http, + url: "http://127.0.0.1:27123/mcp/", + bearerToken: "secret-token", + headers: ["X-Extra": "value"], + timeout: 45 + ) + let (encoder, decoder) = Self.makeCoders() + let data = try encoder.encode(original) + let decoded = try decoder.decode(ExternalMCPServer.self, from: data) + + #expect(decoded.id == original.id) + #expect(decoded.transportKind == .http) + #expect(decoded.url == "http://127.0.0.1:27123/mcp/") + #expect(decoded.bearerToken == "secret-token") + #expect(decoded.headers == ["X-Extra": "value"]) + } + + @Test("Round-trips a stdio-transport server with env vars") + func roundTripsStdioServerWithEnv() throws { + let original = ExternalMCPServer( + name: "Custom", + command: "npx", + args: ["-y", "some-tool"], + env: ["API_KEY": "abc123"], + timeout: 30 + ) + let (encoder, decoder) = Self.makeCoders() + let data = try encoder.encode(original) + let decoded = try decoder.decode(ExternalMCPServer.self, from: data) + + #expect(decoded.transportKind == .stdio) + #expect(decoded.env == ["API_KEY": "abc123"]) + #expect(decoded.command == "npx") + #expect(decoded.args == ["-y", "some-tool"]) + } +} diff --git a/oAITests/JarvisModelsTests.swift b/oAITests/JarvisModelsTests.swift new file mode 100644 index 0000000..e3165d1 --- /dev/null +++ b/oAITests/JarvisModelsTests.swift @@ -0,0 +1,67 @@ +// +// JarvisModelsTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import Confab + +@Suite("JarvisAgentRun decoding") +struct JarvisModelsTests { + + // Real shape returned by the oAI-Web /api/agents/{id}/runs endpoint — confirmed by Rune + // against a live run. Guards against the "result"/"output" and "ended_at"/"finished_at" + // field-name mismatch that shipped in the initial Run Details modal (output always showed + // "No output for this run" even when the API had real content). + private static let sampleJSON = """ + { + "id": "8628fa26-8ff1-4053-b106-8ba84a0e10e0", + "agent_id": "531c884b-a401-4985-8717-bfa6bcc1d148", + "started_at": "2026-08-07T10:00:00.135999+00:00", + "ended_at": "2026-08-07T10:00:13.815956+00:00", + "status": "success", + "input_tokens": 25615, + "output_tokens": 1233, + "cost_usd": 0.031780000776052475, + "result": "Infrastructure Status: HEALTHY", + "error": null, + "model": "anthropic:claude-haiku-4-5-20251001" + } + """ + + @Test("Decodes the API's 'result' field into .output") + func decodesResultIntoOutput() throws { + let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8)) + #expect(run.output == "Infrastructure Status: HEALTHY") + } + + @Test("Decodes the API's 'ended_at' field into .finishedAt") + func decodesEndedAtIntoFinishedAt() throws { + let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8)) + #expect(run.finishedAt == "2026-08-07T10:00:13.815956+00:00") + } + + @Test("Decodes a full real run without losing any field") + func decodesAllFields() throws { + let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8)) + #expect(run.id == "8628fa26-8ff1-4053-b106-8ba84a0e10e0") + #expect(run.agentId == "531c884b-a401-4985-8717-bfa6bcc1d148") + #expect(run.status == "success") + #expect(run.startedAt == "2026-08-07T10:00:00.135999+00:00") + #expect(run.inputTokens == 25615) + #expect(run.outputTokens == 1233) + #expect(run.totalTokens == 26848) + #expect(run.costUsd == 0.031780000776052475) + #expect(run.error == nil) + } + + @Test("A finished run with both started_at and ended_at produces a non-nil duration") + func computesDurationFromRealFieldNames() throws { + let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8)) + #expect(run.formattedDuration != nil) + #expect(run.formattedDuration == "13s") + } +} diff --git a/oAITests/MCPTransportTests.swift b/oAITests/MCPTransportTests.swift new file mode 100644 index 0000000..2a50f93 --- /dev/null +++ b/oAITests/MCPTransportTests.swift @@ -0,0 +1,131 @@ +// +// MCPTransportTests.swift +// ConfabTests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import Confab + +@Suite("MCPTransportSupport.extractResult") +struct MCPTransportSupportTests { + + @Test("Extracts the result payload from a successful JSON-RPC response") + func extractsResult() throws { + let json: [String: Any] = ["jsonrpc": "2.0", "id": 1, "result": ["tools": []]] + let data = try MCPTransportSupport.extractResult(from: json) + let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(decoded?["tools"] != nil) + } + + @Test("Throws with the server's message when the response is a JSON-RPC error") + func throwsOnJSONRPCError() { + let json: [String: Any] = ["jsonrpc": "2.0", "id": 1, "error": ["code": -32601, "message": "Method not found"]] + do { + _ = try MCPTransportSupport.extractResult(from: json) + Issue.record("Expected extractResult to throw") + } catch let error as MCPClientError { + switch error { + case .invalidResponse(let message): #expect(message == "Method not found") + default: Issue.record("Expected .invalidResponse, got \(error)") + } + } catch { + Issue.record("Expected MCPClientError, got \(error)") + } + } + + @Test("Throws when the response has neither result nor error") + func throwsOnMissingResult() { + let json: [String: Any] = ["jsonrpc": "2.0", "id": 1] + do { + _ = try MCPTransportSupport.extractResult(from: json) + Issue.record("Expected extractResult to throw") + } catch let error as MCPClientError { + switch error { + case .invalidResponse(let message): #expect(message == "Missing result field") + default: Issue.record("Expected .invalidResponse, got \(error)") + } + } catch { + Issue.record("Expected MCPClientError, got \(error)") + } + } +} + +@Suite("HTTPMCPTransport.parseResponseBody") +struct HTTPMCPTransportParseTests { + + @Test("Parses a plain application/json response body directly") + func parsesPlainJSON() throws { + let body = Data(#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#.utf8) + let json = try HTTPMCPTransport.parseResponseBody(body, contentType: "application/json", expectedId: 7) + #expect(json["id"] as? Int == 7) + } + + @Test("Parses a text/event-stream body, finding the data: line matching the expected id") + func parsesSSEMatchingId() throws { + let sse = """ + event: message + data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}} + + """ + let json = try HTTPMCPTransport.parseResponseBody( + Data(sse.utf8), contentType: "text/event-stream", expectedId: 7 + ) + #expect(json["id"] as? Int == 7) + } + + @Test("Skips unrelated server-sent messages before the matching response, per the Streamable HTTP spec") + func skipsUnrelatedMessagesInSSE() throws { + // Spec: "The server MAY send JSON-RPC requests and notifications before sending the + // JSON-RPC response." Simulated here as an unrelated id=99 message before the real id=7 one. + let sse = """ + data: {"jsonrpc":"2.0","id":99,"method":"unrelated/notification"} + + data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}} + + """ + let json = try HTTPMCPTransport.parseResponseBody( + Data(sse.utf8), contentType: "text/event-stream", expectedId: 7 + ) + #expect(json["id"] as? Int == 7) + } + + @Test("Throws when no SSE data: line matches the expected id") + func throwsWhenNoMatchInSSE() { + let sse = """ + data: {"jsonrpc":"2.0","id":99,"result":{}} + + """ + do { + _ = try HTTPMCPTransport.parseResponseBody( + Data(sse.utf8), contentType: "text/event-stream", expectedId: 7 + ) + Issue.record("Expected parseResponseBody to throw") + } catch let error as MCPClientError { + switch error { + case .invalidResponse: break + default: Issue.record("Expected .invalidResponse, got \(error)") + } + } catch { + Issue.record("Expected MCPClientError, got \(error)") + } + } + + @Test("Throws on malformed JSON in a plain application/json body") + func throwsOnMalformedJSON() { + let body = Data("not json".utf8) + do { + _ = try HTTPMCPTransport.parseResponseBody(body, contentType: "application/json", expectedId: 1) + Issue.record("Expected parseResponseBody to throw") + } catch let error as MCPClientError { + switch error { + case .invalidResponse: break + default: Issue.record("Expected .invalidResponse, got \(error)") + } + } catch { + Issue.record("Expected MCPClientError, got \(error)") + } + } +} diff --git a/oAITests/ThinkingVerbsTests.swift b/oAITests/ThinkingVerbsTests.swift new file mode 100644 index 0000000..8632c4d --- /dev/null +++ b/oAITests/ThinkingVerbsTests.swift @@ -0,0 +1,60 @@ +// +// ThinkingVerbsTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import Confab + +@Suite("ThinkingVerbs language selection") +struct ThinkingVerbsTests { + + private let supportedCodes = ["en", "nb", "sv", "da", "de", "fr"] + + @Test("Every supported language has its own non-empty verb list") + func perLanguageListsAreNonEmpty() { + for code in supportedCodes { + #expect(!ThinkingVerbs.verbs(for: code).isEmpty) + } + } + + @Test("Unknown language codes fall back to English") + func unknownCodeFallsBackToEnglish() { + #expect(ThinkingVerbs.verbs(for: "xx") == ThinkingVerbs.verbs(for: "en")) + #expect(ThinkingVerbs.verbs(for: "") == ThinkingVerbs.verbs(for: "en")) + } + + @Test("Each supported language's list is genuinely distinct, not a fallback copy of English") + func perLanguageListsAreDistinctFromEnglish() { + let english = ThinkingVerbs.verbs(for: "en") + for code in supportedCodes where code != "en" { + #expect(ThinkingVerbs.verbs(for: code) != english) + } + } + + @Test("Language lists are comparable in size — none is a token stub") + func perLanguageListsAreComparablyMore() { + let minimumCount = 40 + for code in supportedCodes { + #expect(ThinkingVerbs.verbs(for: code).count >= minimumCount) + } + } + + @Test("No duplicate entries within a single language's list") + func noDuplicatesWithinLanguage() { + for code in supportedCodes { + let list = ThinkingVerbs.verbs(for: code) + #expect(Set(list).count == list.count) + } + } + + @Test("random() returns a non-empty string ending in an ellipsis") + func randomProducesEllipsisSuffixedString() { + let result = ThinkingVerbs.random() + #expect(!result.isEmpty) + #expect(result.hasSuffix("...")) + } +}