Просмотр исходного кода

Move AI into the integrations catalog (#317)

The provider, key and model lived in four AppSetting rows behind a settings
page of their own, which is exactly what every other vendor with a key had
before the catalog existed. OpenAI and Anthropic are connectors now: the key
sealed in the vault, the model read from the account as a remote-select, and
one AI vendor in charge at a time the way messaging channels already work.

A workshop that had AI switched on keeps generating descriptions without
touching anything. The first AI call, or opening the catalog or a connector
page, adopts the old rows into an active connection, once, recorded by
integrations.ai.adoptedAt so a later disconnect is not undone by the next
call. The old rows are read and left alone, so a rollback finds them where
it left them.

Two things fell out of the move. The free plan has ai but not integrations,
so a connector that names its own plan feature is now gated by that feature
rather than by the catalog's, which keeps AI where it was for free plans and
changes nothing for anyone else. And connecting a vendor that stands another
down said nothing until it had happened, for messaging as much as for AI, so
the connect page now names what it will disconnect first.

The catalog also gets a search box over the available grid, matching on the
translated name, description, category and capabilities.
Bernt Christian Egeland 3 недель назад
Родитель
Сommit
f64675675a
52 измененных файлов с 1472 добавлено и 703 удалено
  1. 1 1
      messages/de/aiChat.json
  2. 32 3
      messages/de/integrations.json
  3. 1 1
      messages/en/aiChat.json
  4. 32 3
      messages/en/integrations.json
  5. 1 1
      messages/es/aiChat.json
  6. 32 3
      messages/es/integrations.json
  7. 1 1
      messages/fr/aiChat.json
  8. 32 3
      messages/fr/integrations.json
  9. 1 1
      messages/it/aiChat.json
  10. 32 3
      messages/it/integrations.json
  11. 1 1
      messages/lt/aiChat.json
  12. 32 3
      messages/lt/integrations.json
  13. 1 1
      messages/nb/aiChat.json
  14. 32 3
      messages/nb/integrations.json
  15. 1 1
      messages/nl/aiChat.json
  16. 32 3
      messages/nl/integrations.json
  17. 1 1
      messages/pl/aiChat.json
  18. 32 3
      messages/pl/integrations.json
  19. 1 1
      messages/pt-BR/aiChat.json
  20. 32 3
      messages/pt-BR/integrations.json
  21. 1 1
      messages/ru/aiChat.json
  22. 32 3
      messages/ru/integrations.json
  23. 1 1
      messages/tr/aiChat.json
  24. 32 3
      messages/tr/integrations.json
  25. 1 0
      public/images/integrations/anthropic.svg
  26. 1 0
      public/images/integrations/openai.svg
  27. 135 0
      src/__tests__/components/integrations-catalog.test.tsx
  28. 189 0
      src/__tests__/features/integrations/ai.test.ts
  29. 8 27
      src/app/(authenticated)/settings/ai/page.tsx
  30. 15 0
      src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx
  31. 5 3
      src/app/(authenticated)/settings/integrations/[connector]/page.tsx
  32. 74 9
      src/app/(authenticated)/settings/integrations/integrations-catalog.tsx
  33. 2 1
      src/app/(authenticated)/settings/integrations/page.tsx
  34. 0 2
      src/app/(authenticated)/settings/settings-nav.tsx
  35. 2 1
      src/app/api/integrations/[connector]/oauth/start/route.ts
  36. 0 1
      src/components/page-header.tsx
  37. 0 202
      src/features/ai/Actions/aiSettingsActions.ts
  38. 0 288
      src/features/ai/Components/AiSettingsForm.tsx
  39. 8 54
      src/features/ai/Schema/aiSettingsSchema.ts
  40. 84 21
      src/features/integrations/Actions/integrationActions.ts
  41. 267 0
      src/features/integrations/Lib/ai.ts
  42. 24 0
      src/features/integrations/Lib/plan.ts
  43. 1 0
      src/features/integrations/Lib/types.ts
  44. 4 21
      src/features/vehicles/Actions/aiAnalyzeVehicleDocument.ts
  45. 109 0
      src/integrations/ai/models.ts
  46. 37 0
      src/integrations/anthropic/manifest.ts
  47. 29 0
      src/integrations/anthropic/server.ts
  48. 39 0
      src/integrations/openai/manifest.ts
  49. 26 0
      src/integrations/openai/server.ts
  50. 4 0
      src/integrations/registry.ts
  51. 2 2
      src/lib/ai-error.ts
  52. 10 23
      src/lib/ai.ts

+ 1 - 1
messages/de/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Fragen Sie zu Ihrer Werkstatt...",
   "clear": "Gespräch löschen",
   "error": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
-  "notEnabled": "KI ist nicht aktiviert. Konfigurieren Sie es unter Einstellungen → KI.",
+  "notEnabled": "KI ist nicht verbunden. Verbinden Sie OpenAI oder Anthropic unter Einstellungen → Integrationen.",
   "suggestion1": "Welche Fahrzeuge sind bald fällig für den Service?",
   "suggestion2": "Zeige mir unbezahlte Rechnungen",
   "suggestion3": "Wie ist der Umsatz diesen Monat?",

+ 32 - 3
messages/de/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Noch nichts verbunden. Wählen Sie unten einen Dienst, um zu beginnen.",
     "availableTitle": "Verfügbar",
     "availableDescription": "Dienste, die Sie hinzufügen können. Jeder wird einmal eingerichtet und läuft im Hintergrund.",
-    "allConnected": "Alle verfügbaren Dienste sind verbunden."
+    "allConnected": "Alle verfügbaren Dienste sind verbunden.",
+    "searchPlaceholder": "Integrationen durchsuchen",
+    "noMatches": "Nichts passt zu „{query}“."
   },
   "categories": {
     "calendar": "Kalender",
@@ -23,7 +25,8 @@
     "automation": "Automatisierung",
     "storage": "Dateien",
     "registry": "Fahrzeugregister",
-    "other": "Sonstiges"
+    "other": "Sonstiges",
+    "ai": "KI"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Kennzeichen- und FIN-Abfrage",
       "inspection": "HU-Fälligkeitsdaten"
+    },
+    "ai": {
+      "chat": "Texte und Zusammenfassungen"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "Ein Durchlauf läuft",
       "off": "Einstellung einschalten und speichern; bis dahin prüft ein Durchlauf nichts.",
       "syncNowHelp": "Jetzt synchronisieren startet sofort einen Durchlauf statt auf den stündlichen zu warten. Er fragt 25 Fahrzeuge auf einmal ab und macht von selbst weiter, bis alle fälligen Fahrzeuge erledigt sind."
-    }
+    },
+    "supersedes": "Wenn Sie {next} verbinden, wird {current} getrennt. Nur einer kann zuständig sein: Sobald {next} aktiv ist, wird {current} nicht mehr genutzt."
   },
   "activity": {
     "title": "Aktivität",
@@ -397,6 +404,28 @@
         "usState": "Bundesstaat",
         "usStateHelp": "Der zweibuchstabige Code des Bundesstaats, in dem die Fahrzeuge zugelassen sind, etwa CA oder TX. Amerikanische Register werden je Bundesstaat durchsucht."
       }
+    },
+    "openai": {
+      "description": "Servicebeschreibungen, Verlaufszusammenfassungen, Dokumentenerkennung und der Assistent, beantwortet über Ihr eigenes OpenAI-Konto. Jeder Aufruf wird diesem Konto berechnet, und Sie wählen das Modell.",
+      "fields": {
+        "apiKey": "API-Schlüssel",
+        "apiKeyHelp": "Auf platform.openai.com unter API keys anlegen. Das Konto braucht Guthaben; OpenAI rechnet pro Aufruf ab."
+      },
+      "settings": {
+        "model": "Modell",
+        "modelHelp": "Welches Modell antwortet. Die Liste kommt aus Ihrem Konto, neueste zuerst. Kleinere Modelle kosten pro Aufruf weniger und reichen für die meisten Werkstatttexte."
+      }
+    },
+    "anthropic": {
+      "description": "Servicebeschreibungen, Verlaufszusammenfassungen, Dokumentenerkennung und der Assistent, beantwortet von Claude über Ihr eigenes Anthropic-Konto. Jeder Aufruf wird diesem Konto berechnet, und Sie wählen das Modell.",
+      "fields": {
+        "apiKey": "API-Schlüssel",
+        "apiKeyHelp": "Auf console.anthropic.com unter API keys anlegen. Das Konto braucht Guthaben; Anthropic rechnet pro Aufruf ab."
+      },
+      "settings": {
+        "model": "Modell",
+        "modelHelp": "Welches Modell antwortet. Die Liste kommt aus Ihrem Konto, neueste zuerst. Kleinere Modelle kosten pro Aufruf weniger und reichen für die meisten Werkstatttexte."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/en/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Ask about your workshop...",
   "clear": "Clear conversation",
   "error": "Something went wrong. Please try again.",
-  "notEnabled": "AI is not enabled. Configure it in Settings → AI.",
+  "notEnabled": "AI is not connected. Connect OpenAI or Anthropic under Settings → Integrations.",
   "suggestion1": "Which vehicles are due for service?",
   "suggestion2": "Show me unpaid invoices",
   "suggestion3": "What's the revenue this month?",

+ 32 - 3
messages/en/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Nothing connected yet. Pick a service below to get started.",
     "availableTitle": "Available",
     "availableDescription": "Services you can add. Each one is set up once and runs in the background.",
-    "allConnected": "Every available service is connected."
+    "allConnected": "Every available service is connected.",
+    "searchPlaceholder": "Search integrations",
+    "noMatches": "Nothing matches \"{query}\"."
   },
   "categories": {
     "calendar": "Calendar",
@@ -23,7 +25,8 @@
     "automation": "Automation",
     "storage": "Files",
     "registry": "Vehicle registry",
-    "other": "Other"
+    "other": "Other",
+    "ai": "AI"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Plate and VIN lookup",
       "inspection": "Inspection due dates"
+    },
+    "ai": {
+      "chat": "Writing and summaries"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "A pass is running",
       "off": "Turn the setting on and save; until then a pass checks nothing.",
       "syncNowHelp": "Sync now starts a pass straight away instead of waiting for the hourly one. It looks up 25 vehicles at a time and continues on its own until every due vehicle is done."
-    }
+    },
+    "supersedes": "Connecting {next} disconnects {current}. Only one at a time can be in charge, so {current} stops being used the moment {next} goes live."
   },
   "activity": {
     "title": "Activity",
@@ -397,6 +404,28 @@
         "usState": "State",
         "usStateHelp": "The two-letter code of the state the vehicles are registered in, such as CA or TX. American registries are searched per state."
       }
+    },
+    "openai": {
+      "description": "Service descriptions, history summaries, document scanning and the assistant, answered by your own OpenAI account. Every call is billed to that account, and you choose which model answers.",
+      "fields": {
+        "apiKey": "API key",
+        "apiKeyHelp": "Create one at platform.openai.com under API keys. The account needs credit on it; OpenAI bills the usage, per call."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Which model answers. The list is read from your account, newest first. Smaller models cost less per call and are enough for most workshop text."
+      }
+    },
+    "anthropic": {
+      "description": "Service descriptions, history summaries, document scanning and the assistant, answered by Claude on your own Anthropic account. Every call is billed to that account, and you choose which model answers.",
+      "fields": {
+        "apiKey": "API key",
+        "apiKeyHelp": "Create one at console.anthropic.com under API keys. The account needs credit on it; Anthropic bills the usage, per call."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Which model answers. The list is read from your account, newest first. Smaller models cost less per call and are enough for most workshop text."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/es/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Pregunte sobre su taller...",
   "clear": "Borrar conversación",
   "error": "Algo salió mal. Por favor, inténtelo de nuevo.",
-  "notEnabled": "La IA no está habilitada. Configúrela en Ajustes → IA.",
+  "notEnabled": "La IA no está conectada. Conecta OpenAI o Anthropic en Ajustes → Integraciones.",
   "suggestion1": "¿Qué vehículos necesitan servicio pronto?",
   "suggestion2": "Muéstrame facturas sin pagar",
   "suggestion3": "¿Cuáles son los ingresos de este mes?",

+ 32 - 3
messages/es/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Aún no hay nada conectado. Elija un servicio abajo para empezar.",
     "availableTitle": "Disponibles",
     "availableDescription": "Servicios que puede añadir. Cada uno se configura una vez y funciona en segundo plano.",
-    "allConnected": "Todos los servicios disponibles están conectados."
+    "allConnected": "Todos los servicios disponibles están conectados.",
+    "searchPlaceholder": "Buscar integraciones",
+    "noMatches": "Nada coincide con «{query}»."
   },
   "categories": {
     "calendar": "Calendario",
@@ -23,7 +25,8 @@
     "automation": "Automatización",
     "storage": "Archivos",
     "registry": "Registro de vehículos",
-    "other": "Otras"
+    "other": "Otras",
+    "ai": "IA"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Consulta por matrícula y VIN",
       "inspection": "Fechas de inspección"
+    },
+    "ai": {
+      "chat": "Textos y resúmenes"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "Hay una pasada en curso",
       "off": "Activa el ajuste y guarda; hasta entonces una pasada no comprueba nada.",
       "syncNowHelp": "Sincronizar ahora inicia una pasada de inmediato en vez de esperar a la horaria. Consulta 25 vehículos cada vez y continúa sola hasta terminar con todos los pendientes."
-    }
+    },
+    "supersedes": "Conectar {next} desconecta {current}. Solo uno puede estar activo a la vez, así que {current} deja de usarse en cuanto {next} entra en servicio."
   },
   "activity": {
     "title": "Actividad",
@@ -397,6 +404,28 @@
         "usState": "Estado",
         "usStateHelp": "El código de dos letras del estado donde están matriculados los vehículos, como CA o TX. Los registros estadounidenses se consultan por estado."
       }
+    },
+    "openai": {
+      "description": "Descripciones de servicio, resúmenes de historial, lectura de documentos y el asistente, respondidos por tu propia cuenta de OpenAI. Cada llamada se factura a esa cuenta y tú eliges el modelo.",
+      "fields": {
+        "apiKey": "Clave de API",
+        "apiKeyHelp": "Créala en platform.openai.com, en API keys. La cuenta necesita saldo; OpenAI factura el uso por llamada."
+      },
+      "settings": {
+        "model": "Modelo",
+        "modelHelp": "Qué modelo responde. La lista se lee de tu cuenta, primero los más nuevos. Los modelos pequeños cuestan menos por llamada y bastan para la mayoría de los textos del taller."
+      }
+    },
+    "anthropic": {
+      "description": "Descripciones de servicio, resúmenes de historial, lectura de documentos y el asistente, respondidos por Claude en tu propia cuenta de Anthropic. Cada llamada se factura a esa cuenta y tú eliges el modelo.",
+      "fields": {
+        "apiKey": "Clave de API",
+        "apiKeyHelp": "Créala en console.anthropic.com, en API keys. La cuenta necesita saldo; Anthropic factura el uso por llamada."
+      },
+      "settings": {
+        "model": "Modelo",
+        "modelHelp": "Qué modelo responde. La lista se lee de tu cuenta, primero los más nuevos. Los modelos pequeños cuestan menos por llamada y bastan para la mayoría de los textos del taller."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/fr/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Demandez à propos de votre atelier...",
   "clear": "Effacer la conversation",
   "error": "Une erreur s'est produite. Veuillez réessayer.",
-  "notEnabled": "L'IA n'est pas activée. Configurez-la dans Paramètres → IA.",
+  "notEnabled": "L'IA n'est pas connectée. Connectez OpenAI ou Anthropic dans Paramètres → Intégrations.",
   "suggestion1": "Quels véhicules doivent bientôt être révisés ?",
   "suggestion2": "Montrez-moi les factures impayées",
   "suggestion3": "Quel est le chiffre d'affaires ce mois-ci ?",

+ 32 - 3
messages/fr/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Rien de connecté pour l'instant. Choisissez un service ci-dessous pour commencer.",
     "availableTitle": "Disponibles",
     "availableDescription": "Services que vous pouvez ajouter. Chacun se configure une fois et fonctionne en arrière-plan.",
-    "allConnected": "Tous les services disponibles sont connectés."
+    "allConnected": "Tous les services disponibles sont connectés.",
+    "searchPlaceholder": "Rechercher une intégration",
+    "noMatches": "Rien ne correspond à « {query} »."
   },
   "categories": {
     "calendar": "Calendrier",
@@ -23,7 +25,8 @@
     "automation": "Automatisation",
     "storage": "Fichiers",
     "registry": "Registre des véhicules",
-    "other": "Autre"
+    "other": "Autre",
+    "ai": "IA"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Recherche par plaque et VIN",
       "inspection": "Dates de contrôle technique"
+    },
+    "ai": {
+      "chat": "Textes et résumés"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "Un passage est en cours",
       "off": "Activez le réglage et enregistrez ; d'ici là, un passage ne vérifie rien.",
       "syncNowHelp": "Synchroniser maintenant lance un passage immédiatement au lieu d'attendre celui de l'heure. Il recherche 25 véhicules à la fois et continue seul jusqu'à ce que tous les véhicules à vérifier soient traités."
-    }
+    },
+    "supersedes": "Connecter {next} déconnecte {current}. Un seul peut être actif à la fois : {current} cesse d'être utilisé dès que {next} est en service."
   },
   "activity": {
     "title": "Activité",
@@ -397,6 +404,28 @@
         "usState": "État",
         "usStateHelp": "Le code à deux lettres de l'État où les véhicules sont immatriculés, comme CA ou TX. Les registres américains sont interrogés par État."
       }
