Added an assistant to view the AI Studio logs (#859)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Paul Koudelka 2026-07-15 19:44:43 +02:00 committed by GitHub
parent 79fbeb636c
commit b1459523d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1683 additions and 3 deletions

View File

@ -1636,6 +1636,99 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T4254597
-- Ask your questions
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T467099852"] = "Ask your questions"
-- Find
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1042076026"] = "Find"
-- The log file could not be read: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1147062477"] = "The log file could not be read: {0}"
-- Select a log file
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1231773010"] = "Select a log file"
-- Log level
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1318706515"] = "Log level"
-- Refresh
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T135637716"] = "Refresh"
-- Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1747827400"] = "Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}."
-- The log file does not exist: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1807514273"] = "The log file does not exist: {0}"
-- Could not open the log file location.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1828231197"] = "Could not open the log file location."
-- Other
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1849229205"] = "Other"
-- Max lines
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1904230854"] = "Max lines"
-- All
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1974461284"] = "All"
-- Startup log
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2288538420"] = "Startup log"
-- Showing {0} of {1} lines. Last refresh: {2}.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2378353570"] = "Showing {0} of {1} lines. Last refresh: {2}."
-- No matching log lines.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2511997530"] = "No matching log lines."
-- Could not open the log file location: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2533784927"] = "Could not open the log file location: {0}"
-- Source details
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2686813966"] = "Source details"
-- Loaded {0} lines. Last refresh: {1}.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2920304709"] = "Loaded {0} lines. Last refresh: {1}."
-- Filter only
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3152625639"] = "Filter only"
-- Loading log file...
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T333036481"] = "Loading log file..."
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3461425987"] = "Unknown error"
-- The log file path is not available yet.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3686775689"] = "The log file path is not available yet."
-- Logger
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T376222229"] = "Logger"
-- Auto-refresh
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3837203600"] = "Auto-refresh"
-- not loaded yet
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3863250749"] = "not loaded yet"
-- Loading...
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T397479987"] = "Loading..."
-- Usage log
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4031747274"] = "Usage log"
-- Open in folder
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4048746540"] = "Open in folder"
-- Log Viewer
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4130241777"] = "Log Viewer"
-- Opened the log file location.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4162897654"] = "Opened the log file location."
-- Show timestamps
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T469116133"] = "Show timestamps"
-- Clear
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T77955010"] = "Clear"
-- You can enter text, attach one or more documents, or use both. At least one input is required.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T1442535450"] = "You can enter text, attach one or more documents, or use both. At least one input is required."
@ -6565,6 +6658,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3930052338"] = "Job Posting"
-- Ask a question about a legal document.
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3970214537"] = "Ask a question about a legal document."
-- Log Viewer
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4130241777"] = "Log Viewer"
-- ERI Server
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4204533420"] = "ERI Server"
@ -6586,6 +6682,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T782102948"] = "Bias of the Day"
-- Learn about one cognitive bias every day.
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Learn about one cognitive bias every day."
-- View and filter AI Studio log files.
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T892147062"] = "View and filter AI Studio log files."
-- Localization
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Localization"
@ -7798,6 +7897,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T4262148639"] = "Rewrite
-- Localization Assistant
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T446674624"] = "Localization Assistant"
-- Log Viewer Assistant
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T555062689"] = "Log Viewer Assistant"
-- New Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T826248509"] = "New Chat"
@ -8608,9 +8710,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Failed
-- Failed to store the API key due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Failed to store the API key due to an API issue."
-- The runtime file manager endpoint returned '{0}'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2158262203"] = "The runtime file manager endpoint returned '{0}'."
-- Failed to delete the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Failed to delete the secret data due to an API issue."
-- The runtime file manager endpoint is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2486847754"] = "The runtime file manager endpoint is not available."
-- The runtime file manager endpoint failed without details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3082220817"] = "The runtime file manager endpoint failed without details."
-- Successfully copied the text to your clipboard
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Successfully copied the text to your clipboard"

View File

