From b1459523d9dc171e9d4326521775595ef5637532 Mon Sep 17 00:00:00 2001 From: Paul Koudelka <106623909+PaulKoudelka@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:44:43 +0200 Subject: [PATCH] Added an assistant to view the AI Studio logs (#859) Co-authored-by: Thorsten Sommer --- .../Assistants/I18N/allTexts.lua | 111 +++ .../LogViewer/AssistantLogViewer.razor | 111 +++ .../LogViewer/AssistantLogViewer.razor.cs | 770 ++++++++++++++++++ .../Assistants/LogViewer/LogFileKind.cs | 7 + app/MindWork AI Studio/Pages/Assistants.razor | 4 +- .../Plugins/configuration/plugin.lua | 3 +- .../plugin.lua | 111 +++ .../plugin.lua | 111 +++ app/MindWork AI Studio/Routes.razor.cs | 1 + .../Settings/ConfigurableAssistant.cs | 2 + .../Tools/AssistantVisibilityExtensions.cs | 1 + app/MindWork AI Studio/Tools/Components.cs | 1 + .../Tools/ComponentsExtensions.cs | 2 + .../Tools/Rust/OpenPathRequest.cs | 3 + .../Tools/Rust/OpenPathResponse.cs | 3 + .../Tools/Services/RustService.FileSystem.cs | 44 + app/MindWork AI Studio/wwwroot/app.css | 99 +++ .../wwwroot/changelog/v26.7.3.md | 2 + runtime/Cargo.lock | 17 + runtime/Cargo.toml | 1 + runtime/src/file_actions.rs | 281 ++++++- runtime/src/runtime_api.rs | 1 + 22 files changed, 1683 insertions(+), 3 deletions(-) create mode 100644 app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor create mode 100644 app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs create mode 100644 app/MindWork AI Studio/Assistants/LogViewer/LogFileKind.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/OpenPathRequest.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/OpenPathResponse.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index d5b95dd6..9142de1b 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -1636,6 +1636,99 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T4254597 -- Ask your questions UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T467099852"] = "Ask your questions" +-- Find +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1042076026"] = "Find" + +-- The log file could not be read: {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1147062477"] = "The log file could not be read: {0}" + +-- Select a log file +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1231773010"] = "Select a log file" + +-- Log level +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1318706515"] = "Log level" + +-- Refresh +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T135637716"] = "Refresh" + +-- Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1747827400"] = "Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}." + +-- The log file does not exist: {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1807514273"] = "The log file does not exist: {0}" + +-- Could not open the log file location. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1828231197"] = "Could not open the log file location." + +-- Other +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1849229205"] = "Other" + +-- Max lines +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1904230854"] = "Max lines" + +-- All +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1974461284"] = "All" + +-- Startup log +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2288538420"] = "Startup log" + +-- Showing {0} of {1} lines. Last refresh: {2}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2378353570"] = "Showing {0} of {1} lines. Last refresh: {2}." + +-- No matching log lines. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2511997530"] = "No matching log lines." + +-- Could not open the log file location: {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2533784927"] = "Could not open the log file location: {0}" + +-- Source details +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2686813966"] = "Source details" + +-- Loaded {0} lines. Last refresh: {1}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2920304709"] = "Loaded {0} lines. Last refresh: {1}." + +-- Filter only +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3152625639"] = "Filter only" + +-- Loading log file... +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T333036481"] = "Loading log file..." + +-- Unknown error +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3461425987"] = "Unknown error" + +-- The log file path is not available yet. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3686775689"] = "The log file path is not available yet." + +-- Logger +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T376222229"] = "Logger" + +-- Auto-refresh +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3837203600"] = "Auto-refresh" + +-- not loaded yet +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3863250749"] = "not loaded yet" + +-- Loading... +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T397479987"] = "Loading..." + +-- Usage log +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4031747274"] = "Usage log" + +-- Open in folder +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4048746540"] = "Open in folder" + +-- Log Viewer +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4130241777"] = "Log Viewer" + +-- Opened the log file location. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4162897654"] = "Opened the log file location." + +-- Show timestamps +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T469116133"] = "Show timestamps" + +-- Clear +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T77955010"] = "Clear" + -- You can enter text, attach one or more documents, or use both. At least one input is required. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T1442535450"] = "You can enter text, attach one or more documents, or use both. At least one input is required." @@ -6565,6 +6658,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3930052338"] = "Job Posting" -- Ask a question about a legal document. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3970214537"] = "Ask a question about a legal document." +-- Log Viewer +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4130241777"] = "Log Viewer" + -- ERI Server UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4204533420"] = "ERI Server" @@ -6586,6 +6682,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T782102948"] = "Bias of the Day" -- Learn about one cognitive bias every day. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Learn about one cognitive bias every day." +-- View and filter AI Studio log files. +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T892147062"] = "View and filter AI Studio log files." + -- Localization UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Localization" @@ -7798,6 +7897,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T4262148639"] = "Rewrite -- Localization Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T446674624"] = "Localization Assistant" +-- Log Viewer Assistant +UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T555062689"] = "Log Viewer Assistant" + -- New Chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T826248509"] = "New Chat" @@ -8608,9 +8710,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Failed -- Failed to store the API key due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Failed to store the API key due to an API issue." +-- The runtime file manager endpoint returned '{0}'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2158262203"] = "The runtime file manager endpoint returned '{0}'." + -- Failed to delete the secret data due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Failed to delete the secret data due to an API issue." +-- The runtime file manager endpoint is not available. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2486847754"] = "The runtime file manager endpoint is not available." + +-- The runtime file manager endpoint failed without details. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3082220817"] = "The runtime file manager endpoint failed without details." + -- Successfully copied the text to your clipboard UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Successfully copied the text to your clipboard" diff --git a/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor b/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor new file mode 100644 index 00000000..6d939900 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor @@ -0,0 +1,111 @@ +@attribute [Route(Routes.ASSISTANT_LOG_VIEWER)] +@inherits MSGComponentBase + +
+ + @T("Log Viewer") + + + + + + + @T("Usage log") + @T("Startup log") + + + @if (!this.autoRefresh) + { + + @T("Refresh") + + } + + + @T("Open in folder") + + + + + + @foreach (var option in this.logLevelOptions) + { + + @this.GetFilterOptionDisplay(option) + + } + + + @foreach (var option in this.loggerOptions) + { + + @this.GetFilterOptionDisplay(option) + + } + + + @foreach (var option in this.sourceDetailOptions) + { + + @this.GetFilterOptionDisplay(option) + + } + + + + + + + + @T("Clear") + + + + + + @this.CurrentLogPath + + + @this.StatusText + + + + + @if (!string.IsNullOrWhiteSpace(this.loadError)) + { + + @this.loadError + + } + +
+ @if (this.isLoading && this.loadedLines.Count == 0) + { +
+ + @T("Loading log file...") +
+ } + else if (this.displayLines.Count == 0) + { +
+ + @T("No matching log lines.") +
+ } + else + { +
+ @foreach (var line in this.displayLines) + { +
+ @line.Number + @((MarkupString)this.RenderLine(line)) +
+ } +
+ } +
+
+
+
diff --git a/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs b/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs new file mode 100644 index 00000000..c08ec8e3 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs @@ -0,0 +1,770 @@ +using System.Globalization; +using System.Net; +using System.Text; + +using AIStudio.Components; +using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; + +using Microsoft.AspNetCore.Components; +// ReSharper disable NotAccessedPositionalProperty.Local + +namespace AIStudio.Assistants.LogViewer; + +public partial class AssistantLogViewer : MSGComponentBase +{ + private static readonly TimeSpan AUTO_REFRESH_INTERVAL = TimeSpan.FromSeconds(5); + private static readonly char[] WORD_SPLIT_CHARS = [' ', '\t', '\r', '\n']; + private static readonly Dictionary LOG_LEVEL_ORDER = new(StringComparer.OrdinalIgnoreCase) + { + ["ERROR"] = 0, + ["CRITICAL"] = 1, + ["WARN"] = 2, + ["WARNING"] = 3, + ["INFO"] = 4, + ["INFORMATION"] = 5, + ["DEBUG"] = 6, + ["TRACE"] = 7, + }; + + private const int DEFAULT_MAX_LINES = 5_000; + private const int MIN_MAX_LINES = 100; + private const int MAX_MAX_LINES = 100_000; + private const string OTHER_OPTION_VALUE = "__OTHER__"; + + [Inject] + private RustService RustService { get; init; } = null!; + + [Inject] + private ISnackbar Snackbar { get; init; } = null!; + + [Inject] + private NavigationManager NavigationManager { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; + + private readonly HashSet selectedLogLevels = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet selectedLoggers = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet selectedSourceDetails = new(StringComparer.OrdinalIgnoreCase); + + private GetLogPathsResponse logPaths; + private LogFileKind selectedLogFile = LogFileKind.APP; + private List loadedLines = []; + private List displayLines = []; + private List logLevelOptions = [OTHER_OPTION_VALUE]; + private List loggerOptions = [OTHER_OPTION_VALUE]; + private List sourceDetailOptions = [OTHER_OPTION_VALUE]; + + private string[] activeSearchTerms = []; + private CancellationTokenSource? autoRefreshCancellationTokenSource; + private string filterText = string.Empty; + private string loadError = string.Empty; + private bool isLoading; + private bool autoRefresh; + private bool filterOnly = true; + private bool showTimestamps = true; + private int maxLines = DEFAULT_MAX_LINES; + private int totalLineCount; + private int skippedLineCount; + private DateTimeOffset? lastLoadedAt; + + private string CurrentLogPath => this.selectedLogFile is LogFileKind.APP ? this.logPaths.LogAppPath : this.logPaths.LogStartupPath; + + private bool CanOpenCurrentLogPath => !string.IsNullOrWhiteSpace(this.CurrentLogPath); + + private bool HasDropdownFilter => this.selectedLogLevels.Count > 0 || this.selectedLoggers.Count > 0 || this.selectedSourceDetails.Count > 0; + + private bool HasActiveFilter => !string.IsNullOrWhiteSpace(this.filterText) || this.HasDropdownFilter; + + private string FilterText + { + get => this.filterText; + set + { + if (this.filterText == value) + return; + + this.filterText = value; + this.RefreshDisplayLines(); + } + } + + private bool FilterOnly + { + get => this.filterOnly; + set + { + if (this.filterOnly == value) + return; + + this.filterOnly = value; + this.RefreshDisplayLines(); + } + } + + private bool ShowTimestamps + { + get => this.showTimestamps; + set + { + if (this.showTimestamps == value) + return; + + this.showTimestamps = value; + this.RefreshDisplayLines(); + } + } + + private string StatusText + { + get + { + if (this.isLoading) + return T("Loading..."); + + var visibleLineCount = this.displayLines.Count.ToString("N0", CultureInfo.CurrentCulture); + var loadedLineCount = this.loadedLines.Count.ToString("N0", CultureInfo.CurrentCulture); + var totalLineCountText = this.totalLineCount.ToString("N0", CultureInfo.CurrentCulture); + var lastLoadedText = this.lastLoadedAt?.LocalDateTime.ToString("g", CultureInfo.CurrentCulture) ?? T("not loaded yet"); + + if (this.loadedLines.Count == 0) + return string.Format(T("Loaded {0} lines. Last refresh: {1}."), loadedLineCount, lastLoadedText); + + if (this.skippedLineCount > 0) + { + var skippedLineCountText = this.skippedLineCount.ToString("N0", CultureInfo.CurrentCulture); + return string.Format(T("Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}."), visibleLineCount, loadedLineCount, skippedLineCountText, lastLoadedText); + } + + return string.Format(T("Showing {0} of {1} lines. Last refresh: {2}."), visibleLineCount, totalLineCountText, lastLoadedText); + } + } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + if (!this.SettingsManager.IsAssistantVisible(Tools.Components.LOG_VIEWER_ASSISTANT, assistantName: T("Log Viewer"))) + { + this.NavigationManager.NavigateTo(Routes.ASSISTANTS); + return; + } + + this.logPaths = await this.RustService.GetLogPaths(); + await this.RefreshLogAsync(); + } + + protected override void DisposeResources() + { + this.StopAutoRefresh(); + } + + private async Task SelectedLogFileChanged(LogFileKind value) + { + if (this.selectedLogFile == value) + return; + + this.selectedLogFile = value; + await this.RefreshLogAsync(); + } + + private Task SelectedLogLevelsChanged(IEnumerable? selectedValues) + { + UpdateSelectedValues(this.selectedLogLevels, selectedValues); + this.RefreshDisplayLines(); + return Task.CompletedTask; + } + + private Task SelectedLoggersChanged(IEnumerable? selectedValues) + { + UpdateSelectedValues(this.selectedLoggers, selectedValues); + this.RefreshDisplayLines(); + return Task.CompletedTask; + } + + private Task SelectedSourceDetailsChanged(IEnumerable? selectedValues) + { + UpdateSelectedValues(this.selectedSourceDetails, selectedValues); + this.RefreshDisplayLines(); + return Task.CompletedTask; + } + + private async Task AutoRefreshChanged(bool value) + { + this.autoRefresh = value; + if (this.autoRefresh) + this.StartAutoRefresh(); + else + this.StopAutoRefresh(); + + await Task.CompletedTask; + } + + private async Task MaxLinesChanged(int value) + { + var normalizedValue = Math.Clamp(value, MIN_MAX_LINES, MAX_MAX_LINES); + if (this.maxLines == normalizedValue) + return; + + this.maxLines = normalizedValue; + await this.RefreshLogAsync(); + } + + private async Task OpenCurrentLogInFileManager() + { + var path = this.CurrentLogPath; + if (string.IsNullOrWhiteSpace(path)) + { + this.Snackbar.Add(T("The log file path is not available yet."), Severity.Warning, config => + { + config.Icon = Icons.Material.Filled.Folder; + config.IconSize = Size.Large; + }); + return; + } + + OpenPathResponse response; + try + { + response = await this.RustService.TryOpenPathInRuntimeFileManager(path); + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Could not open the log file location in the file manager."); + this.Snackbar.Add(T("Could not open the log file location."), Severity.Error, config => + { + config.Icon = Icons.Material.Filled.Folder; + config.IconSize = Size.Large; + }); + return; + } + + if (response.Success) + { + this.Snackbar.Add(T("Opened the log file location."), Severity.Success, config => + { + config.Icon = Icons.Material.Filled.FolderOpen; + config.IconSize = Size.Large; + }); + return; + } + + var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue; + this.Snackbar.Add(string.Format(T("Could not open the log file location: {0}"), issue), Severity.Error, config => + { + config.Icon = Icons.Material.Filled.Folder; + config.IconSize = Size.Large; + }); + } + + private void ClearFilters() + { + this.filterText = string.Empty; + this.selectedLogLevels.Clear(); + this.selectedLoggers.Clear(); + this.selectedSourceDetails.Clear(); + this.RefreshDisplayLines(); + } + + private async Task RefreshLogAsync() + { + if (this.isLoading) + return; + + this.isLoading = true; + this.loadError = string.Empty; + await this.InvokeAsync(this.StateHasChanged); + + try + { + var path = this.CurrentLogPath; + if (string.IsNullOrWhiteSpace(path)) + { + this.loadedLines = []; + this.totalLineCount = 0; + this.skippedLineCount = 0; + this.lastLoadedAt = null; + this.loadError = T("The log file path is not available yet."); + return; + } + + if (!File.Exists(path)) + { + this.loadedLines = []; + this.totalLineCount = 0; + this.skippedLineCount = 0; + this.lastLoadedAt = null; + this.loadError = string.Format(T("The log file does not exist: {0}"), path); + return; + } + + var snapshot = await ReadLogSnapshotAsync(path, this.maxLines); + this.loadedLines = snapshot.Lines; + this.totalLineCount = snapshot.TotalLineCount; + this.skippedLineCount = snapshot.SkippedLineCount; + this.lastLoadedAt = DateTimeOffset.Now; + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Could not read the log file for the log viewer assistant."); + this.loadedLines = []; + this.totalLineCount = 0; + this.skippedLineCount = 0; + this.lastLoadedAt = null; + this.loadError = string.Format(T("The log file could not be read: {0}"), e.Message); + } + finally + { + this.isLoading = false; + this.RebuildFilterOptions(); + this.RefreshDisplayLines(); + await this.InvokeAsync(this.StateHasChanged); + } + } + + private static async Task ReadLogSnapshotAsync(string path, int maxLines) + { + var queue = new Queue(Math.Min(maxLines, 4096)); + var totalLineCount = 0; + var skippedLineCount = 0; + + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 65536, true); + using var reader = new StreamReader(stream, Encoding.UTF8, true); + + while (await reader.ReadLineAsync() is { } line) + { + totalLineCount++; + queue.Enqueue(line); + + if (queue.Count <= maxLines) + continue; + + queue.Dequeue(); + skippedLineCount++; + } + + var firstLineNumber = skippedLineCount + 1; + var lines = queue + .Select((line, index) => new LogLine(firstLineNumber + index, line, ParseLogSegments(line))) + .ToList(); + + return new(lines, totalLineCount, skippedLineCount); + } + + private void RebuildFilterOptions() + { + this.logLevelOptions = BuildFilterOptions(this.loadedLines.Select(line => line.Segments.Level), CompareLogLevels); + this.loggerOptions = BuildFilterOptions(this.loadedLines.Select(line => line.Segments.Logger), (left, right) => StringComparer.OrdinalIgnoreCase.Compare(left, right)); + this.sourceDetailOptions = BuildFilterOptions(this.loadedLines.Select(line => line.Segments.SourceDetails), (left, right) => StringComparer.OrdinalIgnoreCase.Compare(left, right)); + + NormalizeSelectedValues(this.selectedLogLevels, this.logLevelOptions); + NormalizeSelectedValues(this.selectedLoggers, this.loggerOptions); + NormalizeSelectedValues(this.selectedSourceDetails, this.sourceDetailOptions); + } + + private void RefreshDisplayLines() + { + this.activeSearchTerms = BuildSearchTerms(this.filterText); + this.displayLines = this.loadedLines + .Where(this.LineMatchesFilters) + .ToList(); + } + + private bool LineMatchesFilters(LogLine line) + { + if (!MatchesSelection(line.Segments.Level, this.selectedLogLevels)) + return false; + + if (!MatchesSelection(line.Segments.Logger, this.selectedLoggers)) + return false; + + if (!MatchesSelection(line.Segments.SourceDetails, this.selectedSourceDetails)) + return false; + + if (!this.filterOnly || this.activeSearchTerms.Length == 0) + return true; + + return MatchesSearchTerms(this.GetPlainRenderedLine(line), this.activeSearchTerms); + } + + private string RenderLine(LogLine line) + { + var text = this.GetPlainRenderedLine(line); + var ranges = new List(); + AddSearchTermRanges(text, this.activeSearchTerms, ranges); + + if (ranges.Count == 0) + return WebUtility.HtmlEncode(text); + + ranges = MergeRanges(ranges); + var sb = new StringBuilder(); + var position = 0; + + foreach (var range in ranges) + { + AppendEncoded(sb, text, position, range.Start - position); + sb.Append(""""""); + AppendEncoded(sb, text, range.Start, range.Length); + sb.Append(""); + position = range.Start + range.Length; + } + + AppendEncoded(sb, text, position, text.Length - position); + return sb.ToString(); + } + + private string GetPlainRenderedLine(LogLine line) + { + var parts = new List(); + var segments = line.Segments; + + if (this.showTimestamps && !string.IsNullOrWhiteSpace(segments.Timestamp)) + parts.Add(segments.Timestamp); + + if (!ShouldHideSelectedSegment(segments.Level, this.selectedLogLevels)) + AddIfNotWhiteSpace(parts, segments.Level); + + if (!ShouldHideSelectedSegment(segments.Logger, this.selectedLoggers)) + AddIfNotWhiteSpace(parts, segments.Logger); + + if (!ShouldHideSelectedSegment(segments.SourceDetails, this.selectedSourceDetails)) + AddIfNotWhiteSpace(parts, segments.SourceDetails); + + AddIfNotWhiteSpace(parts, segments.Message); + + return parts.Count == 0 ? string.Empty : string.Join(" ", parts); + } + + private static string GetLineClass(LogLine line) + { + var level = line.Segments.Level ?? string.Empty; + + if (level.Contains("ERROR", StringComparison.OrdinalIgnoreCase) || level.Contains("CRITICAL", StringComparison.OrdinalIgnoreCase)) + return "log-viewer-line log-viewer-line-error"; + + if (level.Contains("WARN", StringComparison.OrdinalIgnoreCase)) + return "log-viewer-line log-viewer-line-warn"; + + if (level.Equals("INFO", StringComparison.OrdinalIgnoreCase) || level.Equals("INFORMATION", StringComparison.OrdinalIgnoreCase)) + return "log-viewer-line log-viewer-line-info"; + + if (level.Contains("DEBUG", StringComparison.OrdinalIgnoreCase)) + return "log-viewer-line log-viewer-line-debug"; + + if (level.Contains("TRACE", StringComparison.OrdinalIgnoreCase)) + return "log-viewer-line log-viewer-line-trace"; + + return "log-viewer-line"; + } + + private string GetFilterOptionDisplay(string value) + { + return value == OTHER_OPTION_VALUE ? T("Other") : value; + } + + private string GetMultiSelectionText(List? selectedValues) + { + if (selectedValues is null || selectedValues.Count == 0) + return T("All"); + + var selectedLabels = selectedValues + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => this.GetFilterOptionDisplay(value!)) + .ToList(); + + return selectedLabels.Count == 0 ? T("All") : string.Join(", ", selectedLabels); + } + + private void StartAutoRefresh() + { + this.StopAutoRefresh(); + this.autoRefreshCancellationTokenSource = new CancellationTokenSource(); + _ = this.AutoRefreshLoopAsync(this.autoRefreshCancellationTokenSource.Token); + } + + private void StopAutoRefresh() + { + this.autoRefreshCancellationTokenSource?.Cancel(); + this.autoRefreshCancellationTokenSource?.Dispose(); + this.autoRefreshCancellationTokenSource = null; + } + + private async Task AutoRefreshLoopAsync(CancellationToken token) + { + try + { + using var timer = new PeriodicTimer(AUTO_REFRESH_INTERVAL); + while (await timer.WaitForNextTickAsync(token)) + await this.InvokeAsync(this.RefreshLogAsync); + } + catch (OperationCanceledException) + { + } + } + + private static LogSegments ParseLogSegments(string line) + { + var index = 0; + var parsedAnySegment = false; + string? timestamp = null; + string? level = null; + string? logger = null; + string? sourceDetails = null; + + if (TryReadBracket(line, index, out var bracket, out var content, out var nextIndex) && IsTimestamp(content)) + { + timestamp = bracket; + index = nextIndex; + parsedAnySegment = true; + } + + var candidateIndex = SkipWhiteSpace(line, index); + if (TryReadLogLevel(line, candidateIndex, out var detectedLevel, out nextIndex)) + { + level = detectedLevel; + index = nextIndex; + parsedAnySegment = true; + } + + candidateIndex = SkipWhiteSpace(line, index); + if (TryReadBracket(line, candidateIndex, out bracket, out content, out nextIndex)) + { + if (IsSourceDetails(content)) + { + sourceDetails = bracket; + index = nextIndex; + parsedAnySegment = true; + } + else + { + logger = bracket; + index = nextIndex; + parsedAnySegment = true; + + candidateIndex = SkipWhiteSpace(line, index); + if (TryReadBracket(line, candidateIndex, out bracket, out content, out nextIndex) && IsSourceDetails(content)) + { + sourceDetails = bracket; + index = nextIndex; + parsedAnySegment = true; + } + } + } + + var message = parsedAnySegment ? ReadMessage(line, index) : line; + return new(timestamp, level, logger, sourceDetails, message); + } + + private static bool TryReadBracket(string text, int start, out string bracket, out string content, out int nextIndex) + { + bracket = string.Empty; + content = string.Empty; + nextIndex = start; + + if (start >= text.Length || text[start] != '[') + return false; + + var end = text.IndexOf(']', start + 1); + if (end < 0) + return false; + + bracket = text[start..(end + 1)]; + content = text[(start + 1)..end]; + nextIndex = end + 1; + return true; + } + + private static bool TryReadLogLevel(string text, int start, out string level, out int nextIndex) + { + level = string.Empty; + nextIndex = start; + + if (start >= text.Length || text[start] == '[') + return false; + + var end = start; + while (end < text.Length && !char.IsWhiteSpace(text[end])) + end++; + + if (end == start) + return false; + + var candidate = text[start..end]; + if (candidate.Length > 20 || candidate.Any(character => !char.IsLetter(character))) + return false; + + var afterCandidate = SkipWhiteSpace(text, end); + if (afterCandidate >= text.Length || text[afterCandidate] != '[') + return false; + + level = candidate; + nextIndex = end; + return true; + } + + private static bool IsTimestamp(string content) + { + return DateTimeOffset.TryParse(content, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out _); + } + + private static bool IsSourceDetails(string content) + { + return content.Contains('=', StringComparison.Ordinal); + } + + private static int SkipWhiteSpace(string text, int start) + { + var index = start; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + + return index; + } + + private static string ReadMessage(string text, int start) + { + if (start >= text.Length) + return string.Empty; + + if (char.IsWhiteSpace(text[start])) + start++; + + return start >= text.Length ? string.Empty : text[start..]; + } + + private static List BuildFilterOptions(IEnumerable values, Comparison comparison) + { + var options = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + continue; + + options.TryAdd(value, value); + } + + var sortedOptions = options.Values.ToList(); + sortedOptions.Sort(comparison); + sortedOptions.Add(OTHER_OPTION_VALUE); + return sortedOptions; + } + + private static int CompareLogLevels(string left, string right) + { + var leftRank = LOG_LEVEL_ORDER.GetValueOrDefault(left, int.MaxValue); + var rightRank = LOG_LEVEL_ORDER.GetValueOrDefault(right, int.MaxValue); + var rankComparison = leftRank.CompareTo(rightRank); + return rankComparison != 0 ? rankComparison : StringComparer.OrdinalIgnoreCase.Compare(left, right); + } + + private static void NormalizeSelectedValues(HashSet selectedValues, List options) + { + var validOptions = options.ToHashSet(StringComparer.OrdinalIgnoreCase); + selectedValues.RemoveWhere(value => !validOptions.Contains(value)); + } + + private static void UpdateSelectedValues(HashSet target, IEnumerable? selectedValues) + { + target.Clear(); + if (selectedValues is null) + return; + + foreach (var value in selectedValues) + if (!string.IsNullOrWhiteSpace(value)) + target.Add(value); + } + + private static bool MatchesSelection(string? value, HashSet selectedValues) + { + if (selectedValues.Count == 0) + return true; + + var normalizedValue = string.IsNullOrWhiteSpace(value) ? OTHER_OPTION_VALUE : value; + return selectedValues.Contains(normalizedValue); + } + + private static bool ShouldHideSelectedSegment(string? value, HashSet selectedValues) + { + return selectedValues.Count == 1 && !string.IsNullOrWhiteSpace(value) && selectedValues.Contains(value); + } + + private static string[] BuildSearchTerms(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return []; + + return text + .Split(WORD_SPLIT_CHARS, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool MatchesSearchTerms(string text, string[] terms) + { + return terms.Length == 0 || terms.Any(term => text.Contains(term, StringComparison.OrdinalIgnoreCase)); + } + + private static void AddSearchTermRanges(string text, string[] terms, List ranges) + { + foreach (var term in terms) + AddLiteralRanges(text, term, ranges); + } + + private static void AddLiteralRanges(string line, string value, List ranges) + { + var index = 0; + while ((index = line.IndexOf(value, index, StringComparison.OrdinalIgnoreCase)) >= 0) + { + ranges.Add(new(index, value.Length)); + index += value.Length; + } + } + + private static List MergeRanges(List ranges) + { + var mergedRanges = new List(); + foreach (var range in ranges.OrderBy(x => x.Start).ThenByDescending(x => x.Length)) + { + if (mergedRanges.Count == 0) + { + mergedRanges.Add(range); + continue; + } + + var previous = mergedRanges[^1]; + var previousEnd = previous.Start + previous.Length; + var currentEnd = range.Start + range.Length; + if (range.Start <= previousEnd) + { + mergedRanges[^1] = previous with { Length = Math.Max(previousEnd, currentEnd) - previous.Start }; + continue; + } + + mergedRanges.Add(range); + } + + return mergedRanges; + } + + private static void AppendEncoded(StringBuilder sb, string value, int start, int length) + { + if (length <= 0) + return; + + sb.Append(WebUtility.HtmlEncode(value.Substring(start, length))); + } + + private static void AddIfNotWhiteSpace(List parts, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + parts.Add(value); + } + + private readonly record struct LogLine(int Number, string Text, LogSegments Segments); + + private readonly record struct LogSegments(string? Timestamp, string? Level, string? Logger, string? SourceDetails, string Message); + + private readonly record struct LogSnapshot(List Lines, int TotalLineCount, int SkippedLineCount); + + private readonly record struct HighlightRange(int Start, int Length); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/LogViewer/LogFileKind.cs b/app/MindWork AI Studio/Assistants/LogViewer/LogFileKind.cs new file mode 100644 index 00000000..8b453ecd --- /dev/null +++ b/app/MindWork AI Studio/Assistants/LogViewer/LogFileKind.cs @@ -0,0 +1,7 @@ +namespace AIStudio.Assistants.LogViewer; + +public enum LogFileKind +{ + APP, + STARTUP, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Pages/Assistants.razor b/app/MindWork AI Studio/Pages/Assistants.razor index 5a8acdae..6b66071e 100644 --- a/app/MindWork AI Studio/Pages/Assistants.razor +++ b/app/MindWork AI Studio/Pages/Assistants.razor @@ -101,7 +101,8 @@ @if (this.SettingsManager.IsAnyCategoryAssistantVisible("Software Engineering", (Components.CODING_ASSISTANT, PreviewFeatures.NONE), - (Components.ERI_ASSISTANT, PreviewFeatures.PRE_RAG_2024) + (Components.ERI_ASSISTANT, PreviewFeatures.PRE_RAG_2024), + (Components.LOG_VIEWER_ASSISTANT, PreviewFeatures.NONE) )) { @@ -110,6 +111,7 @@ + } diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 1d49cab6..8acdb4cf 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -325,7 +325,8 @@ CONFIG["SETTINGS"] = {} -- CODING_ASSISTANT, TEXT_SUMMARIZER_ASSISTANT, EMAIL_ASSISTANT, -- LEGAL_CHECK_ASSISTANT, SYNONYMS_ASSISTANT, MY_TASKS_ASSISTANT, -- JOB_POSTING_ASSISTANT, BIAS_DAY_ASSISTANT, ERI_ASSISTANT, --- DOCUMENT_ANALYSIS_ASSISTANT, SLIDE_BUILDER_ASSISTANT, I18N_ASSISTANT +-- DOCUMENT_ANALYSIS_ASSISTANT, SLIDE_BUILDER_ASSISTANT, I18N_ASSISTANT, +-- LOG_VIEWER_ASSISTANT -- CONFIG["SETTINGS"]["DataApp.HiddenAssistants"] = { "ERI_ASSISTANT", "I18N_ASSISTANT" } -- Configure enterprise approvals for assistant plugins. diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 68f299b5..48ca396f 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -1638,6 +1638,99 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T4254597 -- Ask your questions UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T467099852"] = "Stellen Sie ihre Fragen" +-- Find +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1042076026"] = "Suchen" + +-- The log file could not be read: {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1147062477"] = "Die Protokolldatei konnte nicht gelesen werden: {0}" + +-- Select a log file +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1231773010"] = "Protokolldatei auswählen" + +-- Log level +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1318706515"] = "Protokollierungsstufe" + +-- Refresh +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T135637716"] = "Aktualisieren" + +-- Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1747827400"] = "Anzeige von {0} von {1} geladenen Zeilen. {2} ältere Zeilen wurden übersprungen. Letzte Aktualisierung: {3}." + +-- The log file does not exist: {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1807514273"] = "Die Protokolldatei existiert nicht: {0}" + +-- Could not open the log file location. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1828231197"] = "Konnte den Speicherort der Protokolldatei nicht öffnen." + +-- Other +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1849229205"] = "Andere" + +-- Max lines +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1904230854"] = "Max. Zeilen" + +-- All +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1974461284"] = "Alle" + +-- Startup log +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2288538420"] = "Startprotokoll" + +-- Showing {0} of {1} lines. Last refresh: {2}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2378353570"] = "Anzeige von {0} von {1} Zeilen. Letzte Aktualisierung: {2}." + +-- No matching log lines. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2511997530"] = "Keine passenden Protokollzeilen." + +-- Could not open the log file location: {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2533784927"] = "Konnte den Speicherort der Protokolldatei nicht öffnen: {0}" + +-- Source details +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2686813966"] = "Quellendetails" + +-- Loaded {0} lines. Last refresh: {1}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2920304709"] = "{0} Zeilen geladen. Letzte Aktualisierung: {1}." + +-- Filter only +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3152625639"] = "Nur filtern" + +-- Loading log file... +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T333036481"] = "Lade Protokolldatei..." + +-- Unknown error +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3461425987"] = "Unbekannter Fehler" + +-- The log file path is not available yet. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3686775689"] = "Der Pfad zur Protokolldatei ist noch nicht verfügbar." + +-- Logger +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T376222229"] = "Logger" + +-- Auto-refresh +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3837203600"] = "Automatisch aktualisieren" + +-- not loaded yet +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3863250749"] = "noch nicht geladen" + +-- Loading... +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T397479987"] = "Wird geladen..." + +-- Usage log +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4031747274"] = "Nutzungsprotokoll" + +-- Open in folder +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4048746540"] = "Im Ordner öffnen" + +-- Log Viewer +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4130241777"] = "Protokollanzeige" + +-- Opened the log file location. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4162897654"] = "Der Speicherort der Protokolldatei wurde geöffnet." + +-- Show timestamps +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T469116133"] = "Zeitstempel anzeigen" + +-- Clear +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T77955010"] = "Löschen" + -- You can enter text, attach one or more documents, or use both. At least one input is required. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T1442535450"] = "Sie können Text eingeben, ein oder mehrere Dokumente anhängen oder beides verwenden. Mindestens eine Eingabe ist erforderlich." @@ -6567,6 +6660,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3930052338"] = "Stellenanzeige" -- Ask a question about a legal document. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3970214537"] = "Stellen Sie Fragen zu einem juristischen Dokument." +-- Log Viewer +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4130241777"] = "Protokollanzeige" + -- ERI Server UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4204533420"] = "ERI-Server" @@ -6588,6 +6684,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T782102948"] = "Vorurteil des Tage -- Learn about one cognitive bias every day. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Lerne jeden Tag einen kognitiven Bias kennen." +-- View and filter AI Studio log files. +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T892147062"] = "AI Studio-Protokolldateien anzeigen und filtern." + -- Localization UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Lokalisierung" @@ -7800,6 +7899,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T4262148639"] = "Umformu -- Localization Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T446674624"] = "Lokalisierungs-Assistent" +-- Log Viewer Assistant +UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T555062689"] = "Assistent für die Protokollanzeige" + -- New Chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T826248509"] = "Neuer Chat" @@ -8610,9 +8712,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Fehler -- Failed to store the API key due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Fehler beim Speichern des API-Schlüssels aufgrund eines API-Problems." +-- The runtime file manager endpoint returned '{0}'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2158262203"] = "Der Laufzeit-Dateimanager-Endpunkt hat '{0}' zurückgegeben." + -- Failed to delete the secret data due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Das Löschen der geheimen Daten ist aufgrund eines API-Problems fehlgeschlagen." +-- The runtime file manager endpoint is not available. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2486847754"] = "Der Laufzeit-Dateimanager-Endpunkt ist nicht verfügbar." + +-- The runtime file manager endpoint failed without details. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3082220817"] = "Der Laufzeit-Dateimanager-Endpunkt ist ohne Details fehlgeschlagen." + -- Successfully copied the text to your clipboard UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Der Text wurde erfolgreich in die Zwischenablage kopiert." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 2162562a..cf1ae825 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -1638,6 +1638,99 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T4254597 -- Ask your questions UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T467099852"] = "Ask your questions" +-- Find +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1042076026"] = "Find" + +-- The log file could not be read: {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1147062477"] = "The log file could not be read: {0}" + +-- Select a log file +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1231773010"] = "Select a log file" + +-- Log level +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1318706515"] = "Log level" + +-- Refresh +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T135637716"] = "Refresh" + +-- Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1747827400"] = "Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}." + +-- The log file does not exist: {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1807514273"] = "The log file does not exist: {0}" + +-- Could not open the log file location. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1828231197"] = "Could not open the log file location." + +-- Other +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1849229205"] = "Other" + +-- Max lines +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1904230854"] = "Max lines" + +-- All +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1974461284"] = "All" + +-- Startup log +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2288538420"] = "Startup log" + +-- Showing {0} of {1} lines. Last refresh: {2}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2378353570"] = "Showing {0} of {1} lines. Last refresh: {2}." + +-- No matching log lines. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2511997530"] = "No matching log lines." + +-- Could not open the log file location: {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2533784927"] = "Could not open the log file location: {0}" + +-- Source details +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2686813966"] = "Source details" + +-- Loaded {0} lines. Last refresh: {1}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2920304709"] = "Loaded {0} lines. Last refresh: {1}." + +-- Filter only +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3152625639"] = "Filter only" + +-- Loading log file... +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T333036481"] = "Loading log file..." + +-- Unknown error +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3461425987"] = "Unknown error" + +-- The log file path is not available yet. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3686775689"] = "The log file path is not available yet." + +-- Logger +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T376222229"] = "Logger" + +-- Auto-refresh +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3837203600"] = "Auto-refresh" + +-- not loaded yet +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3863250749"] = "not loaded yet" + +-- Loading... +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T397479987"] = "Loading..." + +-- Usage log +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4031747274"] = "Usage log" + +-- Open in folder +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4048746540"] = "Open in folder" + +-- Log Viewer +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4130241777"] = "Log Viewer" + +-- Opened the log file location. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4162897654"] = "Opened the log file location." + +-- Show timestamps +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T469116133"] = "Show timestamps" + +-- Clear +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T77955010"] = "Clear" + -- You can enter text, attach one or more documents, or use both. At least one input is required. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T1442535450"] = "You can enter text, attach one or more documents, or use both. At least one input is required." @@ -6567,6 +6660,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3930052338"] = "Job Posting" -- Ask a question about a legal document. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3970214537"] = "Ask a question about a legal document." +-- Log Viewer +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4130241777"] = "Log Viewer" + -- ERI Server UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4204533420"] = "ERI Server" @@ -6588,6 +6684,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T782102948"] = "Bias of the Day" -- Learn about one cognitive bias every day. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Learn about one cognitive bias every day." +-- View and filter AI Studio log files. +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T892147062"] = "View and filter AI Studio log files." + -- Localization UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Localization" @@ -7800,6 +7899,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T4262148639"] = "Rewrite -- Localization Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T446674624"] = "Localization Assistant" +-- Log Viewer Assistant +UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T555062689"] = "Log Viewer Assistant" + -- New Chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T826248509"] = "New Chat" @@ -8610,9 +8712,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Failed -- Failed to store the API key due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Failed to store the API key due to an API issue." +-- The runtime file manager endpoint returned '{0}'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2158262203"] = "The runtime file manager endpoint returned '{0}'." + -- Failed to delete the secret data due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Failed to delete the secret data due to an API issue." +-- The runtime file manager endpoint is not available. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2486847754"] = "The runtime file manager endpoint is not available." + +-- The runtime file manager endpoint failed without details. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3082220817"] = "The runtime file manager endpoint failed without details." + -- Successfully copied the text to your clipboard UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Successfully copied the text to your clipboard" diff --git a/app/MindWork AI Studio/Routes.razor.cs b/app/MindWork AI Studio/Routes.razor.cs index fa1aa89f..a6199639 100644 --- a/app/MindWork AI Studio/Routes.razor.cs +++ b/app/MindWork AI Studio/Routes.razor.cs @@ -32,5 +32,6 @@ public sealed partial class Routes public const string ASSISTANT_DOCUMENT_ANALYSIS = "/assistant/document-analysis"; public const string ASSISTANT_DYNAMIC = "/assistant/dynamic"; public const string ASSISTANT_META_ASSISTANT = "/assistant/builder"; + public const string ASSISTANT_LOG_VIEWER = "/assistant/log-viewer"; // ReSharper restore InconsistentNaming } diff --git a/app/MindWork AI Studio/Settings/ConfigurableAssistant.cs b/app/MindWork AI Studio/Settings/ConfigurableAssistant.cs index 004dda76..0b5f343e 100644 --- a/app/MindWork AI Studio/Settings/ConfigurableAssistant.cs +++ b/app/MindWork AI Studio/Settings/ConfigurableAssistant.cs @@ -29,4 +29,6 @@ public enum ConfigurableAssistant // ReSharper disable InconsistentNaming I18N_ASSISTANT, // ReSharper restore InconsistentNaming + + LOG_VIEWER_ASSISTANT, } diff --git a/app/MindWork AI Studio/Tools/AssistantVisibilityExtensions.cs b/app/MindWork AI Studio/Tools/AssistantVisibilityExtensions.cs index 6f0646e2..cdd42360 100644 --- a/app/MindWork AI Studio/Tools/AssistantVisibilityExtensions.cs +++ b/app/MindWork AI Studio/Tools/AssistantVisibilityExtensions.cs @@ -62,6 +62,7 @@ public static class AssistantVisibilityExtensions Components.DOCUMENT_ANALYSIS_ASSISTANT => ConfigurableAssistant.DOCUMENT_ANALYSIS_ASSISTANT, Components.SLIDE_BUILDER_ASSISTANT => ConfigurableAssistant.SLIDE_BUILDER_ASSISTANT, Components.I18N_ASSISTANT => ConfigurableAssistant.I18N_ASSISTANT, + Components.LOG_VIEWER_ASSISTANT => ConfigurableAssistant.LOG_VIEWER_ASSISTANT, _ => ConfigurableAssistant.UNKNOWN, }; diff --git a/app/MindWork AI Studio/Tools/Components.cs b/app/MindWork AI Studio/Tools/Components.cs index 8b12b073..156cde2e 100644 --- a/app/MindWork AI Studio/Tools/Components.cs +++ b/app/MindWork AI Studio/Tools/Components.cs @@ -35,4 +35,5 @@ public enum Components AGENT_DATA_SOURCE_SELECTION, AGENT_RETRIEVAL_CONTEXT_VALIDATION, AGENT_ASSISTANT_PLUGIN_AUDIT, + LOG_VIEWER_ASSISTANT, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs index f5d18d54..ccdcad8a 100644 --- a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs +++ b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs @@ -17,6 +17,7 @@ public static class ComponentsExtensions Components.BIAS_DAY_ASSISTANT => false, Components.I18N_ASSISTANT => false, Components.DOCUMENT_ANALYSIS_ASSISTANT => false, + Components.LOG_VIEWER_ASSISTANT => false, Components.APP_SETTINGS => false, Components.WRITER => false, @@ -50,6 +51,7 @@ public static class ComponentsExtensions Components.DOCUMENT_ANALYSIS_ASSISTANT => TB("Document Analysis Assistant"), Components.SLIDE_BUILDER_ASSISTANT => TB("Slide Planner Assistant"), Components.META_ASSISTANT => TB("Assistant Builder"), + Components.LOG_VIEWER_ASSISTANT => TB("Log Viewer Assistant"), Components.CHAT => TB("New Chat"), diff --git a/app/MindWork AI Studio/Tools/Rust/OpenPathRequest.cs b/app/MindWork AI Studio/Tools/Rust/OpenPathRequest.cs new file mode 100644 index 00000000..efa51098 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/OpenPathRequest.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public readonly record struct OpenPathRequest(string Path); diff --git a/app/MindWork AI Studio/Tools/Rust/OpenPathResponse.cs b/app/MindWork AI Studio/Tools/Rust/OpenPathResponse.cs new file mode 100644 index 00000000..22197783 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/OpenPathResponse.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public readonly record struct OpenPathResponse(bool Success, string Issue); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs index a9c0b337..89fef1f4 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs @@ -84,4 +84,48 @@ public sealed partial class RustService return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); } + + public async Task TryOpenPathInRuntimeFileManager(string path) + { + HttpResponseMessage result; + try + { + result = await this.http.PostAsJsonAsync("/open/path", new OpenPathRequest(path), this.jsonRustSerializerOptions); + } + catch (HttpRequestException e) + { + this.logger!.LogWarning(e, "Failed to reach the Rust runtime file manager endpoint."); + return new OpenPathResponse(false, TB("The runtime file manager endpoint is not available.")); + } + catch (TaskCanceledException e) + { + this.logger!.LogWarning(e, "Timed out while reaching the Rust runtime file manager endpoint."); + return new OpenPathResponse(false, TB("The runtime file manager endpoint is not available.")); + } + + try + { + if (!result.IsSuccessStatusCode) + { + this.logger!.LogWarning("Failed to open a path in the file manager through the Rust runtime: '{StatusCode}'", result.StatusCode); + return new OpenPathResponse(false, string.Format(TB("The runtime file manager endpoint returned '{0}'."), result.StatusCode)); + } + + var response = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var normalizedResponse = response.Success + ? response + : new OpenPathResponse(false, string.IsNullOrWhiteSpace(response.Issue) ? TB("The runtime file manager endpoint failed without details.") : response.Issue); + + return normalizedResponse; + } + catch (Exception e) + { + this.logger!.LogWarning(e, "Failed to process the Rust runtime file manager endpoint response."); + return new OpenPathResponse(false, TB("The runtime file manager endpoint failed without details.")); + } + finally + { + result.Dispose(); + } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/app.css b/app/MindWork AI Studio/wwwroot/app.css index a6631ea6..4dda2982 100644 --- a/app/MindWork AI Studio/wwwroot/app.css +++ b/app/MindWork AI Studio/wwwroot/app.css @@ -192,3 +192,102 @@ margin-left: 0 !important; margin-right: 0 !important; } + +.log-viewer-shell { + min-width: 0; +} + +.log-viewer-select { + min-width: 12rem; +} + +.log-viewer-number { + max-width: 9rem; +} + +.log-viewer-filter { + min-width: 18rem; + max-width: 32rem; +} + +.log-viewer-multiselect { + min-width: 16rem; + max-width: 34rem; +} + +.log-viewer-path { + color: var(--mud-palette-text-secondary); + word-break: break-all; +} + +.log-viewer-pane { + min-height: 28rem; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 6px; + background-color: var(--mud-palette-background-grey); +} + +.log-viewer-lines { + margin: 0; + padding: 0.5rem 0; + font-family: Consolas, "Courier New", monospace; + font-size: 0.875rem; + line-height: 1.45; +} + +.log-viewer-line { + display: grid; + grid-template-columns: 5.5rem minmax(0, 1fr); + min-height: 1.25rem; +} + +.log-viewer-line:hover { + background-color: var(--mud-palette-action-default-hover); +} + +.log-viewer-line-number { + padding-right: 0.75rem; + color: var(--mud-palette-text-secondary); + text-align: right; + user-select: none; + border-right: 1px solid var(--mud-palette-lines-default); +} + +.log-viewer-line-text { + padding-left: 0.75rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.log-viewer-line-error { + color: var(--mud-palette-error); +} + +.log-viewer-line-warn { + color: var(--mud-palette-warning-darken); +} + +.log-viewer-line-info { + color: var(--mud-palette-info); +} + +.log-viewer-line-debug, +.log-viewer-line-trace { + color: var(--mud-palette-text-secondary); +} + +.log-viewer-highlight { + padding: 0 2px; + border-radius: 2px; + background-color: var(--mud-palette-warning); + color: var(--mud-palette-warning-text); +} + +.log-viewer-empty { + display: flex; + min-height: 20rem; + align-items: center; + justify-content: center; + gap: 0.75rem; + color: var(--mud-palette-text-secondary); +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md index d075dd45..8ecda8eb 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md @@ -1,5 +1,6 @@ # v26.7.3, build 245 (2026-07-xx xx:xx UTC) - Added support for OpenAI GPT-5.6 Sol, Terra, and Luna; Anthropic Claude Fable 5 and Mythos 5; and Google Gemini 3 Flash, Gemini 3.1 Flash-Lite, Gemini 3.1 Pro, and Gemini 3.5 Flash. +- Added a log viewer assistant that shows AI Studio log files in a read-only view with search, log filters, highlighting, and auto-refresh. - Added audio and video transcription for chats and assistants. AI Studio now prepares supported media locally, sends only normalized audio to the configured transcription provider, and attaches the resulting transcript instead of the original media. - Added enterprise configuration options to hide the last changelog and vision panels on the welcome page. Thanks, Dominic Neuburg (`donework`), for the contribution. - Improved the "My Tasks Assistant": you can now provide one or more documents in addition to text or use documents alone when asking to identify tasks. @@ -9,6 +10,7 @@ - Fixed enterprise configuration plugins from Windows-created ZIP files may not load correctly on Linux when the ZIP contained plugin files inside a folder. - Fixed voice recording and transcription on Linux. - Fixed being able to switch document analysis policies while an analysis or media transcription was still in progress. +- Fixed file extension handling so files are recognized correctly regardless of uppercase or lowercase letters in their extensions. Thanks, Paul Schweiß, for reporting this issue. - Upgraded Rust to v1.97.0. - Upgraded Tauri to v2.11.5. - Upgraded common dependencies. \ No newline at end of file diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 0c8bd4de..d48d820b 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -242,6 +242,20 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ashpd" +version = "0.13.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281e6645758940dee594495e28807a7672ce40f11ebf4df6c22c4fcd59e2689f" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.2", + "serde", + "tokio", + "zbus", +] + [[package]] name = "asn1-rs" version = "0.7.1" @@ -4072,6 +4086,7 @@ dependencies = [ "aes 0.9.1", "apple-native-keyring-store", "arboard", + "ashpd", "async-stream", "axum", "axum-server", @@ -7664,6 +7679,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -9566,6 +9582,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index f86dbfad..1a8f58b5 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -72,6 +72,7 @@ windows-native-keyring-store = "1.1.0" apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] } [target.'cfg(target_os = "linux")'.dependencies] +ashpd = { version = "0.13.12", default-features = false, features = ["tokio", "open_uri"] } dbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] } webkit2gtk = { version = "2.0.2", features = ["v2_8"] } diff --git a/runtime/src/file_actions.rs b/runtime/src/file_actions.rs index 3ef7d81d..b917158f 100644 --- a/runtime/src/file_actions.rs +++ b/runtime/src/file_actions.rs @@ -2,10 +2,24 @@ use axum::extract::Query; use axum::Json; use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; use tauri_plugin_dialog::{DialogExt, FileDialogBuilder}; use crate::api_token::APIToken; use crate::app_window::MAIN_WINDOW; +#[cfg(any(windows, target_os = "macos"))] +use std::process::Command; + +#[cfg(target_os = "linux")] +use ashpd::desktop::open_uri::{OpenDirectoryRequest, OpenFileRequest}; + +#[cfg(windows)] +use std::os::windows::process::CommandExt; + +/// Microsoft documents CREATE_NO_WINDOW as a process creation flag with value 0x08000000. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x08000000; + #[derive(Clone, Deserialize)] pub struct PreviousDirectory { path: String, @@ -36,6 +50,11 @@ pub struct SaveFileOptions { filter: Option, } +#[derive(Clone, Deserialize)] +pub struct OpenPathOptions { + path: String, +} + #[derive(Serialize)] pub struct DirectorySelectionResponse { user_cancelled: bool, @@ -60,6 +79,12 @@ pub struct FileSaveResponse { save_file_path: String, } +#[derive(Serialize)] +pub struct OpenPathResponse { + success: bool, + issue: String, +} + #[derive(Clone, Deserialize)] pub struct PreviousFile { file_path: String, @@ -286,6 +311,79 @@ pub async fn save_file(_token: APIToken, payload: Json) -> Json } } +pub async fn open_path_in_file_manager( + _token: APIToken, + payload: Json, +) -> Json { + let requested_path = PathBuf::from(payload.path.trim()); + if requested_path.as_os_str().is_empty() { + return Json(OpenPathResponse { + success: false, + issue: String::from("The path is empty."), + }); + } + + let Some(target) = resolve_file_manager_target(&requested_path) else { + let issue = format!( + "The path does not exist and its parent folder could not be found: {}", + requested_path.to_string_lossy(), + ); + error!(Source = "Tauri"; "{issue}"); + return Json(OpenPathResponse { + success: false, + issue, + }); + }; + + #[cfg(target_os = "linux")] + { + return match open_path_in_linux_file_manager(&target).await { + Ok(()) => { + info!("Opened file manager for path: {:?}", target.path); + Json(OpenPathResponse { + success: true, + issue: String::new(), + }) + } + + Err(issue) => { + error!(Source = "Tauri"; "{issue}"); + Json(OpenPathResponse { + success: false, + issue, + }) + } + }; + } + + #[cfg(any(windows, target_os = "macos"))] + { + let mut command = create_file_manager_command(&target); + + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + + match command.spawn() { + Ok(_) => { + info!("Opened file manager for path: {:?}", target.path); + Json(OpenPathResponse { + success: true, + issue: String::new(), + }) + } + + Err(error) => { + let issue = format!("Failed to open the file manager: {error}"); + error!(Source = "Tauri"; "{issue}"); + Json(OpenPathResponse { + success: false, + issue, + }) + } + } + } +} + /// Applies an optional file type filter to a FileDialogBuilder. fn apply_filter(file_dialog: FileDialogBuilder, filter: &Option) -> FileDialogBuilder { match filter { @@ -296,4 +394,185 @@ fn apply_filter(file_dialog: FileDialogBuilder, filter: &O None => file_dialog, } -} \ No newline at end of file +} + +#[derive(Debug, PartialEq, Eq)] +struct FileManagerTarget { + path: PathBuf, + reveal_file: bool, +} + +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, PartialEq, Eq)] +enum LinuxPortalOperation { + RevealFile, + OpenDirectory, +} + +fn resolve_file_manager_target(requested_path: &Path) -> Option { + if requested_path.is_file() { + return Some(FileManagerTarget { + path: requested_path.to_path_buf(), + reveal_file: true, + }); + } + + if requested_path.is_dir() { + return Some(FileManagerTarget { + path: requested_path.to_path_buf(), + reveal_file: false, + }); + } + + requested_path.parent() + .filter(|parent| parent.is_dir()) + .map(|parent| FileManagerTarget { + path: parent.to_path_buf(), + reveal_file: false, + }) +} + +#[cfg(any(target_os = "linux", test))] +fn linux_portal_operation(target: &FileManagerTarget) -> LinuxPortalOperation { + if target.reveal_file { + LinuxPortalOperation::RevealFile + } else { + LinuxPortalOperation::OpenDirectory + } +} + +#[cfg(any(target_os = "linux", test))] +fn xdg_open_fallback_path(target: &FileManagerTarget) -> &Path { + if target.reveal_file { + target.path.parent().unwrap_or(&target.path) + } else { + &target.path + } +} + +#[cfg(target_os = "linux")] +enum LinuxPortalError { + Unavailable(String), + RequestFailed(String), +} + +#[cfg(target_os = "linux")] +async fn open_path_with_linux_portal(target: &FileManagerTarget) -> Result<(), LinuxPortalError> { + let file = std::fs::File::open(&target.path) + .map_err(|error| LinuxPortalError::Unavailable(format!("Failed to open the path for the desktop portal: {error}")))?; + + let request = match linux_portal_operation(target) { + LinuxPortalOperation::RevealFile => OpenDirectoryRequest::default().send(&file).await, + LinuxPortalOperation::OpenDirectory => OpenFileRequest::default().send_file(&file).await, + } + .map_err(|error| LinuxPortalError::Unavailable(format!("Desktop portal invocation failed: {error}")))?; + + request.response() + .map_err(|error| LinuxPortalError::RequestFailed(format!("Desktop portal request failed: {error}"))) +} + +#[cfg(target_os = "linux")] +async fn open_path_with_xdg_open(target: &FileManagerTarget) -> Result<(), String> { + let fallback_path = xdg_open_fallback_path(target); + let status = tokio::process::Command::new("xdg-open") + .arg(fallback_path) + .status() + .await + .map_err(|error| format!("xdg-open failed to start for '{}': {error}", fallback_path.to_string_lossy()))?; + + if status.success() { + Ok(()) + } else { + Err(format!("xdg-open failed for '{}' with exit status {status}", fallback_path.to_string_lossy())) + } +} + +#[cfg(target_os = "linux")] +async fn open_path_in_linux_file_manager(target: &FileManagerTarget) -> Result<(), String> { + match open_path_with_linux_portal(target).await { + Ok(()) => Ok(()), + Err(LinuxPortalError::RequestFailed(error)) => Err(error), + Err(LinuxPortalError::Unavailable(portal_error)) => { + match open_path_with_xdg_open(target).await { + Ok(()) => Ok(()), + Err(fallback_error) => Err(format!("{portal_error} Fallback failed: {fallback_error}")), + } + } + } +} + +#[cfg(target_os = "windows")] +fn create_file_manager_command(target: &FileManagerTarget) -> Command { + let mut command = Command::new("explorer.exe"); + if target.reveal_file { + command.arg(format!("/select,{}", target.path.to_string_lossy())); + } else { + command.arg(&target.path); + } + + command +} + +#[cfg(target_os = "macos")] +fn create_file_manager_command(target: &FileManagerTarget) -> Command { + let mut command = Command::new("open"); + if target.reveal_file { + command.arg("-R"); + } + + command.arg(&target.path); + command +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn existing_file_is_revealed_and_falls_back_to_its_parent() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("application.log"); + fs::write(&file_path, "log").unwrap(); + + let target = resolve_file_manager_target(&file_path).unwrap(); + + assert_eq!(target.path, file_path); + assert!(target.reveal_file); + assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::RevealFile); + assert_eq!(xdg_open_fallback_path(&target), temp_dir.path()); + } + + #[test] + fn existing_directory_is_opened_directly() { + let temp_dir = tempfile::tempdir().unwrap(); + + let target = resolve_file_manager_target(temp_dir.path()).unwrap(); + + assert_eq!(target.path, temp_dir.path()); + assert!(!target.reveal_file); + assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory); + assert_eq!(xdg_open_fallback_path(&target), temp_dir.path()); + } + + #[test] + fn missing_file_uses_its_existing_parent_directory() { + let temp_dir = tempfile::tempdir().unwrap(); + let missing_file = temp_dir.path().join("missing.log"); + + let target = resolve_file_manager_target(&missing_file).unwrap(); + + assert_eq!(target.path, temp_dir.path()); + assert!(!target.reveal_file); + assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory); + assert_eq!(xdg_open_fallback_path(&target), temp_dir.path()); + } + + #[test] + fn invalid_path_without_existing_parent_is_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let invalid_path = temp_dir.path().join("missing-directory").join("missing.log"); + + assert!(resolve_file_manager_target(&invalid_path).is_none()); + } +} diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index c7de61d4..94bea961 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -46,6 +46,7 @@ pub fn start_runtime_api() { .route("/select/file", post(crate::file_actions::select_file)) .route("/select/files", post(crate::file_actions::select_files)) .route("/save/file", post(crate::file_actions::save_file)) + .route("/open/path", post(crate::file_actions::open_path_in_file_manager)) .route("/secrets/get", post(crate::secret::get_secret)) .route("/secrets/store", post(crate::secret::store_secret)) .route("/secrets/delete", post(crate::secret::delete_secret))