< Summary

Information
Class: UIBlazor.Services.ChatService
Assembly: UIBlazor
File(s): /home/runner/work/InvAit/InvAit/UIBlazor/Services/ChatService.cs
Tag: 71_26091983037
Line coverage
47%
Covered lines: 126
Uncovered lines: 139
Coverable lines: 265
Total lines: 587
Line coverage: 47.5%
Branch coverage
38%
Covered branches: 72
Total branches: 186
Branch coverage: 38.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Options()100%11100%
Initialize()100%210%
get_Session()100%11100%
set_Session(...)50%9871.42%
SessionPropertyChanged(...)0%620%
GetModelsAsync()16.66%691226.66%
get_NeedCompression()0%2040%
CompressSessionAsync()0%420200%
SaveSessionAsync()100%210%
UpdateSessionCache(...)0%156120%
get_LastCompletionsModel()100%11100%
get_LastError()100%11100%
get_LastUsage()100%11100%
get_FinishReason()100%11100%
GetCompletionsAsync()63.04%2499273.52%
GetCompletionsAsync()75%44100%
GetRecentSessionsAsync()0%272160%
NewSessionAsync()0%2040%
CleanupOldSessionsAsync()0%2040%
LoadSessionAsync()0%620%
DeleteSessionAsync()0%4260%
GetAllSessionIdsAsync()100%11100%
GenerateSessionId()100%11100%
CreateNewSession()100%11100%
LoadLastSessionOrGenerateNewAsync()83.33%66100%
Dispose()100%210%

File(s)

/home/runner/work/InvAit/InvAit/UIBlazor/Services/ChatService.cs