@ -0,0 +1,111 @@
@attribute [Route(Routes.ASSISTANT_LOG_VIEWER)]
@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("Select a 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" />
<MudButton Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.FolderOpen" OnClick="@this.OpenCurrentLogInFileManager" Disabled="@(!this.CanOpenCurrentLogPath)">
@T("Open in folder")
</MudButton>
</MudStack>
<MudStack Row="@true" Wrap="@Wrap.Wrap" AlignItems="@AlignItems.Center" Spacing="2" Class="mb-1">
<MudSelect T="string" Label="@T("Log level")" 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="@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,770 @@
using System.Globalization;
using System.Net;
using System.Text;
using AIStudio.Components;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
// ReSharper disable NotAccessedPositionalProperty.Local
namespace AIStudio.Assistants.LogViewer;
public partial class AssistantLogViewer : MSGComponentBase
{
private static readonly TimeSpan AUTO_REFRESH_INTERVAL = TimeSpan.FromSeconds(5);
private static readonly char[] WORD_SPLIT_CHARS = [' ', '\t', '\r', '\n'];
private static readonly Dictionary<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 = 5_000;
private const int MIN_MAX_LINES = 100;
private const int MAX_MAX_LINES = 100_000;
private const string OTHER_OPTION_VALUE = "__OTHER__";
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private ISnackbar Snackbar { get; init; } = null!;
[Inject]
private NavigationManager NavigationManager { get; init; } = null!;
[Inject]
private ILogger<AssistantLogViewer> Logger { get; init; } = null!;
private readonly HashSet<string> selectedLogLevels = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> selectedLoggers = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> selectedSourceDetails = new(StringComparer.OrdinalIgnoreCase);
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 string[] activeSearchTerms = [];
private CancellationTokenSource? autoRefreshCancellationTokenSource;
private string filterText = string.Empty;
private string loadError = string.Empty;
private bool isLoading;
private bool autoRefresh;
private bool filterOnly = true;
private bool showTimestamps = true;
private int maxLines = DEFAULT_MAX_LINES;
private int totalLineCount;
private int skippedLineCount;
private DateTimeOffset? lastLoadedAt;
private string CurrentLogPath => this.selectedLogFile is LogFileKind.APP ? this.logPaths.LogAppPath : this.logPaths.LogStartupPath;
private bool CanOpenCurrentLogPath => !string.IsNullOrWhiteSpace(this.CurrentLogPath);
private bool HasDropdownFilter => this.selectedLogLevels.Count > 0 || this.selectedLoggers.Count > 0 || this.selectedSourceDetails.Count > 0;
private bool HasActiveFilter => !string.IsNullOrWhiteSpace(this.filterText) || this.HasDropdownFilter;
private string FilterText
{
get => this.filterText;
set
{
if (this.filterText == value)
return;
this.filterText = value;
this.RefreshDisplayLines();
}
}
private bool FilterOnly
{
get => this.filterOnly;
set
{
if (this.filterOnly == value)
return;
this.filterOnly = value;
this.RefreshDisplayLines();
}
}
private bool ShowTimestamps
{
get => this.showTimestamps;
set
{
if (this.showTimestamps == value)
return;
this.showTimestamps = value;
this.RefreshDisplayLines();
}
}
private string StatusText
{
get
{
if (this.isLoading)
return T("Loading...");
var visibleLineCount = this.displayLines.Count.ToString("N0", CultureInfo.CurrentCulture);
var loadedLineCount = this.loadedLines.Count.ToString("N0", CultureInfo.CurrentCulture);
var totalLineCountText = this.totalLineCount.ToString("N0", CultureInfo.CurrentCulture);
var lastLoadedText = this.lastLoadedAt?.LocalDateTime.ToString("g", CultureInfo.CurrentCulture) ?? T("not loaded yet");
if (this.loadedLines.Count == 0)
return string.Format(T("Loaded {0} lines. Last refresh: {1}."), loadedLineCount, lastLoadedText);
if (this.skippedLineCount > 0)
{
var skippedLineCountText = this.skippedLineCount.ToString("N0", CultureInfo.CurrentCulture);
return string.Format(T("Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}."), visibleLineCount, loadedLineCount, skippedLineCountText, lastLoadedText);
}
return string.Format(T("Showing {0} of {1} lines. Last refresh: {2}."), visibleLineCount, totalLineCountText, lastLoadedText);
}
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
if (!this.SettingsManager.IsAssistantVisible(Tools.Components.LOG_VIEWER_ASSISTANT, assistantName: T("Log Viewer")))
{
this.NavigationManager.NavigateTo(Routes.ASSISTANTS);
return;
}
this.logPaths = await this.RustService.GetLogPaths();
await this.RefreshLogAsync();
}
protected override void DisposeResources()
{
this.StopAutoRefresh();
}
private async Task SelectedLogFileChanged(LogFileKind value)
{
if (this.selectedLogFile == value)
return;
this.selectedLogFile = value;
await this.RefreshLogAsync();
}
private Task SelectedLogLevelsChanged(IEnumerable<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 async Task OpenCurrentLogInFileManager()
{
var path = this.CurrentLogPath;
if (string.IsNullOrWhiteSpace(path))
{
this.Snackbar.Add(T("The log file path is not available yet."), Severity.Warning, config =>
{
config.Icon = Icons.Material.Filled.Folder;
config.IconSize = Size.Large;
});
return;
}
OpenPathResponse response;
try
{
response = await this.RustService.TryOpenPathInRuntimeFileManager(path);
}
catch (Exception e)
{
this.Logger.LogWarning(e, "Could not open the log file location in the file manager.");
this.Snackbar.Add(T("Could not open the log file location."), Severity.Error, config =>
{
config.Icon = Icons.Material.Filled.Folder;
config.IconSize = Size.Large;
});
return;
}
if (response.Success)
{
this.Snackbar.Add(T("Opened the log file location."), Severity.Success, config =>
{
config.Icon = Icons.Material.Filled.FolderOpen;
config.IconSize = Size.Large;
});
return;
}
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
this.Snackbar.Add(string.Format(T("Could not open the log file location: {0}"), issue), Severity.Error, config =>
{
config.Icon = Icons.Material.Filled.Folder;
config.IconSize = Size.Large;
});
}
private void ClearFilters()
{
this.filterText = string.Empty;
this.selectedLogLevels.Clear();
this.selectedLoggers.Clear();
this.selectedSourceDetails.Clear();
this.RefreshDisplayLines();
}
private async Task RefreshLogAsync()
{
if (this.isLoading)
return;
this.isLoading = true;
this.loadError = string.Empty;
await this.InvokeAsync(this.StateHasChanged);
try
{
var path = this.CurrentLogPath;
if (string.IsNullOrWhiteSpace(path))
{
this.loadedLines = [];
this.totalLineCount = 0;
this.skippedLineCount = 0;
this.lastLoadedAt = null;
this.loadError = T("The log file path is not available yet.");
return;
}
if (!File.Exists(path))
{
this.loadedLines = [];
this.totalLineCount = 0;
this.skippedLineCount = 0;
this.lastLoadedAt = null;
this.loadError = string.Format(T("The log file does not exist: {0}"), path);
return;
}
var snapshot = await ReadLogSnapshotAsync(path, this.maxLines);
this.loadedLines = snapshot.Lines;
this.totalLineCount = snapshot.TotalLineCount;
this.skippedLineCount = snapshot.SkippedLineCount;
this.lastLoadedAt = DateTimeOffset.Now;
}
catch (Exception e)
{
this.Logger.LogWarning(e, "Could not read the log file for the log viewer assistant.");
this.loadedLines = [];
this.totalLineCount = 0;
this.skippedLineCount = 0;
this.lastLoadedAt = null;
this.loadError = string.Format(T("The log file could not be read: {0}"), e.Message);
}
finally
{
this.isLoading = false;
this.RebuildFilterOptions();
this.RefreshDisplayLines();
await this.InvokeAsync(this.StateHasChanged);
}
}
private static async Task<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 static string GetLineClass(LogLine line)
{
var level = line.Segments.Level ?? string.Empty;
if (level.Contains("ERROR", StringComparison.OrdinalIgnoreCase) || level.Contains("CRITICAL", StringComparison.OrdinalIgnoreCase))
return "log-viewer-line log-viewer-line-error";
if (level.Contains("WARN", StringComparison.OrdinalIgnoreCase))
return "log-viewer-line log-viewer-line-warn";
if (level.Equals("INFO", StringComparison.OrdinalIgnoreCase) || level.Equals("INFORMATION", StringComparison.OrdinalIgnoreCase))
return "log-viewer-line log-viewer-line-info";
if (level.Contains("DEBUG", StringComparison.OrdinalIgnoreCase))
return "log-viewer-line log-viewer-line-debug";
if (level.Contains("TRACE", StringComparison.OrdinalIgnoreCase))
return "log-viewer-line log-viewer-line-trace";
return "log-viewer-line";
}
private string GetFilterOptionDisplay(string value)
{
return value == OTHER_OPTION_VALUE ? T("Other") : value;
}
private string GetMultiSelectionText(List<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);
}

View File

@ -0,0 +1,7 @@
namespace AIStudio.Assistants.LogViewer;
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

@ -325,7 +325,8 @@ CONFIG["SETTINGS"] = {}
-- CODING_ASSISTANT, TEXT_SUMMARIZER_ASSISTANT, EMAIL_ASSISTANT,
-- LEGAL_CHECK_ASSISTANT, SYNONYMS_ASSISTANT, MY_TASKS_ASSISTANT,
-- JOB_POSTING_ASSISTANT, BIAS_DAY_ASSISTANT, ERI_ASSISTANT,
-- DOCUMENT_ANALYSIS_ASSISTANT, SLIDE_BUILDER_ASSISTANT, I18N_ASSISTANT
-- DOCUMENT_ANALYSIS_ASSISTANT, SLIDE_BUILDER_ASSISTANT, I18N_ASSISTANT,
-- LOG_VIEWER_ASSISTANT
-- CONFIG["SETTINGS"]["DataApp.HiddenAssistants"] = { "ERI_ASSISTANT", "I18N_ASSISTANT" }
-- Configure enterprise approvals for assistant plugins.

View File

@ -1638,6 +1638,99 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T4254597
-- Ask your questions
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T467099852"] = "Stellen Sie ihre Fragen"
-- Find
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1042076026"] = "Suchen"
-- The log file could not be read: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1147062477"] = "Die Protokolldatei konnte nicht gelesen werden: {0}"
-- Select a log file
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1231773010"] = "Protokolldatei auswählen"
-- Log level
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1318706515"] = "Protokollierungsstufe"
-- Refresh
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T135637716"] = "Aktualisieren"
-- Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1747827400"] = "Anzeige von {0} von {1} geladenen Zeilen. {2} ältere Zeilen wurden übersprungen. Letzte Aktualisierung: {3}."
-- The log file does not exist: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1807514273"] = "Die Protokolldatei existiert nicht: {0}"
-- Could not open the log file location.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1828231197"] = "Konnte den Speicherort der Protokolldatei nicht öffnen."
-- Other
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1849229205"] = "Andere"
-- Max lines
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1904230854"] = "Max. Zeilen"
-- All
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1974461284"] = "Alle"
-- Startup log
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2288538420"] = "Startprotokoll"
-- Showing {0} of {1} lines. Last refresh: {2}.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2378353570"] = "Anzeige von {0} von {1} Zeilen. Letzte Aktualisierung: {2}."
-- No matching log lines.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2511997530"] = "Keine passenden Protokollzeilen."
-- Could not open the log file location: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2533784927"] = "Konnte den Speicherort der Protokolldatei nicht öffnen: {0}"
-- Source details
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2686813966"] = "Quellendetails"
-- Loaded {0} lines. Last refresh: {1}.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2920304709"] = "{0} Zeilen geladen. Letzte Aktualisierung: {1}."
-- Filter only
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3152625639"] = "Nur filtern"
-- Loading log file...
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T333036481"] = "Lade Protokolldatei..."
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3461425987"] = "Unbekannter Fehler"
-- The log file path is not available yet.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3686775689"] = "Der Pfad zur Protokolldatei ist noch nicht verfügbar."
-- Logger
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T376222229"] = "Logger"
-- Auto-refresh
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3837203600"] = "Automatisch aktualisieren"
-- not loaded yet
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3863250749"] = "noch nicht geladen"
-- Loading...
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T397479987"] = "Wird geladen..."
-- Usage log
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4031747274"] = "Nutzungsprotokoll"
-- Open in folder
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4048746540"] = "Im Ordner öffnen"
-- Log Viewer
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4130241777"] = "Protokollanzeige"
-- Opened the log file location.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4162897654"] = "Der Speicherort der Protokolldatei wurde geöffnet."
-- Show timestamps
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T469116133"] = "Zeitstempel anzeigen"
-- Clear
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T77955010"] = "Löschen"
-- You can enter text, attach one or more documents, or use both. At least one input is required.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T1442535450"] = "Sie können Text eingeben, ein oder mehrere Dokumente anhängen oder beides verwenden. Mindestens eine Eingabe ist erforderlich."
@ -6567,6 +6660,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3930052338"] = "Stellenanzeige"
-- Ask a question about a legal document.
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3970214537"] = "Stellen Sie Fragen zu einem juristischen Dokument."
-- Log Viewer
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4130241777"] = "Protokollanzeige"
-- ERI Server
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4204533420"] = "ERI-Server"
@ -6588,6 +6684,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T782102948"] = "Vorurteil des Tage
-- Learn about one cognitive bias every day.
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Lerne jeden Tag einen kognitiven Bias kennen."
-- View and filter AI Studio log files.
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T892147062"] = "AI Studio-Protokolldateien anzeigen und filtern."
-- Localization
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Lokalisierung"
@ -7800,6 +7899,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T4262148639"] = "Umformu
-- Localization Assistant
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T446674624"] = "Lokalisierungs-Assistent"
-- Log Viewer Assistant
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T555062689"] = "Assistent für die Protokollanzeige"
-- New Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T826248509"] = "Neuer Chat"
@ -8610,9 +8712,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Fehler
-- Failed to store the API key due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Fehler beim Speichern des API-Schlüssels aufgrund eines API-Problems."
-- The runtime file manager endpoint returned '{0}'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2158262203"] = "Der Laufzeit-Dateimanager-Endpunkt hat '{0}' zurückgegeben."
-- Failed to delete the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Das Löschen der geheimen Daten ist aufgrund eines API-Problems fehlgeschlagen."
-- The runtime file manager endpoint is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2486847754"] = "Der Laufzeit-Dateimanager-Endpunkt ist nicht verfügbar."
-- The runtime file manager endpoint failed without details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3082220817"] = "Der Laufzeit-Dateimanager-Endpunkt ist ohne Details fehlgeschlagen."
-- Successfully copied the text to your clipboard
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Der Text wurde erfolgreich in die Zwischenablage kopiert."

View File

@ -1638,6 +1638,99 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T4254597
-- Ask your questions
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T467099852"] = "Ask your questions"
-- Find
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1042076026"] = "Find"
-- The log file could not be read: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1147062477"] = "The log file could not be read: {0}"
-- Select a log file
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1231773010"] = "Select a log file"
-- Log level
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1318706515"] = "Log level"
-- Refresh
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T135637716"] = "Refresh"
-- Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1747827400"] = "Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}."
-- The log file does not exist: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1807514273"] = "The log file does not exist: {0}"
-- Could not open the log file location.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1828231197"] = "Could not open the log file location."
-- Other
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1849229205"] = "Other"
-- Max lines
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1904230854"] = "Max lines"
-- All
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1974461284"] = "All"
-- Startup log
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2288538420"] = "Startup log"
-- Showing {0} of {1} lines. Last refresh: {2}.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2378353570"] = "Showing {0} of {1} lines. Last refresh: {2}."
-- No matching log lines.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2511997530"] = "No matching log lines."
-- Could not open the log file location: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2533784927"] = "Could not open the log file location: {0}"
-- Source details
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2686813966"] = "Source details"
-- Loaded {0} lines. Last refresh: {1}.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T2920304709"] = "Loaded {0} lines. Last refresh: {1}."
-- Filter only
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3152625639"] = "Filter only"
-- Loading log file...
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T333036481"] = "Loading log file..."
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3461425987"] = "Unknown error"
-- The log file path is not available yet.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3686775689"] = "The log file path is not available yet."
-- Logger
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T376222229"] = "Logger"
-- Auto-refresh
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3837203600"] = "Auto-refresh"
-- not loaded yet
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T3863250749"] = "not loaded yet"
-- Loading...
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T397479987"] = "Loading..."
-- Usage log
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4031747274"] = "Usage log"
-- Open in folder
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4048746540"] = "Open in folder"
-- Log Viewer
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4130241777"] = "Log Viewer"
-- Opened the log file location.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T4162897654"] = "Opened the log file location."
-- Show timestamps
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T469116133"] = "Show timestamps"
-- Clear
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T77955010"] = "Clear"
-- You can enter text, attach one or more documents, or use both. At least one input is required.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T1442535450"] = "You can enter text, attach one or more documents, or use both. At least one input is required."
@ -6567,6 +6660,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3930052338"] = "Job Posting"
-- Ask a question about a legal document.
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3970214537"] = "Ask a question about a legal document."
-- Log Viewer
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4130241777"] = "Log Viewer"
-- ERI Server
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T4204533420"] = "ERI Server"
@ -6588,6 +6684,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T782102948"] = "Bias of the Day"
-- Learn about one cognitive bias every day.
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Learn about one cognitive bias every day."
-- View and filter AI Studio log files.
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T892147062"] = "View and filter AI Studio log files."
-- Localization
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Localization"
@ -7800,6 +7899,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T4262148639"] = "Rewrite
-- Localization Assistant
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T446674624"] = "Localization Assistant"
-- Log Viewer Assistant
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T555062689"] = "Log Viewer Assistant"
-- New Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T826248509"] = "New Chat"
@ -8610,9 +8712,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Failed
-- Failed to store the API key due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Failed to store the API key due to an API issue."
-- The runtime file manager endpoint returned '{0}'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2158262203"] = "The runtime file manager endpoint returned '{0}'."
-- Failed to delete the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Failed to delete the secret data due to an API issue."
-- The runtime file manager endpoint is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2486847754"] = "The runtime file manager endpoint is not available."
-- The runtime file manager endpoint failed without details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3082220817"] = "The runtime file manager endpoint failed without details."
-- Successfully copied the text to your clipboard
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Successfully copied the text to your clipboard"

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

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Rust;
public readonly record struct OpenPathRequest(string Path);

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Rust;
public readonly record struct OpenPathResponse(bool Success, string Issue);

View File

@ -84,4 +84,48 @@ public sealed partial class RustService
return await result.Content.ReadFromJsonAsync<FileSaveResponse>(this.jsonRustSerializerOptions);
}
public async Task<OpenPathResponse> TryOpenPathInRuntimeFileManager(string path)
{
HttpResponseMessage result;
try
{
result = await this.http.PostAsJsonAsync("/open/path", new OpenPathRequest(path), this.jsonRustSerializerOptions);
}
catch (HttpRequestException e)
{
this.logger!.LogWarning(e, "Failed to reach the Rust runtime file manager endpoint.");
return new OpenPathResponse(false, TB("The runtime file manager endpoint is not available."));
}
catch (TaskCanceledException e)
{
this.logger!.LogWarning(e, "Timed out while reaching the Rust runtime file manager endpoint.");
return new OpenPathResponse(false, TB("The runtime file manager endpoint is not available."));
}
try
{
if (!result.IsSuccessStatusCode)
{
this.logger!.LogWarning("Failed to open a path in the file manager through the Rust runtime: '{StatusCode}'", result.StatusCode);
return new OpenPathResponse(false, string.Format(TB("The runtime file manager endpoint returned '{0}'."), result.StatusCode));
}
var response = await result.Content.ReadFromJsonAsync<OpenPathResponse>(this.jsonRustSerializerOptions);
var normalizedResponse = response.Success
? response
: new OpenPathResponse(false, string.IsNullOrWhiteSpace(response.Issue) ? TB("The runtime file manager endpoint failed without details.") : response.Issue);
return normalizedResponse;
}
catch (Exception e)
{
this.logger!.LogWarning(e, "Failed to process the Rust runtime file manager endpoint response.");
return new OpenPathResponse(false, TB("The runtime file manager endpoint failed without details."));
}
finally
{
result.Dispose();
}
}
}