+    },
+    "openai": {
+      "description": "Descriptions de service, résumés d'historique, lecture de documents et assistant, traités par votre propre compte OpenAI. Chaque appel est facturé à ce compte, et vous choisissez le modèle.",
+      "fields": {
+        "apiKey": "Clé API",
+        "apiKeyHelp": "À créer sur platform.openai.com, dans API keys. Le compte doit être approvisionné ; OpenAI facture à l’appel."
+      },
+      "settings": {
+        "model": "Modèle",
+        "modelHelp": "Le modèle qui répond. La liste vient de votre compte, les plus récents en premier. Les petits modèles coûtent moins par appel et suffisent à la plupart des textes d'atelier."
+      }
+    },
+    "anthropic": {
+      "description": "Descriptions de service, résumés d'historique, lecture de documents et assistant, traités par Claude sur votre propre compte Anthropic. Chaque appel est facturé à ce compte, et vous choisissez le modèle.",
+      "fields": {
+        "apiKey": "Clé API",
+        "apiKeyHelp": "À créer sur console.anthropic.com, dans API keys. Le compte doit être approvisionné ; Anthropic facture à l’appel."
+      },
+      "settings": {
+        "model": "Modèle",
+        "modelHelp": "Le modèle qui répond. La liste vient de votre compte, les plus récents en premier. Les petits modèles coûtent moins par appel et suffisent à la plupart des textes d'atelier."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/it/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Chiedi della tua officina...",
   "clear": "Cancella conversazione",
   "error": "Qualcosa è andato storto. Riprova.",
-  "notEnabled": "L'IA non è abilitata. Configurala in Impostazioni → IA.",
+  "notEnabled": "L'IA non è collegata. Collega OpenAI o Anthropic in Impostazioni → Integrazioni.",
   "suggestion1": "Quali veicoli sono in scadenza per il tagliando?",
   "suggestion2": "Mostrami le fatture non pagate",
   "suggestion3": "Qual è il fatturato di questo mese?",

+ 32 - 3
messages/it/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Ancora nulla di collegato. Scegli un servizio qui sotto per iniziare.",
     "availableTitle": "Disponibili",
     "availableDescription": "Servizi che puoi aggiungere. Ognuno si configura una volta e lavora in background.",
-    "allConnected": "Tutti i servizi disponibili sono collegati."
+    "allConnected": "Tutti i servizi disponibili sono collegati.",
+    "searchPlaceholder": "Cerca integrazioni",
+    "noMatches": "Nessun risultato per «{query}»."
   },
   "categories": {
     "calendar": "Calendario",
@@ -23,7 +25,8 @@
     "automation": "Automazione",
     "storage": "File",
     "registry": "Registro veicoli",
-    "other": "Altro"
+    "other": "Altro",
+    "ai": "IA"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Ricerca per targa e VIN",
       "inspection": "Scadenze revisione"
+    },
+    "ai": {
+      "chat": "Testi e riepiloghi"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "Un passaggio è in corso",
       "off": "Attiva l'impostazione e salva; fino ad allora un passaggio non controlla nulla.",
       "syncNowHelp": "Sincronizza ora avvia subito un passaggio invece di attendere quello orario. Cerca 25 veicoli alla volta e prosegue da solo finché tutti i veicoli da controllare sono stati fatti."
-    }
+    },
+    "supersedes": "Collegare {next} scollega {current}. Solo uno può essere attivo alla volta, quindi {current} smette di essere usato appena {next} entra in servizio."
   },
   "activity": {
     "title": "Attività",
@@ -397,6 +404,28 @@
         "usState": "Stato",
         "usStateHelp": "Il codice di due lettere dello stato in cui sono immatricolati i veicoli, come CA o TX. I registri americani si consultano per stato."
       }
+    },
+    "openai": {
+      "description": "Descrizioni di servizio, riepiloghi dello storico, lettura dei documenti e l'assistente, risposti dal vostro account OpenAI. Ogni chiamata è addebitata su quell'account e il modello lo scegliete voi.",
+      "fields": {
+        "apiKey": "Chiave API",
+        "apiKeyHelp": "Da creare su platform.openai.com, in API keys. L'account deve avere credito; OpenAI fattura a chiamata."
+      },
+      "settings": {
+        "model": "Modello",
+        "modelHelp": "Quale modello risponde. L'elenco arriva dal vostro account, prima i più recenti. I modelli piccoli costano meno a chiamata e bastano per quasi tutti i testi dell'officina."
+      }
+    },
+    "anthropic": {
+      "description": "Descrizioni di servizio, riepiloghi dello storico, lettura dei documenti e l'assistente, risposti da Claude sul vostro account Anthropic. Ogni chiamata è addebitata su quell'account e il modello lo scegliete voi.",
+      "fields": {
+        "apiKey": "Chiave API",
+        "apiKeyHelp": "Da creare su console.anthropic.com, in API keys. L'account deve avere credito; Anthropic fattura a chiamata."
+      },
+      "settings": {
+        "model": "Modello",
+        "modelHelp": "Quale modello risponde. L'elenco arriva dal vostro account, prima i più recenti. I modelli piccoli costano meno a chiamata e bastano per quasi tutti i testi dell'officina."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/lt/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Klauskite apie savo dirbtuves...",
   "clear": "Išvalyti pokalbį",
   "error": "Kažkas nutiko. Bandykite dar kartą.",
-  "notEnabled": "DI neįjungtas. Sukonfigūruokite jį skiltyje Nustatymai → DI.",
+  "notEnabled": "DI neprijungtas. Prijunkite OpenAI arba Anthropic skiltyje Nustatymai → Integracijos.",
   "suggestion1": "Kurioms transporto priemonėms reikia aptarnavimo?",
   "suggestion2": "Parodykite neapmokėtas sąskaitas",
   "suggestion3": "Kokios šio mėnesio pajamos?",

+ 32 - 3
messages/lt/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Kol kas niekas neprijungta. Pasirinkite paslaugą žemiau, kad pradėtumėte.",
     "availableTitle": "Galima pridėti",
     "availableDescription": "Paslaugos, kurias galite pridėti. Kiekviena nustatoma vieną kartą ir veikia fone.",
-    "allConnected": "Visos galimos paslaugos prijungtos."
+    "allConnected": "Visos galimos paslaugos prijungtos.",
+    "searchPlaceholder": "Ieškoti integracijų",
+    "noMatches": "Nieko nerasta pagal „{query}“."
   },
   "categories": {
     "calendar": "Kalendorius",
@@ -23,7 +25,8 @@
     "automation": "Automatizavimas",
     "storage": "Failai",
     "registry": "Transporto priemonių registras",
-    "other": "Kita"
+    "other": "Kita",
+    "ai": "DI"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Paieška pagal numerį ir VIN",
       "inspection": "Apžiūros terminai"
+    },
+    "ai": {
+      "chat": "Tekstai ir santraukos"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "Perėjimas vyksta",
       "off": "Įjunkite nustatymą ir išsaugokite; iki tol perėjimas nieko netikrina.",
       "syncNowHelp": "„Sinchronizuoti dabar“ iš karto pradeda perėjimą, nelaukdamas valandinio. Tikrinama po 25 transporto priemones ir tęsiama savaime, kol apdorojamos visos, kurioms atėjo laikas."
-    }
+    },
+    "supersedes": "Prijungus {next}, {current} bus atjungtas. Vienu metu gali veikti tik vienas, todėl {current} nustos būti naudojamas, kai tik {next} pradės veikti."
   },
   "activity": {
     "title": "Veikla",
@@ -397,6 +404,28 @@
         "usState": "Valstija",
         "usStateHelp": "Dviejų raidžių valstijos, kurioje registruotos transporto priemonės, kodas, pvz., CA arba TX. JAV registrai užklausiami pagal valstiją."
       }
+    },
+    "openai": {
+      "description": "Paslaugų aprašymai, istorijos santraukos, dokumentų nuskaitymas ir asistentas — atsako jūsų pačių OpenAI paskyra. Kiekvienas užklausimas apmokestinamas toje paskyroje, o modelį pasirenkate jūs.",
+      "fields": {
+        "apiKey": "API raktas",
+        "apiKeyHelp": "Susikurkite platform.openai.com skiltyje API keys. Paskyroje reikia lėšų; OpenAI apmokestina kiekvieną užklausimą."
+      },
+      "settings": {
+        "model": "Modelis",
+        "modelHelp": "Kuris modelis atsako. Sąrašas nuskaitomas iš jūsų paskyros, naujausi viršuje. Mažesni modeliai kainuoja mažiau ir daugumai serviso tekstų visiškai pakanka."
+      }
+    },
+    "anthropic": {
+      "description": "Paslaugų aprašymai, istorijos santraukos, dokumentų nuskaitymas ir asistentas — atsako Claude jūsų pačių Anthropic paskyroje. Kiekvienas užklausimas apmokestinamas toje paskyroje, o modelį pasirenkate jūs.",
+      "fields": {
+        "apiKey": "API raktas",
+        "apiKeyHelp": "Susikurkite console.anthropic.com skiltyje API keys. Paskyroje reikia lėšų; Anthropic apmokestina kiekvieną užklausimą."
+      },
+      "settings": {
+        "model": "Modelis",
+        "modelHelp": "Kuris modelis atsako. Sąrašas nuskaitomas iš jūsų paskyros, naujausi viršuje. Mažesni modeliai kainuoja mažiau ir daugumai serviso tekstų visiškai pakanka."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/nb/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Spør om verkstedet ditt...",
   "clear": "Tøm samtale",
   "error": "Noe gikk galt. Vennligst prøv igjen.",
-  "notEnabled": "AI er ikke aktivert. Konfigurer det i Innstillinger → AI.",
+  "notEnabled": "AI er ikke koblet til. Koble til OpenAI eller Anthropic under Innstillinger → Integrasjoner.",
   "suggestion1": "Hvilke kjøretøy trenger snart service?",
   "suggestion2": "Vis meg ubetalte fakturaer",
   "suggestion3": "Hva er inntekten denne måneden?",

+ 32 - 3
messages/nb/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Ingenting er koblet til ennå. Velg en tjeneste nedenfor for å komme i gang.",
     "availableTitle": "Tilgjengelig",
     "availableDescription": "Tjenester du kan legge til. Hver settes opp én gang og kjører i bakgrunnen.",
-    "allConnected": "Alle tilgjengelige tjenester er tilkoblet."
+    "allConnected": "Alle tilgjengelige tjenester er tilkoblet.",
+    "searchPlaceholder": "Søk i integrasjoner",
+    "noMatches": "Ingenting passer med «{query}»."
   },
   "categories": {
     "calendar": "Kalender",
@@ -23,7 +25,8 @@
     "automation": "Automatisering",
     "storage": "Filer",
     "registry": "Kjøretøyregister",
-    "other": "Annet"
+    "other": "Annet",
+    "ai": "KI"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Oppslag på skilt og understellsnummer",
       "inspection": "Frist for EU-kontroll"
+    },
+    "ai": {
+      "chat": "Tekst og sammendrag"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "En gjennomgang pågår",
       "off": "Slå på innstillingen og lagre; før det sjekker en gjennomgang ingenting.",
       "syncNowHelp": "Synkroniser nå starter en gjennomgang med en gang i stedet for å vente på den timevise. Den slår opp 25 kjøretøy om gangen og fortsetter av seg selv til alle med forfalt sjekk er ferdige."
-    }
+    },
+    "supersedes": "Å koble til {next} kobler fra {current}. Bare én kan være i bruk om gangen, så {current} tas ut av bruk i det {next} settes i gang."
   },
   "activity": {
     "title": "Aktivitet",
@@ -397,6 +404,28 @@
         "usState": "Delstat",
         "usStateHelp": "Tobokstavskoden for delstaten kjøretøyene er registrert i, som CA eller TX. Amerikanske registre søkes per delstat."
       }
+    },
+    "openai": {
+      "description": "Servicebeskrivelser, historikksammendrag, lesing av vognkort og assistenten, besvart av din egen OpenAI-konto. Hvert kall belastes den kontoen, og du velger modellen.",
+      "fields": {
+        "apiKey": "API-nøkkel",
+        "apiKeyHelp": "Lag en på platform.openai.com under API keys. Kontoen må ha saldo; OpenAI fakturerer per kall."
+      },
+      "settings": {
+        "model": "Modell",
+        "modelHelp": "Hvilken modell som svarer. Listen leses fra kontoen din, nyeste først. Mindre modeller koster mindre per kall og holder for det meste av verkstedteksten."
+      }
+    },
+    "anthropic": {
+      "description": "Servicebeskrivelser, historikksammendrag, lesing av vognkort og assistenten, besvart av Claude på din egen Anthropic-konto. Hvert kall belastes den kontoen, og du velger modellen.",
+      "fields": {
+        "apiKey": "API-nøkkel",
+        "apiKeyHelp": "Lag en på console.anthropic.com under API keys. Kontoen må ha saldo; Anthropic fakturerer per kall."
+      },
+      "settings": {
+        "model": "Modell",
+        "modelHelp": "Hvilken modell som svarer. Listen leses fra kontoen din, nyeste først. Mindre modeller koster mindre per kall og holder for det meste av verkstedteksten."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/nl/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Vraag over uw werkplaats...",
   "clear": "Gesprek wissen",
   "error": "Er ging iets mis. Probeer het opnieuw.",
-  "notEnabled": "AI is niet ingeschakeld. Configureer het in Instellingen → AI.",
+  "notEnabled": "AI is niet verbonden. Verbind OpenAI of Anthropic bij Instellingen → Integraties.",
   "suggestion1": "Welke voertuigen moeten binnenkort onderhouden worden?",
   "suggestion2": "Toon me onbetaalde facturen",
   "suggestion3": "Wat is de omzet deze maand?",

+ 32 - 3
messages/nl/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Nog niets verbonden. Kies hieronder een dienst om te beginnen.",
     "availableTitle": "Beschikbaar",
     "availableDescription": "Diensten die u kunt toevoegen. Elke dienst stelt u één keer in en draait daarna op de achtergrond.",
-    "allConnected": "Alle beschikbare diensten zijn verbonden."
+    "allConnected": "Alle beschikbare diensten zijn verbonden.",
+    "searchPlaceholder": "Integraties zoeken",
+    "noMatches": "Niets komt overeen met \"{query}\"."
   },
   "categories": {
     "calendar": "Agenda",
@@ -23,7 +25,8 @@
     "automation": "Automatisering",
     "storage": "Bestanden",
     "registry": "Voertuigregister",
-    "other": "Overig"
+    "other": "Overig",
+    "ai": "AI"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Kenteken- en VIN-opzoeking",
       "inspection": "APK-vervaldatums"
+    },
+    "ai": {
+      "chat": "Teksten en samenvattingen"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "Er loopt een ronde",
       "off": "Zet de instelling aan en sla op; tot die tijd controleert een ronde niets.",
       "syncNowHelp": "Nu synchroniseren start meteen een ronde in plaats van op de uurlijkse te wachten. Het zoekt 25 voertuigen tegelijk op en gaat vanzelf door tot alle voertuigen aan de beurt zijn geweest."
-    }
+    },
+    "supersedes": "Als u {next} verbindt, wordt {current} losgekoppeld. Er kan er maar één tegelijk actief zijn, dus {current} wordt niet meer gebruikt zodra {next} live gaat."
   },
   "activity": {
     "title": "Activiteit",
@@ -397,6 +404,28 @@
         "usState": "Staat",
         "usStateHelp": "De tweeletterige code van de staat waar de voertuigen geregistreerd zijn, zoals CA of TX. Amerikaanse registers worden per staat doorzocht."
       }
