added an assistant to view logs

This commit is contained in:
PaulKoudelka 2026-07-14 20:48:27 +02:00
parent 5dc6282908
commit 11dd357a05
14 changed files with 1201 additions and 4 deletions

View File

@ -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"

View File

@ -0,0 +1,109 @@
@attribute [Route(Routes.ASSISTANT_LOG_VIEWER)]
@using AIStudio.Tools.Services
@inherits MSGComponentBase
<div class="inner-scrolling-context">
<MudText Typo="Typo.h3" Class="mb-2 mr-3">
@T("Log Viewer")
</MudText>
<InnerScrolling FillEntireHorizontalSpace="@true" Class="log-viewer-shell">
<HeaderContent>
<MudStack Row="@true" Wrap="@Wrap.Wrap" AlignItems="@AlignItems.Center" Spacing="2" Class="mb-2">
<MudSelect T="LogFileKind" Value="@this.selectedLogFile" ValueChanged="@this.SelectedLogFileChanged" Label="@T("Log file")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="log-viewer-select">
<MudSelectItem T="LogFileKind" Value="@LogFileKind.APP">@T("Usage log")</MudSelectItem>
<MudSelectItem T="LogFileKind" Value="@LogFileKind.STARTUP">@T("Startup log")</MudSelectItem>
</MudSelect>
<MudCheckBox T="bool" Value="@this.autoRefresh" ValueChanged="@this.AutoRefreshChanged" Label="@T("Auto-refresh")" Color="Color.Primary" />
@if (!this.autoRefresh)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@this.RefreshLogAsync" Disabled="@this.isLoading">
@T("Refresh")
</MudButton>
}
<MudNumericField T="int" Value="@this.maxLines" ValueChanged="@this.MaxLinesChanged" Label="@T("Max lines")" Variant="Variant.Outlined" Margin="Margin.Dense" Min="@MIN_MAX_LINES" Max="@MAX_MAX_LINES" Step="500" Class="log-viewer-number" />
</MudStack>
<MudStack Row="@true" Wrap="@Wrap.Wrap" AlignItems="@AlignItems.Center" Spacing="2" Class="mb-1">
<MudSelect T="string" Label="@T("Log type")" MultiSelection="@true" SelectedValues="@this.selectedLogLevels" SelectedValuesChanged="@this.SelectedLogLevelsChanged" MultiSelectionTextFunc="@this.GetMultiSelectionText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="log-viewer-multiselect" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Label">
@foreach (var option in this.logLevelOptions)
{
<MudSelectItem T="string" Value="@option">
@this.GetFilterOptionDisplay(option)
</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" Label="@T("Logger")" MultiSelection="@true" SelectedValues="@this.selectedLoggers" SelectedValuesChanged="@this.SelectedLoggersChanged" MultiSelectionTextFunc="@this.GetMultiSelectionText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="log-viewer-multiselect" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Source">
@foreach (var option in this.loggerOptions)
{
<MudSelectItem T="string" Value="@option">
@this.GetFilterOptionDisplay(option)
</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" Label="@T("Source details")" MultiSelection="@true" SelectedValues="@this.selectedSourceDetails" SelectedValuesChanged="@this.SelectedSourceDetailsChanged" MultiSelectionTextFunc="@this.GetMultiSelectionText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="log-viewer-multiselect" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Settings">
@foreach (var option in this.sourceDetailOptions)
{
<MudSelectItem T="string" Value="@option">
@this.GetFilterOptionDisplay(option)
</MudSelectItem>
}
</MudSelect>
<MudCheckBox T="bool" @bind-Value="@this.ShowTimestamps" Label="@T("Show timestamps")" Color="Color.Primary" />
</MudStack>
<MudStack Row="@true" Wrap="@Wrap.Wrap" AlignItems="@AlignItems.Center" Spacing="2" Class="mb-1">
<MudTextField T="string" @bind-Text="@this.FilterText" Immediate="@true" Label="@T("Find")" Variant="Variant.Outlined" Margin="Margin.Dense" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Class="log-viewer-filter" />
<MudButton Variant="Variant.Text" Color="Color.Default" StartIcon="@Icons.Material.Filled.Clear" Disabled="@(!this.HasActiveFilter)" OnClick="@this.ClearFilters">
@T("Clear")
</MudButton>
<MudCheckBox T="bool" @bind-Value="@this.FilterOnly" Label="@T("Filter only")" Color="Color.Primary" />
</MudStack>
<MudText Typo="Typo.body2" Class="log-viewer-path mb-1">
@this.CurrentLogPath
</MudText>
<MudText Typo="Typo.caption" Class="mb-2">
@this.StatusText
</MudText>
</HeaderContent>
<ChildContent>
@if (!string.IsNullOrWhiteSpace(this.loadError))
{
<MudAlert Severity="Severity.Error" Dense="@true" Variant="Variant.Outlined" Class="mb-2">
@this.loadError
</MudAlert>
}
<div class="log-viewer-pane">
@if (this.isLoading && this.loadedLines.Count == 0)
{
<div class="log-viewer-empty">
<MudProgressCircular Size="Size.Small" Indeterminate="@true" />
<MudText Typo="Typo.body2">@T("Loading log file...")</MudText>
</div>
}
else if (this.displayLines.Count == 0)
{
<div class="log-viewer-empty">
<MudIcon Icon="@Icons.Material.Filled.SearchOff" />
<MudText Typo="Typo.body2">@T("No matching log lines.")</MudText>
</div>
}
else
{
<div class="log-viewer-lines" role="textbox" aria-readonly="true">
@foreach (var line in this.displayLines)
{
<div class="@this.GetLineClass(line)">
<span class="log-viewer-line-number">@line.Number</span>
<span class="log-viewer-line-text">@((MarkupString)this.RenderLine(line))</span>
</div>
}
</div>
}
</div>
</ChildContent>
</InnerScrolling>
</div>

View File

@ -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<string, int> 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<AssistantLogViewer> Logger { get; init; } = null!;
private GetLogPathsResponse logPaths;
private LogFileKind selectedLogFile = LogFileKind.APP;
private List<LogLine> loadedLines = [];
private List<LogLine> displayLines = [];
private List<string> logLevelOptions = [OTHER_OPTION_VALUE];
private List<string> loggerOptions = [OTHER_OPTION_VALUE];
private List<string> sourceDetailOptions = [OTHER_OPTION_VALUE];
private HashSet<string> selectedLogLevels = new(StringComparer.OrdinalIgnoreCase);
private HashSet<string> selectedLoggers = new(StringComparer.OrdinalIgnoreCase);
private HashSet<string> 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<string?>? selectedValues)
{
UpdateSelectedValues(this.selectedLogLevels, selectedValues);
this.RefreshDisplayLines();
return Task.CompletedTask;
}
private Task SelectedLoggersChanged(IEnumerable<string?>? selectedValues)
{
UpdateSelectedValues(this.selectedLoggers, selectedValues);
this.RefreshDisplayLines();
return Task.CompletedTask;
}
private Task SelectedSourceDetailsChanged(IEnumerable<string?>? 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<LogSnapshot> ReadLogSnapshotAsync(string path, int maxLines)
{
var queue = new Queue<string>(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<HighlightRange>();
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("""<mark class="log-viewer-highlight">""");
AppendEncoded(sb, text, range.Start, range.Length);
sb.Append("</mark>");
position = range.Start + range.Length;
}
AppendEncoded(sb, text, position, text.Length - position);
return sb.ToString();
}
private string GetPlainRenderedLine(LogLine line)
{
var parts = new List<string>();
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<string?>? 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<string> BuildFilterOptions(IEnumerable<string?> values, Comparison<string> comparison)
{
var options = new Dictionary<string, string>(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<string> selectedValues, List<string> options)
{
var validOptions = options.ToHashSet(StringComparer.OrdinalIgnoreCase);
selectedValues.RemoveWhere(value => !validOptions.Contains(value));
}
private static void UpdateSelectedValues(HashSet<string> target, IEnumerable<string?>? 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<string> 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<string> 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<HighlightRange> ranges)
{
foreach (var term in terms)
AddLiteralRanges(text, term, ranges);
}
private static void AddLiteralRanges(string line, string value, List<HighlightRange> 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<HighlightRange> MergeRanges(List<HighlightRange> ranges)
{
var mergedRanges = new List<HighlightRange>();
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<string> 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<LogLine> Lines, int TotalLineCount, int SkippedLineCount);
private readonly record struct HighlightRange(int Start, int Length);
}
public enum LogFileKind
{
APP,
STARTUP,
}

View File

@ -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)
))
{
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
@ -110,6 +111,7 @@
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogCoding" Component="Components.CODING_ASSISTANT" Name="@T("Coding")" Description="@T("Get coding and debugging support from an LLM.")" Icon="@Icons.Material.Filled.Code" Link="@Routes.ASSISTANT_CODING"/>
<AssistantBlock TSettings="SettingsDialogERIServer" Component="Components.ERI_ASSISTANT" RequiredPreviewFeature="PreviewFeatures.PRE_RAG_2024" Name="@T("ERI Server")" Description="@T("Generate an ERI server to integrate business systems.")" Icon="@Icons.Material.Filled.PrivateConnectivity" Link="@Routes.ASSISTANT_ERI"/>
<AssistantBlock TSettings="NoSettingsPanel" Component="Components.LOG_VIEWER_ASSISTANT" Name="@T("Log Viewer")" Description="@T("View and filter AI Studio log files.")" Icon="@Icons.Material.Filled.Article" Link="@Routes.ASSISTANT_LOG_VIEWER"/>
</MudStack>
}

View File

@ -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.

View File

@ -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"

View File

@ -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"

View File

@ -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
}

View File

@ -29,4 +29,6 @@ public enum ConfigurableAssistant
// ReSharper disable InconsistentNaming
I18N_ASSISTANT,
// ReSharper restore InconsistentNaming
LOG_VIEWER_ASSISTANT,
}

View File

@ -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,
};

View File

@ -35,4 +35,5 @@ public enum Components
AGENT_DATA_SOURCE_SELECTION,
AGENT_RETRIEVAL_CONTEXT_VALIDATION,
AGENT_ASSISTANT_PLUGIN_AUDIT,
LOG_VIEWER_ASSISTANT,
}

View File

@ -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"),

View File

@ -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);
}

View File

@ -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.
- Upgraded common dependencies.