< Summary

Information
Class: UIBlazor.Services.ChatService
Assembly: UIBlazor
File(s): /home/runner/work/InvAit/InvAit/UIBlazor/Services/ChatService.cs
Tag: 14_22728831704
Line coverage
21%
Covered lines: 52
Uncovered lines: 194
Coverable lines: 246
Total lines: 548
Line coverage: 21.1%
Branch coverage
7%
Covered branches: 12
Total branches: 154
Branch coverage: 7.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%
get_Session()100%11100%
set_Session(...)100%22100%
NotifySessionChanged()50%22100%
GetModelsAsync()16.66%691226.66%
AddMessageAsync()100%11100%
AddMessageAsync()100%210%
SaveSessionAsync()100%11100%
UpdateSessionCache(...)10%90107.14%
get_LastCompletionsModel()100%210%
get_LastUsage()100%210%
PrepareSystemPromptAsync()0%156120%
GetCompletionsAsync()0%7140840%
GetRecentSessionsAsync()0%156120%
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%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Net.Http.Headers;
 3using System.Net.Http.Json;
 4using System.Net.Mime;
 5using System.Runtime.CompilerServices;
 6using UIBlazor.Services.Models;
 7using UIBlazor.Services.Settings;
 8
 9namespace UIBlazor.Services;
 10
 511public class ChatService(
 512    HttpClient httpClient,
 513    IProfileManager profileManager,
 514    ICommonSettingsProvider commonSettingsProvider,
 515    IToolManager toolManager,
 516    ILocalStorageService localStorage,
 517    ISkillService skillService,
 518    IRuleService ruleService,
 519    IVsCodeContextService vsCodeContextService
 520    )
 21{
 22    private const string _thinkStart    = "<think>";
 23    private const string _thinkEnd      = "</think>";
 24    private const string _complitions   = "/v1/chat/completions";
 25    private const string _models        = "/v1/models";
 526    private readonly JsonSerializerOptions _jsonSerializerOptions = new()
 527    {
 528        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 529    };
 30
 731    public ConnectionProfile Options => profileManager.ActiveProfile;
 32
 33    public ConversationSession Session
 34    {
 1635        get;
 36        private set
 37        {
 338            if (field != value)
 39            {
 340                field = value;
 341                NotifySessionChanged();
 42            }
 343        }
 544    } = CreateNewSession();
 45
 46    public event Action? OnSessionChanged;
 47
 348    private void NotifySessionChanged() => OnSessionChanged?.Invoke();
 49
 50    /// <summary>
 51    /// Получение списка моделей по API
 52    /// </summary>
 53    /// <exception cref="InvalidOperationException"></exception>
 54    /// <exception cref="HttpRequestException"></exception>
 55    /// <exception cref="JsonException"></exception>
 56    public async Task<AiModelList> GetModelsAsync(CancellationToken cancellationToken)
 57    {
 158        using var request = new HttpRequestMessage(HttpMethod.Get, $"{Options.Endpoint}{_models}");
 59
 160        if (!string.IsNullOrEmpty(Options.ApiKey))
 61        {
 062            if (string.IsNullOrWhiteSpace(Options.ApiKeyHeader))
 63            {
 064                throw new InvalidOperationException("API key header must be specified when an API key is provided.");
 65            }
 66
 067            if (string.Equals(Options.ApiKeyHeader, "Authorization", StringComparison.OrdinalIgnoreCase))
 68            {
 069                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Options.ApiKey);
 70            }
 71            else
 72            {
 073                request.Headers.Add(Options.ApiKeyHeader, Options.ApiKey);
 74            }
 75        }
 76
 177        if (string.IsNullOrEmpty(Options.Endpoint))
 78        {
 179            throw new InvalidOperationException("Endpoint must be specified.");
 80        }
 81
 082        var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).
 83
 084        if (!response.IsSuccessStatusCode)
 85        {
 086            throw new HttpRequestException($"Getting models failed: {await response.Content.ReadAsStringAsync(cancellati
 87        }
 88
 089        return await response.Content.ReadFromJsonAsync<AiModelList>(cancellationToken)
 090               ?? throw new JsonException("Models deserialization exception");
 091    }
 92
 93    public async Task AddMessageAsync(string role, string content)
 94    {
 195        Session.AddMessage(role, content);
 196        await SaveSessionAsync();
 197    }
 98
 99    public async Task AddMessageAsync(VisualChatMessage message)
 100    {
 0101        Session.AddMessage(message);
 0102        await SaveSessionAsync();
 0103    }
 104
 105    /// <summary>
 106    /// Asynchronously saves the current session data to local storage using the session ID as the key.
 107    /// </summary>
 108    /// <returns></returns>
 109    public async Task SaveSessionAsync()
 110    {
 1111        await localStorage.SetItemAsync(Session.Id, Session);
 1112        UpdateSessionCache(Session);
 1113    }
 114
 115    private void UpdateSessionCache(ConversationSession session)
 116    {
 2117        if (_recentSessionsCache == null) return;
 118
 0119        var existing = _recentSessionsCache.FirstOrDefault(s => s.Id == session.Id);
 0120        var firstMessage = session.Messages.FirstOrDefault(m => m.Role == Constants.ChatMessageRole.User)?.Content ?? "N
 0121        var preview = firstMessage.Length > 40 ? firstMessage[..40] + "..." : firstMessage;
 122
 0123        if (existing != null)
 124        {
 0125            existing.FirstUserMessage = preview;
 126        }
 127        else
 128        {
 0129            _recentSessionsCache.Add(new SessionSummary
 0130            {
 0131                Id = session.Id,
 0132                CreatedAt = session.CreatedAt,
 0133                FirstUserMessage = preview
 0134            });
 0135            _recentSessionsCache = [.. _recentSessionsCache.OrderByDescending(s => s.CreatedAt)];
 136        }
 0137    }
 138
 139    /// <summary>
 140    /// Модель, которая последняя отвечала
 141    /// </summary>
 0142    public string? LastCompletionsModel { get; private set; }
 143
 144    /// <summary>
 145    /// Последнее использование токенов
 146    /// </summary>
 0147    public UsageInfo? LastUsage { get; private set; }
 148
 149    /// <summary>
 150    /// Asynchronously prepares the system prompt by combining configured instructions, tool usage guidance, skill
 151    /// metadata, and the current code context.
 152    /// </summary>
 153    /// <remarks>
 154    /// The returned prompt includes information relevant to the current session and code context,
 155    /// which may affect downstream processing. If no code context is available, the prompt will omit that section. This
 156    /// method is intended for internal use when constructing prompts for AI interactions.
 157    /// </remarks>
 158    /// <returns>
 159    /// A string containing the complete system prompt, including instructions, tool information, skill details, and
 160    /// code context if available.
 161    /// </returns>
 162    private async Task<string> PrepareSystemPromptAsync()
 163    {
 164        // Загружаем метаданные скиллов и добавляем в системный промпт
 0165        var skillsMetadata = await skillService.GetSkillsMetadataAsync();
 0166        var skillsSection = skillService.FormatSkillsForSystemPrompt(skillsMetadata);
 167
 0168        var contextSection = new StringBuilder();
 0169        var currentContext = vsCodeContextService.CurrentContext;
 0170        if (currentContext != null)
 171        {
 0172            contextSection.AppendLine("# CURRENT CODE CONTEXT");
 173
 0174            if (commonSettingsProvider.Current.SendCurrentFile && !string.IsNullOrEmpty(currentContext.ActiveFilePath))
 175            {
 0176                contextSection.AppendLine($"""
 0177                                          Active file path: {currentContext.ActiveFilePath}
 0178                                          Selected lines: {currentContext.SelectionStartLine} - {currentContext.Selectio
 0179                                          ```
 0180                                          {currentContext.ActiveFileContent}
 0181                                          ```
 0182                                          """);
 183            }
 184
 0185            if (commonSettingsProvider.Current.SendSolutionsStricture)
 186            {
 0187                contextSection.AppendLine($"""
 0188                                          Solution files:
 0189                                          ```
 0190                                          {string.Join(Environment.NewLine, currentContext.SolutionFiles)}
 0191                                          ```
 0192                                          """);
 193            }
 194        }
 195
 196        // Загружаем правила
 0197        var rules = await ruleService.GetRulesAsync();
 198
 0199        return string.Join(Environment.NewLine,
 0200            Options.SystemPrompt,
 0201            rules ?? string.Empty,
 0202            toolManager.GetToolUseSystemInstructions(Session?.Mode ?? AppMode.Chat, skillsMetadata.Count != 0),
 0203            skillsSection,
 0204            contextSection);
 0205    }
 206
 207    /// <summary>
 208    /// Asynchronously generates a sequence of chat completion deltas for the current conversation session.
 209    /// </summary>
 210    /// <remarks>
 211    /// This method streams chat completion results as they become available, allowing for real-time
 212    /// processing of partial responses. The returned sequence may include reasoning content or message content
 213    /// depending on the model and response format. If streaming is not enabled, the method yields a single completion
 214    /// result.
 215    /// </remarks>
 216    /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</par
 217    /// <returns>
 218    /// An asynchronous stream of <see cref="ChatDelta"/> objects representing incremental updates to the chat
 219    /// completion. The stream completes when the response is fully received.
 220    /// </returns>
 221    /// <exception cref="Exception">Thrown if the chat completion request fails or the server returns an unsuccessful re
 222    public async IAsyncEnumerable<ChatDelta> GetCompletionsAsync([EnumeratorCancellation] CancellationToken cancellation
 223    {
 0224        LastCompletionsModel = null;
 0225        LastUsage = null;
 226
 227        // Use runtime parameters or fall back to configured options
 0228        var url = $"{Options.Endpoint}{_complitions}";
 0229        var effectiveApiKey = Options.ApiKey;
 0230        var effectiveApiKeyHeader = Options.ApiKeyHeader;
 231
 232        // Get formatted messages including conversation history
 0233        var messages = Session?.GetFormattedMessages(await PrepareSystemPromptAsync()) ?? [];
 234
 0235        var payload = new
 0236        {
 0237            model = Options.Model,
 0238            messages = messages,
 0239            temperature = Options.Temperature,
 0240            max_tokens = Options.MaxTokens,
 0241            stream = Options.Stream,
 0242            stream_options = Options.Stream ? new { include_usage = true } : null
 0243        };
 244
 0245        var request = new HttpRequestMessage(HttpMethod.Post, url)
 0246        {
 0247            Content = new StringContent(
 0248                JsonSerializer.Serialize(payload, _jsonSerializerOptions),
 0249                Encoding.UTF8,
 0250                MediaTypeNames.Application.Json)
 0251        };
 252
 0253        if (!string.IsNullOrEmpty(effectiveApiKey))
 254        {
 0255            if (string.Equals(effectiveApiKeyHeader, "Authorization", StringComparison.OrdinalIgnoreCase))
 256            {
 0257                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", effectiveApiKey);
 258            }
 259            else
 260            {
 0261                request.Headers.Add(effectiveApiKeyHeader, effectiveApiKey);
 262            }
 263        }
 264
 0265        var response = await httpClient.SendAsync(request, Options.Stream ? HttpCompletionOption.ResponseHeadersRead : H
 266
 0267        if (!response.IsSuccessStatusCode)
 268        {
 0269            var message = Options.Stream ? "stream" : "request";
 0270            var result = $"HttpCode: {response.StatusCode} | server failed: {await response.Content.ReadAsStringAsync(ca
 0271            throw new Exception(result);
 272        }
 273
 274        // если не стрим, то возвращаем как один чанк
 0275        if (!Options.Stream)
 276        {
 0277            var chunk = await response.Content.ReadFromJsonAsync<StreamChunk>(cancellationToken);
 0278            var message = chunk?.Choice?.Message;
 0279            if (message?.Content != null)
 280            {
 281                // Удаление <think> блока из контента и перенос его в ReasoningContent если его там нет.
 0282                var regex = Regex.Match(message.Content, $"^{_thinkStart}(?<reason>.*){_thinkEnd}", RegexOptions.Singlel
 0283                if (regex.Success)
 284                {
 0285                    message.ReasoningContent ??= regex.Groups["reason"].Value;
 0286                    message.Content = message.Content[regex.Length..];
 287                }
 0288                LastCompletionsModel ??= chunk?.Model;
 0289                if (chunk?.Usage != null)
 290                {
 0291                    LastUsage = chunk.Usage;
 0292                    Session.TotalTokens = chunk.Usage.TotalTokens;
 293                }
 0294                yield return message;
 295            }
 0296            yield break;
 297        }
 298
 299        // стрим
 0300        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
 0301        using var reader = new StreamReader(stream);
 302
 303        string? line;
 0304        var isReasoningContent = false;
 0305        var isStart = true;
 306
 307        // чтобы html-теги <function> склеивать в один чанк
 0308        var _pendingText = string.Empty;
 309
 0310        while ((line = await reader.ReadLineAsync(cancellationToken)) is not null && !cancellationToken.IsCancellationRe
 311        {
 0312            if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:"))
 313            {
 314                continue;
 315            }
 316
 0317            var json = line["data:".Length..].Trim();
 318
 0319            if (json == "[DONE]")
 320            {
 321                break;
 322            }
 323
 0324            var chunk = JsonUtils.Deserialize<StreamChunk>(json);
 0325            if (chunk == null)
 326            {
 327                continue;
 328            }
 329
 0330            if (chunk.Usage != null)
 331            {
 0332                LastUsage = chunk.Usage;
 0333                Session.TotalTokens = chunk.Usage.TotalTokens;
 334            }
 335
 0336            if (chunk.Choices.Count != 1 || chunk.Choices[0].Delta == null)
 337            {
 338                continue;
 339            }
 340
 0341            LastCompletionsModel ??= chunk.Model;
 0342            var delta = chunk.Choices[0].Delta;
 0343            var content = delta!.Content;
 344
 345            // Размышляющие модели по разному отдают размышления
 346            //
 347            //           ReasoningContent | Content
 348            // GLM 4.7         +++        |   ---
 349            // Kimi 2          +++        | <think>
 350            // Deepseek R1     ---        | <think>
 351            //
 352            // обрабатываем размышления как Z.ai GLM.
 353            // Все размышления идут в ReasoningContent с пустым Content
 354
 355            // Преобразрвания нужны если есть контент с блоком <think>
 0356            if (!string.IsNullOrEmpty(content))
 357            {
 0358                if (!isReasoningContent) // не думаем
 359                {
 0360                    if (isStart && content.StartsWith(_thinkStart))
 361                    {
 362                        // начать думать можно только в первом чанке
 0363                        isReasoningContent = true;
 0364                        delta.ReasoningContent = content.Replace(_thinkStart, string.Empty);
 0365                        delta.Content = null;
 366                    }
 367                    else
 368                    {
 369                        // Не думали - нечего и начинать.
 370                    }
 371                }
 372                else // внутри <think> блока
 373                {
 0374                    if (content.Contains(_thinkEnd))
 375                    {
 376                        // если закончил думать, то можно в контент добавить часть чанка (актуально для Kimi2)
 0377                        isReasoningContent = false;
 0378                        delta.Content = content.Replace(_thinkEnd, string.Empty);
 0379                        delta.ReasoningContent = null;
 380                    }
 381                    else
 382                    {
 383                        // если не конец - то все пихаем в ReasoningContent и очищаем Content
 0384                        delta.Content = null;
 0385                        delta.ReasoningContent = content;
 386                    }
 387                }
 388            }
 389
 390            // Если есть контент, то проверяем на разрезанные теги и склеиваем их
 0391            if (delta.Content != null)
 392            {
 393                // Склеиваем с остатком от прошлого раза
 0394                var incomingText = _pendingText + delta.Content;
 0395                _pendingText = string.Empty;
 396
 397                // Ищем последний открывающий тег
 0398                var lastOpenIndex = incomingText.LastIndexOf('<');
 0399                if (lastOpenIndex >= 0)
 400                {
 0401                    var potentialTag = incomingText[lastOpenIndex..];
 402                    // Если тег не закрыт (нет '>') и нет переноса строки (\n), то буферизируем
 0403                    if (potentialTag.IndexOfAny(['>', '\n']) == -1)
 404                    {
 0405                        _pendingText = incomingText[lastOpenIndex..];
 0406                        incomingText = incomingText[..lastOpenIndex];
 407
 408                        // Если после отрезания тега ничего не осталось, пропускаем итерацию
 0409                        if (string.IsNullOrWhiteSpace(incomingText))
 410                            continue;
 411                    }
 412                }
 413
 0414                delta.Content = incomingText;
 415            }
 416
 0417            yield return delta;
 418
 0419            isStart = false;
 420        }
 421
 422        // если после окончания стрима остался неотправленный текст, отправляем его
 0423        if (!string.IsNullOrEmpty(_pendingText))
 424        {
 0425            yield return new ChatDelta() { Content = _pendingText };
 426        }
 0427    }
 428
 429    private const int _maxSessions = 5;
 430    private List<SessionSummary>? _recentSessionsCache;
 431
 432    public async Task<List<SessionSummary>> GetRecentSessionsAsync(int count = _maxSessions)
 433    {
 0434        if (_recentSessionsCache == null)
 435        {
 0436            var sessionIds = await GetAllSessionIdsAsync();
 0437            var summaries = new List<SessionSummary>();
 438
 0439            foreach (var id in sessionIds)
 440            {
 0441                var session = await localStorage.GetItemAsync<ConversationSession>(id);
 0442                if (session != null)
 443                {
 0444                    var firstMessage = session.Messages.FirstOrDefault(m => m.Role == Constants.ChatMessageRole.User)?.C
 0445                    var preview = firstMessage.Length > 40 ? firstMessage[..40] + "..." : firstMessage;
 446
 0447                    summaries.Add(new SessionSummary
 0448                    {
 0449                        Id = id,
 0450                        CreatedAt = session.CreatedAt,
 0451                        FirstUserMessage = preview
 0452                    });
 453                }
 0454            }
 455
 0456            _recentSessionsCache = [.. summaries.OrderByDescending(s => s.CreatedAt)];
 0457        }
 458
 0459        return [.. _recentSessionsCache.Take(count)];
 0460    }
 461
 462    public async Task NewSessionAsync()
 463    {
 464        // Save current session if it has messages
 0465        if (Session?.Messages.Count > 0)
 466        {
 0467            await SaveSessionAsync();
 468        }
 469
 0470        Session = CreateNewSession();
 0471        Session.MaxMessages = Options.MaxMessages;
 472
 0473        await CleanupOldSessionsAsync();
 0474    }
 475
 476    private async Task CleanupOldSessionsAsync()
 477    {
 0478        var recent = await GetRecentSessionsAsync(int.MaxValue);
 0479        if (recent.Count > _maxSessions)
 480        {
 0481            var sessionsToDelete = recent.Skip(_maxSessions).ToList();
 0482            foreach (var sessionToDelete in sessionsToDelete)
 483            {
 0484                await DeleteSessionAsync(sessionToDelete.Id);
 485            }
 486        }
 0487    }
 488
 489    public async Task LoadSessionAsync(string id)
 490    {
 0491        var session = await localStorage.GetItemAsync<ConversationSession>(id);
 0492        if (session != null)
 493        {
 0494            session.Id = id;
 0495            session.MaxMessages = Options.MaxMessages;
 0496            Session = session;
 497        }
 0498    }
 499
 500    public async Task DeleteSessionAsync(string id)
 501    {
 0502        if (Session?.Id == id)
 503        {
 0504            Session = CreateNewSession();
 0505            Session.MaxMessages = Options.MaxMessages;
 506        }
 0507        await localStorage.RemoveItemAsync(id);
 508
 0509        if (_recentSessionsCache != null)
 510        {
 0511            _recentSessionsCache.RemoveAll(s => s.Id == id);
 512        }
 0513    }
 514
 515    private async Task<List<string>> GetAllSessionIdsAsync()
 516    {
 4517        return [.. (await localStorage.GetAllKeysAsync()).Where(k => k.StartsWith("session_"))];
 3518    }
 519
 7520    private static string GenerateSessionId() => $"session_{DateTime.Now:s}";
 521
 7522    private static ConversationSession CreateNewSession() => new() { Id = GenerateSessionId() };
 523
 524    public async Task LoadLastSessionOrGenerateNewAsync()
 525    {
 3526        var sessionList = await GetAllSessionIdsAsync();
 527        // сортируем сессии по времени создания и берем самую свежую
 3528        var lastSessionId = sessionList.OrderByDescending(id =>
 3529        {
 1530            if (DateTime.TryParseExact(id.Substring(8), "s", CultureInfo.InvariantCulture, DateTimeStyles.None, out var 
 3531            {
 1532                return result;
 3533            }
 0534            return DateTime.MinValue;
 3535        }).FirstOrDefault();
 3536        if (lastSessionId != default)
 537        {
 1538            var fromStorage = await localStorage.GetItemAsync<ConversationSession>(lastSessionId);
 1539            fromStorage?.Id = lastSessionId;
 1540            Session = fromStorage ?? CreateNewSession();
 541        }
 542        else
 543        {
 2544            Session = CreateNewSession();
 545        }
 3546        Session.MaxMessages = Options.MaxMessages;
 3547    }
 548}