View File

@ -192,3 +192,102 @@
margin-left: 0 !important;
margin-right: 0 !important;
}
.log-viewer-shell {
min-width: 0;
}
.log-viewer-select {
min-width: 12rem;
}
.log-viewer-number {
max-width: 9rem;
}
.log-viewer-filter {
min-width: 18rem;
max-width: 32rem;
}
.log-viewer-multiselect {
min-width: 16rem;
max-width: 34rem;
}
.log-viewer-path {
color: var(--mud-palette-text-secondary);
word-break: break-all;
}
.log-viewer-pane {
min-height: 28rem;
border: 1px solid var(--mud-palette-lines-default);
border-radius: 6px;
background-color: var(--mud-palette-background-grey);
}
.log-viewer-lines {
margin: 0;
padding: 0.5rem 0;
font-family: Consolas, "Courier New", monospace;
font-size: 0.875rem;
line-height: 1.45;
}
.log-viewer-line {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr);
min-height: 1.25rem;
}
.log-viewer-line:hover {
background-color: var(--mud-palette-action-default-hover);
}
.log-viewer-line-number {
padding-right: 0.75rem;
color: var(--mud-palette-text-secondary);
text-align: right;
user-select: none;
border-right: 1px solid var(--mud-palette-lines-default);
}
.log-viewer-line-text {
padding-left: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.log-viewer-line-error {
color: var(--mud-palette-error);
}
.log-viewer-line-warn {
color: var(--mud-palette-warning-darken);
}
.log-viewer-line-info {
color: var(--mud-palette-info);
}
.log-viewer-line-debug,
.log-viewer-line-trace {
color: var(--mud-palette-text-secondary);
}
.log-viewer-highlight {
padding: 0 2px;
border-radius: 2px;
background-color: var(--mud-palette-warning);
color: var(--mud-palette-warning-text);
}
.log-viewer-empty {
display: flex;
min-height: 20rem;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--mud-palette-text-secondary);
}

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.
- Added audio and video transcription for chats and assistants. AI Studio now prepares supported media locally, sends only normalized audio to the configured transcription provider, and attaches the resulting transcript instead of the original media.
- Added enterprise configuration options to hide the last changelog and vision panels on the welcome page. Thanks, Dominic Neuburg (`donework`), for the contribution.
- Improved the "My Tasks Assistant": you can now provide one or more documents in addition to text or use documents alone when asking to identify tasks.
@ -9,6 +10,7 @@
- Fixed enterprise configuration plugins from Windows-created ZIP files may not load correctly on Linux when the ZIP contained plugin files inside a folder.
- Fixed voice recording and transcription on Linux.
- Fixed being able to switch document analysis policies while an analysis or media transcription was still in progress.
- Fixed file extension handling so files are recognized correctly regardless of uppercase or lowercase letters in their extensions. Thanks, Paul Schweiß, for reporting this issue.
- Upgraded Rust to v1.97.0.
- Upgraded Tauri to v2.11.5.
- Upgraded common dependencies.

