mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-07-16 14:46:26 +00:00
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
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:
parent
79fbeb636c
commit
b1459523d9
@ -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"
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
namespace AIStudio.Assistants.LogViewer;
|
||||
|
||||
public enum LogFileKind
|
||||
{
|
||||
APP,
|
||||
STARTUP,
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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."
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -29,4 +29,6 @@ public enum ConfigurableAssistant
|
||||
// ReSharper disable InconsistentNaming
|
||||
I18N_ASSISTANT,
|
||||
// ReSharper restore InconsistentNaming
|
||||
|
||||
LOG_VIEWER_ASSISTANT,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -35,4 +35,5 @@ public enum Components
|
||||
AGENT_DATA_SOURCE_SELECTION,
|
||||
AGENT_RETRIEVAL_CONTEXT_VALIDATION,
|
||||
AGENT_ASSISTANT_PLUGIN_AUDIT,
|
||||
LOG_VIEWER_ASSISTANT,
|
||||
}
|
||||
@ -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"),
|
||||
|
||||
|
||||
3
app/MindWork AI Studio/Tools/Rust/OpenPathRequest.cs
Normal file
3
app/MindWork AI Studio/Tools/Rust/OpenPathRequest.cs
Normal file
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public readonly record struct OpenPathRequest(string Path);
|
||||
3
app/MindWork AI Studio/Tools/Rust/OpenPathResponse.cs
Normal file
3
app/MindWork AI Studio/Tools/Rust/OpenPathResponse.cs
Normal file
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public readonly record struct OpenPathResponse(bool Success, string Issue);
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
17
runtime/Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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"] }
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user