#LineLine coverage
 1using System.ComponentModel;
 2using System.Globalization;
 3using System.Net.Http.Headers;
 4using System.Net.Http.Json;
 5using System.Net.Mime;
 6using System.Runtime.CompilerServices;
 7using UIBlazor.Services.Models;
 8using UIBlazor.Services.Settings;
 9
 10namespace UIBlazor.Services;
 11
 912public class ChatService(
 913    HttpClient httpClient,
 914    IProfileManager profileManager,
 915    ISystemPromptBuilder systemPromptBuilder,
 916    ILocalStorageService localStorage,
 917    ILogger<IChatService> logger
 918    ) : IChatService
 19{
 20    private const string _thinkStart    = "<think>";
 21    private const string _thinkEnd      = "</think>";
 22    private const string _complitions   = "/v1/chat/completions";
 23    private const string _models        = "/v1/models";
 924    private readonly JsonSerializerOptions _jsonSerializerOptions = new()
 925    {
 926        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
 927        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
 928    };
 29
 6930    public ConnectionProfile Options => profileManager.ActiveProfile;
 31
 32    /// <summary>
 33    /// Подписка на события после создания экземпляра
 34    /// </summary>
 35    public void Initialize()
 36    {
 037        Session.PropertyChanged -= SessionPropertyChanged;
 038        Session.PropertyChanged += SessionPropertyChanged;
 039    }
 40
 41    public ConversationSession Session
 42    {
 15843        get;
 44        private set
 45        {
 246            if (field == value)
 047                return;
 248            field?.PropertyChanged -= SessionPropertyChanged;
 249            field = value;
 250            field?.PropertyChanged += SessionPropertyChanged;
 251            SessionChanged?.Invoke(field, new PropertyChangedEventArgs(nameof(ConversationSession)));
 052        }
 953    } = CreateNewSession();
 54
 55    public event PropertyChangedEventHandler? SessionChanged;
 56
 57    private void SessionPropertyChanged(object? sender, PropertyChangedEventArgs e)
 058        => SessionChanged?.Invoke(sender, e);
 59
 60    public async Task<AiModelList> GetModelsAsync(CancellationToken cancellationToken)
 61    {
 162        using var request = new HttpRequestMessage(HttpMethod.Get, $"{Options.Endpoint}{_models}");
 63
 164        if (!string.IsNullOrEmpty(Options.ApiKey))
 65        {
 066            if (string.IsNullOrWhiteSpace(Options.ApiKeyHeader))
 67            {
 068                throw new InvalidOperationException("API key header must be specified when an API key is provided.");
 69            }
 70
 071            if (string.Equals(Options.ApiKeyHeader, "Authorization", StringComparison.OrdinalIgnoreCase))
 72            {
 073                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Options.ApiKey);
 74            }
 75            else
 76            {
 077                request.Headers.Add(Options.ApiKeyHeader, Options.ApiKey);
 78            }
 79        }
 80
 181        if (string.IsNullOrEmpty(Options.Endpoint))
 82        {
 183            throw new InvalidOperationException("Endpoint must be specified.");
 84        }
 85
 086        var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).
 87
 088        if (!response.IsSuccessStatusCode)
 89        {
 090            throw new HttpRequestException($"Getting models failed: {await response.Content.ReadAsStringAsync(cancellati
 91        }
 92
 093        return await response.Content.ReadFromJsonAsync<AiModelList>(cancellationToken)
 094               ?? throw new JsonException("Models deserialization exception");
 095    }
 96
 097    public bool NeedCompression => Options.TokensToCompress > 0 && Session.Messages.Count > 5 && Session.TotalTokens > O
 98
 99    public async IAsyncEnumerable<ChatDelta> CompressSessionAsync([EnumeratorCancellation] CancellationToken cancellatio
 100    {
 0101        var (Messages, LastUserMessage) = Session.GetFormattedMessagesForCompress(await systemPromptBuilder.PrepareSyste
 102
 103        // Получаем сжатый текст от LLM
 0104        var contentSb = new StringBuilder();
 0105        await foreach (var chatDelta in GetCompletionsAsync(Messages, cancellationToken))
 106        {
 0107            if (chatDelta.Content is not null)
 108            {
 0109                contentSb.Append(chatDelta.Content);
 110            }
 0111            yield return chatDelta;
 112        }
 113
 0114        if (!cancellationToken.IsCancellationRequested)
 115        {
 116            // Создаем новый объект сообщения со сжатым контекстом
 0117            var compressedMessage = new VisualChatMessage()
 0118            {
 0119                Content = contentSb.ToString(),
 0120                Role = ChatMessageRole.Assistant,
 0121                IsExpanded = true,
 0122            };
 123
 0124            int totalCount = Session.Messages.Count;
 0125            int windowSize = totalCount < 6 ? 2 : 3;
 126
 0127            var topMessages = new List<VisualChatMessage>();
 0128            var bottomMessages = new List<VisualChatMessage>();
 129
 0130            for (int i = 0; i < totalCount - 1; i++)
 131            {
 0132                var msg = Session.Messages[i];
 133
 0134                if (msg.Id == LastUserMessage?.Id)
 135                    continue;
 136
 137                // Первые сообщения
 0138                if (i < windowSize)
 139                {
 0140                    topMessages.Add(msg);
 141                }
 142
 143                // Оставшиеся сообщения
 0144                else if (i >= totalCount - 1 - windowSize)
 145                {
 0146                    bottomMessages.Add(msg);
 147                }
 148            }
 149
 0150            var keptMessages = new List<VisualChatMessage>(topMessages.Count + bottomMessages.Count + 2);
 0151            keptMessages.AddRange(topMessages);
 0152            keptMessages.AddRange(bottomMessages);
 0153            keptMessages.Add(compressedMessage);
 154
 155            // Восстанавливаем сообщение пользователя после компрессии
 0156            if (LastUserMessage is not null)
 157            {
 0158                keptMessages.Add(LastUserMessage);
 159            }
 160
 161            // Перезаписываем историю
 0162            Session.Messages = keptMessages;
 163        }
 0164    }
 165
 166    /// <summary>
 167    /// Asynchronously saves the current session data to local storage using the session ID as the key.
 168    /// </summary>
 169    /// <returns></returns>
 170    public async Task SaveSessionAsync()
 171    {
 0172        await localStorage.SetItemAsync(Session.Id, Session);
 0173        UpdateSessionCache(Session);
 0174    }
 175
 176    private void UpdateSessionCache(ConversationSession session)
 177    {
 0178        if (_recentSessionsCache == null) return;
 179
 0180        var existing = _recentSessionsCache.FirstOrDefault(s => s.Id == session.Id);
 0181        var firstMessage = session.Messages.FirstOrDefault(m => m.Role == ChatMessageRole.User)?.Content ?? string.Empty
 0182        var preview = firstMessage is { Length: > 40 } ? firstMessage[..40] + "..." : firstMessage;
 183
 0184        if (existing != null)
 185        {
 0186            existing.FirstUserMessage = preview;
 187        }
 188        else
 189        {
 0190            _recentSessionsCache.Add(new SessionSummary
 0191            {
 0192                Id = session.Id,
 0193                CreatedAt = session.CreatedAt,
 0194                FirstUserMessage = preview
 0195            });
 0196            _recentSessionsCache = [.. _recentSessionsCache.OrderByDescending(s => s.CreatedAt)];
 197        }
 0198    }
 199
 200    /// <summary>
 201    /// Модель, которая последняя отвечала
 202    /// </summary>
 151203    public string? LastCompletionsModel { get; private set; }
 204
 205    /// <summary>
 206    /// Текст ошибки
 207    /// </summary>
 7208    public string? LastError { get; private set; }
 209
 210    /// <summary>
 211    /// Последнее использование токенов
 212    /// </summary>
 6213    public UsageInfo? LastUsage { get; private set; }
 214
 9215    public string? FinishReason { get; private set; }
 216
 217    private async IAsyncEnumerable<ChatDelta> GetCompletionsAsync(IEnumerable<object> messages, [EnumeratorCancellation]
 218    {
 5219        LastCompletionsModel = null;
 5220        LastUsage = null;
 5221        LastError = null;
 5222        FinishReason = null;
 223
 224        // Use runtime parameters or fall back to configured options
 5225        var url = $"{Options.Endpoint}{_complitions}";
 5226        var effectiveApiKeyHeader = Options.ApiKeyHeader;
 227
 5228        var payload = new
 5229        {
 5230            model = Options.Model,
 5231            messages = messages,
 5232            temperature = Options.Temperature,
 5233            max_tokens = Options.MaxTokens >= 1000 ? Options.MaxTokens : -1,
 5234            stream = Options.Stream,
 5235            stream_options = Options.Stream ? new { include_usage = true } : null
 5236        };
 237
 5238        var request = new HttpRequestMessage(HttpMethod.Post, url)
 5239        {
 5240            Content = new StringContent(
 5241                JsonSerializer.Serialize(payload, _jsonSerializerOptions),
 5242                Encoding.UTF8,
 5243                MediaTypeNames.Application.Json)
 5244        };
 245
 5246        if (!string.IsNullOrEmpty(Options.ApiKey))
 247        {
 5248            if (string.Equals(effectiveApiKeyHeader, "Authorization", StringComparison.OrdinalIgnoreCase))
 249            {
 5250                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Options.ApiKey);
 251            }
 252            else
 253            {
 0254                request.Headers.Add(effectiveApiKeyHeader, Options.ApiKey);
 255            }
 256        }
 257
 10258        foreach (var header in Options.ExtraHeaders.Where(h => !string.IsNullOrEmpty(h.Name)))
 259        {
 0260            request.Headers.TryAddWithoutValidation(header.Name, header.Value);
 261        }
 262
 5263        var response = await httpClient.SendAsync(request, Options.Stream ? HttpCompletionOption.ResponseHeadersRead : H
 264
 5265        if (!response.IsSuccessStatusCode)
 266        {
 0267            var result = $"HttpCode: {response.StatusCode} | server failed: {await response.Content.ReadAsStringAsync(ca
 0268            throw new Exception(result);
 269        }
 270
 271        // если не стрим, то возвращаем как один чанк
 5272        if (!Options.Stream)
 273        {
 0274            var chunk = await response.Content.ReadFromJsonAsync<StreamChunk>(cancellationToken);
 0275            var message = chunk?.Choice?.Message;
 0276            if (message?.Content != null)
 277            {
 278                // Удаление <think> блока из контента и перенос его в ReasoningContent если его там нет.
 0279                var regex = Regex.Match(message.Content, $"^{_thinkStart}(?<reason>.*){_thinkEnd}", RegexOptions.Singlel
 0280                if (regex.Success)
 281                {
 0282                    message.ReasoningContent ??= regex.Groups["reason"].Value;
 0283                    message.Content = message.Content[regex.Length..];
 284                }
 0285                LastCompletionsModel ??= chunk?.Model;
 0286                if (chunk?.Usage != null)
 287                {
 0288                    LastUsage = chunk.Usage;
 0289                    Session.TotalTokens = chunk.Usage.TotalTokens;
 290                }
 0291                yield return message;
 292            }
 0293            yield break;
 294        }
 295
 296        // стрим
 5297        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
 5298        using var reader = new StreamReader(stream);
 299
 300        string? line;
 5301        var isReasoningContent = false;
 5302        var isStart = true;
 5303        string? role = null;
 304
 305        // чтобы html-теги <function> склеивать в один чанк
 5306        var _pendingText = string.Empty;
 5307        ChatChoice lastChoise = null!;
 148308        while ((line = await reader.ReadLineAsync(cancellationToken)) is not null && !cancellationToken.IsCancellationRe
 309        {
 147310            if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:"))
 311            {
 312                continue;
 313            }
 314
 147315            var json = line[6..];
 316
 147317            if (json == "[DONE]")
 318            {
 4319                FinishReason = lastChoise?.FinishReason;
 4320                break;
 321            }
 322
 143323            if (json.StartsWith("{\"error\""))
 324            {
 1325                LastError = json;
 1326                continue;
 327            }
 328
 142329            var chunk = JsonUtils.Deserialize<StreamChunk>(json);
 142330            if (chunk == null)
 331            {
 332                continue;
 333            }
 334
 142335            if (chunk.Usage != null)
 336            {
 1337                LastUsage = chunk.Usage;
 1338                Session.TotalTokens = chunk.Usage.TotalTokens;
 339            }
 340
 142341            if (chunk.Choices.Count != 1 || chunk.Choices[0].Delta == null)
 342            {
 343                continue;
 344            }
 345
 346            // Динамический подсчёт токенов во время стрима (приблизительный)
 142347            Session.TotalTokens++;
 348
 142349            LastCompletionsModel ??= chunk.Model;
 142350            lastChoise = chunk.Choices[0];
 142351            var delta = lastChoise.Delta;
 142352            var content = delta!.Content;
 142353            role ??= delta?.Role;
 354
 355            // Размышляющие модели по разному отдают размышления
 356            //
 357            //           ReasoningContent | Content
 358            // GLM 4.7         +++        |   ---
 359            // Kimi 2          +++        | <think>
 360            // Deepseek R1     ---        | <think>
 361            //
 362            // обрабатываем размышления как Z.ai GLM.
 363            // Все размышления идут в ReasoningContent с пустым Content
 364
 365            // Преобразрвания нужны если есть контент с блоком <think>
 142366            if (!string.IsNullOrEmpty(content))
 367            {
 140368                if (!isReasoningContent) // не думаем
 369                {
 140370                    if (isStart && content.StartsWith(_thinkStart))
 371                    {
 372                        // начать думать можно только в первом чанке
 0373                        isReasoningContent = true;
 0374                        delta.ReasoningContent = content.Replace(_thinkStart, string.Empty);
 0375                        delta.Content = null;
 376                    }
 377                }
 378                else // внутри <think> блока
 379                {
 0380                    if (content.Contains(_thinkEnd))
 381                    {
 382                        // если закончил думать, то можно в контент добавить часть чанка (актуально для Kimi2)
 0383                        isReasoningContent = false;
 0384                        delta.Content = content.Replace(_thinkEnd, string.Empty);
 0385                        delta.ReasoningContent = null;
 386                    }
 387                    else
 388                    {
 389                        // если не конец - то все пихаем в ReasoningContent и очищаем Content
 0390                        delta.Content = null;
 0391                        delta.ReasoningContent = content;
 392                    }
 393                }
 394            }
 395
 396            // Если есть контент, то проверяем на разрезанные теги и склеиваем их
 142397            if (delta.Content != null)
 398            {
 142399                var incomingText = _pendingText + delta.Content;
 142400                _pendingText = string.Empty;
 401
 142402                var lastOpenIndex = incomingText.LastIndexOf('<');
 403
 404                // Проверяем, есть ли незакрытый тег в конце строки
 142405                if (lastOpenIndex >= 0)
 406                {
 83407                    var potentialTag = incomingText[lastOpenIndex..];
 408
 409                    // Если в "потенциальном теге" нет символов закрытия
 83410                    if (potentialTag.IndexOfAny(['>', '\n']) == -1)
 411                    {
 412                        // Сохраняем в буфер ТОЛЬКО незакрытую часть
 71413                        _pendingText = potentialTag;
 414                        // А из текущей дельты вырезаем этот кусок
 71415                        incomingText = incomingText[..lastOpenIndex];
 416                    }
 417                }
 418
 419                // Если после обрезки буфера текста не осталось — идем за следующей дельтой
 142420                if (string.IsNullOrEmpty(incomingText) && !string.IsNullOrEmpty(_pendingText))
 421                    continue;
 422
 74423                delta.Role ??= role;
 74424                delta.Content = incomingText;
 425            }
 426
 74427            yield return delta;
 428
 74429            isStart = false;
 430        }
 431
 432        // если после окончания стрима остался неотправленный текст, отправляем его
 5433        if (!string.IsNullOrEmpty(_pendingText))
 434        {
 0435            yield return new ChatDelta() { Content = _pendingText };
 436        }
 5437    }
 438
 439    /// <summary>
 440    /// Asynchronously generates a sequence of chat completion deltas for the current conversation session.
 441    /// </summary>
 442    /// <remarks>
 443    /// This method streams chat completion results as they become available, allowing for real-time
 444    /// processing of partial responses. The returned sequence may include reasoning content or message content
 445    /// depending on the model and response format. If streaming is not enabled, the method yields a single completion
 446    /// result.
 447    /// </remarks>
 448    /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</par
 449    /// <returns>
 450    /// An asynchronous stream of <see cref="ChatDelta"/> objects representing incremental updates to the chat
 451    /// completion. The stream completes when the response is fully received.
 452    /// </returns>
 453    /// <exception cref="Exception">Thrown if the chat completion request fails or the server returns an unsuccessful re
 454    public async IAsyncEnumerable<ChatDelta> GetCompletionsAsync([EnumeratorCancellation] CancellationToken cancellation
 455    {
 456        // Get formatted messages including conversation history
 5457        var messages = Session.GetFormattedMessages(await systemPromptBuilder.PrepareSystemPromptAsync(Session.Mode, can
 458
 158459        await foreach (var chatDelta in GetCompletionsAsync(messages, cancellationToken))
 460        {
 74461            yield return chatDelta;
 462        }
 5463    }
 464
 465    private const int _maxSessions = 5;
 466    private List<SessionSummary>? _recentSessionsCache;
 467
 468    public async Task<List<SessionSummary>> GetRecentSessionsAsync(int count)
 469    {
 0470        if (_recentSessionsCache != null)
 0471            return [.. _recentSessionsCache.Take(count)];
 472
 0473        var sessionIds = await GetAllSessionIdsAsync();
 0474        var summaries = new List<SessionSummary>();
 475
 0476        foreach (var id in sessionIds)
 477        {
 0478            var session = await localStorage.TryGetItemAsync<ConversationSession>(id);
 0479            var firstMessage = session?.Messages.FirstOrDefault(m => m.Role == ChatMessageRole.User)?.Content;
 0480            if (session != null && firstMessage != null)
 481            {
 0482                var preview = firstMessage.Length > 40 ? firstMessage[..40] + "..." : firstMessage;
 483
 0484                summaries.Add(new SessionSummary
 0485                {
 0486                    Id = id,
 0487                    CreatedAt = session.CreatedAt,
 0488                    FirstUserMessage = preview
 0489                });
 490            }
 491            else
 492            {
 0493                await localStorage.RemoveItemAsync(id);
 0494                logger.LogError("Invalid session {id} is removed", id);
 495            }
 0496        }
 497
 0498        _recentSessionsCache = [.. summaries.OrderByDescending(s => s.CreatedAt)];
 499
 0500        return [.. _recentSessionsCache.Take(count)];
 0501    }
 502
 503    public async Task NewSessionAsync()
 504    {
 505        // Save current session if it has messages
 0506        if (Session?.Messages.Count > 0)
 507        {
 0508            await SaveSessionAsync();
 509        }
 510
 0511        Session = CreateNewSession();
 512
 0513        await CleanupOldSessionsAsync();
 0514    }
 515
 516    private async Task CleanupOldSessionsAsync()
 517    {
 0518        var recent = await GetRecentSessionsAsync(int.MaxValue);
 0519        if (recent.Count > _maxSessions)
 520        {
 0521            var sessionsToDelete = recent.Skip(_maxSessions).ToList();
 0522            foreach (var sessionToDelete in sessionsToDelete)
 523            {
 0524                await DeleteSessionAsync(sessionToDelete.Id);
 525            }
 526        }
 0527    }
 528
 529    public async Task LoadSessionAsync(string id)
 530    {
 0531        var session = await localStorage.TryGetItemAsync<ConversationSession>(id);
 0532        if (session != null)
 533        {
 0534            session.Id = id;
 0535            Session = session;
 536        }
 0537    }
 538
 539    public async Task DeleteSessionAsync(string id)
 540    {
 0541        if (Session?.Id == id)
 542        {
 0543            Session = CreateNewSession();
 544        }
 0545        await localStorage.RemoveItemAsync(id);
 546
 0547        _recentSessionsCache?.RemoveAll(s => s.Id == id);
 0548    }
 549
 550    private async Task<List<string>> GetAllSessionIdsAsync()
 551    {
 3552        return [.. (await localStorage.GetAllKeysAsync()).Where(k => k.StartsWith("session_"))];
 2553    }
 554
 10555    private static string GenerateSessionId() => $"session_{DateTime.Now:s}";
 556
 10557    private static ConversationSession CreateNewSession() => new() { Id = GenerateSessionId() };
 558
 559    public async Task LoadLastSessionOrGenerateNewAsync()
 560    {
 2561        var sessionList = await GetAllSessionIdsAsync();
 562        // сортируем сессии по времени создания и берем самую свежую
 2563        var lastSessionId = sessionList.OrderByDescending(id =>
 2564        {
 1565            if (DateTime.TryParseExact(id.Substring(8), "s", CultureInfo.InvariantCulture, DateTimeStyles.None, out var 
 2566            {
 1567                return result;
 2568            }
 0569            return DateTime.MinValue;
 2570        }).FirstOrDefault();
 2571        if (lastSessionId != default)
 572        {
 1573            var fromStorage = await localStorage.TryGetItemAsync<ConversationSession>(lastSessionId);
 1574            fromStorage?.Id = lastSessionId;
 1575            Session = fromStorage ?? CreateNewSession();
 576        }
 577        else
 578        {
 1579            Session = CreateNewSession();
 580        }
 2581    }
 582
 583    public void Dispose()
 584    {
 0585        Session.PropertyChanged -= SessionPropertyChanged;
 0586    }
 587}