17
runtime/Cargo.lock generated
View File

@ -242,6 +242,20 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "ashpd"
version = "0.13.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "281e6645758940dee594495e28807a7672ce40f11ebf4df6c22c4fcd59e2689f"
dependencies = [
"enumflags2",
"futures-util",
"getrandom 0.4.2",
"serde",
"tokio",
"zbus",
]
[[package]]
name = "asn1-rs"
version = "0.7.1"
@ -4072,6 +4086,7 @@ dependencies = [
"aes 0.9.1",
"apple-native-keyring-store",
"arboard",
"ashpd",
"async-stream",
"axum",
"axum-server",
@ -7664,6 +7679,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@ -9566,6 +9582,7 @@ dependencies = [
"rustix 1.1.4",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",

View File

@ -72,6 +72,7 @@ windows-native-keyring-store = "1.1.0"
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
[target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.13.12", default-features = false, features = ["tokio", "open_uri"] }
dbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
webkit2gtk = { version = "2.0.2", features = ["v2_8"] }

View File

@ -2,10 +2,24 @@
use axum::extract::Query;
use axum::Json;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri_plugin_dialog::{DialogExt, FileDialogBuilder};
use crate::api_token::APIToken;
use crate::app_window::MAIN_WINDOW;
#[cfg(any(windows, target_os = "macos"))]
use std::process::Command;
#[cfg(target_os = "linux")]
use ashpd::desktop::open_uri::{OpenDirectoryRequest, OpenFileRequest};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
/// Microsoft documents CREATE_NO_WINDOW as a process creation flag with value 0x08000000.
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[derive(Clone, Deserialize)]
pub struct PreviousDirectory {
path: String,
@ -36,6 +50,11 @@ pub struct SaveFileOptions {
filter: Option<FileTypeFilter>,
}
#[derive(Clone, Deserialize)]
pub struct OpenPathOptions {
path: String,
}
#[derive(Serialize)]
pub struct DirectorySelectionResponse {
user_cancelled: bool,
@ -60,6 +79,12 @@ pub struct FileSaveResponse {
save_file_path: String,
}
#[derive(Serialize)]
pub struct OpenPathResponse {
success: bool,
issue: String,
}
#[derive(Clone, Deserialize)]
pub struct PreviousFile {
file_path: String,
@ -286,6 +311,79 @@ pub async fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json
}
}
pub async fn open_path_in_file_manager(
_token: APIToken,
payload: Json<OpenPathOptions>,
) -> Json<OpenPathResponse> {
let requested_path = PathBuf::from(payload.path.trim());
if requested_path.as_os_str().is_empty() {
return Json(OpenPathResponse {
success: false,
issue: String::from("The path is empty."),
});
}
let Some(target) = resolve_file_manager_target(&requested_path) else {
let issue = format!(
"The path does not exist and its parent folder could not be found: {}",
requested_path.to_string_lossy(),
);
error!(Source = "Tauri"; "{issue}");
return Json(OpenPathResponse {
success: false,
issue,
});
};
#[cfg(target_os = "linux")]
{
return match open_path_in_linux_file_manager(&target).await {
Ok(()) => {
info!("Opened file manager for path: {:?}", target.path);
Json(OpenPathResponse {
success: true,
issue: String::new(),
})
}
Err(issue) => {
error!(Source = "Tauri"; "{issue}");
Json(OpenPathResponse {
success: false,
issue,
})
}
};
}
#[cfg(any(windows, target_os = "macos"))]
{
let mut command = create_file_manager_command(&target);
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
match command.spawn() {
Ok(_) => {
info!("Opened file manager for path: {:?}", target.path);
Json(OpenPathResponse {
success: true,
issue: String::new(),
})
}
Err(error) => {
let issue = format!("Failed to open the file manager: {error}");
error!(Source = "Tauri"; "{issue}");
Json(OpenPathResponse {
success: false,
issue,
})
}
}
}
}
/// Applies an optional file type filter to a FileDialogBuilder.
fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &Option<FileTypeFilter>) -> FileDialogBuilder<R> {
match filter {
@ -296,4 +394,185 @@ fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &O
None => file_dialog,
}
}
}
#[derive(Debug, PartialEq, Eq)]
struct FileManagerTarget {
path: PathBuf,
reveal_file: bool,
}
#[cfg(any(target_os = "linux", test))]
#[derive(Debug, PartialEq, Eq)]
enum LinuxPortalOperation {
RevealFile,
OpenDirectory,
}
fn resolve_file_manager_target(requested_path: &Path) -> Option<FileManagerTarget> {
if requested_path.is_file() {
return Some(FileManagerTarget {
path: requested_path.to_path_buf(),
reveal_file: true,
});
}
if requested_path.is_dir() {
return Some(FileManagerTarget {
path: requested_path.to_path_buf(),
reveal_file: false,
});
}
requested_path.parent()
.filter(|parent| parent.is_dir())
.map(|parent| FileManagerTarget {
path: parent.to_path_buf(),
reveal_file: false,
})
}
#[cfg(any(target_os = "linux", test))]
fn linux_portal_operation(target: &FileManagerTarget) -> LinuxPortalOperation {
if target.reveal_file {
LinuxPortalOperation::RevealFile
} else {
LinuxPortalOperation::OpenDirectory
}
}
#[cfg(any(target_os = "linux", test))]
fn xdg_open_fallback_path(target: &FileManagerTarget) -> &Path {
if target.reveal_file {
target.path.parent().unwrap_or(&target.path)
} else {
&target.path
}
}
#[cfg(target_os = "linux")]
enum LinuxPortalError {
Unavailable(String),
RequestFailed(String),
}
#[cfg(target_os = "linux")]
async fn open_path_with_linux_portal(target: &FileManagerTarget) -> Result<(), LinuxPortalError> {
let file = std::fs::File::open(&target.path)
.map_err(|error| LinuxPortalError::Unavailable(format!("Failed to open the path for the desktop portal: {error}")))?;
let request = match linux_portal_operation(target) {
LinuxPortalOperation::RevealFile => OpenDirectoryRequest::default().send(&file).await,
LinuxPortalOperation::OpenDirectory => OpenFileRequest::default().send_file(&file).await,
}
.map_err(|error| LinuxPortalError::Unavailable(format!("Desktop portal invocation failed: {error}")))?;
request.response()
.map_err(|error| LinuxPortalError::RequestFailed(format!("Desktop portal request failed: {error}")))
}
#[cfg(target_os = "linux")]
async fn open_path_with_xdg_open(target: &FileManagerTarget) -> Result<(), String> {
let fallback_path = xdg_open_fallback_path(target);
let status = tokio::process::Command::new("xdg-open")
.arg(fallback_path)
.status()
.await
.map_err(|error| format!("xdg-open failed to start for '{}': {error}", fallback_path.to_string_lossy()))?;
if status.success() {
Ok(())
} else {
Err(format!("xdg-open failed for '{}' with exit status {status}", fallback_path.to_string_lossy()))
}
}
#[cfg(target_os = "linux")]
async fn open_path_in_linux_file_manager(target: &FileManagerTarget) -> Result<(), String> {
match open_path_with_linux_portal(target).await {
Ok(()) => Ok(()),
Err(LinuxPortalError::RequestFailed(error)) => Err(error),
Err(LinuxPortalError::Unavailable(portal_error)) => {
match open_path_with_xdg_open(target).await {
Ok(()) => Ok(()),
Err(fallback_error) => Err(format!("{portal_error} Fallback failed: {fallback_error}")),
}
}
}
}
#[cfg(target_os = "windows")]
fn create_file_manager_command(target: &FileManagerTarget) -> Command {
let mut command = Command::new("explorer.exe");
if target.reveal_file {
command.arg(format!("/select,{}", target.path.to_string_lossy()));
} else {
command.arg(&target.path);
}
command
}
#[cfg(target_os = "macos")]
fn create_file_manager_command(target: &FileManagerTarget) -> Command {
let mut command = Command::new("open");
if target.reveal_file {
command.arg("-R");
}
command.arg(&target.path);
command
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn existing_file_is_revealed_and_falls_back_to_its_parent() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("application.log");
fs::write(&file_path, "log").unwrap();
let target = resolve_file_manager_target(&file_path).unwrap();
assert_eq!(target.path, file_path);
assert!(target.reveal_file);
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::RevealFile);
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
}
#[test]
fn existing_directory_is_opened_directly() {
let temp_dir = tempfile::tempdir().unwrap();
let target = resolve_file_manager_target(temp_dir.path()).unwrap();
assert_eq!(target.path, temp_dir.path());
assert!(!target.reveal_file);
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory);
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
}
#[test]
fn missing_file_uses_its_existing_parent_directory() {
let temp_dir = tempfile::tempdir().unwrap();
let missing_file = temp_dir.path().join("missing.log");
let target = resolve_file_manager_target(&missing_file).unwrap();
assert_eq!(target.path, temp_dir.path());
assert!(!target.reveal_file);
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory);
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
}
#[test]
fn invalid_path_without_existing_parent_is_rejected() {
let temp_dir = tempfile::tempdir().unwrap();
let invalid_path = temp_dir.path().join("missing-directory").join("missing.log");
assert!(resolve_file_manager_target(&invalid_path).is_none());
}
}

View File

@ -46,6 +46,7 @@ pub fn start_runtime_api() {
.route("/select/file", post(crate::file_actions::select_file))
.route("/select/files", post(crate::file_actions::select_files))
.route("/save/file", post(crate::file_actions::save_file))
.route("/open/path", post(crate::file_actions::open_path_in_file_manager))
.route("/secrets/get", post(crate::secret::get_secret))
.route("/secrets/store", post(crate::secret::store_secret))
.route("/secrets/delete", post(crate::secret::delete_secret))