Files
trainhub-flutter/lib/data/services/llm_client.dart
Kazimierz Ciołek 6dd7213eb0
Some checks failed
Build Linux App / build (push) Failing after 1m18s
Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
2026-07-06 23:44:17 +02:00

201 lines
6.9 KiB
Dart

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