168 lines
5.1 KiB
Dart
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');
|
|
}
|
|
}
|
|
}
|