import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; /// Which backend answers chat requests. Embeddings (knowledge base) always /// stay on the local Nomic server regardless of this choice. enum AiProvider { local, ollama, lmstudio, anthropic, openai, gemini, openrouter } extension AiProviderX on AiProvider { String get label => switch (this) { AiProvider.local => 'Built-in (Qwen3 4B)', AiProvider.ollama => 'Ollama (local)', AiProvider.lmstudio => 'LM Studio (local)', AiProvider.anthropic => 'Anthropic (Claude)', AiProvider.openai => 'OpenAI (GPT)', AiProvider.gemini => 'Google (Gemini)', AiProvider.openrouter => 'OpenRouter', }; String get defaultModel => switch (this) { AiProvider.local => 'local', AiProvider.ollama => 'qwen3:4b', AiProvider.lmstudio => 'local-model', AiProvider.anthropic => 'claude-opus-4-8', AiProvider.openai => 'gpt-5.1', AiProvider.gemini => 'gemini-2.5-flash', // OpenRouter routes to any model — IDs like "anthropic/claude-opus-4.8", // "openai/gpt-4o", "meta-llama/llama-3.3-70b-instruct". AiProvider.openrouter => 'openai/gpt-4o', }; /// Self-hosted OpenAI-compatible servers reachable over a base URL. bool get isSelfHosted => this == AiProvider.ollama || this == AiProvider.lmstudio; String get defaultBaseUrl => switch (this) { AiProvider.ollama => 'http://localhost:11434', AiProvider.lmstudio => 'http://localhost:1234', _ => '', }; bool get needsApiKey => switch (this) { AiProvider.anthropic || AiProvider.openai || AiProvider.gemini || AiProvider.openrouter => true, _ => false, }; } class AiSettings { const AiSettings({ this.provider = AiProvider.local, this.apiKeys = const {}, this.models = const {}, this.baseUrls = const {}, }); final AiProvider provider; /// API key per provider name — kept separately so switching providers /// doesn't lose previously entered keys. final Map apiKeys; /// Model override per provider name; falls back to [AiProviderX.defaultModel]. final Map models; /// Base URL override per provider name (Ollama / LM Studio); /// falls back to [AiProviderX.defaultBaseUrl]. final Map baseUrls; String? apiKeyFor(AiProvider p) { final key = apiKeys[p.name]?.trim(); return (key == null || key.isEmpty) ? null : key; } String modelFor(AiProvider p) { final model = models[p.name]?.trim(); return (model == null || model.isEmpty) ? p.defaultModel : model; } String baseUrlFor(AiProvider p) { final url = baseUrls[p.name]?.trim(); final resolved = (url == null || url.isEmpty) ? p.defaultBaseUrl : url; // Tolerate a trailing slash pasted in by the user. return resolved.endsWith('/') ? resolved.substring(0, resolved.length - 1) : resolved; } /// True when [provider] can serve chat: self-hosted and built-in servers /// need no key, cloud providers do. bool get isProviderConfigured => !provider.needsApiKey || apiKeyFor(provider) != null; AiSettings copyWith({ AiProvider? provider, Map? apiKeys, Map? models, Map? baseUrls, }) { return AiSettings( provider: provider ?? this.provider, apiKeys: apiKeys ?? this.apiKeys, models: models ?? this.models, baseUrls: baseUrls ?? this.baseUrls, ); } Map toJson() => { 'provider': provider.name, 'apiKeys': apiKeys, 'models': models, 'baseUrls': baseUrls, }; factory AiSettings.fromJson(Map json) { return AiSettings( provider: AiProvider.values.firstWhere( (p) => p.name == json['provider'], orElse: () => AiProvider.local, ), apiKeys: Map.from(json['apiKeys'] as Map? ?? {}), models: Map.from(json['models'] as Map? ?? {}), baseUrls: Map.from(json['baseUrls'] as Map? ?? {}), ); } } /// Loads and persists [AiSettings] as a JSON file in the app documents dir. class AiSettingsService extends ChangeNotifier { static const _fileName = 'trainhub_ai_settings.json'; AiSettings _settings = const AiSettings(); AiSettings get settings => _settings; Future _file() async { final dir = await getApplicationDocumentsDirectory(); return File(p.join(dir.path, _fileName)); } Future load() async { try { final file = await _file(); if (!file.existsSync()) return; final json = jsonDecode(await file.readAsString()); _settings = AiSettings.fromJson(json as Map); notifyListeners(); } catch (e) { if (kDebugMode) debugPrint('Failed to load AI settings: $e'); } } Future save(AiSettings settings) async { _settings = settings; notifyListeners(); try { final file = await _file(); await file.writeAsString(jsonEncode(settings.toJson())); } catch (e) { if (kDebugMode) debugPrint('Failed to save AI settings: $e'); } } }