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.