+    },
+    "openai": {
+      "description": "Servicebeschrijvingen, samenvattingen van de historie, documenten lezen en de assistent, beantwoord door uw eigen OpenAI-account. Elke aanroep wordt op dat account afgerekend, en u kiest het model.",
+      "fields": {
+        "apiKey": "API-sleutel",
+        "apiKeyHelp": "Maak er een aan op platform.openai.com onder API keys. Het account moet tegoed hebben; OpenAI rekent per aanroep af."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Welk model antwoordt. De lijst komt uit uw account, nieuwste eerst. Kleinere modellen kosten minder per aanroep en volstaan voor de meeste werkplaatsteksten."
+      }
+    },
+    "anthropic": {
+      "description": "Servicebeschrijvingen, samenvattingen van de historie, documenten lezen en de assistent, beantwoord door Claude op uw eigen Anthropic-account. Elke aanroep wordt op dat account afgerekend, en u kiest het model.",
+      "fields": {
+        "apiKey": "API-sleutel",
+        "apiKeyHelp": "Maak er een aan op console.anthropic.com onder API keys. Het account moet tegoed hebben; Anthropic rekent per aanroep af."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Welk model antwoordt. De lijst komt uit uw account, nieuwste eerst. Kleinere modellen kosten minder per aanroep en volstaan voor de meeste werkplaatsteksten."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/pl/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Zapytaj o swój warsztat...",
   "clear": "Wyczyść rozmowę",
   "error": "Coś poszło nie tak. Spróbuj ponownie.",
-  "notEnabled": "AI nie jest włączone. Skonfiguruj w Ustawienia → AI.",
+  "notEnabled": "AI nie jest połączone. Podłącz OpenAI lub Anthropic w Ustawienia → Integracje.",
   "suggestion1": "Które pojazdy wkrótce wymagają serwisu?",
   "suggestion2": "Pokaż mi nieopłacone faktury",
   "suggestion3": "Jaki jest przychód w tym miesiącu?",

+ 32 - 3
messages/pl/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Nic jeszcze nie jest połączone. Wybierz usługę poniżej, aby zacząć.",
     "availableTitle": "Dostępne",
     "availableDescription": "Usługi, które możesz dodać. Każdą konfiguruje się raz, a potem działa w tle.",
-    "allConnected": "Wszystkie dostępne usługi są połączone."
+    "allConnected": "Wszystkie dostępne usługi są połączone.",
+    "searchPlaceholder": "Szukaj integracji",
+    "noMatches": "Nic nie pasuje do „{query}”."
   },
   "categories": {
     "calendar": "Kalendarz",
@@ -23,7 +25,8 @@
     "automation": "Automatyzacja",
     "storage": "Pliki",
     "registry": "Rejestr pojazdów",
-    "other": "Inne"
+    "other": "Inne",
+    "ai": "SI"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Wyszukiwanie po tablicy i VIN",
       "inspection": "Terminy przeglądów"
+    },
+    "ai": {
+      "chat": "Teksty i podsumowania"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "Trwa przebieg",
       "off": "Włącz ustawienie i zapisz; do tego czasu przebieg niczego nie sprawdza.",
       "syncNowHelp": "Synchronizuj teraz uruchamia przebieg od razu zamiast czekać na cogodzinny. Sprawdza po 25 pojazdów i kontynuuje sam, aż wszystkie oczekujące pojazdy zostaną obsłużone."
-    }
+    },
+    "supersedes": "Podłączenie {next} odłączy {current}. Naraz może działać tylko jedno, więc {current} przestanie być używane, gdy tylko {next} zacznie działać."
   },
   "activity": {
     "title": "Aktywność",
@@ -397,6 +404,28 @@
         "usState": "Stan",
         "usStateHelp": "Dwuliterowy kod stanu, w którym zarejestrowane są pojazdy, np. CA lub TX. Amerykańskie rejestry są przeszukiwane według stanu."
       }
+    },
+    "openai": {
+      "description": "Opisy usług, podsumowania historii, odczyt dokumentów i asystent, obsługiwane przez Twoje własne konto OpenAI. Każde wywołanie jest rozliczane na tym koncie, a model wybierasz sam.",
+      "fields": {
+        "apiKey": "Klucz API",
+        "apiKeyHelp": "Utwórz go na platform.openai.com w sekcji API keys. Konto musi mieć środki; OpenAI rozlicza każde wywołanie."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Który model odpowiada. Lista pochodzi z Twojego konta, najnowsze na górze. Mniejsze modele kosztują mniej za wywołanie i wystarczają do większości tekstów warsztatowych."
+      }
+    },
+    "anthropic": {
+      "description": "Opisy usług, podsumowania historii, odczyt dokumentów i asystent, obsługiwane przez Claude na Twoim własnym koncie Anthropic. Każde wywołanie jest rozliczane na tym koncie, a model wybierasz sam.",
+      "fields": {
+        "apiKey": "Klucz API",
+        "apiKeyHelp": "Utwórz go na console.anthropic.com w sekcji API keys. Konto musi mieć środki; Anthropic rozlicza każde wywołanie."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Który model odpowiada. Lista pochodzi z Twojego konta, najnowsze na górze. Mniejsze modele kosztują mniej za wywołanie i wystarczają do większości tekstów warsztatowych."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/pt-BR/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Pergunte sobre sua oficina...",
   "clear": "Limpar conversa",
   "error": "Algo deu errado. Tente novamente.",
-  "notEnabled": "A IA não está habilitada. Configure em Configurações → IA.",
+  "notEnabled": "A IA não está conectada. Conecte a OpenAI ou a Anthropic em Configurações → Integrações.",
   "suggestion1": "Quais veículos precisam de serviço em breve?",
   "suggestion2": "Mostre-me faturas não pagas",
   "suggestion3": "Qual é a receita deste mês?",

+ 32 - 3
messages/pt-BR/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Nada conectado ainda. Escolha um serviço abaixo para começar.",
     "availableTitle": "Disponíveis",
     "availableDescription": "Serviços que você pode adicionar. Cada um é configurado uma vez e roda em segundo plano.",
-    "allConnected": "Todos os serviços disponíveis estão conectados."
+    "allConnected": "Todos os serviços disponíveis estão conectados.",
+    "searchPlaceholder": "Buscar integrações",
+    "noMatches": "Nada corresponde a \"{query}\"."
   },
   "categories": {
     "calendar": "Calendário",
@@ -23,7 +25,8 @@
     "automation": "Automação",
     "storage": "Arquivos",
     "registry": "Registro de veículos",
-    "other": "Outros"
+    "other": "Outros",
+    "ai": "IA"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Consulta por placa e VIN",
       "inspection": "Datas de inspeção"
+    },
+    "ai": {
+      "chat": "Textos e resumos"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "Uma passagem está em andamento",
       "off": "Ative a opção e salve; até lá uma passagem não verifica nada.",
       "syncNowHelp": "Sincronizar agora inicia uma passagem imediatamente em vez de esperar a de hora em hora. Consulta 25 veículos por vez e continua sozinha até terminar todos os veículos pendentes."
-    }
+    },
+    "supersedes": "Conectar a {next} desconecta a {current}. Só uma pode estar ativa por vez, então {current} deixa de ser usada assim que {next} entrar no ar."
   },
   "activity": {
     "title": "Atividade",
@@ -397,6 +404,28 @@
         "usState": "Estado",
         "usStateHelp": "O código de duas letras do estado em que os veículos estão registrados, como CA ou TX. Os registros americanos são consultados por estado."
       }
+    },
+    "openai": {
+      "description": "Descrições de serviço, resumos de histórico, leitura de documentos e o assistente, respondidos pela sua própria conta OpenAI. Cada chamada é cobrada nessa conta, e você escolhe o modelo.",
+      "fields": {
+        "apiKey": "Chave de API",
+        "apiKeyHelp": "Crie uma em platform.openai.com, em API keys. A conta precisa de saldo; a OpenAI cobra por chamada."
+      },
+      "settings": {
+        "model": "Modelo",
+        "modelHelp": "Qual modelo responde. A lista vem da sua conta, os mais novos primeiro. Modelos menores custam menos por chamada e bastam para a maior parte dos textos da oficina."
+      }
+    },
+    "anthropic": {
+      "description": "Descrições de serviço, resumos de histórico, leitura de documentos e o assistente, respondidos pelo Claude na sua própria conta Anthropic. Cada chamada é cobrada nessa conta, e você escolhe o modelo.",
+      "fields": {
+        "apiKey": "Chave de API",
+        "apiKeyHelp": "Crie uma em console.anthropic.com, em API keys. A conta precisa de saldo; a Anthropic cobra por chamada."
+      },
+      "settings": {
+        "model": "Modelo",
+        "modelHelp": "Qual modelo responde. A lista vem da sua conta, os mais novos primeiro. Modelos menores custam menos por chamada e bastam para a maior parte dos textos da oficina."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/ru/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Спросите о вашей мастерской...",
   "clear": "Очистить беседу",
   "error": "Что-то пошло не так. Пожалуйста, попробуйте снова.",
-  "notEnabled": "ИИ не включён. Настройте его в Настройки → ИИ.",
+  "notEnabled": "ИИ не подключён. Подключите OpenAI или Anthropic в разделе Настройки → Интеграции.",
   "suggestion1": "Какие транспортные средства требуют обслуживания?",
   "suggestion2": "Покажи неоплаченные счета",
   "suggestion3": "Какой доход за этот месяц?",

+ 32 - 3
messages/ru/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Пока ничего не подключено. Выберите сервис ниже, чтобы начать.",
     "availableTitle": "Доступно",
     "availableDescription": "Сервисы, которые можно добавить. Каждый настраивается один раз и работает в фоне.",
-    "allConnected": "Все доступные сервисы подключены."
+    "allConnected": "Все доступные сервисы подключены.",
+    "searchPlaceholder": "Поиск интеграций",
+    "noMatches": "Ничего не найдено по запросу «{query}»."
   },
   "categories": {
     "calendar": "Календарь",
@@ -23,7 +25,8 @@
     "automation": "Автоматизация",
     "storage": "Файлы",
     "registry": "Реестр транспортных средств",
-    "other": "Другое"
+    "other": "Другое",
+    "ai": "ИИ"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Поиск по номеру и VIN",
       "inspection": "Сроки техосмотра"
+    },
+    "ai": {
+      "chat": "Тексты и сводки"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "Проход выполняется",
       "off": "Включите настройку и сохраните; до этого проход ничего не проверяет.",
       "syncNowHelp": "«Синхронизировать сейчас» запускает проход немедленно, не дожидаясь ежечасного. Он проверяет по 25 автомобилей и продолжает сам, пока не обработает все ожидающие."
-    }
+    },
+    "supersedes": "Подключение {next} отключит {current}. Одновременно работает только один, поэтому {current} перестанет использоваться, как только заработает {next}."
   },
   "activity": {
     "title": "Активность",
@@ -397,6 +404,28 @@
         "usState": "Штат",
         "usStateHelp": "Двухбуквенный код штата, в котором зарегистрированы автомобили, например CA или TX. Американские реестры запрашиваются по штатам."
       }
+    },
+    "openai": {
+      "description": "Описания работ, сводки по истории, распознавание документов и ассистент — отвечает ваш собственный аккаунт OpenAI. Каждый запрос оплачивается с этого аккаунта, модель выбираете вы.",
+      "fields": {
+        "apiKey": "API-ключ",
+        "apiKeyHelp": "Создайте его на platform.openai.com в разделе API keys. На аккаунте нужен баланс; OpenAI списывает плату за каждый запрос."
+      },
+      "settings": {
+        "model": "Модель",
+        "modelHelp": "Какая модель отвечает. Список берётся из вашего аккаунта, новые сверху. Модели поменьше дешевле за запрос и справляются с большинством текстов мастерской."
+      }
+    },
+    "anthropic": {
+      "description": "Описания работ, сводки по истории, распознавание документов и ассистент — отвечает Claude в вашем собственном аккаунте Anthropic. Каждый запрос оплачивается с этого аккаунта, модель выбираете вы.",
+      "fields": {
+        "apiKey": "API-ключ",
+        "apiKeyHelp": "Создайте его на console.anthropic.com в разделе API keys. На аккаунте нужен баланс; Anthropic списывает плату за каждый запрос."
+      },
+      "settings": {
+        "model": "Модель",
+        "modelHelp": "Какая модель отвечает. Список берётся из вашего аккаунта, новые сверху. Модели поменьше дешевле за запрос и справляются с большинством текстов мастерской."
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/tr/aiChat.json

@@ -4,7 +4,7 @@
   "placeholder": "Atölyeniz hakkında sorun...",
   "clear": "Sohbeti temizle",
   "error": "Bir şeyler yanlış gitti. Lütfen tekrar deneyin.",
-  "notEnabled": "AI etkin değil. Ayarlar → AI bölümünden yapılandırın.",
+  "notEnabled": "Yapay zekâ bağlı değil. Ayarlar → Entegrasyonlar bölümünden OpenAI veya Anthropic bağlayın.",
   "suggestion1": "Hangi araçların yakında servise ihtiyacı var?",
   "suggestion2": "Ödenmemiş faturaları göster",
   "suggestion3": "Bu ayın geliri ne kadar?",

+ 32 - 3
messages/tr/integrations.json

@@ -12,7 +12,9 @@
     "noneConnected": "Henüz bağlı bir hizmet yok. Başlamak için aşağıdan bir hizmet seçin.",
     "availableTitle": "Kullanılabilir",
     "availableDescription": "Ekleyebileceğiniz hizmetler. Her biri bir kez kurulur ve arka planda çalışır.",
-    "allConnected": "Kullanılabilir tüm hizmetler bağlı."
+    "allConnected": "Kullanılabilir tüm hizmetler bağlı.",
+    "searchPlaceholder": "Entegrasyonlarda ara",
+    "noMatches": "\"{query}\" ile eşleşen bir şey yok."
   },
   "categories": {
     "calendar": "Takvim",
@@ -23,7 +25,8 @@
     "automation": "Otomasyon",
     "storage": "Dosyalar",
     "registry": "Araç kayıt sistemi",
-    "other": "Diğer"
+    "other": "Diğer",
+    "ai": "Yapay zekâ"
   },
   "capabilities": {
     "calendar": {
@@ -49,6 +52,9 @@
     "vehicle": {
       "lookup": "Plaka ve VIN sorgulama",
       "inspection": "Muayene tarihleri"
+    },
+    "ai": {
+      "chat": "Metinler ve özetler"
     }
   },
   "statuses": {
@@ -113,7 +119,8 @@
       "inProgress": "Bir tarama sürüyor",
       "off": "Ayarı açıp kaydedin; o zamana kadar tarama hiçbir şeyi kontrol etmez.",
       "syncNowHelp": "Şimdi eşitle, saatlik taramayı beklemek yerine hemen bir tarama başlatır. Her seferinde 25 aracı sorgular ve sırası gelen tüm araçlar bitene kadar kendi kendine devam eder."
-    }
+    },
+    "supersedes": "{next} bağlandığında {current} bağlantısı kesilir. Aynı anda yalnızca biri geçerli olabilir; {next} devreye girdiği anda {current} kullanılmayı bırakır."
   },
   "activity": {
     "title": "Aktivite",
@@ -397,6 +404,28 @@
         "usState": "Eyalet",
         "usStateHelp": "Araçların tescilli olduğu eyaletin iki harfli kodu, örneğin CA veya TX. ABD sicilleri eyalet bazında sorgulanır."
       }
