diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index 3ff17464..14faea58 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -1630,6 +1630,84 @@ 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}"
+
+-- Refresh
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T135637716"] = "Refresh"
+
+-- Log type
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1649755707"] = "Log type"
+
+-- 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}"
+
+-- 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."
+
+-- 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..."
+
+-- 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"
+
+-- Log file
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4129341821"] = "Log file"
+
+-- Log Viewer
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4130241777"] = "Log Viewer"
+
+-- 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."
@@ -6502,6 +6580,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"
@@ -6523,6 +6604,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"
@@ -7723,6 +7807,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"
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..45337bfb
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor
@@ -0,0 +1,109 @@
+@attribute [Route(Routes.ASSISTANT_LOG_VIEWER)]
+@using AIStudio.Tools.Services
+@inherits MSGComponentBase
+
+
+
+ @T("Log Viewer")
+
+
+
+
+
+
+ @T("Usage log")
+ @T("Startup log")
+
+
+ @if (!this.autoRefresh)
+ {
+
+ @T("Refresh")
+
+ }
+
+
+
+
+
+ @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..44aa9d4e
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs
@@ -0,0 +1,721 @@
+using System.Globalization;
+using System.Net;
+using System.Text;
+
+using AIStudio.Components;
+using AIStudio.Tools.Rust;
+using AIStudio.Tools.Services;
+
+using Microsoft.AspNetCore.Components;
+
+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 = 5000;
+ protected const int MIN_MAX_LINES = 100;
+ protected const int MAX_MAX_LINES = 100000;
+ private const string OTHER_OPTION_VALUE = "__OTHER__";
+
+ [Inject]
+ private RustService RustService { get; init; } = null!;
+
+ [Inject]
+ private NavigationManager NavigationManager { get; init; } = null!;
+
+ [Inject]
+ private ILogger Logger { get; init; } = null!;
+
+ 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 HashSet selectedLogLevels = new(StringComparer.OrdinalIgnoreCase);
+ private HashSet selectedLoggers = new(StringComparer.OrdinalIgnoreCase);
+ private HashSet selectedSourceDetails = new(StringComparer.OrdinalIgnoreCase);
+ 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 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;
+ }
+
+ 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 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
+ {
+ this.logPaths = await this.RustService.GetLogPaths();
+ 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 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.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);
+}
+
+public enum LogFileKind
+{
+ APP,
+ STARTUP,
+}
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 30e042af..085d59e4 100644
--- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua
@@ -319,7 +319,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 98099068..228c43a9 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
@@ -1632,6 +1632,84 @@ 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}"
+
+-- Refresh
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T135637716"] = "Aktualisieren"
+
+-- Log type
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1649755707"] = "Protokolltyp"
+
+-- 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}"
+
+-- 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."
+
+-- 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..."
+
+-- 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"] = "Protokoll"
+
+-- 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"
+
+-- Log file
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4129341821"] = "Protokolldatei"
+
+-- Log Viewer
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4130241777"] = "Protokollanzeige"
+
+-- 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."
@@ -6504,6 +6582,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"
@@ -6525,6 +6606,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"
@@ -7045,7 +7129,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T864851737"] = "Axum wird verwend
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T870640199"] = "Für einige Datenübertragungen müssen wir die Daten in Base64 kodieren. Diese Rust-Bibliothek eignet sich dafür hervorragend."
-- How to update
-UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "Update-Anleitung "
+UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "Update-Anleitung"
-- Install Pandoc
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Pandoc installieren"
@@ -7725,6 +7809,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"
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 90677d36..73711e4b 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
@@ -1632,6 +1632,84 @@ 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}"
+
+-- Refresh
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T135637716"] = "Refresh"
+
+-- Log type
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1649755707"] = "Log type"
+
+-- 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}"
+
+-- 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."
+
+-- 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..."
+
+-- 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"
+
+-- Log file
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4129341821"] = "Log file"
+
+-- Log Viewer
+UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4130241777"] = "Log Viewer"
+
+-- 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."
@@ -6504,6 +6582,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"
@@ -6525,6 +6606,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"
@@ -7725,6 +7809,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"
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/wwwroot/app.css b/app/MindWork AI Studio/wwwroot/app.css
index a6631ea6..9b4390de 100644
--- a/app/MindWork AI Studio/wwwroot/app.css
+++ b/app/MindWork AI Studio/wwwroot/app.css
@@ -192,3 +192,98 @@
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-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 346091e4..69135cd5 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.
- 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.
- Improved update guidance for Flatpak installations and added an enterprise option that lets organizations manage updates entirely through their IT department.
- Fixed an issue that could leave AI Studio unresponsive after waking the computer from sleep.
@@ -7,4 +8,4 @@
- Fixed voice recording not starting on Linux.
- Upgraded Rust to v1.97.0.
- Upgraded Tauri to v2.11.5.
-- Upgraded common dependencies.
\ No newline at end of file
+- Upgraded common dependencies.