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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user