+    },
+    "openai": {
+      "description": "Servis açıklamaları, geçmiş özetleri, belge okuma ve asistan; kendi OpenAI hesabınız yanıtlar. Her çağrı o hesaba faturalanır ve modeli siz seçersiniz.",
+      "fields": {
+        "apiKey": "API anahtarı",
+        "apiKeyHelp": "platform.openai.com adresinde API keys bölümünden oluşturun. Hesapta bakiye olmalı; OpenAI her çağrı için ücretlendirir."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Hangi modelin yanıtlayacağı. Liste hesabınızdan okunur, en yenisi üstte. Küçük modeller çağrı başına daha ucuzdur ve servis metinlerinin çoğuna yeter."
+      }
+    },
+    "anthropic": {
+      "description": "Servis açıklamaları, geçmiş özetleri, belge okuma ve asistan; kendi Anthropic hesabınızda Claude yanıtlar. Her çağrı o hesaba faturalanır ve modeli siz seçersiniz.",
+      "fields": {
+        "apiKey": "API anahtarı",
+        "apiKeyHelp": "console.anthropic.com adresinde API keys bölümünden oluşturun. Hesapta bakiye olmalı; Anthropic her çağrı için ücretlendirir."
+      },
+      "settings": {
+        "model": "Model",
+        "modelHelp": "Hangi modelin yanıtlayacağı. Liste hesabınızdan okunur, en yenisi üstte. Küçük modeller çağrı başına daha ucuzdur ve servis metinlerinin çoğuna yeter."
+      }
     }
   },
   "jobKinds": {

+ 1 - 0
public/images/integrations/anthropic.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Anthropic"><title>Anthropic</title><rect width="48" height="48" rx="11" fill="#d97757"/><path d="M20.6 13h6.8l8.6 22h-6.4l-1.7-4.6h-7.8L18.4 35H12zm.9 12.4h5l-2.5-6.8z" fill="#fff"/></svg>

+ 1 - 0
public/images/integrations/openai.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="OpenAI"><title>OpenAI</title><rect width="48" height="48" rx="11" fill="#0d0d0d"/><g fill="none" stroke="#fff" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"><path d="M24 13.5l8.5 4.9v9.8L24 33.1l-8.5-4.9v-9.8z"/><path d="M24 13.5v9.8l8.5 4.9M24 23.3l-8.5 4.9M24 23.3V33.1"/></g></svg>

+ 135 - 0
src/__tests__/components/integrations-catalog.test.tsx

@@ -0,0 +1,135 @@
+/**
+ * The catalog's search box.
+ *
+ * Twenty-odd vendors is more than a page of cards worth scanning, so the box
+ * has to find one by whatever the workshop happens to remember: the vendor's
+ * name, what it does, or the category it sits in. It searches the translated
+ * text the cards actually show, and only the list of what can be added: what
+ * the workshop already runs is not what it is shopping through, and watching
+ * that list empty out while you type reads as though something came undone.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { IntegrationsCatalog } from '@/app/(authenticated)/settings/integrations/integrations-catalog'
+import type { CatalogEntry } from '@/features/integrations/Actions/integrationActions'
+import { getManifest } from '@/integrations/registry'
+
+function entry(id: string, overrides: Partial<CatalogEntry> = {}): CatalogEntry {
+  const manifest = getManifest(id)
+  if (!manifest) throw new Error(`no manifest for ${id}`)
+  return {
+    manifest,
+    status: null,
+    externalAccountName: null,
+    lastError: null,
+    platformApp: true,
+    featured: true,
+    ...overrides,
+  }
+}
+
+const ENTRIES: CatalogEntry[] = [
+  entry('openai'),
+  entry('google-calendar'),
+  entry('twilio-sms'),
+  entry('zoom', { status: 'active', externalAccountName: 'workshop@example.com' }),
+]
+
+function show() {
+  render(<IntegrationsCatalog entries={ENTRIES} />)
+  return screen.getByRole('searchbox')
+}
+
+/**
+ * The two lists, told apart by their call to action: a connected row offers
+ * Manage, an available card offers Connect. ("Connect" alone would not do
+ * it: a connected row also carries the word Connected.)
+ */
+function listed(action: 'Manage' | 'Connect'): string[] {
+  const links = screen.queryAllByRole('link')
+  return ENTRIES.map((e) => e.manifest.name).filter((name) =>
+    links.some((l) => {
+      if (!l.textContent?.startsWith(name)) return false
+      const manage = l.textContent.includes('Manage')
+      return action === 'Manage' ? manage : !manage
+    })
+  )
+}
+
+/** The vendors offered in the "Available" grid, which the search narrows. */
+function offered(): string[] {
+  return listed('Connect')
+}
+
+/** The vendors listed as already set up, which it does not. */
+function connected(): string[] {
+  return listed('Manage')
+}
+
+describe('integration search', () => {
+  it('shows everything until something is typed', () => {
+    show()
+    expect(offered()).toEqual(['OpenAI', 'Google Calendar', 'Twilio'])
+    expect(connected()).toEqual(['Zoom'])
+  })
+
+  it('finds a vendor by name, whatever it is called', () => {
+    const box = show()
+    fireEvent.change(box, { target: { value: 'twil' } })
+    expect(offered()).toEqual(['Twilio'])
+  })
+
+  it('leaves what is already connected alone', () => {
+    const box = show()
+    fireEvent.change(box, { target: { value: 'twil' } })
+    // Zoom is connected: it stays listed, with its account, whatever is typed.
+    expect(connected()).toEqual(['Zoom'])
+    expect(screen.getByText('workshop@example.com')).toBeInTheDocument()
+  })
+
+  it('searches what a vendor does, not just its name', () => {
+    const box = show()
+    // "Plate and VIN lookup" is a capability badge; "Model" is in OpenAI's text.
+    fireEvent.change(box, { target: { value: 'summaries' } })
+    expect(offered()).toEqual(['OpenAI'])
+  })
+
+  it('searches the category as it is shown', () => {
+    const box = show()
+    fireEvent.change(box, { target: { value: 'messaging' } })
+    expect(offered()).toEqual(['Twilio'])
+  })
+
+  it('finds every vendor that does a thing, not only the one named after it', () => {
+    const box = show()
+    // Google Calendar puts video call links on the events it writes.
+    fireEvent.change(box, { target: { value: 'video call' } })
+    expect(offered()).toEqual(['Google Calendar'])
+  })
+
+  it('ignores case and accents', () => {
+    const box = show()
+    fireEvent.change(box, { target: { value: '  GOOGLE  ' } })
+    expect(offered()).toEqual(['Google Calendar'])
+  })
+
+  it('says a search found nothing, rather than looking empty', () => {
+    const box = show()
+    fireEvent.change(box, { target: { value: 'quickbooks' } })
+    expect(offered()).toEqual([])
+    expect(screen.getByText('Nothing matches "quickbooks".')).toBeInTheDocument()
+    // The connected card is untouched: nothing there says the search failed.
+    expect(connected()).toEqual(['Zoom'])
+  })
+
+  it('composes with the category filter', () => {
+    const box = show()
+    fireEvent.click(screen.getByRole('button', { name: 'AI' }))
+    expect(offered()).toEqual(['OpenAI'])
+
+    fireEvent.change(box, { target: { value: 'google' } })
+    expect(screen.queryByText('Google Calendar')).toBeNull()
+    expect(screen.getByText('Nothing matches "google".')).toBeInTheDocument()
+  })
+})

+ 189 - 0
src/__tests__/features/integrations/ai.test.ts

@@ -0,0 +1,189 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { AI_KEYS } from '@/features/ai/Schema/aiSettingsSchema'
+
+const appSetting = { findMany: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() }
+const integrationConnection = {
+  findMany: vi.fn(),
+  findUnique: vi.fn(),
+  create: vi.fn(),
+  update: vi.fn(),
+  updateMany: vi.fn(),
+}
+
+vi.mock('@/lib/db', () => ({ db: { appSetting, integrationConnection } }))
+
+const { AI_ADOPTED_KEY, aiSetup, isAiConfigured } = await import('@/features/integrations/Lib/ai')
+const { openCredentials, sealCredentials } = await import('@/features/integrations/Lib/vault')
+
+const ORG = 'org-1'
+
+/** The four rows a workshop had before AI moved into the catalog. */
+function legacyRows(overrides: Record<string, string> = {}) {
+  const values: Record<string, string> = {
+    [AI_KEYS.AI_ENABLED]: 'true',
+    [AI_KEYS.AI_PROVIDER]: 'openai',
+    [AI_KEYS.AI_API_KEY]: 'sk-old-key',
+    [AI_KEYS.AI_MODEL]: 'gpt-4.1-mini',
+    ...overrides,
+  }
+  return Object.entries(values)
+    .filter(([, value]) => value !== '')
+    .map(([key, value]) => ({ key, value, userId: 'user-1' }))
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  process.env.INTEGRATIONS_ENCRYPTION_KEY = 'a'.repeat(64)
+  integrationConnection.findMany.mockResolvedValue([])
+  integrationConnection.findUnique.mockResolvedValue(null)
+  appSetting.findMany.mockResolvedValue([])
+  integrationConnection.create.mockImplementation(async ({ data }) => ({
+    id: 'conn-new',
+    status: data.status,
+  }))
+})
+
+/**
+ * The move is only safe if a workshop that pasted a key months ago keeps
+ * generating descriptions without touching anything, so that is what these
+ * check.
+ */
+describe('AI connection', () => {
+  it('runs on an active connection, with the key from the vault', async () => {
+    integrationConnection.findMany.mockResolvedValue([
+      {
+        id: 'conn-1',
+        connectorId: 'anthropic',
+        credentials: sealCredentials({ apiKey: 'sk-ant-new' }),
+        settings: { model: 'claude-sonnet-4-6' },
+      },
+    ])
+
+    await expect(aiSetup(ORG)).resolves.toEqual({
+      connectionId: 'conn-1',
+      provider: 'anthropic',
+      apiKey: 'sk-ant-new',
+      model: 'claude-sonnet-4-6',
+    })
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+
+  it('adopts an old settings-page setup on first use, sealing the key', async () => {
+    appSetting.findMany.mockResolvedValue(legacyRows())
+
+    const setup = await aiSetup(ORG)
+
+    expect(setup).toMatchObject({ provider: 'openai', apiKey: 'sk-old-key', model: 'gpt-4.1-mini' })
+    const created = integrationConnection.create.mock.calls[0][0].data
+    expect(created).toMatchObject({
+      organizationId: ORG,
+      connectorId: 'openai',
+      status: 'active',
+      settings: { model: 'gpt-4.1-mini' },
+    })
+    // Sealed, not stored as it was typed.
+    expect(created.credentials).not.toContain('sk-old-key')
+    expect(openCredentials(created.credentials)).toEqual({ apiKey: 'sk-old-key' })
+    // And recorded, so the next call does not adopt all over again.
+    expect(appSetting.upsert).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { organizationId_key: { organizationId: ORG, key: AI_ADOPTED_KEY } },
+      })
+    )
+  })
+
+  it('leaves a half-finished old setup alone', async () => {
+    appSetting.findMany.mockResolvedValue(legacyRows({ [AI_KEYS.AI_API_KEY]: '' }))
+
+    await expect(aiSetup(ORG)).resolves.toBeNull()
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+
+  it('does not adopt a setup that was switched off', async () => {
+    appSetting.findMany.mockResolvedValue(legacyRows({ [AI_KEYS.AI_ENABLED]: 'false' }))
+
+    await expect(aiSetup(ORG)).resolves.toBeNull()
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+
+  it('adopts once: a disconnected connection is not resurrected by the old rows', async () => {
+    appSetting.findMany.mockResolvedValue([
+      ...legacyRows(),
+      { key: AI_ADOPTED_KEY, value: '2026-09-04T00:00:00.000Z', userId: 'user-1' },
+    ])
+
+    await expect(aiSetup(ORG)).resolves.toBeNull()
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+
+  it('never writes over a connection the workshop made themselves', async () => {
+    appSetting.findMany.mockResolvedValue(legacyRows())
+    integrationConnection.findUnique.mockResolvedValue({ id: 'conn-theirs' })
+
+    await expect(aiSetup(ORG)).resolves.toBeNull()
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+    expect(appSetting.upsert).not.toHaveBeenCalled()
+  })
+
+  it('stands the other vendor down when an old setup goes live', async () => {
+    appSetting.findMany.mockResolvedValue(legacyRows({ [AI_KEYS.AI_PROVIDER]: 'anthropic' }))
+
+    await aiSetup(ORG)
+
+    expect(integrationConnection.updateMany).toHaveBeenCalledWith({
+      where: { organizationId: ORG, connectorId: { in: ['openai'] }, status: 'active' },
+      data: { status: 'disconnected', lastError: null },
+    })
+  })
+
+  it('falls back to the old rows when the vault cannot open a connection', async () => {
+    const quiet = vi.spyOn(console, 'error').mockImplementation(() => {})
+    integrationConnection.findMany.mockResolvedValue([
+      { id: 'conn-1', connectorId: 'openai', credentials: 'v1.bad.bad.bad', settings: {} },
+    ])
+    appSetting.findMany.mockResolvedValue(legacyRows())
+
+    await expect(aiSetup(ORG)).resolves.toMatchObject({ apiKey: 'sk-old-key' })
+    expect(quiet).toHaveBeenCalled()
+    quiet.mockRestore()
+  })
+
+  it('answers the "can we scan?" question without connecting anything', async () => {
+    appSetting.findMany.mockResolvedValue(legacyRows())
+
+    await expect(isAiConfigured(ORG)).resolves.toBe(true)
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+})
+
+/**
+ * AI used to live on a settings page that the free plan could open, and the
+ * integrations catalog is not on that plan. Moving the page must not take the
+ * feature away, so a connector that names its own plan feature is gated by
+ * that feature rather than by the catalog's.
+ */
+describe('plan gates', () => {
+  it('lets a plan with AI but no integrations reach the AI connectors only', async () => {
+    const { anyConnectorAllowed, connectorAllowed } = await import(
+      '@/features/integrations/Lib/plan'
+    )
+    const { PLAN_FEATURES } = await import('@/lib/features')
+    const { getManifest, listManifests } = await import('@/integrations/registry')
+
+    const free = PLAN_FEATURES.free
+    expect(free.integrations).toBe(false)
+    expect(free.ai).toBe(true)
+
+    expect(anyConnectorAllowed(free)).toBe(true)
+    const reachable = listManifests()
+      .filter((m) => connectorAllowed(m, free))
+      .map((m) => m.id)
+      .sort()
+    expect(reachable).toEqual(['anthropic', 'openai'])
+
+    const pro = PLAN_FEATURES.pro
+    expect(connectorAllowed(getManifest('openai')!, pro)).toBe(true)
+    expect(connectorAllowed(getManifest('zoom')!, pro)).toBe(true)
+    expect(connectorAllowed(getManifest('zoom')!, free)).toBe(false)
+  })
+})

+ 8 - 27
src/app/(authenticated)/settings/ai/page.tsx

@@ -1,30 +1,11 @@
-import { getAiSettings } from '@/features/ai/Actions/aiSettingsActions'
-import { getLayoutData } from '@/lib/get-layout-data'
-import { getFeatures, isCloudMode } from '@/lib/features'
-import { AiSettingsForm } from '@/features/ai/Components/AiSettingsForm'
-import { FeatureLockedMessage } from '../feature-locked-message'
 import { redirect } from 'next/navigation'
 
-export default async function AiSettingsPage() {
-  const data = await getLayoutData()
-
-  if (data.status === 'unauthenticated') redirect('/auth/sign-in')
-  if (data.status === 'no-organization') redirect('/onboarding')
-
-  const features = await getFeatures(data.organizationId)
-
-  if (!features.ai) {
-    return (
-      <FeatureLockedMessage
-        feature="AI Assistant"
-        description="Use AI to auto-generate service descriptions, summarize vehicle history, and build quotes from plain text."
-        isCloud={isCloudMode()}
-      />
-    )
-  }
-
-  const result = await getAiSettings()
-  const settings = result.success && result.data ? result.data : {}
-
-  return <AiSettingsForm initial={settings} />
+/**
+ * AI moved into the integrations catalog, where every other vendor with a key
+ * lives. The route stays as a redirect: it is in menus, bookmarks and the
+ * docs, and a workshop following an old link should land on the catalog
+ * rather than on a 404.
+ */
+export default function AiSettingsPage() {
+  redirect('/settings/integrations')
 }

+ 15 - 0
src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx

@@ -249,6 +249,21 @@ export function ConnectionSettings({
           </div>
         )}
 
+        {/* Only one AI provider, and one vendor per messaging channel, stays in
+            charge. Connecting this one stands the other down, which is worth
+            knowing before the button is pressed rather than after. */}
+        {!connected && view.supersedes && (
+          <div className="mb-4 flex gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm">
+            <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
+            <p className="text-muted-foreground">
+              {t('connection.supersedes', {
+                current: view.supersedes.name,
+                next: manifest.name,
+              })}
+            </p>
+          </div>
+        )}
+
         {connected ? (
           <div className={`grid gap-4 text-sm ${hasSync ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
             <div>

+ 5 - 3
src/app/(authenticated)/settings/integrations/[connector]/page.tsx

@@ -6,6 +6,7 @@ import {
   getIntegrationConnection,
 } from '@/features/integrations/Actions/integrationActions'
 import { getManifest } from '@/integrations/registry'
+import { connectorAllowed } from '@/features/integrations/Lib/plan'
 import { FeatureLockedMessage } from '../../feature-locked-message'
 import { ConnectionSettings } from './connection-settings'
 
@@ -15,17 +16,18 @@ export default async function IntegrationConnectionPage({
   params: Promise<{ connector: string }>
 }) {
   const { connector } = await params
-  if (!getManifest(connector)) notFound()
+  const manifest = getManifest(connector)
+  if (!manifest) notFound()
 
   const data = await getLayoutData()
   if (data.status === 'unauthenticated') redirect('/auth/sign-in')
   if (data.status === 'no-organization') redirect('/onboarding')
 
   const features = await getFeatures(data.organizationId)
-  if (!features.integrations) {
+  if (!connectorAllowed(manifest, features)) {
     return (
       <FeatureLockedMessage
-        feature="Integrations"
+        feature={manifest.plan ? manifest.name : 'Integrations'}
         description="Connect calendars, video calls and other services to your workshop."
         isCloud={isCloudMode()}
       />

+ 74 - 9
src/app/(authenticated)/settings/integrations/integrations-catalog.tsx

@@ -1,17 +1,19 @@
 'use client'
 
-import { useMemo, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
 import Image from 'next/image'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
-import { AlertTriangle, ArrowRight, Check, Plug, Settings2 } from 'lucide-react'
+import { AlertTriangle, ArrowRight, Check, Plug, Search, Settings2 } from 'lucide-react'
 import { AppCard } from '@/components/app-card'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
 import type { CatalogEntry } from '@/features/integrations/Actions/integrationActions'
 import type { IntegrationCategory } from '@/features/integrations/Lib/types'
 
 const CATEGORY_ORDER: IntegrationCategory[] = [
+  'ai',
   'calendar',
   'conferencing',
   'accounting',
@@ -23,14 +25,60 @@ const CATEGORY_ORDER: IntegrationCategory[] = [
   'other',
 ]
 
+/** Lower case and without accents, so "intégrations" is found by "integr". */
+function normalize(value: string): string {
+  return value
+    .toLowerCase()
+    .normalize('NFD')
+    .replace(/[\u0300-\u036f]/g, '')
+    .trim()
+}
+
 /**
  * Two cards: what the workshop has set up, then what it could add. The
  * first is a plain list, since a connected service is something to check on
- * rather than shop for; the second is the browsable grid with categories.
+ * rather than shop for; the second is the browsable grid with categories,
+ * and the one the search box narrows. Searching is shopping: the list of
+ * what this workshop already runs stays whole while you look for something
+ * to add to it.
  */
 export function IntegrationsCatalog({ entries }: { entries: CatalogEntry[] }) {
   const t = useTranslations('integrations')
   const [category, setCategory] = useState<IntegrationCategory | 'all'>('all')
+  const [query, setQuery] = useState('')
+
+  /**
+   * Everything a card shows, in the reader's own language: the vendor name,
+   * what it does, its category and its capability badges. Searching the
+   * translated text is what lets "kalender" find Google Calendar.
+   */
+  const haystacks = useMemo(() => {
+    const index = new Map<string, string>()
+    for (const e of entries) {
+      const m = e.manifest
+      index.set(
+        m.id,
+        normalize(
+          [
+            m.name,
+            m.id,
+            t(`connectors.${m.id}.description`),
+            t(`categories.${m.category}`),
+            ...(m.also ?? []).map((c) => t(`categories.${c}`)),
+            ...m.capabilities.map((cap) => t(`capabilities.${cap}`)),
+          ].join(' ')
+        )
+      )
+    }
+    return index
+  }, [entries, t])
+
+  const needle = normalize(query)
+  const matches = useCallback(
+    (entry: CatalogEntry) =>
+      !needle || (haystacks.get(entry.manifest.id)?.includes(needle) ?? false),
+    [haystacks, needle]
+  )
 
   const connected = useMemo(
     () => entries.filter((e) => e.status && e.status !== 'disconnected'),
@@ -53,13 +101,14 @@ export function IntegrationsCatalog({ entries }: { entries: CatalogEntry[] }) {
   const visible = useMemo(() => {
     const list = available.filter(
       (e) =>
-        category === 'all' ||
-        e.manifest.category === category ||
-        e.manifest.also?.includes(category)
+        (category === 'all' ||
+          e.manifest.category === category ||
+          e.manifest.also?.includes(category)) &&
+        matches(e)
     )
     // The ones that matter for this workshop's country first.
     return list.sort((a, b) => Number(b.featured) - Number(a.featured))
-  }, [available, category])
+  }, [available, category, matches])
 
   return (
     <div className="space-y-4">
@@ -121,6 +170,18 @@ export function IntegrationsCatalog({ entries }: { entries: CatalogEntry[] }) {
         title={t('catalog.availableTitle')}
         description={t('catalog.availableDescription')}
       >
+        <div className="relative mb-4">
+          <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+          <Input
+            type="search"
+            value={query}
+            onChange={(e) => setQuery(e.target.value)}
+            placeholder={t('catalog.searchPlaceholder')}
+            aria-label={t('catalog.searchPlaceholder')}
+            className="pl-9"
+          />
+        </div>
+
         {categories.length > 1 && (
           <div className="-mx-1 mb-4 flex gap-2 overflow-x-auto px-1 sm:flex-wrap sm:overflow-visible">
             <Button
@@ -146,8 +207,12 @@ export function IntegrationsCatalog({ entries }: { entries: CatalogEntry[] }) {
         )}
 
         {visible.length === 0 ? (
-          <div className="flex h-24 items-center justify-center rounded-lg border text-sm text-muted-foreground">
-            {available.length === 0 ? t('catalog.allConnected') : t('catalog.empty')}
+          <div className="flex h-24 items-center justify-center rounded-lg border px-4 text-center text-sm text-muted-foreground">
+            {needle
+              ? t('catalog.noMatches', { query: query.trim() })
+              : available.length === 0
+                ? t('catalog.allConnected')
+                : t('catalog.empty')}
           </div>
         ) : (
           <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">

+ 2 - 1
src/app/(authenticated)/settings/integrations/page.tsx

@@ -1,6 +1,7 @@
 import { redirect } from 'next/navigation'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getFeatures, isCloudMode } from '@/lib/features'
+import { anyConnectorAllowed } from '@/features/integrations/Lib/plan'
 import { getIntegrationCatalog } from '@/features/integrations/Actions/integrationActions'
 import { FeatureLockedMessage } from '../feature-locked-message'
 import { IntegrationsCatalog } from './integrations-catalog'
@@ -11,7 +12,7 @@ export default async function IntegrationsPage() {
   if (data.status === 'no-organization') redirect('/onboarding')
 
   const features = await getFeatures(data.organizationId)
-  if (!features.integrations) {
+  if (!anyConnectorAllowed(features)) {
     return (
       <FeatureLockedMessage
         feature="Integrations"

+ 0 - 2
src/app/(authenticated)/settings/settings-nav.tsx

@@ -28,7 +28,6 @@ import {
   Layout,
   MessagesSquare,
   Percent,
-  Sparkles,
   UserCog,
   UsersRound,
   Webhook,
@@ -99,7 +98,6 @@ const settingsCategories: SettingsCategory[] = [
       { key: 'inspectionReminders', href: '/settings/inspection-reminders', icon: ClipboardCheck },
       { key: 'alerts', href: '/settings/alerts', icon: BellRing },
       { key: 'tireHotel', href: '/settings/tire-hotel', icon: Disc3, gate: 'tireHotel' },
-      { key: 'ai', href: '/settings/ai', icon: Sparkles, gate: 'ai' },
       {
         key: 'reportSchedule',
         href: '/settings/report-schedule',

+ 2 - 1
src/app/api/integrations/[connector]/oauth/start/route.ts

@@ -5,6 +5,7 @@ import { getFeatures } from '@/lib/features'
 import { isDemoMode } from '@/lib/demo'
 import { getManifest } from '@/integrations/registry'
 import { appUrl } from '@/features/integrations/Lib/connections'
+import { connectorAllowed } from '@/features/integrations/Lib/plan'
 import {
   buildAuthorizeUrl,
   newCodeVerifier,
@@ -38,7 +39,7 @@ export async function GET(
   if (!manifest || !spec) return fail('unknown')
 
   const features = await getFeatures(ctx.organizationId)
-  if (!features.integrations || (manifest.plan && !features[manifest.plan])) return fail('plan')
+  if (!connectorAllowed(manifest, features)) return fail('plan')
 
   const existing = await db.integrationConnection.findUnique({
     where: {

+ 0 - 1
src/components/page-header.tsx

@@ -119,7 +119,6 @@ const docsMap: Record<string, string> = {
   '/settings/maintenance': '/docs/configuration/predicted-maintenance',
   '/settings/data': '/docs/configuration/backup-and-restore',
   '/settings/alerts': '/docs/features/low-stock-alerts',
-  '/settings/ai': '/docs/integrations/ai',
   '/settings/telegram': '/docs/integrations/telegram',
   '/telegram': '/docs/integrations/telegram',
   '/settings/report-schedule': '/docs/features/reports',

+ 0 - 202
src/features/ai/Actions/aiSettingsActions.ts

@@ -1,202 +0,0 @@
-'use server'
-
-import { db } from '@/lib/db'
-import { withAuth } from '@/lib/with-auth'
-import { revalidatePath } from 'next/cache'
-import {
-  ALL_AI_KEYS,
-  AI_KEYS,
-  type AiProvider,
-  type AiModel,
-  getModelCost,
-  formatModelLabel,
-} from '../Schema/aiSettingsSchema'
-import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import { demoGuard } from '@/lib/demo'
-
-const API_KEY_MASK = '••••••••••••••••'
-
-export async function getAiSettings() {
-  return withAuth(
-    async ({ organizationId }) => {
-      const settings = await db.appSetting.findMany({
-        where: { organizationId, key: { in: ALL_AI_KEYS } },
-      })
-      const map: Record<string, string> = {}
-      for (const s of settings) {
-        // Never send the actual API key to the client
-        if (s.key === AI_KEYS.AI_API_KEY) {
-          map[s.key] = s.value ? API_KEY_MASK : ''
-        } else {
-          map[s.key] = s.value
-        }
-      }
-      return map
-    },
-    {
-      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
-    }
-  )
-}
-
-export async function setAiSettings(entries: Record<string, string>) {
-  return withAuth(
-    async ({ userId, organizationId }) => {
-      demoGuard()
-      // Filter out masked API key — only update if user provided a new one
-      const filtered = Object.entries(entries).filter(
-        ([key, value]) => !(key === AI_KEYS.AI_API_KEY && value === API_KEY_MASK)
-      )
-      await db.$transaction(
-        filtered.map(([key, value]) =>
-          db.appSetting.upsert({
-            where: { organizationId_key: { organizationId, key } },
-            update: { value },
-            create: { userId, organizationId, key, value },
-          })
-        )
-      )
-      revalidatePath('/settings/ai')
-      return true
-    },
-    {
-      requiredPermissions: [
-        {
-          action: PermissionAction.UPDATE,
-          subject: PermissionSubject.SETTINGS,
-        },
-      ],
-    }
-  )
-}
-
-// Chat-capable model prefixes for OpenAI (filter out embeddings, whisper, dall-e, etc.)
-const OPENAI_CHAT_PREFIXES = ['gpt-', 'o1', 'o3', 'o4', 'chatgpt-']
-
-function isOpenAiChatModel(id: string): boolean {
-  return OPENAI_CHAT_PREFIXES.some((prefix) => id.startsWith(prefix))
-}
-
-// Anthropic model patterns to include
-function isAnthropicChatModel(id: string): boolean {
-  return id.includes('claude')
-}
-
-/**
- * Sort OpenAI models: newest version first, within same version: full > mini > nano.
- * o-series models come first (o4 > o3 > o1), then GPT-4.x, then GPT-4o, then older.
- */
-function openAiModelOrder(id: string): number {
-  const l = id.toLowerCase()
-  // o-series: o4, o3, o1 (newest first)
-  if (l.startsWith('o4')) return 100 + (l.includes('mini') ? 1 : 0)
-  if (l.startsWith('o3')) return 200 + (l.includes('mini') ? 1 : 0)
-  if (l.startsWith('o1')) return 300 + (l.includes('mini') ? 1 : 0)
-  // GPT-4.1
-  if (l.startsWith('gpt-4.1')) return 400 + (l.includes('nano') ? 2 : l.includes('mini') ? 1 : 0)
-  // GPT-4.5
-  if (l.startsWith('gpt-4.5')) return 350 + (l.includes('nano') ? 2 : l.includes('mini') ? 1 : 0)
-  // GPT-4o
-  if (l.startsWith('gpt-4o')) return 500 + (l.includes('mini') ? 1 : 0)
-  // GPT-4 (non-turbo / turbo)
-  if (l.startsWith('gpt-4'))
-    return 600 + (l.includes('turbo') ? 0 : 1) + (l.includes('mini') ? 2 : 0)
-  // chatgpt-4o
-  if (l.startsWith('chatgpt')) return 700
-  // Everything else
-  return 900
-}
-
-/**
- * Sort Anthropic models: newest version first, within same version: opus > sonnet > haiku.
- */
-function anthropicModelOrder(id: string): number {
-  const l = id.toLowerCase()
-  // Extract version: claude-4, claude-3.5, claude-3, etc.
-  const versionMatch = l.match(/(\d+)[\.\-]?(\d*)/)
-  const major = versionMatch ? parseInt(versionMatch[1]) : 0
-  const minor = versionMatch && versionMatch[2] ? parseInt(versionMatch[2]) : 0
-  // Higher version = lower order number (sorts first)
-  const versionScore = (10 - major) * 100 + (10 - minor) * 10
-  // Tier within version
-  const tierScore = l.includes('opus') ? 0 : l.includes('sonnet') ? 1 : l.includes('haiku') ? 2 : 3
-  return versionScore + tierScore
-}
-
-export async function fetchAiModels(provider: AiProvider) {
-  return withAuth(
-    async ({ organizationId }) => {
-      demoGuard()
-      // Read API key from DB — never accept it from the client
-      const setting = await db.appSetting.findUnique({
-        where: { organizationId_key: { organizationId, key: AI_KEYS.AI_API_KEY } },
-        select: { value: true },
-      })
-      const apiKey = setting?.value
-      if (!apiKey) throw new Error('API key is not configured')
-
-      const models: AiModel[] = []
-
-      if (provider === 'openai') {
-        const res = await fetch('https://api.openai.com/v1/models', {
-          headers: { Authorization: `Bearer ${apiKey}` },
-        })
-        if (!res.ok) throw new Error('Failed to fetch models. Check your API key.')
-        const data = (await res.json()) as { data: { id: string }[] }
-        for (const m of data.data) {
-          if (isOpenAiChatModel(m.id)) {
-            models.push({
-              id: m.id,
-              label: formatModelLabel(m.id),
-              cost: getModelCost('openai', m.id),
-            })
-          }
-        }
-      } else {
-        // Anthropic paginates models — fetch all pages
-        let afterId: string | undefined
-        let hasMore = true
-        while (hasMore) {
-          const url = new URL('https://api.anthropic.com/v1/models')
-          url.searchParams.set('limit', '100')
-          if (afterId) url.searchParams.set('after_id', afterId)
-          const res = await fetch(url.toString(), {
-            headers: {
-              'x-api-key': apiKey,
-              'anthropic-version': '2023-06-01',
-            },
-          })
-          if (!res.ok) throw new Error('Failed to fetch models. Check your API key.')
-          const data = (await res.json()) as {
-            data: { id: string; display_name?: string }[]
-            has_more?: boolean
-            last_id?: string
-          }
-          for (const m of data.data) {
-            if (isAnthropicChatModel(m.id)) {
-              models.push({
-                id: m.id,
-                label: m.display_name || formatModelLabel(m.id),
-                cost: getModelCost('anthropic', m.id),
-              })
-            }
-          }
-          hasMore = data.has_more === true && !!data.last_id
-          afterId = data.last_id
-        }
-      }
-
-      // Sort by model version (newest first) within each provider
-      if (provider === 'openai') {
-        models.sort((a, b) => openAiModelOrder(a.id) - openAiModelOrder(b.id))
-      } else {
-        models.sort((a, b) => anthropicModelOrder(a.id) - anthropicModelOrder(b.id))
-      }
-
-      return models
-    },
-    {
-      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
-    }
-  )
-}

+ 0 - 288
src/features/ai/Components/AiSettingsForm.tsx

@@ -1,288 +0,0 @@
-'use client'
-
-import { useState, useTransition, useEffect, useCallback } from 'react'
-import { useTranslations } from 'next-intl'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import { Switch } from '@/components/ui/switch'
-import { Badge } from '@/components/ui/badge'
-import { AppCard } from '@/components/app-card'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
-import { Info, Loader2, Sparkles, Zap } from 'lucide-react'
-import {
-  AI_KEYS,
-  AI_PROVIDERS,
-  OPENAI_FALLBACK_MODELS,
-  ANTHROPIC_FALLBACK_MODELS,
-  type AiProvider,
-  type AiModel,
-} from '../Schema/aiSettingsSchema'
-import { setAiSettings, fetchAiModels } from '../Actions/aiSettingsActions'
-import { aiTestConnection } from '../Actions/aiActions'
-import {
-  ReadOnlyBanner,
-  SaveButton,
-  ReadOnlyWrapper,
-} from '@/app/(authenticated)/settings/read-only-guard'
-
-const COST_COLORS: Record<AiModel['cost'], string> = {
-  high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
-  medium: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
-  low: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
-}
-
-export function AiSettingsForm({ initial }: { initial: Record<string, string> }) {
-  const t = useTranslations('settings')
-  const router = useRouter()
-  const [isPending, startTransition] = useTransition()
-  const [isTesting, setIsTesting] = useState(false)
-
-  const hasConfig = !!initial[AI_KEYS.AI_API_KEY]
-  const [enabled, setEnabled] = useState(initial[AI_KEYS.AI_ENABLED] === 'true' || false)
-  const [provider, setProvider] = useState<AiProvider>(
-    (initial[AI_KEYS.AI_PROVIDER] as AiProvider) || 'openai'
-  )
-  const [apiKey, setApiKey] = useState(initial[AI_KEYS.AI_API_KEY] || '')
-  const [model, setModel] = useState(initial[AI_KEYS.AI_MODEL] || 'gpt-4o-mini')
-
-  const [models, setModels] = useState<AiModel[]>(
-    provider === 'anthropic' ? ANTHROPIC_FALLBACK_MODELS : OPENAI_FALLBACK_MODELS
-  )
-  const [fetchingModels, setFetchingModels] = useState(false)
-
-  const loadModels = useCallback(async (p: AiProvider) => {
-    setFetchingModels(true)
-    try {
-      const result = await fetchAiModels(p)
-      if (result.success && result.data && result.data.length > 0) {
-        setModels(result.data)
-      } else {
-        setModels(p === 'anthropic' ? ANTHROPIC_FALLBACK_MODELS : OPENAI_FALLBACK_MODELS)
-      }
-    } catch {
-      setModels(p === 'anthropic' ? ANTHROPIC_FALLBACK_MODELS : OPENAI_FALLBACK_MODELS)
-    } finally {
-      setFetchingModels(false)
-    }
-  }, [])
-
-  // Fetch models on mount if API key is configured (hasConfig means key exists in DB)
-  useEffect(() => {
-    if (hasConfig) {
-      loadModels(provider)
-    }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [])
-
-  const handleProviderChange = (value: string) => {
-    const p = value as AiProvider
-    setProvider(p)
-    const fallback = p === 'anthropic' ? ANTHROPIC_FALLBACK_MODELS : OPENAI_FALLBACK_MODELS
-    setModels(fallback)
-    setModel(fallback[0].id)
-    if (hasConfig) {
-      loadModels(p)
-    }
-  }
-
-  const handleApiKeyBlur = () => {
-    // After saving a new key, models can be refreshed
-    // But we don't send the key to the server from here — it reads from DB
-  }
-
-  const handleSave = () => {
-    startTransition(async () => {
-      const data: Record<string, string> = {
-        [AI_KEYS.AI_ENABLED]: enabled ? 'true' : 'false',
-        [AI_KEYS.AI_PROVIDER]: provider,
-        [AI_KEYS.AI_API_KEY]: apiKey,
-        [AI_KEYS.AI_MODEL]: model,
-      }
-
-      const result = await setAiSettings(data)
-      if (result.success) {
-        toast.success(t('ai.saved'))
-        router.refresh()
-      } else {
-        toast.error(result.error ?? t('ai.failedSave'))
-      }
-    })
-  }
-
-  const handleTestConnection = async () => {
-    setIsTesting(true)
-    try {
-      const result = await aiTestConnection()
-      if (result.success && result.data) {
-        toast.success(t('ai.testSuccess'))
-      } else {
-        toast.error(result.error ?? t('ai.testFailed'))
-      }
-    } catch (err) {
-      toast.error(err instanceof Error ? err.message : t('ai.testFailed'))
-    } finally {
-      setIsTesting(false)
-    }
-  }
-
-  const canTest = enabled && apiKey && model
-
-  return (
-    <div className="space-y-6">
-      <ReadOnlyBanner />
-      <ReadOnlyWrapper>
-        <AppCard
-          icon={Sparkles}
-          title={t('ai.title')}
-          description={t('ai.description')}
-          action={
-            <a
-              href="https://torqvoice.com/docs/integrations/ai"
-              target="_blank"
-              rel="noopener noreferrer"
-              className="text-[11px] text-muted-foreground transition-colors hover:text-foreground"
-            >
-              {t('ai.readMore')} →
-            </a>
-          }
-          contentClassName="space-y-6"
-        >
-          <div className="flex items-center justify-between">
-            <div className="space-y-0.5">
-              <Label htmlFor="enable-ai">{t('ai.enableLabel')}</Label>
-              <p className="text-xs text-muted-foreground">{t('ai.enableHint')}</p>
-            </div>
-            <Switch id="enable-ai" checked={enabled} onCheckedChange={setEnabled} />
-          </div>
-
-          {!enabled && (
-            <div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
-              <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
-              <p className="text-sm text-muted-foreground">{t('ai.disabledInfo')}</p>
-            </div>
-          )}
-
-          {enabled && (
-            <>
-              {/* Provider selection */}
-              <div className="space-y-2">
-                <Label>{t('ai.provider')}</Label>
-                <div className="flex flex-wrap gap-2">
-                  {AI_PROVIDERS.map((p) => (
-                    <Button
-                      key={p}
-                      type="button"
-                      variant={provider === p ? 'default' : 'outline'}
-                      onClick={() => handleProviderChange(p)}
-                      className="flex-1"
-                    >
-                      {p === 'openai' ? 'OpenAI' : 'Anthropic'}
-                    </Button>
-                  ))}
-                </div>
-              </div>
-
-              {/* API Key */}
-              <div className="space-y-2">
-                <Label htmlFor="ai-api-key">{t('ai.apiKey')}</Label>
-                <Input
-                  id="ai-api-key"
-                  type="password"
-                  placeholder={
-                    provider === 'openai'
-                      ? 'sk-••••••••••••••••••••••••••••••••'
-                      : 'sk-ant-••••••••••••••••••••••••••'
-                  }
-                  value={apiKey}
-                  onChange={(e) => setApiKey(e.target.value)}
-                  onBlur={handleApiKeyBlur}
-                />
-                <p className="text-xs text-muted-foreground">
-                  {t('ai.apiKeyHint', {
-                    provider: provider === 'openai' ? 'OpenAI' : 'Anthropic',
-                  })}
-                </p>
-              </div>
-
-              {/* Model selection */}
-              <div className="space-y-2">
-                <div className="flex items-center gap-2">
-                  <Label htmlFor="ai-model">{t('ai.model')}</Label>
-                  {fetchingModels && (
-                    <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
-                  )}
-                </div>
-                <Select value={model} onValueChange={setModel}>
-                  <SelectTrigger>
-                    <SelectValue />
-                  </SelectTrigger>
-                  <SelectContent>
-                    {models.map((m) => (
-                      <SelectItem key={m.id} value={m.id}>
-                        <span className="flex items-center gap-2">
-                          {m.label}
-                          <Badge
-                            variant="outline"
-                            className={`ml-1 text-[10px] px-1.5 py-0 font-medium ${COST_COLORS[m.cost]}`}
-                          >
-                            {t(`ai.cost.${m.cost}`)}
-                          </Badge>
-                        </span>
-                      </SelectItem>
-                    ))}
-                  </SelectContent>
-                </Select>
-              </div>
-
-              {/* Test connection */}
-              {hasConfig && (
-                <div className="pt-2">
-                  <Button
-                    type="button"
-                    variant="outline"
-                    onClick={handleTestConnection}
-                    disabled={!canTest || isTesting}
-                  >
-                    {isTesting ? (
-                      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                    ) : (
-                      <Zap className="mr-2 h-4 w-4" />
-                    )}
-                    {t('ai.testConnection')}
-                  </Button>
-                </div>
-              )}
-
-              {/* Features info */}
-              <div className="rounded-lg border bg-muted/30 p-4 space-y-2">
-                <p className="text-sm font-medium">{t('ai.featuresTitle')}</p>
-                <ul className="text-sm text-muted-foreground space-y-1">
-                  <li>• {t('ai.featureServiceNotes')}</li>
-                  <li>• {t('ai.featureHistorySummary')}</li>
-                </ul>
-              </div>
-            </>
-          )}
-        </AppCard>
-
-        <SaveButton>
-          <div className="flex justify-end">
-            <Button onClick={handleSave} disabled={isPending}>
-              {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {t('ai.saveSettings')}
-            </Button>
-          </div>
-        </SaveButton>
-      </ReadOnlyWrapper>
-    </div>
-  )
-}

+ 8 - 54
src/features/ai/Schema/aiSettingsSchema.ts

@@ -1,3 +1,11 @@
+/**
+ * The settings rows AI used before it moved into the integrations catalog.
+ *
+ * Nothing writes them any more: a workshop's provider, key and model live on
+ * an `IntegrationConnection` now. They are still read once, when an old setup
+ * is adopted into a connection, and are left in place afterwards so a
+ * rollback finds them where it left them.
+ */
 export const AI_KEYS = {
   AI_PROVIDER: 'ai.provider',
   AI_API_KEY: 'ai.apiKey',
@@ -8,57 +16,3 @@ export const AI_KEYS = {
 export type AiKey = (typeof AI_KEYS)[keyof typeof AI_KEYS]
 
 export const ALL_AI_KEYS = Object.values(AI_KEYS)
-
-export const AI_PROVIDERS = ['openai', 'anthropic'] as const
-export type AiProvider = (typeof AI_PROVIDERS)[number]
-
-export interface AiModel {
-  id: string
-  label: string
-  cost: 'high' | 'medium' | 'low'
-}
-
-// Fallback models when API fetch fails (ordered by version, newest first)
-export const OPENAI_FALLBACK_MODELS: AiModel[] = [
-  { id: 'gpt-4.1', label: 'GPT-4.1', cost: 'medium' },
-  { id: 'gpt-4.1-mini', label: 'GPT-4.1 Mini', cost: 'low' },
-  { id: 'gpt-4.1-nano', label: 'GPT-4.1 Nano', cost: 'low' },
-  { id: 'gpt-4o', label: 'GPT-4o', cost: 'medium' },
-  { id: 'gpt-4o-mini', label: 'GPT-4o Mini', cost: 'low' },
-]
-
-export const ANTHROPIC_FALLBACK_MODELS: AiModel[] = [
-  { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', cost: 'medium' },
-  { id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', cost: 'low' },
-]
-
-/** Classify a model ID into a cost tier */
-export function getModelCost(provider: AiProvider, modelId: string): AiModel['cost'] {
-  const id = modelId.toLowerCase()
-
-  if (provider === 'anthropic') {
-    if (id.includes('opus')) return 'high'
-    if (id.includes('sonnet')) return 'medium'
-    if (id.includes('haiku')) return 'low'
-    return 'medium'
-  }
-
-  // OpenAI
-  if (id.includes('o1') || id.includes('o3') || id.includes('o4-mini')) return 'high'
-  if (id.includes('nano')) return 'low'
-  if (id.includes('mini')) return 'low'
-  if (id.startsWith('gpt-4') || id.startsWith('gpt-4.')) return 'medium'
-  return 'medium'
-}
-
-/** Build a human-readable label from a model ID */
-export function formatModelLabel(modelId: string): string {
-  return modelId
-    .replace(/-(\d{8})$/, '') // strip date suffix like -20251001
-    .replace(/^gpt-/i, 'GPT-')
-    .replace(/^claude-/i, 'Claude ')
-    .replace(/^o(\d)/i, 'o$1')
-    .replace(/-/g, ' ')
-    .replace(/\b\w/g, (c) => c.toUpperCase())
-    .replace(/Gpt/g, 'GPT')
-}

+ 84 - 21
src/features/integrations/Actions/integrationActions.ts

@@ -26,7 +26,7 @@ import {
 import { enqueueJob, runJob } from '../Lib/jobs'
 import { oauthSpec, platformClient, redirectUriFor } from '../Lib/oauth'
 import type { ConnectionStatus, ConnectorManifest, SettingOption } from '../Lib/types'
-import { messagingProvider } from '@/integrations/messaging/catalog'
+import { messagingProvider, providersForChannel } from '@/integrations/messaging/catalog'
 import {
   type InboundWebhook,
   completeMessagingCredentials,
@@ -35,6 +35,8 @@ import {
   retireOtherProviders,
 } from '../Lib/messaging'
 import { openCredentials } from '../Lib/vault'
+import { AI_CONNECTOR_IDS, adoptLegacyAi, markAiAdopted, retireOtherAiProviders } from '../Lib/ai'
+import { anyConnectorAllowed, connectorAllowed } from '../Lib/plan'
 
 /**
  * The inbound URL for a messaging connection, from its sealed secret. A row
@@ -89,6 +91,11 @@ async function activateConnection(
   userId: string
 ): Promise<void> {
   await setConnectionStatus(connectionId, 'active')
+  if (AI_CONNECTOR_IDS.includes(connectorId as (typeof AI_CONNECTOR_IDS)[number])) {
+    await retireOtherAiProviders(organizationId, connectorId)
+    await markAiAdopted(organizationId, userId)
+    return
+  }
   const provider = messagingProvider(connectorId)
   if (!provider) return
   await retireOtherProviders(organizationId, connectorId)
@@ -131,6 +138,10 @@ function manifestForClient(m: ConnectorManifest): ConnectorManifest {
 export async function getIntegrationCatalog() {
   return withAuth(
     async ({ organizationId }) => {
+      // AI moved into the catalog from a settings page of its own. Adopting
+      // here as well as on first use means a workshop that had it switched on
+      // opens this page and finds it already connected.
+      await adoptLegacyAi(organizationId)
       const [features, connections, countrySetting] = await Promise.all([
         getFeatures(organizationId),
         db.integrationConnection.findMany({
@@ -146,23 +157,25 @@ export async function getIntegrationCatalog() {
       ])
       const byId = new Map(connections.map((c) => [c.connectorId, c]))
       const country = countrySetting?.value ?? null
-      const entries: CatalogEntry[] = listManifests().map((manifest) => {
-        const c = byId.get(manifest.id)
-        const spec = oauthSpec(manifest)
-        return {
-          manifest: manifestForClient(manifest),
-          status: (c?.status as ConnectionStatus | undefined) ?? null,
-          externalAccountName: c?.externalAccountName ?? null,
-          lastError: c?.lastError ?? null,
-          platformApp: spec ? Boolean(platformClient(spec)) : true,
-          featured:
-            manifest.countries === 'global' ||
-            (country ? manifest.countries.includes(country) : false),
-        }
-      })
+      const entries: CatalogEntry[] = listManifests()
+        .filter((manifest) => features.integrations || connectorAllowed(manifest, features))
+        .map((manifest) => {
+          const c = byId.get(manifest.id)
+          const spec = oauthSpec(manifest)
+          return {
+            manifest: manifestForClient(manifest),
+            status: (c?.status as ConnectionStatus | undefined) ?? null,
+            externalAccountName: c?.externalAccountName ?? null,
+            lastError: c?.lastError ?? null,
+            platformApp: spec ? Boolean(platformClient(spec)) : true,
+            featured:
+              manifest.countries === 'global' ||
+              (country ? manifest.countries.includes(country) : false),
+          }
+        })
       return {
         entries,
-        enabled: features.integrations,
+        enabled: anyConnectorAllowed(features),
         isCloud: isCloudMode(),
       }
     },
@@ -197,6 +210,12 @@ export interface ConnectionView {
    * or have nothing inbound.
    */
   inbound: InboundWebhook | null
+  /**
+   * The vendor connecting this one would stand down, when only one at a time
+   * is allowed: the other AI provider, or the vendor already sending on this
+   * messaging channel. Null when connecting takes nothing away.
+   */
+  supersedes: { connectorId: string; name: string } | null
   /**
    * The callback the OAuth start route will send to the vendor. Computed on
    * the server from the configured app URL, so it is the same on the server
@@ -205,16 +224,58 @@ export interface ConnectionView {
   redirectUri: string
 }
 
+/**
+ * Vendors that would be stood down by connecting this one.
+ *
+ * A workshop sends through one SMS vendor and asks one AI vendor, so
+ * connecting a second retires the first rather than leaving the app to guess.
+ * That is worth saying before the button is pressed rather than after.
+ */
+function siblingIdsOf(connectorId: string): string[] {
+  if (AI_CONNECTOR_IDS.includes(connectorId as (typeof AI_CONNECTOR_IDS)[number])) {
+    return AI_CONNECTOR_IDS.filter((id) => id !== connectorId)
+  }
+  const provider = messagingProvider(connectorId)
+  if (!provider) return []
+  return providersForChannel(provider.channel)
+    .map((p) => p.id)
+    .filter((id) => id !== connectorId)
+}
+
+async function supersededVendor(
+  organizationId: string,
+  connectorId: string
+): Promise<{ connectorId: string; name: string } | null> {
+  const siblings = siblingIdsOf(connectorId)
+  if (siblings.length === 0) return null
+  const rows = await db.integrationConnection.findMany({
+    where: { organizationId, connectorId: { in: siblings }, status: 'active' },
+    orderBy: { updatedAt: 'desc' },
+    select: { connectorId: true },
+  })
+  for (const row of rows) {
+    const manifest = getManifest(row.connectorId)
+    if (manifest) return { connectorId: manifest.id, name: manifest.name }
+  }
+  return null
+}
+
 export async function getIntegrationConnection(connectorId: string) {
   return withAuth(
     async ({ organizationId }): Promise<ConnectionView> => {
       const manifest = getManifest(connectorId)
       if (!manifest) throw new Error('Unknown integration')
-      const [features, row] = await Promise.all([
+      // Opening an AI vendor's page directly, without passing the catalog,
+      // still finds an old settings-page setup already connected.
+      if (AI_CONNECTOR_IDS.includes(connectorId as (typeof AI_CONNECTOR_IDS)[number])) {
+        await adoptLegacyAi(organizationId)
+      }
+      const [features, row, supersedes] = await Promise.all([
         getFeatures(organizationId),
         db.integrationConnection.findUnique({
           where: { organizationId_connectorId: { organizationId, connectorId } },
         }),
+        supersededVendor(organizationId, connectorId),
       ])
       const spec = oauthSpec(manifest)
       let tenantClientId: string | null = null
@@ -243,11 +304,12 @@ export async function getIntegrationConnection(connectorId: string) {
             }
           : null,
         platformApp: spec ? Boolean(platformClient(spec)) : true,
-        enabled: features.integrations && (!manifest.plan || Boolean(features[manifest.plan])),
+        enabled: connectorAllowed(manifest, features),
         isCloud: isCloudMode(),
         webhookUrl:
           row && appUrl ? `${appUrl}/api/integrations/${connectorId}/${row.id}/webhook` : null,
         inbound: row ? inboundFor(row, configuredAppUrl()) : null,
+        supersedes,
         redirectUri: redirectUriFor(configuredAppUrl(), connectorId),
         inspectionSync:
           row && manifest.capabilities.includes(INSPECTION_CAPABILITY)
@@ -279,11 +341,12 @@ export async function saveIntegrationCredentials(
       const manifest = getManifest(connectorId)
       if (!manifest) throw new Error('Unknown integration')
       const features = await getFeatures(organizationId)
-      if (!features.integrations)
-        throw new FeatureGatedError('integrations', 'Integrations are not included in your plan.')
-      // A channel keeps the plan gate it had as a settings page.
+      // A connector that names its own plan feature keeps the gate it had as a
+      // settings page; everything else needs the integrations flag.
       if (manifest.plan && !features[manifest.plan])
         throw new FeatureGatedError(manifest.plan, `${manifest.name} is not included in your plan.`)
+      if (!manifest.plan && !features.integrations)
+        throw new FeatureGatedError('integrations', 'Integrations are not included in your plan.')
       const input = credentialsSchema.parse(raw)
       // Settings typed on the connect page, saved before the key check runs:
       // an SMTP port needs its TLS choice and a Mailgun key needs its region

+ 267 - 0
src/features/integrations/Lib/ai.ts

@@ -0,0 +1,267 @@
+/**
+ * Which connection the AI features run on.
+ *
+ * The provider, key and model used to live in four `AppSetting` rows written
+ * by a settings page of their own. They are an integration like any other, so
+ * they moved into the catalog: the key is sealed on an `IntegrationConnection`
+ * and the model is one of its settings. What must not change is that a
+ * workshop which pasted a key months ago keeps generating descriptions
+ * without touching anything. The first time AI is used, or the catalog is
+ * opened, an old setup is adopted into a connection, sealed, and used from
+ * then on. Nothing is asked of the workshop and nothing is deleted, so a
+ * rollback still finds the old rows where it left them.
+ */
+
+import { db } from '@/lib/db'
+import { AI_KEYS } from '@/features/ai/Schema/aiSettingsSchema'
+import { openCredentials, sealCredentials } from './vault'
+
+/** Connector ids that can answer a chat completion, most recently used first. */
+export const AI_CONNECTOR_IDS = ['openai', 'anthropic'] as const
+export type AiConnectorId = (typeof AI_CONNECTOR_IDS)[number]
+
+export const AI_CHAT_CAPABILITY = 'ai.chat'
+
+/**
+ * Row that records the move having happened, so it happens once.
+ *
+ * Without it, disconnecting an adopted connection would be undone by the next
+ * completion, which would read the old rows and adopt them all over again.
+ * The row is one more setting the old code never read, so a rollback ignores
+ * it.
+ */
+export const AI_ADOPTED_KEY = 'integrations.ai.adoptedAt'
+
+export interface AiSetup {
+  connectionId: string
+  /** The connector id, which is also the provider name the client is built for. */
+  provider: AiConnectorId
+  apiKey: string
+  model: string
+}
+
+function isAiConnector(id: string): id is AiConnectorId {
+  return (AI_CONNECTOR_IDS as readonly string[]).includes(id)
+}
+
+function modelOf(settings: unknown): string {
+  const model = (settings as Record<string, unknown> | null)?.model
+  return typeof model === 'string' ? model.trim() : ''
+}
+
+/**
+ * One AI vendor at a time, the way the old provider dropdown worked.
+ * Connecting Anthropic stands OpenAI down rather than leaving the app to
+ * guess which key a completion should go out through.
+ */
+export async function retireOtherAiProviders(
+  organizationId: string,
+  connectorId: string
+): Promise<void> {
+  if (!isAiConnector(connectorId)) return
+  const siblings = AI_CONNECTOR_IDS.filter((id) => id !== connectorId)
+  await db.integrationConnection.updateMany({
+    where: { organizationId, connectorId: { in: [...siblings] }, status: 'active' },
+    data: { status: 'disconnected', lastError: null },
+  })
+}
+
+/** Record that the connections table decides AI from now on. */
+export async function markAiAdopted(organizationId: string, userId: string): Promise<void> {
+  await db.appSetting.upsert({
+    where: { organizationId_key: { organizationId, key: AI_ADOPTED_KEY } },
+    create: { organizationId, userId, key: AI_ADOPTED_KEY, value: new Date().toISOString() },
+    update: {},
+  })
+}
+
+export interface LegacyAiSetup {
+  provider: AiConnectorId
+  apiKey: string
+  model: string
+  userId: string | null
+}
+
+/**
+ * The setup an organization had before the move, read without writing
+ * anything.
+ *
+ * All four rows have to agree: switched on, a provider this app can talk to,
+ * a key and a model. A setup abandoned half way is left alone rather than
+ * adopted into a connection that cannot answer.
+ */
+export async function legacyAiSetup(
+  organizationId: string
+): Promise<{ setup: LegacyAiSetup | null; adopted: boolean }> {
+  const rows = await db.appSetting.findMany({
+    where: {
+      organizationId,
+      key: { in: [...Object.values(AI_KEYS), AI_ADOPTED_KEY] },
+    },
+    select: { key: true, value: true, userId: true },
+  })
+  const values = new Map(rows.map((r) => [r.key, r.value]))
+  const adopted = values.has(AI_ADOPTED_KEY)
+
+  if (values.get(AI_KEYS.AI_ENABLED) !== 'true') return { setup: null, adopted }
+  const provider = values.get(AI_KEYS.AI_PROVIDER)?.trim() ?? ''
+  const apiKey = values.get(AI_KEYS.AI_API_KEY)?.trim() ?? ''
+  const model = values.get(AI_KEYS.AI_MODEL)?.trim() ?? ''
+  if (!isAiConnector(provider) || !apiKey || !model) return { setup: null, adopted }
+
+  return {
+    setup: { provider, apiKey, model, userId: rows.find((r) => r.userId)?.userId ?? null },
+    adopted,
+  }
+}
+
+/** Prisma's code for a unique constraint the row already satisfies. */
+function isUniqueViolation(err: unknown): boolean {
+  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002'
+}
+
+/**
+ * Turn an old setup into a live connection. Runs once per organization: the
+ * marker is written whether or not the row survives, and a vendor row the
+ * workshop put there themselves is never written over.
+ */
+export async function adoptLegacyAi(organizationId: string): Promise<AiSetup | null> {
+  const { setup, adopted } = await legacyAiSetup(organizationId)
+  // Adopted once already: whatever happened to that connection since, such as
+  // the workshop disconnecting it, is the current truth.
+  if (adopted || !setup) return null
+
+  // A row for this vendor that the workshop created themselves stays theirs,
+  // even in error: the old rows are neither copied over it nor marked as
+  // adopted.
+  const existing = await db.integrationConnection.findUnique({
+    where: {
+      organizationId_connectorId: { organizationId, connectorId: setup.provider },
+    },
+    select: { id: true },
+  })
+  if (existing) return null
+
+  let connection: { id: string; status: string }
+  try {
+    connection = await db.integrationConnection.create({
+      data: {
+        organizationId,
+        connectorId: setup.provider,
+        status: 'active',
+        credentials: sealCredentials({ apiKey: setup.apiKey }),
+        settings: { model: setup.model } as object,
+        createdById: setup.userId ?? 'migration',
+        label: 'Adopted from settings',
+        // An adopted setup never went through a vendor's identify call; the
+        // model it was pointed at is the nearest thing to an account name.
+        externalAccountName: setup.model,
+      },
+      select: { id: true, status: true },
+    })
+  } catch (err) {
+    // Two AI calls can land here at once on the first use after a deploy. The
+    // loser of that race uses the winner's row.
+    if (!isUniqueViolation(err)) throw err
+    const winner = await db.integrationConnection.findUnique({
+      where: {
+        organizationId_connectorId: { organizationId, connectorId: setup.provider },
+      },
+      select: { id: true, status: true },
+    })
+    if (!winner) throw err
+    connection = winner
+  }
+
+  if (setup.userId) await markAiAdopted(organizationId, setup.userId)
+  if (connection.status !== 'active') return null
+
+  await retireOtherAiProviders(organizationId, setup.provider)
+  return {
+    connectionId: connection.id,
+    provider: setup.provider,
+    apiKey: setup.apiKey,
+    model: setup.model,
+  }
+}
+
+/**
+ * Last resort when a connection's credentials cannot be opened: the old rows
+ * are still there, so AI keeps working from them while someone sorts the key
+ * out. Logged every time, because it should never be quiet.
+ */
+async function unsealedFallback(
+  organizationId: string,
+  connectionId: string,
+  err: unknown
+): Promise<AiSetup | null> {
+  console.error(
+    `[integrations] cannot open credentials for AI connection ${connectionId} of organization ${organizationId}; ` +
+      'check INTEGRATIONS_ENCRYPTION_KEY / BETTER_AUTH_SECRET (see scripts/rekey-integrations.ts). ' +
+      'Falling back to the settings rows from before the move.',
+    err
+  )
+  const { setup } = await legacyAiSetup(organizationId)
+  if (!setup) return null
+  return {
+    connectionId,
+    provider: setup.provider,
+    apiKey: setup.apiKey,
+    model: setup.model,
+  }
+}
+
+/**
+ * The connection AI runs on, adopting an old setup on first use.
+ *
+ * Returns null when the workshop has never set AI up, which is the caller's
+ * cue to raise its own "not configured" message.
+ */
+export async function aiSetup(organizationId: string): Promise<AiSetup | null> {
+  const rows = await db.integrationConnection.findMany({
+    where: {
+      organizationId,
+      connectorId: { in: [...AI_CONNECTOR_IDS] },
+      status: 'active',
+    },
+    orderBy: { updatedAt: 'desc' },
+    select: { id: true, connectorId: true, credentials: true, settings: true },
+  })
+
+  const row = rows[0]
+  if (!row || !isAiConnector(row.connectorId)) return adoptLegacyAi(organizationId)
+
+  let apiKey: string
+  try {
+    const credentials = openCredentials(row.credentials)
+    apiKey = typeof credentials.apiKey === 'string' ? credentials.apiKey : ''
+  } catch (err) {
+    return unsealedFallback(organizationId, row.id, err)
+  }
+
+  const model = modelOf(row.settings)
+  if (!apiKey || !model) return null
+
+  return { connectionId: row.id, provider: row.connectorId, apiKey, model }
+}
+
+/**
+ * Whether a completion would find a vendor, without adopting anything.
+ *
+ * Pages call this to decide whether to offer an AI button, and a page render
+ * is no place to create a connection; the adoption happens on the first real
+ * use, or when the catalog is opened.
+ */
+export async function isAiConfigured(organizationId: string): Promise<boolean> {
+  const rows = await db.integrationConnection.findMany({
+    where: {
+      organizationId,
+      connectorId: { in: [...AI_CONNECTOR_IDS] },
+      status: 'active',
+    },
+    select: { settings: true },
+  })
+  if (rows.some((row) => modelOf(row.settings))) return true
+  const { setup } = await legacyAiSetup(organizationId)
+  return setup !== null
+}

+ 24 - 0
src/features/integrations/Lib/plan.ts

@@ -0,0 +1,24 @@
+/**
+ * Which connectors a plan reaches.
+ *
+ * A connector that names its own plan feature is gated by that feature alone.
+ * AI is why: it came from a settings page that every plan with the `ai`
+ * feature could open, the free one included, and moving it into the catalog
+ * must not put it behind the `integrations` flag it never had. Everything
+ * else is gated by `integrations`, as before.
+ */
+
+import type { PlanFeatures } from '@/lib/features'
+import { listManifests } from '@/integrations/registry'
+import type { ConnectorManifest } from './types'
+
+export function connectorAllowed(manifest: ConnectorManifest, features: PlanFeatures): boolean {
+  if (manifest.plan) return Boolean(features[manifest.plan])
+  return features.integrations
+}
+
+/** Whether the catalog is worth opening at all on this plan. */
+export function anyConnectorAllowed(features: PlanFeatures): boolean {
+  if (features.integrations) return true
+  return listManifests().some((m) => connectorAllowed(m, features))
+}

+ 1 - 0
src/features/integrations/Lib/types.ts

@@ -12,6 +12,7 @@
 import type { PlanFeatures } from '@/lib/features'
 
 export type IntegrationCategory =
+  | 'ai'
   | 'calendar'
   | 'conferencing'
   | 'accounting'

+ 4 - 21
src/features/vehicles/Actions/aiAnalyzeVehicleDocument.ts

@@ -2,8 +2,7 @@
 
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import { db } from '@/lib/db'
-import { AI_KEYS } from '@/features/ai/Schema/aiSettingsSchema'
+import { isAiConfigured } from '@/features/integrations/Lib/ai'
 import { visionCompletion } from '@/lib/ai'
 import { getLocale } from 'next-intl/server'
 import { localeNames, type Locale } from '@/i18n/config'
@@ -37,25 +36,9 @@ export interface VehicleDocumentScan {
  * have to thread that down from its own server page.
  */
 export async function isVehicleScanAvailable() {
-  return withAuth(
-    async ({ organizationId }) => {
-      const settings = await db.appSetting.findMany({
-        where: {
-          organizationId,
-          key: { in: [AI_KEYS.AI_ENABLED, AI_KEYS.AI_API_KEY, AI_KEYS.AI_MODEL] },
-        },
-      })
-      const map = new Map(settings.map((s) => [s.key, s.value]))
-      return (
-        map.get(AI_KEYS.AI_ENABLED) === 'true' &&
-        Boolean(map.get(AI_KEYS.AI_API_KEY)) &&
-        Boolean(map.get(AI_KEYS.AI_MODEL))
-      )
-    },
-    {
-      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
-    }
-  )
+  return withAuth(async ({ organizationId }) => isAiConfigured(organizationId), {
+    requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+  })
 }
 
 const FUEL_TYPES = ['gasoline', 'diesel', 'electric', 'hybrid', 'two-stroke', 'other']

+ 109 - 0
src/integrations/ai/models.ts

@@ -0,0 +1,109 @@
+/**
+ * The model list behind both AI connectors.
+ *
+ * OpenAI and Anthropic each publish everything the key can reach, embeddings
+ * and image models included, so the list is filtered to the chat models the
+ * app actually calls and sorted newest first: a workshop picking a model
+ * should find this year's at the top rather than hunting for it among
+ * whatever order the vendor returned.
+ */
+
+import type { ConnectorContext, SettingOption } from '@/features/integrations/Lib/types'
+
+export const OPENAI_BASE = 'https://api.openai.com/v1'
+export const ANTHROPIC_BASE = 'https://api.anthropic.com/v1'
+export const ANTHROPIC_VERSION = '2023-06-01'
+
+/** The key as the connect form stored it. */
+export function apiKeyOf(ctx: ConnectorContext): string {
+  const key = ctx.credentials.apiKey
+  return typeof key === 'string' ? key.trim() : ''
+}
+
+export function openAiHeaders(apiKey: string): Record<string, string> {
+  return { Authorization: `Bearer ${apiKey}` }
+}
+
+export function anthropicHeaders(apiKey: string): Record<string, string> {
+  return { 'x-api-key': apiKey, 'anthropic-version': ANTHROPIC_VERSION }
+}
+
+/** Chat-capable OpenAI models; the rest of the list is embeddings, audio and images. */
+const OPENAI_CHAT_PREFIXES = ['gpt-', 'o1', 'o3', 'o4', 'chatgpt-']
+
+function isOpenAiChatModel(id: string): boolean {
+  return OPENAI_CHAT_PREFIXES.some((prefix) => id.startsWith(prefix))
+}
+
+/**
+ * Sort OpenAI models: newest version first, within a version full > mini >
+ * nano. The o-series leads, then GPT-4.x, then GPT-4o, then the rest.
+ */
+function openAiModelOrder(id: string): number {
+  const l = id.toLowerCase()
+  if (l.startsWith('o4')) return 100 + (l.includes('mini') ? 1 : 0)
+  if (l.startsWith('o3')) return 200 + (l.includes('mini') ? 1 : 0)
+  if (l.startsWith('o1')) return 300 + (l.includes('mini') ? 1 : 0)
+  if (l.startsWith('gpt-4.1')) return 400 + (l.includes('nano') ? 2 : l.includes('mini') ? 1 : 0)
+  if (l.startsWith('gpt-4.5')) return 350 + (l.includes('nano') ? 2 : l.includes('mini') ? 1 : 0)
+  if (l.startsWith('gpt-4o')) return 500 + (l.includes('mini') ? 1 : 0)
+  if (l.startsWith('gpt-4'))
+    return 600 + (l.includes('turbo') ? 0 : 1) + (l.includes('mini') ? 2 : 0)
+  if (l.startsWith('chatgpt')) return 700
+  return 900
+}
+
+/** Sort Anthropic models: newest version first, within a version opus > sonnet > haiku. */
+function anthropicModelOrder(id: string): number {
+  const l = id.toLowerCase()
+  const versionMatch = l.match(/(\d+)[.\-]?(\d*)/)
+  const major = versionMatch ? Number.parseInt(versionMatch[1], 10) : 0
+  const minor = versionMatch?.[2] ? Number.parseInt(versionMatch[2], 10) : 0
+  const versionScore = (10 - major) * 100 + (10 - minor) * 10
+  const tierScore = l.includes('opus') ? 0 : l.includes('sonnet') ? 1 : l.includes('haiku') ? 2 : 3
+  return versionScore + tierScore
+}
+
+/** A model id as something to read in a dropdown: gpt-4.1-mini becomes GPT 4.1 Mini. */
+export function formatModelLabel(modelId: string): string {
+  return modelId
+    .replace(/-(\d{8})$/, '') // strip a date suffix such as -20251001
+    .replace(/^gpt-/i, 'GPT-')
+    .replace(/^claude-/i, 'Claude ')
+    .replace(/^o(\d)/i, 'o$1')
+    .replace(/-/g, ' ')
+    .replace(/\b\w/g, (c) => c.toUpperCase())
+    .replace(/Gpt/g, 'GPT')
+}
+
+export async function listOpenAiModels(ctx: ConnectorContext): Promise<SettingOption[]> {
+  const data = await ctx.http.json<{ data: { id: string }[] }>(`${OPENAI_BASE}/models`, {
+    headers: openAiHeaders(apiKeyOf(ctx)),
+  })
+  return data.data
+    .filter((m) => isOpenAiChatModel(m.id))
+    .sort((a, b) => openAiModelOrder(a.id) - openAiModelOrder(b.id))
+    .map((m) => ({ value: m.id, label: formatModelLabel(m.id) }))
+}
+
+/** Anthropic pages its model list, twenty at a time by default. */
+export async function listAnthropicModels(ctx: ConnectorContext): Promise<SettingOption[]> {
+  const models: { id: string; display_name?: string }[] = []
+  let afterId: string | undefined
+  for (;;) {
+    const url = new URL(`${ANTHROPIC_BASE}/models`)
+    url.searchParams.set('limit', '100')
+    if (afterId) url.searchParams.set('after_id', afterId)
+    const page = await ctx.http.json<{
+      data: { id: string; display_name?: string }[]
+      has_more?: boolean
+      last_id?: string
+    }>(url.toString(), { headers: anthropicHeaders(apiKeyOf(ctx)) })
+    models.push(...page.data.filter((m) => m.id.includes('claude')))
+    if (page.has_more !== true || !page.last_id) break
+    afterId = page.last_id
+  }
+  return models
+    .sort((a, b) => anthropicModelOrder(a.id) - anthropicModelOrder(b.id))
+    .map((m) => ({ value: m.id, label: m.display_name || formatModelLabel(m.id) }))
+}

+ 37 - 0
src/integrations/anthropic/manifest.ts

@@ -0,0 +1,37 @@
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * Anthropic as the workshop's AI provider, on the same terms as OpenAI: the
+ * workshop's own key, their own bill, and the model chosen from what their
+ * account can reach.
+ *
+ * The app talks to Anthropic through its OpenAI-compatible endpoint, so the
+ * two connectors differ in credentials and model list rather than in how the
+ * features that use them are written.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'anthropic',
+  name: 'Anthropic',
+  category: 'ai',
+  countries: 'global',
+  logo: '/images/integrations/anthropic.svg',
+  docs: '/docs/integrations/ai',
+  auth: {
+    type: 'api-key',
+    fields: [
+      { key: 'apiKey', label: 'apiKey', type: 'password', required: true, help: 'apiKeyHelp' },
+    ],
+  },
+  capabilities: ['ai.chat'],
+  settings: [
+    {
+      key: 'model',
+      type: 'remote-select',
+      label: 'model',
+      help: 'modelHelp',
+      source: 'models',
+      required: true,
+    },
+  ],
+  plan: 'ai',
+}

+ 29 - 0
src/integrations/anthropic/server.ts

@@ -0,0 +1,29 @@
+import type { ConnectorServer } from '@/features/integrations/Lib/types'
+import { ANTHROPIC_BASE, anthropicHeaders, apiKeyOf, listAnthropicModels } from '../ai/models'
+import { manifest } from './manifest'
+
+/**
+ * Anthropic authenticates with x-api-key rather than a bearer token, and
+ * wants its API version on every request. Listing models proves the key
+ * without spending tokens.
+ */
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    const apiKey = apiKeyOf(ctx)
+    if (!apiKey) return { ok: false, message: 'Anthropic: an API key is required' }
+    const res = await ctx.http.fetch(`${ANTHROPIC_BASE}/models?limit=1`, {
+      headers: anthropicHeaders(apiKey),
+    })
+    if (res.status === 401 || res.status === 403) {
+      return { ok: false, message: 'Anthropic rejected the key' }
+    }
+    if (!res.ok) return { ok: false, message: `Anthropic answered HTTP ${res.status}` }
+    return { ok: true }
+  },
+
+  remoteOptions: { models: listAnthropicModels },
+
+  jobs: {},
+}

+ 39 - 0
src/integrations/openai/manifest.ts

@@ -0,0 +1,39 @@
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * OpenAI as the workshop's AI provider: service descriptions, history
+ * summaries, document reading and the assistant all go through whichever AI
+ * connection is active.
+ *
+ * The key is the workshop's own and billed to their OpenAI account, so the
+ * platform never carries a bill it cannot attribute. The model is a setting
+ * rather than a fixed choice because what a workshop wants to pay per call
+ * is theirs to decide, and the list is read from the account so a model
+ * released next month needs no release here.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'openai',
+  name: 'OpenAI',
+  category: 'ai',
+  countries: 'global',
+  logo: '/images/integrations/openai.svg',
+  docs: '/docs/integrations/ai',
+  auth: {
+    type: 'api-key',
+    fields: [
+      { key: 'apiKey', label: 'apiKey', type: 'password', required: true, help: 'apiKeyHelp' },
+    ],
+  },
+  capabilities: ['ai.chat'],
+  settings: [
+    {
+      key: 'model',
+      type: 'remote-select',
+      label: 'model',
+      help: 'modelHelp',
+      source: 'models',
+      required: true,
+    },
+  ],
+  plan: 'ai',
+}

+ 26 - 0
src/integrations/openai/server.ts

@@ -0,0 +1,26 @@
+import type { ConnectorServer } from '@/features/integrations/Lib/types'
+import { OPENAI_BASE, apiKeyOf, listOpenAiModels, openAiHeaders } from '../ai/models'
+import { manifest } from './manifest'
+
+/**
+ * OpenAI needs nothing set up remotely: the key either reaches the account or
+ * it does not. Listing models is the cheapest request that proves it, and it
+ * is the same call the model dropdown makes.
+ */
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    const apiKey = apiKeyOf(ctx)
+    if (!apiKey) return { ok: false, message: 'OpenAI: an API key is required' }
+    const res = await ctx.http.fetch(`${OPENAI_BASE}/models`, { headers: openAiHeaders(apiKey) })
+    if (res.status === 401 || res.status === 403)
+      return { ok: false, message: 'OpenAI rejected the key' }
+    if (!res.ok) return { ok: false, message: `OpenAI answered HTTP ${res.status}` }
+    return { ok: true }
+  },
+
+  remoteOptions: { models: listOpenAiModels },
+
+  jobs: {},
+}

+ 4 - 0
src/integrations/registry.ts

@@ -9,9 +9,11 @@
 
 import type { ConnectorManifest, ConnectorServer } from '@/features/integrations/Lib/types'
 import { manifest as amazonSes } from './amazon-ses/manifest'
+import { manifest as anthropic } from './anthropic/manifest'
 import { manifest as googleCalendar } from './google-calendar/manifest'
 import { manifest as mailgun } from './mailgun/manifest'
 import { manifest as microsoft365 } from './microsoft-365/manifest'
+import { manifest as openai } from './openai/manifest'
 import { manifest as openapiAutomotive } from './openapi-automotive/manifest'
 import { manifest as postmark } from './postmark/manifest'
 import { manifest as rdw } from './rdw/manifest'
@@ -34,6 +36,8 @@ interface RegistryEntry {
 }
 
 const ENTRIES: readonly RegistryEntry[] = [
+  { manifest: openai, load: () => import('./openai/server') },
+  { manifest: anthropic, load: () => import('./anthropic/server') },
   { manifest: googleCalendar, load: () => import('./google-calendar/server') },
   { manifest: microsoft365, load: () => import('./microsoft-365/server') },
   { manifest: zoom, load: () => import('./zoom/server') },

+ 2 - 2
src/lib/ai-error.ts

@@ -14,11 +14,11 @@ export function describeAiError(error: unknown): string {
   switch (status) {
     case 401:
     case 403:
-      return 'The AI provider rejected the API key. Check it in Settings → AI.'
+      return 'The AI provider rejected the API key. Check it in Settings → Integrations.'
     case 402:
       return 'The AI provider reports no usable credit for this API key.'
     case 404:
-      return 'The AI provider does not offer the selected model. Pick another in Settings → AI.'
+      return 'The AI provider does not offer the selected model. Pick another in Settings → Integrations.'
     case 413:
       return 'The image was too large for the AI provider.'
     case 429:

+ 10 - 23
src/lib/ai.ts

@@ -1,6 +1,5 @@
 import 'server-only'
-import { db } from '@/lib/db'
-import { AI_KEYS } from '@/features/ai/Schema/aiSettingsSchema'
+import { aiSetup } from '@/features/integrations/Lib/ai'
 import { localeNames, type Locale } from '@/i18n/config'
 import OpenAI from 'openai'
 import { describeAiError } from '@/lib/ai-error'
@@ -11,33 +10,21 @@ interface AiConfig {
   model: string
 }
 
+/**
+ * The AI vendor this workshop sends to. Either an active connection in the
+ * integrations catalog, or the settings a workshop saved before AI moved
+ * there, adopted into one on this first call.
+ */
 export async function getAiConfig(organizationId: string): Promise<AiConfig> {
-  const settings = await db.appSetting.findMany({
-    where: {
-      organizationId,
-      key: {
-        in: [AI_KEYS.AI_ENABLED, AI_KEYS.AI_PROVIDER, AI_KEYS.AI_API_KEY, AI_KEYS.AI_MODEL],
-      },
-    },
-  })
-
-  const map = new Map(settings.map((s) => [s.key, s.value]))
-
-  if (map.get(AI_KEYS.AI_ENABLED) !== 'true') {
-    throw new Error('AI is not enabled. Configure it in Settings → AI.')
-  }
-
-  const provider = map.get(AI_KEYS.AI_PROVIDER)
-  const apiKey = map.get(AI_KEYS.AI_API_KEY)
-  const model = map.get(AI_KEYS.AI_MODEL)
+  const setup = await aiSetup(organizationId)
 
-  if (!provider || !apiKey || !model) {
+  if (!setup) {
     throw new Error(
-      'AI is not fully configured. Set provider, API key, and model in Settings → AI.'
+      'AI is not connected. Connect OpenAI or Anthropic in Settings → Integrations.'
     )
   }
 
-  return { provider, apiKey, model }
+  return { provider: setup.provider, apiKey: setup.apiKey, model: setup.model }
 }
 
 export function createClient(config: AiConfig): OpenAI {