Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
Some checks failed
Build Linux App / build (push) Failing after 1m18s

This commit is contained in:
Kazimierz Ciołek
2026-07-06 23:44:17 +02:00
parent 9dcc4b87de
commit 6dd7213eb0
48 changed files with 3229 additions and 669 deletions

View File

@@ -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 {

View File

@@ -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;

View 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');
}
}
}

View 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');
}
}
}

View File

@@ -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 =

View 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;
}
}