Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
Some checks failed
Build Linux App / build (push) Failing after 1m18s
Some checks failed
Build Linux App / build (push) Failing after 1m18s
This commit is contained in:
160
lib/data/services/ai_settings_service.dart
Normal file
160
lib/data/services/ai_settings_service.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
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 }
|
||||
|
||||
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)',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
/// 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 => 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user