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:
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:math' show sqrt;
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||
import 'package:trainhub_flutter/data/database/app_database.dart';
|
||||
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||
@@ -29,7 +30,11 @@ class NoteRepositoryImpl implements NoteRepository {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
for (final chunk in chunks) {
|
||||
final embedding = await _embeddingService.embed(chunk);
|
||||
// Nomic v1.5 is trained with task prefixes — embedding without them
|
||||
// noticeably degrades retrieval quality.
|
||||
final embedding = await _embeddingService.embed(
|
||||
'${AiConstants.nomicDocumentPrefix}$chunk',
|
||||
);
|
||||
await _dao.insertChunk(
|
||||
KnowledgeChunksCompanion(
|
||||
id: Value(_uuid.v4()),
|
||||
@@ -47,19 +52,19 @@ class NoteRepositoryImpl implements NoteRepository {
|
||||
final allRows = await _dao.getAllChunks();
|
||||
if (allRows.isEmpty) return [];
|
||||
|
||||
final queryEmbedding = await _embeddingService.embed(query);
|
||||
final queryEmbedding = await _embeddingService.embed(
|
||||
'${AiConstants.nomicQueryPrefix}$query',
|
||||
);
|
||||
|
||||
final scored = allRows.map((row) {
|
||||
final emb =
|
||||
(jsonDecode(row.embedding) as List<dynamic>)
|
||||
.map((e) => (e as num).toDouble())
|
||||
.toList();
|
||||
final emb = (jsonDecode(row.embedding) as List<dynamic>)
|
||||
.map((e) => (e as num).toDouble())
|
||||
.toList();
|
||||
return _Scored(
|
||||
score: _cosineSimilarity(queryEmbedding, emb),
|
||||
text: row.content,
|
||||
);
|
||||
}).toList()
|
||||
..sort((a, b) => b.score.compareTo(a.score));
|
||||
}).toList()..sort((a, b) => b.score.compareTo(a.score));
|
||||
|
||||
return scored.take(topK).map((s) => s.text).toList();
|
||||
}
|
||||
@@ -92,13 +97,11 @@ class NoteRepositoryImpl implements NoteRepository {
|
||||
}
|
||||
|
||||
// Split long paragraph by sentence boundaries (. ! ?)
|
||||
final sentences =
|
||||
p.split(RegExp(r'(?<=[.!?])\s+'));
|
||||
final sentences = p.split(RegExp(r'(?<=[.!?])\s+'));
|
||||
var current = '';
|
||||
|
||||
for (final sentence in sentences) {
|
||||
final candidate =
|
||||
current.isEmpty ? sentence : '$current $sentence';
|
||||
final candidate = current.isEmpty ? sentence : '$current $sentence';
|
||||
if (candidate.length <= maxChars) {
|
||||
current = candidate;
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||
@@ -12,13 +14,26 @@ enum AiServerStatus { offline, starting, ready, error }
|
||||
/// Both processes are kept alive for the lifetime of the app and must be
|
||||
/// killed on shutdown to prevent zombie processes from consuming RAM.
|
||||
///
|
||||
/// - Qwen 2.5 7B → port 8080 (chat / completions)
|
||||
/// - Nomic Embed → port 8081 (embeddings)
|
||||
/// - Qwen3 4B → port 8080 (chat / completions)
|
||||
/// - Nomic Embed → port 8081 (embeddings)
|
||||
///
|
||||
/// Startup strategy: first attempt offloads all layers to the GPU (Vulkan).
|
||||
/// If the chat server does not become healthy in time — first-run pipeline
|
||||
/// compilation can be very slow, and some GPUs hang outright — everything is
|
||||
/// restarted in CPU-only mode, which loads via mmap and is reliably fast.
|
||||
class AiProcessManager extends ChangeNotifier {
|
||||
Process? _qwenProcess;
|
||||
Process? _nomicProcess;
|
||||
AiServerStatus _status = AiServerStatus.offline;
|
||||
String? _lastError;
|
||||
String? _statusDetail;
|
||||
|
||||
// Last stderr lines per server — llama.cpp prints its actual failure
|
||||
// reason (bad model file, out of memory, …) there, so keep a short tail
|
||||
// to show the user instead of a generic "crashed" message.
|
||||
final List<String> _qwenStderrTail = [];
|
||||
final List<String> _nomicStderrTail = [];
|
||||
static const int _stderrTailLines = 8;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
@@ -28,6 +43,10 @@ class AiProcessManager extends ChangeNotifier {
|
||||
bool get isRunning => _status == AiServerStatus.ready;
|
||||
String? get errorMessage => _lastError;
|
||||
|
||||
/// Live progress line while [status] is [AiServerStatus.starting],
|
||||
/// e.g. "Loading AI model on GPU… 45s".
|
||||
String? get statusDetail => _statusDetail;
|
||||
|
||||
/// Starts both inference servers. No-ops if already running or starting.
|
||||
Future<void> startServers() async {
|
||||
if (_status == AiServerStatus.starting || _status == AiServerStatus.ready) {
|
||||
@@ -48,97 +67,64 @@ class AiProcessManager extends ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
_qwenProcess = await Process.start(serverBin, [
|
||||
'-m', p.join(base, AiConstants.qwenModelFile),
|
||||
'--port', '${AiConstants.chatServerPort}',
|
||||
'--ctx-size', '${AiConstants.qwenContextSize}',
|
||||
'-ngl', '${AiConstants.gpuLayerOffload}',
|
||||
], runInShell: false);
|
||||
|
||||
_qwenProcess!.stdout.listen((event) {
|
||||
if (kDebugMode) print('[QWEN STDOUT] ${String.fromCharCodes(event)}');
|
||||
});
|
||||
_qwenProcess!.stderr.listen((event) {
|
||||
if (kDebugMode) print('[QWEN STDERR] ${String.fromCharCodes(event)}');
|
||||
});
|
||||
|
||||
// Monitor for unexpected crash
|
||||
_qwenProcess!.exitCode.then((code) {
|
||||
if (_status == AiServerStatus.ready ||
|
||||
_status == AiServerStatus.starting) {
|
||||
_lastError = 'Qwen Chat Server crashed with code $code';
|
||||
_updateStatus(AiServerStatus.error);
|
||||
}
|
||||
});
|
||||
|
||||
_nomicProcess = await Process.start(serverBin, [
|
||||
'-m', p.join(base, AiConstants.nomicModelFile),
|
||||
'--port', '${AiConstants.embeddingServerPort}',
|
||||
'--ctx-size', '${AiConstants.nomicContextSize}',
|
||||
'--embedding',
|
||||
], runInShell: false);
|
||||
|
||||
_nomicProcess!.stdout.listen((event) {
|
||||
if (kDebugMode) print('[NOMIC STDOUT] ${String.fromCharCodes(event)}');
|
||||
});
|
||||
_nomicProcess!.stderr.listen((event) {
|
||||
if (kDebugMode) print('[NOMIC STDERR] ${String.fromCharCodes(event)}');
|
||||
});
|
||||
|
||||
// Monitor for unexpected crash
|
||||
_nomicProcess!.exitCode.then((code) {
|
||||
if (_status == AiServerStatus.ready ||
|
||||
_status == AiServerStatus.starting) {
|
||||
_lastError = 'Nomic Embedding Server crashed with code $code';
|
||||
_updateStatus(AiServerStatus.error);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for servers to bind to their ports and allocate memory.
|
||||
// This is crucial because loading models (especially 7B) takes several
|
||||
// seconds and significant RAM, which might cause the dart process to appear hung.
|
||||
int attempts = 0;
|
||||
bool qwenReady = false;
|
||||
bool nomicReady = false;
|
||||
|
||||
while (attempts < 20 && (!qwenReady || !nomicReady)) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (!qwenReady) {
|
||||
qwenReady = await _isPortReady(AiConstants.chatServerPort);
|
||||
}
|
||||
if (!nomicReady) {
|
||||
nomicReady = await _isPortReady(AiConstants.embeddingServerPort);
|
||||
}
|
||||
|
||||
attempts++;
|
||||
// Attempt 1: full GPU offload.
|
||||
final gpuReady = await _startAttempt(
|
||||
serverBin,
|
||||
base,
|
||||
gpuLayers: AiConstants.gpuLayerOffload,
|
||||
timeout: AiConstants.serverStartupTimeout,
|
||||
detailLabel: 'GPU',
|
||||
);
|
||||
if (gpuReady) {
|
||||
_updateStatus(AiServerStatus.ready);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!qwenReady || !nomicReady) {
|
||||
throw Exception('Servers failed to start within 10 seconds.');
|
||||
// Attempt 2: CPU only. Covers GPUs where Vulkan hangs, crashes
|
||||
// (e.g. out of VRAM) or pipeline compilation never finishes.
|
||||
await _killProcesses();
|
||||
_lastError = null;
|
||||
_qwenStderrTail.clear();
|
||||
_nomicStderrTail.clear();
|
||||
final cpuReady = await _startAttempt(
|
||||
serverBin,
|
||||
base,
|
||||
gpuLayers: 0,
|
||||
timeout: AiConstants.serverStartupTimeoutCpu,
|
||||
detailLabel: 'CPU',
|
||||
);
|
||||
if (cpuReady) {
|
||||
_updateStatus(AiServerStatus.ready);
|
||||
return;
|
||||
}
|
||||
|
||||
_updateStatus(AiServerStatus.ready);
|
||||
throw Exception(
|
||||
_lastError ??
|
||||
'The AI server did not become ready (tried GPU, then CPU).'
|
||||
'${_diagnostics()}',
|
||||
);
|
||||
} catch (e) {
|
||||
// Clean up any partially-started processes before returning error.
|
||||
_qwenProcess?.kill();
|
||||
_nomicProcess?.kill();
|
||||
_qwenProcess = null;
|
||||
_nomicProcess = null;
|
||||
await _killProcesses();
|
||||
_lastError = e.toString();
|
||||
_updateStatus(AiServerStatus.error);
|
||||
} finally {
|
||||
_statusDetail = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kills both processes and resets the running flag.
|
||||
/// Safe to call even if servers were never started.
|
||||
Future<void> stopServers() async {
|
||||
_qwenProcess?.kill();
|
||||
_nomicProcess?.kill();
|
||||
await _killProcesses();
|
||||
|
||||
if (Platform.isWindows) {
|
||||
try {
|
||||
await Process.run('taskkill', ['/F', '/IM', AiConstants.serverBinaryName]);
|
||||
await Process.run('taskkill', [
|
||||
'/F',
|
||||
'/IM',
|
||||
AiConstants.serverBinaryName,
|
||||
]);
|
||||
} catch (_) {}
|
||||
} else if (Platform.isMacOS || Platform.isLinux) {
|
||||
try {
|
||||
@@ -146,25 +132,157 @@ class AiProcessManager extends ChangeNotifier {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_qwenProcess = null;
|
||||
_nomicProcess = null;
|
||||
_updateStatus(AiServerStatus.offline);
|
||||
}
|
||||
|
||||
Future<bool> _isPortReady(int port) async {
|
||||
// -------------------------------------------------------------------------
|
||||
// Internals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Spawns both servers with the given GPU offload and waits for health.
|
||||
/// Returns false on timeout; rethrows nothing — a crashed process is
|
||||
/// reported through [_lastError] by the exit watcher and detected here.
|
||||
Future<bool> _startAttempt(
|
||||
String serverBin,
|
||||
String base, {
|
||||
required int gpuLayers,
|
||||
required Duration timeout,
|
||||
required String detailLabel,
|
||||
}) async {
|
||||
_qwenProcess = await _spawn(
|
||||
serverBin,
|
||||
[
|
||||
'-m', p.join(base, AiConstants.qwenModelFile),
|
||||
'--port', '${AiConstants.chatServerPort}',
|
||||
'--ctx-size', '${AiConstants.qwenContextSize}',
|
||||
'-ngl', '$gpuLayers',
|
||||
// Use the chat template embedded in the GGUF (required for
|
||||
// correct Qwen3 formatting).
|
||||
'--jinja',
|
||||
],
|
||||
label: 'QWEN',
|
||||
stderrTail: _qwenStderrTail,
|
||||
crashMessage: 'Qwen Chat Server crashed',
|
||||
);
|
||||
|
||||
_nomicProcess = await _spawn(
|
||||
serverBin,
|
||||
[
|
||||
'-m',
|
||||
p.join(base, AiConstants.nomicModelFile),
|
||||
'--port',
|
||||
'${AiConstants.embeddingServerPort}',
|
||||
'--ctx-size',
|
||||
'${AiConstants.nomicContextSize}',
|
||||
'--embedding',
|
||||
],
|
||||
label: 'NOMIC',
|
||||
stderrTail: _nomicStderrTail,
|
||||
crashMessage: 'Nomic Embedding Server crashed',
|
||||
);
|
||||
|
||||
// llama-server binds its port almost immediately but returns 503 until
|
||||
// the model is fully loaded, so poll /health rather than the TCP port.
|
||||
final started = DateTime.now();
|
||||
final deadline = started.add(timeout);
|
||||
var qwenReady = false;
|
||||
var nomicReady = false;
|
||||
|
||||
while (DateTime.now().isBefore(deadline) && (!qwenReady || !nomicReady)) {
|
||||
// A crashed process will never become healthy — give up on this
|
||||
// attempt right away (the caller may retry on CPU).
|
||||
if (_lastError != null) return false;
|
||||
|
||||
await Future.delayed(AiConstants.healthPollInterval);
|
||||
if (!qwenReady) {
|
||||
qwenReady = await _isHealthy(AiConstants.chatHealthUrl);
|
||||
}
|
||||
if (!nomicReady) {
|
||||
nomicReady = await _isHealthy(AiConstants.embeddingHealthUrl);
|
||||
}
|
||||
|
||||
final elapsed = DateTime.now().difference(started).inSeconds;
|
||||
_statusDetail =
|
||||
'Loading AI model on $detailLabel… ${elapsed}s '
|
||||
'(first run can take a few minutes)';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
return qwenReady && nomicReady;
|
||||
}
|
||||
|
||||
Future<void> _killProcesses() async {
|
||||
// Detach before killing so the exit watchers recognise an intentional
|
||||
// shutdown and don't flag it as a crash.
|
||||
final qwen = _qwenProcess;
|
||||
final nomic = _nomicProcess;
|
||||
_qwenProcess = null;
|
||||
_nomicProcess = null;
|
||||
qwen?.kill();
|
||||
nomic?.kill();
|
||||
}
|
||||
|
||||
Future<Process> _spawn(
|
||||
String binary,
|
||||
List<String> args, {
|
||||
required String label,
|
||||
required List<String> stderrTail,
|
||||
required String crashMessage,
|
||||
}) async {
|
||||
final process = await Process.start(binary, args, runInShell: false);
|
||||
|
||||
process.stdout.transform(utf8.decoder).listen((text) {
|
||||
if (kDebugMode) debugPrint('[$label STDOUT] $text');
|
||||
});
|
||||
process.stderr.transform(utf8.decoder).listen((text) {
|
||||
for (final line in const LineSplitter().convert(text)) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
stderrTail.add(line);
|
||||
if (stderrTail.length > _stderrTailLines) stderrTail.removeAt(0);
|
||||
}
|
||||
if (kDebugMode) debugPrint('[$label STDERR] $text');
|
||||
});
|
||||
|
||||
// Monitor for unexpected crash. Intentional kills (retry, shutdown)
|
||||
// null out the process reference first, so they are ignored here.
|
||||
process.exitCode.then((code) {
|
||||
final isCurrent =
|
||||
identical(process, _qwenProcess) || identical(process, _nomicProcess);
|
||||
if (!isCurrent) return;
|
||||
if (_status == AiServerStatus.ready ||
|
||||
_status == AiServerStatus.starting) {
|
||||
_lastError = '$crashMessage (exit code $code).${_diagnostics()}';
|
||||
if (_status == AiServerStatus.ready) {
|
||||
_updateStatus(AiServerStatus.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
Future<bool> _isHealthy(String url) async {
|
||||
try {
|
||||
final socket = await Socket.connect(
|
||||
'127.0.0.1',
|
||||
port,
|
||||
timeout: const Duration(seconds: 1),
|
||||
);
|
||||
socket.destroy();
|
||||
return true;
|
||||
final response = await http
|
||||
.get(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 2));
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _diagnostics() {
|
||||
final buffer = StringBuffer();
|
||||
if (_qwenStderrTail.isNotEmpty) {
|
||||
buffer.write('\n\nChat server log:\n${_qwenStderrTail.join('\n')}');
|
||||
}
|
||||
if (_nomicStderrTail.isNotEmpty) {
|
||||
buffer.write('\n\nEmbedding server log:\n${_nomicStderrTail.join('\n')}');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
void _updateStatus(AiServerStatus newStatus) {
|
||||
if (_status != newStatus) {
|
||||
_status = newStatus;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
204
lib/data/services/combo_service.dart
Normal file
204
lib/data/services/combo_service.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
/// One technique node in a combo graph.
|
||||
class ComboNode {
|
||||
const ComboNode({
|
||||
required this.id,
|
||||
required this.technique,
|
||||
this.translation = '',
|
||||
required this.dx,
|
||||
required this.dy,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String technique;
|
||||
final String translation;
|
||||
final double dx;
|
||||
final double dy;
|
||||
|
||||
ComboNode copyWith({double? dx, double? dy}) => ComboNode(
|
||||
id: id,
|
||||
technique: technique,
|
||||
translation: translation,
|
||||
dx: dx ?? this.dx,
|
||||
dy: dy ?? this.dy,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'technique': technique,
|
||||
'translation': translation,
|
||||
'dx': dx,
|
||||
'dy': dy,
|
||||
};
|
||||
|
||||
factory ComboNode.fromJson(Map<String, dynamic> json) => ComboNode(
|
||||
id: json['id'] as String,
|
||||
technique: json['technique'] as String,
|
||||
translation: (json['translation'] ?? '') as String,
|
||||
dx: (json['dx'] as num).toDouble(),
|
||||
dy: (json['dy'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
/// A directed transition between two techniques.
|
||||
class ComboEdge {
|
||||
const ComboEdge(this.fromId, this.toId);
|
||||
|
||||
final String fromId;
|
||||
final String toId;
|
||||
|
||||
Map<String, dynamic> toJson() => {'from': fromId, 'to': toId};
|
||||
|
||||
factory ComboEdge.fromJson(Map<String, dynamic> json) =>
|
||||
ComboEdge(json['from'] as String, json['to'] as String);
|
||||
}
|
||||
|
||||
/// A named technique graph: nodes are techniques, edges are the transitions
|
||||
/// that make sense between them.
|
||||
class Combo {
|
||||
const Combo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.nodes = const [],
|
||||
this.edges = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final List<ComboNode> nodes;
|
||||
final List<ComboEdge> edges;
|
||||
|
||||
Combo copyWith({
|
||||
String? name,
|
||||
List<ComboNode>? nodes,
|
||||
List<ComboEdge>? edges,
|
||||
}) => Combo(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
nodes: nodes ?? this.nodes,
|
||||
edges: edges ?? this.edges,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'nodes': nodes.map((n) => n.toJson()).toList(),
|
||||
'edges': edges.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory Combo.fromJson(Map<String, dynamic> json) => Combo(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
nodes: [
|
||||
for (final n in (json['nodes'] as List? ?? []))
|
||||
ComboNode.fromJson(n as Map<String, dynamic>),
|
||||
],
|
||||
edges: [
|
||||
for (final e in (json['edges'] as List? ?? []))
|
||||
ComboEdge.fromJson(e as Map<String, dynamic>),
|
||||
],
|
||||
);
|
||||
|
||||
/// Random walk over the graph — the generated combination to drill.
|
||||
/// Follows outgoing edges; if a node is a dead end the walk stops there.
|
||||
List<ComboNode> generateSequence({int length = 4, Random? random}) {
|
||||
if (nodes.isEmpty) return [];
|
||||
final rnd = random ?? Random();
|
||||
final byId = {for (final n in nodes) n.id: n};
|
||||
final outgoing = <String, List<String>>{};
|
||||
for (final e in edges) {
|
||||
outgoing.putIfAbsent(e.fromId, () => []).add(e.toId);
|
||||
}
|
||||
|
||||
// Prefer starting somewhere that actually leads on.
|
||||
final starts = nodes.where((n) => outgoing.containsKey(n.id)).toList();
|
||||
var current = (starts.isEmpty
|
||||
? nodes
|
||||
: starts)[rnd.nextInt((starts.isEmpty ? nodes : starts).length)];
|
||||
|
||||
final sequence = <ComboNode>[current];
|
||||
while (sequence.length < length) {
|
||||
final next = outgoing[current.id];
|
||||
if (next == null || next.isEmpty) break;
|
||||
current = byId[next[rnd.nextInt(next.length)]]!;
|
||||
sequence.add(current);
|
||||
}
|
||||
return sequence;
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads and persists combos as a JSON file in the app documents dir.
|
||||
class ComboService extends ChangeNotifier {
|
||||
static const _fileName = 'trainhub_combos.json';
|
||||
|
||||
List<Combo> _combos = [];
|
||||
bool _loaded = false;
|
||||
|
||||
List<Combo> get combos => _combos;
|
||||
|
||||
Future<File> _file() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
return File(p.join(dir.path, _fileName));
|
||||
}
|
||||
|
||||
Future<void> ensureLoaded() async {
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
try {
|
||||
final file = await _file();
|
||||
if (!file.existsSync()) return;
|
||||
final json = jsonDecode(await file.readAsString()) as List<dynamic>;
|
||||
_combos = [
|
||||
for (final c in json) Combo.fromJson(c as Map<String, dynamic>),
|
||||
];
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('Failed to load combos: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Combo create(String name) {
|
||||
final combo = Combo(id: _uuid.v4(), name: name);
|
||||
_combos = [..._combos, combo];
|
||||
notifyListeners();
|
||||
_persist();
|
||||
return combo;
|
||||
}
|
||||
|
||||
void delete(String id) {
|
||||
_combos = _combos.where((c) => c.id != id).toList();
|
||||
notifyListeners();
|
||||
_persist();
|
||||
}
|
||||
|
||||
/// Replaces a combo. Set [persist] false for high-frequency updates
|
||||
/// (node dragging) and call [persistNow] once afterwards.
|
||||
void update(Combo combo, {bool persist = true}) {
|
||||
_combos = [for (final c in _combos) c.id == combo.id ? combo : c];
|
||||
notifyListeners();
|
||||
if (persist) _persist();
|
||||
}
|
||||
|
||||
void persistNow() => _persist();
|
||||
|
||||
Future<void> _persist() async {
|
||||
try {
|
||||
final file = await _file();
|
||||
await file.writeAsString(
|
||||
jsonEncode(_combos.map((c) => c.toJson()).toList()),
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('Failed to save combos: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,7 @@ class EmbeddingService {
|
||||
Future<List<double>> embed(String text) async {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
AiConstants.embeddingApiUrl,
|
||||
data: {
|
||||
'input': text,
|
||||
'model': AiConstants.nomicModelName,
|
||||
},
|
||||
data: {'input': text, 'model': AiConstants.nomicModelName},
|
||||
);
|
||||
|
||||
final raw =
|
||||
|
||||
200
lib/data/services/llm_client.dart
Normal file
200
lib/data/services/llm_client.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
|
||||
|
||||
/// Streams chat completions from the configured AI provider.
|
||||
///
|
||||
/// - Local llama.cpp, OpenAI and Gemini speak the OpenAI-compatible
|
||||
/// `/chat/completions` SSE format.
|
||||
/// - Anthropic uses its native `/v1/messages` SSE format.
|
||||
///
|
||||
/// Owns all HTTP/SSE plumbing so that controllers only deal with a plain
|
||||
/// `Stream<String>` of content deltas.
|
||||
class LlmClient {
|
||||
LlmClient(this._settingsService, {Dio? dio})
|
||||
: _dio =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: AiConstants.serverConnectTimeout,
|
||||
receiveTimeout: AiConstants.serverReceiveTimeout,
|
||||
),
|
||||
);
|
||||
|
||||
final AiSettingsService _settingsService;
|
||||
final Dio _dio;
|
||||
|
||||
AiProvider get activeProvider => _settingsService.settings.provider;
|
||||
|
||||
/// Streams content deltas for a chat completion.
|
||||
///
|
||||
/// [messages] must already include the system prompt and history.
|
||||
/// Pass a [cancelToken] to allow aborting generation mid-stream.
|
||||
/// Throws [DioException] if the server is unreachable; errors that occur
|
||||
/// mid-stream are surfaced through the returned stream.
|
||||
Stream<String> streamChat(
|
||||
List<Map<String, String>> messages, {
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
final settings = _settingsService.settings;
|
||||
return switch (settings.provider) {
|
||||
AiProvider.anthropic => _streamAnthropic(
|
||||
messages,
|
||||
settings,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
_ => _streamOpenAiCompatible(
|
||||
messages,
|
||||
settings,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI-compatible providers: local llama.cpp, OpenAI, Gemini
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Stream<String> _streamOpenAiCompatible(
|
||||
List<Map<String, String>> messages,
|
||||
AiSettings settings, {
|
||||
CancelToken? cancelToken,
|
||||
}) async* {
|
||||
final provider = settings.provider;
|
||||
final isBuiltIn = provider == AiProvider.local;
|
||||
|
||||
final url = switch (provider) {
|
||||
AiProvider.local => AiConstants.chatApiUrl,
|
||||
AiProvider.ollama || AiProvider.lmstudio =>
|
||||
'${settings.baseUrlFor(provider)}/v1/chat/completions',
|
||||
AiProvider.openai => 'https://api.openai.com/v1/chat/completions',
|
||||
AiProvider.gemini =>
|
||||
'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
|
||||
AiProvider.anthropic => throw StateError('handled separately'),
|
||||
};
|
||||
|
||||
final apiKey = settings.apiKeyFor(provider);
|
||||
if (provider.needsApiKey && apiKey == null) {
|
||||
throw Exception('No API key configured for ${provider.label}.');
|
||||
}
|
||||
|
||||
final response = await _dio.post<ResponseBody>(
|
||||
url,
|
||||
cancelToken: cancelToken,
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: apiKey == null ? null : {'Authorization': 'Bearer $apiKey'},
|
||||
),
|
||||
data: {
|
||||
if (!isBuiltIn) 'model': settings.modelFor(provider),
|
||||
'messages': messages,
|
||||
if (isBuiltIn) 'temperature': AiConstants.chatTemperature,
|
||||
'max_tokens': AiConstants.chatMaxTokens,
|
||||
// llama.cpp-specific: reuses the KV-cache of the shared prefix
|
||||
// between turns — makes multi-turn conversations respond much faster.
|
||||
if (isBuiltIn) 'cache_prompt': true,
|
||||
'stream': true,
|
||||
},
|
||||
);
|
||||
|
||||
await for (final line in _sseLines(response)) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
final dataStr = line.substring(6).trim();
|
||||
if (dataStr == '[DONE]') return;
|
||||
if (dataStr.isEmpty) continue;
|
||||
try {
|
||||
final data = jsonDecode(dataStr);
|
||||
final delta =
|
||||
(data['choices']?[0]?['delta']?['content'] ?? '') as String;
|
||||
if (delta.isNotEmpty) yield delta;
|
||||
} catch (_) {
|
||||
// Malformed line — dropped. Thanks to the line buffering this only
|
||||
// happens on genuinely corrupt data, not on chunk boundaries.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anthropic native Messages API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Stream<String> _streamAnthropic(
|
||||
List<Map<String, String>> messages,
|
||||
AiSettings settings, {
|
||||
CancelToken? cancelToken,
|
||||
}) async* {
|
||||
final apiKey = settings.apiKeyFor(AiProvider.anthropic);
|
||||
if (apiKey == null) {
|
||||
throw Exception('No API key configured for Anthropic.');
|
||||
}
|
||||
|
||||
// Anthropic takes the system prompt as a top-level field, not a message.
|
||||
final system = messages
|
||||
.where((m) => m['role'] == 'system')
|
||||
.map((m) => m['content'])
|
||||
.join('\n\n');
|
||||
final chat = messages.where((m) => m['role'] != 'system').toList();
|
||||
|
||||
final response = await _dio.post<ResponseBody>(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
cancelToken: cancelToken,
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: {'x-api-key': apiKey, 'anthropic-version': '2023-06-01'},
|
||||
),
|
||||
data: {
|
||||
'model': settings.modelFor(AiProvider.anthropic),
|
||||
'max_tokens': AiConstants.chatMaxTokens,
|
||||
if (system.isNotEmpty) 'system': system,
|
||||
'messages': chat,
|
||||
'stream': true,
|
||||
},
|
||||
);
|
||||
|
||||
await for (final line in _sseLines(response)) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
final dataStr = line.substring(6).trim();
|
||||
if (dataStr.isEmpty) continue;
|
||||
try {
|
||||
final data = jsonDecode(dataStr);
|
||||
final type = data['type'] as String?;
|
||||
if (type == 'content_block_delta') {
|
||||
final delta = data['delta'];
|
||||
if (delta?['type'] == 'text_delta') {
|
||||
final text = (delta['text'] ?? '') as String;
|
||||
if (text.isNotEmpty) yield text;
|
||||
}
|
||||
} else if (type == 'message_stop') {
|
||||
return;
|
||||
} else if (type == 'error') {
|
||||
throw Exception(
|
||||
'Anthropic API error: ${data['error']?['message'] ?? 'unknown'}',
|
||||
);
|
||||
}
|
||||
} on FormatException {
|
||||
// Malformed line — dropped.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE line splitting with chunk-boundary buffering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// SSE lines can be split across TCP chunks, so carry the unfinished tail
|
||||
/// of each chunk over to the next one instead of dropping it.
|
||||
Stream<String> _sseLines(Response<ResponseBody> response) async* {
|
||||
var pending = '';
|
||||
await for (final chunk in response.data!.stream) {
|
||||
pending += utf8.decode(chunk, allowMalformed: true);
|
||||
final lines = pending.split('\n');
|
||||
pending = lines.removeLast();
|
||||
for (final line in lines) {
|
||||
yield line;
|
||||
}
|
||||
}
|
||||
if (pending.isNotEmpty) yield pending;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user