Files
trainhub-flutter/lib/data/services/ai_settings_service.dart
Kazimierz Ciołek ebb7426c67
Some checks failed
Build Linux App / build (push) Failing after 1m12s
Poprawki
2026-07-07 22:03:36 +02:00

168 lines
5.1 KiB
Dart

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<String, String> apiKeys;
/// Model override per provider name; falls back to [AiProviderX.defaultModel].
final Map<String, String> models;
/// Base URL override per provider name (Ollama / LM Studio);
/// falls back to [AiProviderX.defaultBaseUrl].
final Map<String, String> 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<String, String>? apiKeys,
Map<String, String>? models,
Map<String, String>? baseUrls,
}) {
return AiSettings(
provider: provider ?? this.provider,
apiKeys: apiKeys ?? this.apiKeys,
models: models ?? this.models,
baseUrls: baseUrls ?? this.baseUrls,
);
}
Map<String, dynamic> toJson() => {
'provider': provider.name,
'apiKeys': apiKeys,
'models': models,
'baseUrls': baseUrls,
};
factory AiSettings.fromJson(Map<String, dynamic> json) {
return AiSettings(
provider: AiProvider.values.firstWhere(
(p) => p.name == json['provider'],
orElse: () => AiProvider.local,
),
apiKeys: Map<String, String>.from(json['apiKeys'] as Map? ?? {}),
models: Map<String, String>.from(json['models'] as Map? ?? {}),
baseUrls: Map<String, String>.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> _file() async {
final dir = await getApplicationDocumentsDirectory();
return File(p.join(dir.path, _fileName));
}
Future<void> load() async {
try {
final file = await _file();
if (!file.existsSync()) return;
final json = jsonDecode(await file.readAsString());
_settings = AiSettings.fromJson(json as Map<String, dynamic>);
notifyListeners();
} catch (e) {
if (kDebugMode) debugPrint('Failed to load AI settings: $e');
}
}
Future<void> 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');
}
}
}