mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-02-12 02:01:36 +00:00
Manage global shortcuts (#635)
Some checks are pending
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (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) (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 deb updater) (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 updater) (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) (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 deb updater) (push) Blocked by required conditions
Some checks are pending
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (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) (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 deb updater) (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 updater) (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) (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 deb updater) (push) Blocked by required conditions
This commit is contained in:
parent
c9f037bb2c
commit
127778df42
@ -1675,6 +1675,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218
|
||||
-- Use app default
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "Use app default"
|
||||
|
||||
-- No shortcut configured
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T3099115336"] = "No shortcut configured"
|
||||
|
||||
-- Change shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T4081853237"] = "Change shortcut"
|
||||
|
||||
-- Configure Keyboard Shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T636303786"] = "Configure Keyboard Shortcut"
|
||||
|
||||
-- Yes, let the AI decide which data sources are needed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Yes, let the AI decide which data sources are needed."
|
||||
|
||||
@ -2017,6 +2026,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1059411425"]
|
||||
-- Do you want to show preview features in the app?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Do you want to show preview features in the app?"
|
||||
|
||||
-- Voice recording shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1278320412"] = "Voice recording shortcut"
|
||||
|
||||
-- How often should we check for app updates?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "How often should we check for app updates?"
|
||||
|
||||
@ -2047,6 +2059,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"]
|
||||
-- Select the language for the app.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app."
|
||||
|
||||
-- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused."
|
||||
|
||||
-- Disable dictation and transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription"
|
||||
|
||||
@ -4612,6 +4627,42 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T3832
|
||||
-- Preselect one of your profiles?
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Preselect one of your profiles?"
|
||||
|
||||
-- Save
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1294818664"] = "Save"
|
||||
|
||||
-- Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1464973299"] = "Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused."
|
||||
|
||||
-- Press a key combination...
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1468443151"] = "Press a key combination..."
|
||||
|
||||
-- Clear Shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1807313248"] = "Clear Shortcut"
|
||||
|
||||
-- Invalid shortcut: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T189893682"] = "Invalid shortcut: {0}"
|
||||
|
||||
-- This shortcut conflicts with: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T2633102934"] = "This shortcut conflicts with: {0}"
|
||||
|
||||
-- Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3060573513"] = "Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd)."
|
||||
|
||||
-- Shortcut is valid and available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3159532525"] = "Shortcut is valid and available."
|
||||
|
||||
-- Define a shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3734850493"] = "Define a shortcut"
|
||||
|
||||
-- This is the shortcut you previously used.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T4167229652"] = "This is the shortcut you previously used."
|
||||
|
||||
-- Supported modifiers: Ctrl/Cmd, Shift, Alt.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T889258890"] = "Supported modifiers: Ctrl/Cmd, Shift, Alt."
|
||||
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T900713019"] = "Cancel"
|
||||
|
||||
-- Please enter a value.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Please enter a value."
|
||||
|
||||
@ -5050,6 +5101,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2557066213"] = "Used Open Source
|
||||
-- Build time
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build time"
|
||||
|
||||
-- This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2635482790"] = "This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems."
|
||||
|
||||
-- To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2644379659"] = "To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system."
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
OnAdornmentClick="() => this.SendMessage()"
|
||||
Disabled="@this.IsInputForbidden()"
|
||||
Immediate="@true"
|
||||
OnKeyUp="this.InputKeyEvent"
|
||||
OnKeyUp="@this.InputKeyEvent"
|
||||
UserAttributes="@USER_INPUT_ATTRIBUTES"
|
||||
Class="@this.UserInputClass"
|
||||
Style="@this.UserInputStyle"/>
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
@inherits ConfigurationBaseCore
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@this.Icon" Color="@this.IconColor"/>
|
||||
<MudText Typo="Typo.body1" Class="flex-grow-1">
|
||||
@if (string.IsNullOrWhiteSpace(this.Shortcut()))
|
||||
{
|
||||
@T("No shortcut configured")
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Outlined">
|
||||
@this.GetDisplayShortcut()
|
||||
</MudChip>
|
||||
}
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Edit"
|
||||
OnClick="@this.OpenDialog"
|
||||
Disabled="@this.IsDisabled"
|
||||
Class="mb-1">
|
||||
@T("Change shortcut")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
109
app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs
Normal file
109
app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs
Normal file
@ -0,0 +1,109 @@
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// A configuration component for capturing and displaying keyboard shortcuts.
|
||||
/// </summary>
|
||||
public partial class ConfigurationShortcut : ConfigurationBaseCore
|
||||
{
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The current shortcut value.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<string> Shortcut { get; set; } = () => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// An action which is called when the shortcut was changed.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Action<string> ShortcutUpdate { get; set; } = _ => { };
|
||||
|
||||
/// <summary>
|
||||
/// The name/identifier of the shortcut (used for conflict detection and registration).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Shortcut ShortcutId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The icon to display.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Icon { get; set; } = Icons.Material.Filled.Keyboard;
|
||||
|
||||
/// <summary>
|
||||
/// The color of the icon.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Color IconColor { get; set; } = Color.Default;
|
||||
|
||||
#region Overrides of ConfigurationBase
|
||||
|
||||
protected override bool Stretch => true;
|
||||
|
||||
protected override Variant Variant => Variant.Outlined;
|
||||
|
||||
protected override string Label => this.OptionDescription;
|
||||
|
||||
#endregion
|
||||
|
||||
private string GetDisplayShortcut()
|
||||
{
|
||||
var shortcut = this.Shortcut();
|
||||
if (string.IsNullOrWhiteSpace(shortcut))
|
||||
return string.Empty;
|
||||
|
||||
// Convert internal format to display format:
|
||||
return shortcut
|
||||
.Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl")
|
||||
.Replace("CommandOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl");
|
||||
}
|
||||
|
||||
private async Task OpenDialog()
|
||||
{
|
||||
// Suspend shortcut processing while the dialog is open, so the user can
|
||||
// press the current shortcut to re-enter it without triggering the action:
|
||||
await this.RustService.SuspendShortcutProcessing();
|
||||
|
||||
try
|
||||
{
|
||||
var dialogParameters = new DialogParameters<ShortcutDialog>
|
||||
{
|
||||
{ x => x.InitialShortcut, this.Shortcut() },
|
||||
{ x => x.ShortcutId, this.ShortcutId },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ShortcutDialog>(
|
||||
this.T("Configure Keyboard Shortcut"),
|
||||
dialogParameters,
|
||||
DialogOptions.FULLSCREEN);
|
||||
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
if (dialogResult.Data is string newShortcut)
|
||||
{
|
||||
this.ShortcutUpdate(newShortcut);
|
||||
await this.SettingsManager.StoreSettings();
|
||||
await this.InformAboutChange();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Resume the shortcut processing when the dialog is closed:
|
||||
await this.RustService.ResumeShortcutProcessing();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
@using AIStudio.Settings
|
||||
@using AIStudio.Settings.DataModel
|
||||
@using AIStudio.Tools.Rust
|
||||
@inherits SettingsPanelBase
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Apps" HeaderText="@T("App Options")">
|
||||
@ -33,5 +34,6 @@
|
||||
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
|
||||
{
|
||||
<ConfigurationSelect OptionDescription="@T("Select a transcription provider")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider)" Data="@this.GetFilteredTranscriptionProviders()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider = selectedValue)" OptionHelp="@T("Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.UseTranscriptionProvider, out var meta) && meta.IsLocked"/>
|
||||
<ConfigurationShortcut ShortcutId="Shortcut.VOICE_RECORDING_TOGGLE" OptionDescription="@T("Voice recording shortcut")" Shortcut="@(() => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording)" ShortcutUpdate="@(shortcut => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording = shortcut)" OptionHelp="@T("The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.ShortcutVoiceRecording, out var meta) && meta.IsLocked"/>
|
||||
}
|
||||
</ExpansionPanel>
|
||||
@ -1,5 +1,6 @@
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Tools.MIME;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
@ -24,6 +25,9 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Register for global shortcut events:
|
||||
this.ApplyFilters([], [Event.TAURI_EVENT_RECEIVED]);
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
try
|
||||
@ -37,6 +41,38 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
|
||||
{
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.GLOBAL_SHORTCUT_PRESSED } tauriEvent:
|
||||
// Check if this is the voice recording toggle shortcut:
|
||||
if (tauriEvent.TryGetShortcut(out var shortcutId) && shortcutId == Shortcut.VOICE_RECORDING_TOGGLE)
|
||||
{
|
||||
this.Logger.LogInformation("Global shortcut triggered for voice recording toggle.");
|
||||
await this.ToggleRecordingFromShortcut();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the recording state when triggered by a global shortcut.
|
||||
/// </summary>
|
||||
private async Task ToggleRecordingFromShortcut()
|
||||
{
|
||||
// Don't allow toggle if transcription is in progress or preparing:
|
||||
if (this.isTranscribing || this.isPreparing)
|
||||
{
|
||||
this.Logger.LogDebug("Ignoring shortcut: transcription or preparation is in progress.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle the recording state:
|
||||
await this.OnRecordingToggled(!this.isRecording);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private uint numReceivedChunks;
|
||||
@ -109,6 +145,10 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
// Clean up the recording stream if starting failed:
|
||||
await this.FinalizeRecordingStream();
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.StateHasChanged();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
50
app/MindWork AI Studio/Dialogs/ShortcutDialog.razor
Normal file
50
app/MindWork AI Studio/Dialogs/ShortcutDialog.razor
Normal file
@ -0,0 +1,50 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused.")
|
||||
</MudJustifiedText>
|
||||
|
||||
<MudFocusTrap DefaultFocus="DefaultFocus.FirstChild">
|
||||
<MudTextField
|
||||
@ref="@this.inputField"
|
||||
T="string"
|
||||
Text="@this.ShowText"
|
||||
Variant="Variant.Outlined"
|
||||
Label="@T("Define a shortcut")"
|
||||
Placeholder="@T("Press a key combination...")"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Keyboard"
|
||||
Immediate="@true"
|
||||
TextUpdateSuppression="false"
|
||||
OnKeyDown="@this.HandleKeyDown"
|
||||
OnBlur="@this.HandleBlur"
|
||||
UserAttributes="@USER_INPUT_ATTRIBUTES"
|
||||
AutoFocus="true"
|
||||
KeyDownPreventDefault="true"
|
||||
KeyUpPreventDefault="true"
|
||||
HelperText="@T("Supported modifiers: Ctrl/Cmd, Shift, Alt.")"
|
||||
Class="me-3"/>
|
||||
</MudFocusTrap>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(this.validationMessage))
|
||||
{
|
||||
<MudAlert Severity="@this.validationSeverity" Variant="Variant.Filled" Class="mb-3">
|
||||
@this.validationMessage
|
||||
</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@this.ClearShortcut" Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Clear">
|
||||
@T("Clear Shortcut")
|
||||
</MudButton>
|
||||
<MudSpacer/>
|
||||
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
|
||||
@T("Cancel")
|
||||
</MudButton>
|
||||
<MudButton OnClick="@this.Confirm" Variant="Variant.Filled" Color="Color.Primary" Disabled="@this.hasValidationError">
|
||||
@T("Save")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
385
app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs
Normal file
385
app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs
Normal file
@ -0,0 +1,385 @@
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
|
||||
namespace AIStudio.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// A dialog for capturing and configuring keyboard shortcuts.
|
||||
/// </summary>
|
||||
public partial class ShortcutDialog : MSGComponentBase
|
||||
{
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The initial shortcut value (in internal format, e.g., "CmdOrControl+1").
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string InitialShortcut { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The identifier of the shortcut for conflict detection.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Shortcut ShortcutId { get; set; }
|
||||
|
||||
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
|
||||
|
||||
private string currentShortcut = string.Empty;
|
||||
private string originalShortcut = string.Empty;
|
||||
private string validationMessage = string.Empty;
|
||||
private Severity validationSeverity = Severity.Info;
|
||||
private bool hasValidationError;
|
||||
|
||||
//
|
||||
// Current key state:
|
||||
//
|
||||
private bool hasCtrl;
|
||||
private bool hasShift;
|
||||
private bool hasAlt;
|
||||
private bool hasMeta;
|
||||
private string? currentKey;
|
||||
private MudTextField<string>? inputField;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
// Configure the spellchecking for the user input:
|
||||
this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES);
|
||||
|
||||
this.currentShortcut = this.InitialShortcut;
|
||||
this.originalShortcut = this.InitialShortcut;
|
||||
this.ParseExistingShortcut();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private string ShowText => string.IsNullOrWhiteSpace(this.currentShortcut)
|
||||
? T("Press a key combination...")
|
||||
: this.GetDisplayShortcut();
|
||||
|
||||
private void ParseExistingShortcut()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this.currentShortcut))
|
||||
return;
|
||||
|
||||
// Parse the existing shortcut to set the state
|
||||
var parts = this.currentShortcut.Split('+');
|
||||
foreach (var part in parts)
|
||||
{
|
||||
switch (part.ToLowerInvariant())
|
||||
{
|
||||
case "cmdorcontrol":
|
||||
case "commandorcontrol":
|
||||
case "ctrl":
|
||||
case "control":
|
||||
case "cmd":
|
||||
case "command":
|
||||
this.hasCtrl = true;
|
||||
break;
|
||||
|
||||
case "shift":
|
||||
this.hasShift = true;
|
||||
break;
|
||||
|
||||
case "alt":
|
||||
this.hasAlt = true;
|
||||
break;
|
||||
|
||||
case "meta":
|
||||
case "super":
|
||||
this.hasMeta = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
this.currentKey = part;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
// Ignore pure modifier key presses:
|
||||
if (IsModifierKey(e.Code))
|
||||
{
|
||||
this.UpdateModifiers(e);
|
||||
this.currentKey = null;
|
||||
this.UpdateShortcutString();
|
||||
return;
|
||||
}
|
||||
|
||||
this.UpdateModifiers(e);
|
||||
|
||||
// Get the key:
|
||||
this.currentKey = TranslateKeyCode(e.Code);
|
||||
|
||||
// Validate: must have at least one modifier + a key
|
||||
if (!this.hasCtrl && !this.hasShift && !this.hasAlt && !this.hasMeta)
|
||||
{
|
||||
this.validationMessage = T("Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).");
|
||||
this.validationSeverity = Severity.Warning;
|
||||
this.hasValidationError = true;
|
||||
this.StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
this.UpdateShortcutString();
|
||||
await this.ValidateShortcut();
|
||||
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
private void UpdateModifiers(KeyboardEventArgs e)
|
||||
{
|
||||
this.hasCtrl = e.CtrlKey || e.MetaKey; // Treat Meta (Cmd on Mac) same as Ctrl for cross-platform
|
||||
this.hasShift = e.ShiftKey;
|
||||
this.hasAlt = e.AltKey;
|
||||
this.hasMeta = e is { MetaKey: true, CtrlKey: false }; // Only set meta if not already using ctrl
|
||||
}
|
||||
|
||||
private void UpdateShortcutString()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (this.hasCtrl)
|
||||
parts.Add("CmdOrControl");
|
||||
|
||||
if (this.hasShift)
|
||||
parts.Add("Shift");
|
||||
|
||||
if (this.hasAlt)
|
||||
parts.Add("Alt");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this.currentKey))
|
||||
parts.Add(this.currentKey);
|
||||
|
||||
this.currentShortcut = parts.Count > 0 ? string.Join("+", parts) : string.Empty;
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ValidateShortcut()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this.currentShortcut) || string.IsNullOrWhiteSpace(this.currentKey))
|
||||
{
|
||||
this.validationMessage = string.Empty;
|
||||
this.hasValidationError = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the shortcut is valid by trying to register it with Rust
|
||||
var result = await this.RustService.ValidateShortcut(this.currentShortcut);
|
||||
if (result.IsValid)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(this.originalShortcut)
|
||||
&& this.currentShortcut.Equals(this.originalShortcut, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
this.validationMessage = T("This is the shortcut you previously used.");
|
||||
this.validationSeverity = Severity.Info;
|
||||
this.hasValidationError = false;
|
||||
this.StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.HasConflict)
|
||||
{
|
||||
this.validationMessage = string.Format(T("This shortcut conflicts with: {0}"), result.ConflictDescription);
|
||||
this.validationSeverity = Severity.Warning;
|
||||
this.hasValidationError = false; // Allow saving, but warn
|
||||
}
|
||||
else
|
||||
{
|
||||
this.validationMessage = T("Shortcut is valid and available.");
|
||||
this.validationSeverity = Severity.Success;
|
||||
this.hasValidationError = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
this.validationMessage = string.Format(T("Invalid shortcut: {0}"), result.ErrorMessage);
|
||||
this.validationSeverity = Severity.Error;
|
||||
this.hasValidationError = true;
|
||||
}
|
||||
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
private string GetDisplayShortcut()
|
||||
{
|
||||
// Convert internal format to display format:
|
||||
return this.currentShortcut
|
||||
.Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl")
|
||||
.Replace("CommandOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl");
|
||||
}
|
||||
|
||||
private void ClearShortcut()
|
||||
{
|
||||
this.currentShortcut = string.Empty;
|
||||
this.currentKey = null;
|
||||
this.hasCtrl = false;
|
||||
this.hasShift = false;
|
||||
this.hasAlt = false;
|
||||
this.hasMeta = false;
|
||||
this.validationMessage = string.Empty;
|
||||
this.hasValidationError = false;
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
|
||||
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(this.currentShortcut));
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the key code represents a modifier key.
|
||||
/// </summary>
|
||||
private static bool IsModifierKey(string code) => code switch
|
||||
{
|
||||
"ShiftLeft" or "ShiftRight" => true,
|
||||
"ControlLeft" or "ControlRight" => true,
|
||||
"AltLeft" or "AltRight" => true,
|
||||
"MetaLeft" or "MetaRight" => true,
|
||||
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Translates a JavaScript KeyboardEvent.code to Tauri shortcut format.
|
||||
/// </summary>
|
||||
private static string TranslateKeyCode(string code) => code switch
|
||||
{
|
||||
// Letters
|
||||
"KeyA" => "A",
|
||||
"KeyB" => "B",
|
||||
"KeyC" => "C",
|
||||
"KeyD" => "D",
|
||||
"KeyE" => "E",
|
||||
"KeyF" => "F",
|
||||
"KeyG" => "G",
|
||||
"KeyH" => "H",
|
||||
"KeyI" => "I",
|
||||
"KeyJ" => "J",
|
||||
"KeyK" => "K",
|
||||
"KeyL" => "L",
|
||||
"KeyM" => "M",
|
||||
"KeyN" => "N",
|
||||
"KeyO" => "O",
|
||||
"KeyP" => "P",
|
||||
"KeyQ" => "Q",
|
||||
"KeyR" => "R",
|
||||
"KeyS" => "S",
|
||||
"KeyT" => "T",
|
||||
"KeyU" => "U",
|
||||
"KeyV" => "V",
|
||||
"KeyW" => "W",
|
||||
"KeyX" => "X",
|
||||
"KeyY" => "Y",
|
||||
"KeyZ" => "Z",
|
||||
|
||||
// Numbers
|
||||
"Digit0" => "0",
|
||||
"Digit1" => "1",
|
||||
"Digit2" => "2",
|
||||
"Digit3" => "3",
|
||||
"Digit4" => "4",
|
||||
"Digit5" => "5",
|
||||
"Digit6" => "6",
|
||||
"Digit7" => "7",
|
||||
"Digit8" => "8",
|
||||
"Digit9" => "9",
|
||||
|
||||
// Function keys
|
||||
"F1" => "F1",
|
||||
"F2" => "F2",
|
||||
"F3" => "F3",
|
||||
"F4" => "F4",
|
||||
"F5" => "F5",
|
||||
"F6" => "F6",
|
||||
"F7" => "F7",
|
||||
"F8" => "F8",
|
||||
"F9" => "F9",
|
||||
"F10" => "F10",
|
||||
"F11" => "F11",
|
||||
"F12" => "F12",
|
||||
"F13" => "F13",
|
||||
"F14" => "F14",
|
||||
"F15" => "F15",
|
||||
"F16" => "F16",
|
||||
"F17" => "F17",
|
||||
"F18" => "F18",
|
||||
"F19" => "F19",
|
||||
"F20" => "F20",
|
||||
"F21" => "F21",
|
||||
"F22" => "F22",
|
||||
"F23" => "F23",
|
||||
"F24" => "F24",
|
||||
|
||||
// Special keys
|
||||
"Space" => "Space",
|
||||
"Enter" => "Enter",
|
||||
"Tab" => "Tab",
|
||||
"Escape" => "Escape",
|
||||
"Backspace" => "Backspace",
|
||||
"Delete" => "Delete",
|
||||
"Insert" => "Insert",
|
||||
"Home" => "Home",
|
||||
"End" => "End",
|
||||
"PageUp" => "PageUp",
|
||||
"PageDown" => "PageDown",
|
||||
|
||||
// Arrow keys
|
||||
"ArrowUp" => "Up",
|
||||
"ArrowDown" => "Down",
|
||||
"ArrowLeft" => "Left",
|
||||
"ArrowRight" => "Right",
|
||||
|
||||
// Numpad
|
||||
"Numpad0" => "Num0",
|
||||
"Numpad1" => "Num1",
|
||||
"Numpad2" => "Num2",
|
||||
"Numpad3" => "Num3",
|
||||
"Numpad4" => "Num4",
|
||||
"Numpad5" => "Num5",
|
||||
"Numpad6" => "Num6",
|
||||
"Numpad7" => "Num7",
|
||||
"Numpad8" => "Num8",
|
||||
"Numpad9" => "Num9",
|
||||
"NumpadAdd" => "NumAdd",
|
||||
"NumpadSubtract" => "NumSubtract",
|
||||
"NumpadMultiply" => "NumMultiply",
|
||||
"NumpadDivide" => "NumDivide",
|
||||
"NumpadDecimal" => "NumDecimal",
|
||||
"NumpadEnter" => "NumEnter",
|
||||
|
||||
// Punctuation
|
||||
"Minus" => "Minus",
|
||||
"Equal" => "Equal",
|
||||
"BracketLeft" => "BracketLeft",
|
||||
"BracketRight" => "BracketRight",
|
||||
"Backslash" => "Backslash",
|
||||
"Semicolon" => "Semicolon",
|
||||
"Quote" => "Quote",
|
||||
"Backquote" => "Backquote",
|
||||
"Comma" => "Comma",
|
||||
"Period" => "Period",
|
||||
"Slash" => "Slash",
|
||||
|
||||
// Default: return as-is
|
||||
_ => code,
|
||||
};
|
||||
|
||||
private void HandleBlur()
|
||||
{
|
||||
// Re-focus the input field to keep capturing keys:
|
||||
this.inputField?.FocusAsync();
|
||||
}
|
||||
}
|
||||
@ -97,6 +97,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
|
||||
// Set the snackbar for the update service:
|
||||
UpdateService.SetBlazorDependencies(this.Snackbar);
|
||||
GlobalShortcutService.Initialize();
|
||||
TemporaryChatService.Initialize();
|
||||
|
||||
// Should the navigation bar be open by default?
|
||||
@ -116,11 +117,6 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
private void LoadNavItems()
|
||||
{
|
||||
this.navItems = new List<NavBarItem>(this.GetNavItems());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of ILang
|
||||
@ -251,6 +247,11 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
|
||||
#endregion
|
||||
|
||||
private void LoadNavItems()
|
||||
{
|
||||
this.navItems = new List<NavBarItem>(this.GetNavItems());
|
||||
}
|
||||
|
||||
private IEnumerable<NavBarItem> GetNavItems()
|
||||
{
|
||||
var palette = this.ColorTheme.GetCurrentPalette(this.SettingsManager);
|
||||
|
||||
@ -196,6 +196,7 @@
|
||||
<ThirdPartyComponent Name="Tauri" Developer="Daniel Thompson-Yvetot, Lucas Nogueira, Tensor, Boscop, Serge Zaitsev, George Burton & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/tauri-apps/tauri/blob/dev/LICENSE_MIT" RepositoryUrl="https://github.com/tauri-apps/tauri" UseCase="@T("Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!")"/>
|
||||
<ThirdPartyComponent Name="Rocket" Developer="Sergio Benitez & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/rwf2/Rocket/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/rwf2/Rocket" UseCase="@T("We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust.")"/>
|
||||
<ThirdPartyComponent Name="serde" Developer="Erick Tryzelaar, David Tolnay & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/serde-rs/serde/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/serde-rs/serde" UseCase="@T("Now we have multiple systems, some developed in .NET and others in Rust. The data format JSON is responsible for translating data between both worlds (called data serialization and deserialization). Serde takes on this task in the Rust world. The counterpart in the .NET world is an integral part of .NET and is located in System.Text.Json.")"/>
|
||||
<ThirdPartyComponent Name="strum_macros" Developer="Peter Glotfelty & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/Peternator7/strum/blob/master/LICENSE" RepositoryUrl="https://github.com/Peternator7/strum" UseCase="@T("This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems.")"/>
|
||||
<ThirdPartyComponent Name="keyring" Developer="Walther Chen, Daniel Brotsky & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/hwchen/keyring-rs/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/hwchen/keyring-rs" UseCase="@T("In order to use any LLM, each user must store their so-called API key for each LLM provider. This key must be kept secure, similar to a password. The safest way to do this is offered by operating systems like macOS, Windows, and Linux: They have mechanisms to store such data, if available, on special security hardware. Since this is currently not possible in .NET, we use this Rust library.")"/>
|
||||
<ThirdPartyComponent Name="arboard" Developer="Artur Kovacs, Avi Weinstock, 1Password & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/1Password/arboard/blob/master/LICENSE-MIT.txt" RepositoryUrl="https://github.com/1Password/arboard" UseCase="@T("To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system.")"/>
|
||||
<ThirdPartyComponent Name="tokio" Developer="Alex Crichton, Carl Lerche, Alice Ryhl, Taiki Endo, Ivan Petkov, Eliza Weisman, Lucio Franco & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/tokio-rs/tokio/blob/master/LICENSE" RepositoryUrl="https://github.com/tokio-rs/tokio" UseCase="@T("Code in the Rust language can be specified as synchronous or asynchronous. Unlike .NET and the C# language, Rust cannot execute asynchronous code by itself. Rust requires support in the form of an executor for this. Tokio is one such executor.")"/>
|
||||
|
||||
@ -153,6 +153,16 @@ CONFIG["SETTINGS"] = {}
|
||||
-- I18N_ASSISTANT
|
||||
-- CONFIG["SETTINGS"]["DataApp.HiddenAssistants"] = { "ERI_ASSISTANT", "I18N_ASSISTANT" }
|
||||
|
||||
-- Configure a global shortcut for starting and stopping dictation.
|
||||
--
|
||||
-- The format follows the Rust and Tauri conventions. Especially,
|
||||
-- when you want to use the CTRL key on Windows (or the CMD key on macOS),
|
||||
-- please use "CmdOrControl" as the key name. All parts of the shortcut
|
||||
-- must be separated by a plus sign (+).
|
||||
--
|
||||
-- Examples are: "CmdOrControl+Shift+D", "Alt+F9", "F8"
|
||||
-- CONFIG["SETTINGS"]["DataApp.ShortcutVoiceRecording"] = "CmdOrControl+1"
|
||||
|
||||
-- Example chat templates for this configuration:
|
||||
CONFIG["CHAT_TEMPLATES"] = {}
|
||||
|
||||
|
||||
@ -1677,6 +1677,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218
|
||||
-- Use app default
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "App-Standard verwenden"
|
||||
|
||||
-- No shortcut configured
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T3099115336"] = "Keinen Tastaturkurzbefehl konfiguriert"
|
||||
|
||||
-- Change shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T4081853237"] = "Tastaturkurzbefehl ändern"
|
||||
|
||||
-- Configure Keyboard Shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T636303786"] = "Tastaturkurzbefehl konfigurieren"
|
||||
|
||||
-- Yes, let the AI decide which data sources are needed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Ja, die KI soll entscheiden, welche Datenquellen benötigt werden."
|
||||
|
||||
@ -2019,6 +2028,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1059411425"]
|
||||
-- Do you want to show preview features in the app?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Möchten Sie Vorschaufunktionen in der App anzeigen lassen?"
|
||||
|
||||
-- Voice recording shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1278320412"] = "Tastaturkurzbefehl für Sprachaufnahme"
|
||||
|
||||
-- How often should we check for app updates?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "Wie oft sollen wir nach App-Updates suchen?"
|
||||
|
||||
@ -2049,6 +2061,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"]
|
||||
-- Select the language for the app.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Wählen Sie die Sprache für die App aus."
|
||||
|
||||
-- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "Der globale Tastaturkurzbefehl zum Ein- und Ausschalten der Sprachaufnahme. Dieser Kurzbefehl funktioniert systemweit, auch wenn die App nicht im Vordergrund ist."
|
||||
|
||||
-- Disable dictation and transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Diktieren und Transkribieren deaktivieren"
|
||||
|
||||
@ -4614,6 +4629,42 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T3832
|
||||
-- Preselect one of your profiles?
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Eines ihrer Profile vorauswählen?"
|
||||
|
||||
-- Save
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1294818664"] = "Speichern"
|
||||
|
||||
-- Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1464973299"] = "Drücken Sie die gewünschte Tastenkombination, um den Kurzbefehl festzulegen. Der Tastaturkurzbefehl wird global registriert und funktioniert auch, wenn die App nicht im Vordergrund ist."
|
||||
|
||||
-- Press a key combination...
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1468443151"] = "Drücken Sie eine Tastenkombination …"
|
||||
|
||||
-- Clear Shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1807313248"] = "Tastaturkurzbefehl löschen"
|
||||
|
||||
-- Invalid shortcut: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T189893682"] = "Ungültige Tastenkombination: {0}"
|
||||
|
||||
-- This shortcut conflicts with: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T2633102934"] = "Dieser Tastaturkurzbefehl steht in Konflikt mit: {0}"
|
||||
|
||||
-- Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3060573513"] = "Bitte fügen Sie mindestens einen Modifikatortaste hinzu (Strg, Umschalt, Alt oder Cmd)."
|
||||
|
||||
-- Shortcut is valid and available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3159532525"] = "Der Tastaturkurzbefehl ist gültig und verfügbar."
|
||||
|
||||
-- Define a shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3734850493"] = "Tastaturkurzbefehl festlegen"
|
||||
|
||||
-- This is the shortcut you previously used.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T4167229652"] = "Dies ist der Tastaturkurzbefehl, den Sie zuvor verwendet haben."
|
||||
|
||||
-- Supported modifiers: Ctrl/Cmd, Shift, Alt.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T889258890"] = "Unterstützte Modifikatortasten: Strg/Cmd, Umschalt, Alt."
|
||||
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T900713019"] = "Abbrechen"
|
||||
|
||||
-- Please enter a value.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Bitte geben Sie einen Wert ein."
|
||||
|
||||
@ -5052,6 +5103,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2557066213"] = "Verwendete Open-
|
||||
-- Build time
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build-Zeit"
|
||||
|
||||
-- This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2635482790"] = "Dieses Crate stellt Derive-Makros für Rust-Enums bereit, die wir verwenden, um Boilerplate zu reduzieren, wenn wir String-Konvertierungen und Metadaten für Laufzeittypen implementieren. Das ist hilfreich für die Kommunikation zwischen unseren Rust- und .NET-Systemen."
|
||||
|
||||
-- To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2644379659"] = "Um die Antworten des LLM in anderen Apps nutzen zu können, verwenden wir häufig die Zwischenablage des jeweiligen Betriebssystems. Leider gibt es in .NET keine Lösung, die auf allen Betriebssystemen funktioniert. Deshalb habe ich mich für diese Bibliothek in Rust entschieden. So funktioniert die Datenübertragung zu anderen Apps auf jedem System."
|
||||
|
||||
|
||||
@ -1677,6 +1677,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218
|
||||
-- Use app default
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "Use app default"
|
||||
|
||||
-- No shortcut configured
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T3099115336"] = "No shortcut configured"
|
||||
|
||||
-- Change shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T4081853237"] = "Change shortcut"
|
||||
|
||||
-- Configure Keyboard Shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T636303786"] = "Configure Keyboard Shortcut"
|
||||
|
||||
-- Yes, let the AI decide which data sources are needed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Yes, let the AI decide which data sources are needed."
|
||||
|
||||
@ -2019,6 +2028,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1059411425"]
|
||||
-- Do you want to show preview features in the app?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Do you want to show preview features in the app?"
|
||||
|
||||
-- Voice recording shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1278320412"] = "Voice recording shortcut"
|
||||
|
||||
-- How often should we check for app updates?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "How often should we check for app updates?"
|
||||
|
||||
@ -2049,6 +2061,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"]
|
||||
-- Select the language for the app.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app."
|
||||
|
||||
-- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused."
|
||||
|
||||
-- Disable dictation and transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription"
|
||||
|
||||
@ -4614,6 +4629,42 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T3832
|
||||
-- Preselect one of your profiles?
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Preselect one of your profiles?"
|
||||
|
||||
-- Save
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1294818664"] = "Save"
|
||||
|
||||
-- Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1464973299"] = "Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused."
|
||||
|
||||
-- Press a key combination...
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1468443151"] = "Press a key combination..."
|
||||
|
||||
-- Clear Shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1807313248"] = "Clear Shortcut"
|
||||
|
||||
-- Invalid shortcut: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T189893682"] = "Invalid shortcut: {0}"
|
||||
|
||||
-- This shortcut conflicts with: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T2633102934"] = "This shortcut conflicts with: {0}"
|
||||
|
||||
-- Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3060573513"] = "Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd)."
|
||||
|
||||
-- Shortcut is valid and available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3159532525"] = "Shortcut is valid and available."
|
||||
|
||||
-- Define a shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3734850493"] = "Define a shortcut"
|
||||
|
||||
-- This is the shortcut you previously used.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T4167229652"] = "This is the shortcut you previously used."
|
||||
|
||||
-- Supported modifiers: Ctrl/Cmd, Shift, Alt.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T889258890"] = "Supported modifiers: Ctrl/Cmd, Shift, Alt."
|
||||
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T900713019"] = "Cancel"
|
||||
|
||||
-- Please enter a value.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Please enter a value."
|
||||
|
||||
@ -5052,6 +5103,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2557066213"] = "Used Open Source
|
||||
-- Build time
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build time"
|
||||
|
||||
-- This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2635482790"] = "This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems."
|
||||
|
||||
-- To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2644379659"] = "To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system."
|
||||
|
||||
|
||||
@ -133,6 +133,7 @@ internal sealed class Program
|
||||
builder.Services.AddHostedService<UpdateService>();
|
||||
builder.Services.AddHostedService<TemporaryChatService>();
|
||||
builder.Services.AddHostedService<EnterpriseEnvironmentService>();
|
||||
builder.Services.AddHostedService<GlobalShortcutService>();
|
||||
|
||||
// ReSharper disable AccessToDisposedClosure
|
||||
builder.Services.AddHostedService<RustService>(_ => rust);
|
||||
|
||||
@ -82,6 +82,13 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
|
||||
/// </summary>
|
||||
public string UseTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.UseTranscriptionProvider, string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// The global keyboard shortcut for toggling voice recording.
|
||||
/// Uses Tauri's shortcut format, e.g., "CmdOrControl+1" (Cmd+1 on macOS, Ctrl+1 on Windows/Linux).
|
||||
/// Set to empty string to disable the global shortcut.
|
||||
/// </summary>
|
||||
public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Should the user be allowed to add providers?
|
||||
/// </summary>
|
||||
|
||||
@ -70,6 +70,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
// Config: hide some assistants?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HiddenAssistants, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: global voice recording shortcut
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Handle configured LLM providers:
|
||||
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun);
|
||||
|
||||
|
||||
@ -181,6 +181,10 @@ public static partial class PluginFactory
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HiddenAssistants, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check for the voice recording shortcut:
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
if (wasConfigurationChanged)
|
||||
{
|
||||
await SETTINGS_MANAGER.StoreSettings();
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public sealed record RegisterShortcutRequest(Shortcut Id, string Shortcut);
|
||||
17
app/MindWork AI Studio/Tools/Rust/Shortcut.cs
Normal file
17
app/MindWork AI Studio/Tools/Rust/Shortcut.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies a global keyboard shortcut.
|
||||
/// </summary>
|
||||
public enum Shortcut
|
||||
{
|
||||
/// <summary>
|
||||
/// Null pattern - no shortcut assigned or unknown shortcut.
|
||||
/// </summary>
|
||||
NONE = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Toggles voice recording on/off.
|
||||
/// </summary>
|
||||
VOICE_RECORDING_TOGGLE,
|
||||
}
|
||||
3
app/MindWork AI Studio/Tools/Rust/ShortcutResponse.cs
Normal file
3
app/MindWork AI Studio/Tools/Rust/ShortcutResponse.cs
Normal file
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public sealed record ShortcutResponse(bool Success, string ErrorMessage);
|
||||
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public sealed record ShortcutValidationResponse(bool IsValid, string ErrorMessage, bool HasConflict, string ConflictDescription);
|
||||
@ -0,0 +1,10 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating a keyboard shortcut.
|
||||
/// </summary>
|
||||
/// <param name="IsValid">Whether the shortcut syntax is valid.</param>
|
||||
/// <param name="ErrorMessage">Error message if not valid.</param>
|
||||
/// <param name="HasConflict">Whether the shortcut conflicts with another registered shortcut.</param>
|
||||
/// <param name="ConflictDescription">Description of the conflict, if any.</param>
|
||||
public sealed record ShortcutValidationResult(bool IsValid, string ErrorMessage, bool HasConflict, string ConflictDescription);
|
||||
@ -5,4 +5,41 @@ namespace AIStudio.Tools.Rust;
|
||||
/// </summary>
|
||||
/// <param name="EventType">The type of the Tauri event.</param>
|
||||
/// <param name="Payload">The payload of the Tauri event.</param>
|
||||
public readonly record struct TauriEvent(TauriEventType EventType, List<string> Payload);
|
||||
public readonly record struct TauriEvent(TauriEventType EventType, List<string> Payload)
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to parse the first payload element as a shortcut.
|
||||
/// </summary>
|
||||
/// <param name="shortcut">The parsed shortcut name if successful.</param>
|
||||
/// <returns>True if parsing was successful, false otherwise.</returns>
|
||||
public bool TryGetShortcut(out Shortcut shortcut)
|
||||
{
|
||||
shortcut = default;
|
||||
if(this.EventType != TauriEventType.GLOBAL_SHORTCUT_PRESSED)
|
||||
return false;
|
||||
|
||||
if (this.Payload.Count == 0)
|
||||
return false;
|
||||
|
||||
// Try standard enum parsing (handles PascalCase and numeric values):
|
||||
if (Enum.TryParse(this.Payload[0], ignoreCase: true, out shortcut))
|
||||
return true;
|
||||
|
||||
// Try parsing snake_case format (e.g., "voice_recording_toggle"):
|
||||
return TryParseSnakeCase(this.Payload[0], out shortcut);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse a snake_case string into a ShortcutName enum value.
|
||||
/// </summary>
|
||||
private static bool TryParseSnakeCase(string value, out Shortcut shortcut)
|
||||
{
|
||||
shortcut = default;
|
||||
|
||||
// Convert snake_case to UPPER_SNAKE_CASE for enum matching:
|
||||
var upperSnakeCase = value.ToUpperInvariant();
|
||||
|
||||
// Try to match against enum names (which are in UPPER_SNAKE_CASE):
|
||||
return Enum.TryParse(upperSnakeCase, ignoreCase: false, out shortcut);
|
||||
}
|
||||
};
|
||||
@ -15,4 +15,6 @@ public enum TauriEventType
|
||||
FILE_DROP_HOVERED,
|
||||
FILE_DROP_DROPPED,
|
||||
FILE_DROP_CANCELED,
|
||||
|
||||
GLOBAL_SHORTCUT_PRESSED,
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public sealed record ValidateShortcutRequest(string Shortcut);
|
||||
111
app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs
Normal file
111
app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs
Normal file
@ -0,0 +1,111 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiver
|
||||
{
|
||||
private static bool IS_INITIALIZED;
|
||||
|
||||
private readonly ILogger<GlobalShortcutService> logger;
|
||||
private readonly SettingsManager settingsManager;
|
||||
private readonly MessageBus messageBus;
|
||||
private readonly RustService rustService;
|
||||
|
||||
public GlobalShortcutService(
|
||||
ILogger<GlobalShortcutService> logger,
|
||||
SettingsManager settingsManager,
|
||||
MessageBus messageBus,
|
||||
RustService rustService)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.settingsManager = settingsManager;
|
||||
this.messageBus = messageBus;
|
||||
this.rustService = rustService;
|
||||
|
||||
this.messageBus.RegisterComponent(this);
|
||||
this.ApplyFilters([], [Event.CONFIGURATION_CHANGED]);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait until the app is fully initialized:
|
||||
while (!stoppingToken.IsCancellationRequested && !IS_INITIALIZED)
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
|
||||
|
||||
// Register shortcuts on startup:
|
||||
await this.RegisterAllShortcuts();
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
this.messageBus.Unregister(this);
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#region IMessageBusReceiver
|
||||
|
||||
public async Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
|
||||
{
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.CONFIGURATION_CHANGED:
|
||||
await this.RegisterAllShortcuts();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult<TResult?>(default);
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task RegisterAllShortcuts()
|
||||
{
|
||||
this.logger.LogInformation("Registering global shortcuts.");
|
||||
foreach (var shortcutId in Enum.GetValues<Shortcut>())
|
||||
{
|
||||
if(shortcutId is Shortcut.NONE)
|
||||
continue;
|
||||
|
||||
var shortcut = this.GetShortcutValue(shortcutId);
|
||||
var isEnabled = this.IsShortcutAllowed(shortcutId);
|
||||
|
||||
if (isEnabled && !string.IsNullOrWhiteSpace(shortcut))
|
||||
{
|
||||
var success = await this.rustService.UpdateGlobalShortcut(shortcutId, shortcut);
|
||||
if (success)
|
||||
this.logger.LogInformation("Global shortcut '{ShortcutId}' ({Shortcut}) registered.", shortcutId, shortcut);
|
||||
else
|
||||
this.logger.LogWarning("Failed to register global shortcut '{ShortcutId}' ({Shortcut}).", shortcutId, shortcut);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Disable the shortcut when empty or feature is disabled:
|
||||
await this.rustService.UpdateGlobalShortcut(shortcutId, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.LogInformation("Global shortcuts registration completed.");
|
||||
}
|
||||
|
||||
private string GetShortcutValue(Shortcut name) => name switch
|
||||
{
|
||||
Shortcut.VOICE_RECORDING_TOGGLE => this.settingsManager.ConfigurationData.App.ShortcutVoiceRecording,
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private bool IsShortcutAllowed(Shortcut name) => name switch
|
||||
{
|
||||
// Voice recording is a preview feature:
|
||||
Shortcut.VOICE_RECORDING_TOGGLE => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.settingsManager),
|
||||
|
||||
// Other shortcuts are always allowed:
|
||||
_ => true,
|
||||
};
|
||||
|
||||
public static void Initialize() => IS_INITIALIZED = true;
|
||||
}
|
||||
112
app/MindWork AI Studio/Tools/Services/RustEnumConverter.cs
Normal file
112
app/MindWork AI Studio/Tools/Services/RustEnumConverter.cs
Normal file
@ -0,0 +1,112 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts enum values for Rust communication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rust expects PascalCase enum values (e.g., "VoiceRecordingToggle"),
|
||||
/// while .NET uses UPPER_SNAKE_CASE (e.g., "VOICE_RECORDING_TOGGLE").
|
||||
/// This converter handles the bidirectional conversion.
|
||||
/// </remarks>
|
||||
public sealed class RustEnumConverter : JsonConverter<object>
|
||||
{
|
||||
private static readonly ILogger<RustEnumConverter> LOG = Program.LOGGER_FACTORY.CreateLogger<RustEnumConverter>();
|
||||
|
||||
public override bool CanConvert(Type typeToConvert) => typeToConvert.IsEnum;
|
||||
|
||||
public override object? Read(ref Utf8JsonReader reader, Type enumType, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.String)
|
||||
{
|
||||
var text = reader.GetString();
|
||||
text = ConvertToUpperSnakeCase(text);
|
||||
|
||||
if (Enum.TryParse(enumType, text, out var result))
|
||||
return result;
|
||||
}
|
||||
|
||||
LOG.LogWarning($"Cannot read '{reader.GetString()}' as '{enumType.Name}' enum; token type: {reader.TokenType}");
|
||||
return Activator.CreateInstance(enumType);
|
||||
}
|
||||
|
||||
public override object ReadAsPropertyName(ref Utf8JsonReader reader, Type enumType, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.PropertyName)
|
||||
{
|
||||
var text = reader.GetString();
|
||||
text = ConvertToUpperSnakeCase(text);
|
||||
|
||||
if (Enum.TryParse(enumType, text, out var result))
|
||||
return result;
|
||||
}
|
||||
|
||||
LOG.LogWarning($"Cannot read '{reader.GetString()}' as '{enumType.Name}' enum; token type: {reader.TokenType}");
|
||||
return Activator.CreateInstance(enumType)!;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(ConvertToPascalCase(value.ToString()));
|
||||
}
|
||||
|
||||
public override void WriteAsPropertyName(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WritePropertyName(ConvertToPascalCase(value.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts UPPER_SNAKE_CASE to PascalCase.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to convert (e.g., "VOICE_RECORDING_TOGGLE").</param>
|
||||
/// <returns>The converted text as PascalCase (e.g., "VoiceRecordingToggle").</returns>
|
||||
private static string ConvertToPascalCase(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return string.Empty;
|
||||
|
||||
var parts = text.Split('_', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.Length == 0)
|
||||
continue;
|
||||
|
||||
// First character uppercase, rest lowercase:
|
||||
sb.Append(char.ToUpperInvariant(part[0]));
|
||||
if (part.Length > 1)
|
||||
sb.Append(part[1..].ToLowerInvariant());
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a string to UPPER_SNAKE_CASE.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to convert.</param>
|
||||
/// <returns>The converted text as UPPER_SNAKE_CASE.</returns>
|
||||
private static string ConvertToUpperSnakeCase(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return string.Empty;
|
||||
|
||||
var sb = new StringBuilder(text.Length);
|
||||
var lastCharWasLowerCase = false;
|
||||
|
||||
foreach (var c in text)
|
||||
{
|
||||
if (char.IsUpper(c) && lastCharWasLowerCase)
|
||||
sb.Append('_');
|
||||
|
||||
sb.Append(char.ToUpperInvariant(c));
|
||||
lastCharWasLowerCase = char.IsLower(c);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
140
app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs
Normal file
140
app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs
Normal file
@ -0,0 +1,140 @@
|
||||
// ReSharper disable NotAccessedPositionalProperty.Local
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class RustService
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers or updates a global keyboard shortcut.
|
||||
/// </summary>
|
||||
/// <param name="shortcutId">The identifier for the shortcut.</param>
|
||||
/// <param name="shortcut">The shortcut string in Tauri format (e.g., "CmdOrControl+1"). Use empty string to disable.</param>
|
||||
/// <returns>True if the shortcut was registered successfully, false otherwise.</returns>
|
||||
public async Task<bool> UpdateGlobalShortcut(Shortcut shortcutId, string shortcut)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new RegisterShortcutRequest(shortcutId, shortcut);
|
||||
var response = await this.http.PostAsJsonAsync("/shortcuts/register", request, this.jsonRustSerializerOptions);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger?.LogError("Failed to register global shortcut '{ShortcutId}' due to network error: {StatusCode}", shortcutId, response.StatusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ShortcutResponse>(this.jsonRustSerializerOptions);
|
||||
if (result is null || !result.Success)
|
||||
{
|
||||
this.logger?.LogError("Failed to register global shortcut '{ShortcutId}': {Error}", shortcutId, result?.ErrorMessage ?? "Unknown error");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger?.LogInformation("Global shortcut '{ShortcutId}' registered successfully with key '{Shortcut}'.", shortcutId, shortcut);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger?.LogError(ex, "Exception while registering global shortcut '{ShortcutId}'.", shortcutId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a shortcut string without registering it.
|
||||
/// </summary>
|
||||
/// <param name="shortcut">The shortcut string to validate.</param>
|
||||
/// <returns>A validation result indicating if the shortcut is valid and any conflicts.</returns>
|
||||
public async Task<ShortcutValidationResult> ValidateShortcut(string shortcut)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new ValidateShortcutRequest(shortcut);
|
||||
var response = await this.http.PostAsJsonAsync("/shortcuts/validate", request, this.jsonRustSerializerOptions);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger?.LogError("Failed to validate shortcut due to network error: {StatusCode}", response.StatusCode);
|
||||
return new ShortcutValidationResult(false, "Network error during validation", false, string.Empty);
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ShortcutValidationResponse>(this.jsonRustSerializerOptions);
|
||||
if (result is null)
|
||||
return new ShortcutValidationResult(false, "Invalid response from server", false, string.Empty);
|
||||
|
||||
return new ShortcutValidationResult(result.IsValid, result.ErrorMessage, result.HasConflict, result.ConflictDescription);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger?.LogError(ex, "Exception while validating shortcut.");
|
||||
return new ShortcutValidationResult(false, ex.Message, false, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suspends shortcut processing. The shortcuts remain registered, but events are not sent.
|
||||
/// This is useful when opening a dialog to configure shortcuts, so the user can
|
||||
/// press the current shortcut to re-enter it without triggering the action.
|
||||
/// </summary>
|
||||
/// <returns>True if successful, false otherwise.</returns>
|
||||
public async Task<bool> SuspendShortcutProcessing()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await this.http.PostAsync("/shortcuts/suspend", null);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger?.LogError("Failed to suspend the shortcut processing due to network error: {StatusCode}.", response.StatusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ShortcutResponse>(this.jsonRustSerializerOptions);
|
||||
if (result is null || !result.Success)
|
||||
{
|
||||
this.logger?.LogError("Failed to suspend shortcut processing: {Error}", result?.ErrorMessage ?? "Unknown error");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger?.LogDebug("Shortcut processing suspended.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger?.LogError(ex, "Exception while suspending shortcut processing.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes the shortcut processing after it was suspended.
|
||||
/// </summary>
|
||||
/// <returns>True if successful, false otherwise.</returns>
|
||||
public async Task<bool> ResumeShortcutProcessing()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await this.http.PostAsync("/shortcuts/resume", null);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger?.LogError("Failed to resume shortcut processing due to network error: {StatusCode}.", response.StatusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ShortcutResponse>(this.jsonRustSerializerOptions);
|
||||
if (result is null || !result.Success)
|
||||
{
|
||||
this.logger?.LogError("Failed to resume shortcut processing: {Error}", result?.ErrorMessage ?? "Unknown error");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger?.LogDebug("Shortcut processing resumed.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger?.LogError(ex, "Exception while resuming shortcut processing.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
using Version = System.Version;
|
||||
@ -22,7 +21,10 @@ public sealed partial class RustService : BackgroundService
|
||||
private readonly JsonSerializerOptions jsonRustSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
Converters = { new TolerantEnumConverter() },
|
||||
Converters =
|
||||
{
|
||||
new RustEnumConverter(),
|
||||
},
|
||||
};
|
||||
|
||||
private ILogger<RustService>? logger;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# v26.1.2, build 232 (2026-01-xx xx:xx UTC)
|
||||
- Added the option to hide specific assistants by configuration plugins. This is useful for enterprise environments in organizations.
|
||||
- Added the current date and time to the system prompt for better context in conversations. Thanks Peer `peerschuett` for the contribution.
|
||||
- Added the ability to control the voice recording with transcription (in preview) by using a system-wide shortcut. The shortcut can be configured in the application settings or by using a configuration plugin. Thus, a uniform shortcut can be defined for an entire organization.
|
||||
- Added error handling for the context window overflow, which can occur with huge file attachments in chats or the document analysis assistant.
|
||||
- Improved error handling for model loading in provider dialogs (LLMs, embeddings, transcriptions).
|
||||
- Improved the microphone handling (transcription preview) so that all sound effects and the voice recording are processed without interruption.
|
||||
|
||||
13
runtime/Cargo.lock
generated
13
runtime/Cargo.lock
generated
@ -2772,6 +2772,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"strum_macros",
|
||||
"sys-locale",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@ -4767,6 +4768,18 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.93",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
||||
@ -9,7 +9,7 @@ authors = ["Thorsten Sommer"]
|
||||
tauri-build = { version = "1.5", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "1.8", features = [ "http-all", "updater", "shell-sidecar", "shell-open", "dialog"] }
|
||||
tauri = { version = "1.8", features = [ "http-all", "updater", "shell-sidecar", "shell-open", "dialog", "global-shortcut"] }
|
||||
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
@ -39,6 +39,7 @@ pdfium-render = "0.8.34"
|
||||
sys-locale = "0.3.2"
|
||||
cfg-if = "1.0.1"
|
||||
pptx-to-md = "0.4.0"
|
||||
strum_macros = "0.27"
|
||||
|
||||
# Fixes security vulnerability downstream, where the upstream is not fixed yet:
|
||||
url = "2.5.7"
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use log::{debug, error, info, trace, warn};
|
||||
@ -7,8 +8,9 @@ use rocket::response::stream::TextStream;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::serde::Serialize;
|
||||
use serde::Deserialize;
|
||||
use strum_macros::Display;
|
||||
use tauri::updater::UpdateResponse;
|
||||
use tauri::{FileDropEvent, UpdaterEvent, RunEvent, Manager, PathResolver, Window, WindowEvent};
|
||||
use tauri::{FileDropEvent, GlobalShortcutManager, UpdaterEvent, RunEvent, Manager, PathResolver, Window, WindowEvent};
|
||||
use tauri::api::dialog::blocking::FileDialogBuilder;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::time;
|
||||
@ -27,6 +29,17 @@ static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> =
|
||||
/// The event broadcast sender for Tauri events.
|
||||
static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
/// Stores the currently registered global shortcuts (name -> shortcut string).
|
||||
static REGISTERED_SHORTCUTS: Lazy<Mutex<HashMap<Shortcut, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Enum identifying global keyboard shortcuts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display)]
|
||||
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Shortcut {
|
||||
None = 0,
|
||||
VoiceRecordingToggle,
|
||||
}
|
||||
|
||||
/// Starts the Tauri app.
|
||||
pub fn start_tauri() {
|
||||
info!("Starting Tauri app...");
|
||||
@ -323,6 +336,8 @@ pub enum TauriEventType {
|
||||
FileDropHovered,
|
||||
FileDropDropped,
|
||||
FileDropCanceled,
|
||||
|
||||
GlobalShortcutPressed,
|
||||
}
|
||||
|
||||
/// Changes the location of the main window to the given URL.
|
||||
@ -522,13 +537,7 @@ pub fn select_file(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<F
|
||||
let file_dialog = file_dialog.set_title(&payload.title);
|
||||
|
||||
// Set the file type filter if provided:
|
||||
let file_dialog = match &payload.filter {
|
||||
Some(filter) => {
|
||||
file_dialog.add_filter(&filter.filter_name, &filter.filter_extensions.iter().map(|s| s.as_str()).collect::<Vec<&str>>())
|
||||
},
|
||||
|
||||
None => file_dialog,
|
||||
};
|
||||
let file_dialog = apply_filter(file_dialog, &payload.filter);
|
||||
|
||||
// Set the previous file path if provided:
|
||||
let file_dialog = match &payload.previous_file {
|
||||
@ -572,13 +581,7 @@ pub fn select_files(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<
|
||||
let file_dialog = file_dialog.set_title(&payload.title);
|
||||
|
||||
// Set the file type filter if provided:
|
||||
let file_dialog = match &payload.filter {
|
||||
Some(filter) => {
|
||||
file_dialog.add_filter(&filter.filter_name, &filter.filter_extensions.iter().map(|s| s.as_str()).collect::<Vec<&str>>())
|
||||
},
|
||||
|
||||
None => file_dialog,
|
||||
};
|
||||
let file_dialog = apply_filter(file_dialog, &payload.filter);
|
||||
|
||||
// Set the previous file path if provided:
|
||||
let file_dialog = match &payload.previous_file {
|
||||
@ -621,13 +624,7 @@ pub fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json<FileS
|
||||
let file_dialog = file_dialog.set_title(&payload.title);
|
||||
|
||||
// Set the file type filter if provided:
|
||||
let file_dialog = match &payload.filter {
|
||||
Some(filter) => {
|
||||
file_dialog.add_filter(&filter.filter_name, &filter.filter_extensions.iter().map(|s| s.as_str()).collect::<Vec<&str>>())
|
||||
},
|
||||
|
||||
None => file_dialog,
|
||||
};
|
||||
let file_dialog = apply_filter(file_dialog, &payload.filter);
|
||||
|
||||
// Set the previous file path if provided:
|
||||
let file_dialog = match &payload.name_file {
|
||||
@ -665,6 +662,18 @@ pub struct PreviousFile {
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
/// Applies an optional file type filter to a FileDialogBuilder.
|
||||
fn apply_filter(file_dialog: FileDialogBuilder, filter: &Option<FileTypeFilter>) -> FileDialogBuilder {
|
||||
match filter {
|
||||
Some(f) => file_dialog.add_filter(
|
||||
&f.filter_name,
|
||||
&f.filter_extensions.iter().map(|s| s.as_str()).collect::<Vec<&str>>(),
|
||||
),
|
||||
|
||||
None => file_dialog,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FileSelectionResponse {
|
||||
user_cancelled: bool,
|
||||
@ -683,6 +692,353 @@ pub struct FileSaveResponse {
|
||||
save_file_path: String,
|
||||
}
|
||||
|
||||
/// Request payload for registering a global shortcut.
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct RegisterShortcutRequest {
|
||||
/// The shortcut ID to use.
|
||||
id: Shortcut,
|
||||
|
||||
/// The shortcut string in Tauri format (e.g., "CmdOrControl+1").
|
||||
/// Use empty string to unregister the shortcut.
|
||||
shortcut: String,
|
||||
}
|
||||
|
||||
/// Response for shortcut registration.
|
||||
#[derive(Serialize)]
|
||||
pub struct ShortcutResponse {
|
||||
success: bool,
|
||||
error_message: String,
|
||||
}
|
||||
|
||||
/// Internal helper function to register a shortcut with its callback.
|
||||
/// This is used by both `register_shortcut` and `resume_shortcuts` to
|
||||
/// avoid code duplication.
|
||||
fn register_shortcut_with_callback(
|
||||
shortcut_manager: &mut impl GlobalShortcutManager,
|
||||
shortcut: &str,
|
||||
shortcut_id: Shortcut,
|
||||
event_sender: broadcast::Sender<Event>,
|
||||
) -> Result<(), tauri::Error> {
|
||||
//
|
||||
// Match the shortcut registration to transform the Tauri result into the Rust result:
|
||||
//
|
||||
match shortcut_manager.register(shortcut, move || {
|
||||
info!(Source = "Tauri"; "Global shortcut triggered for '{}'.", shortcut_id);
|
||||
let event = Event::new(TauriEventType::GlobalShortcutPressed, vec![shortcut_id.to_string()]);
|
||||
let sender = event_sender.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match sender.send(event) {
|
||||
Ok(_) => {}
|
||||
Err(error) => error!(Source = "Tauri"; "Failed to send global shortcut event: {error}"),
|
||||
}
|
||||
});
|
||||
}) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers or updates a global shortcut. If the shortcut string is empty,
|
||||
/// the existing shortcut for that name will be unregistered.
|
||||
#[post("/shortcuts/register", data = "<payload>")]
|
||||
pub fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
|
||||
let id = payload.id;
|
||||
let new_shortcut = payload.shortcut.clone();
|
||||
|
||||
if id == Shortcut::None {
|
||||
error!(Source = "Tauri"; "Cannot register NONE shortcut.");
|
||||
return Json(ShortcutResponse {
|
||||
success: false,
|
||||
error_message: "Cannot register NONE shortcut".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
info!(Source = "Tauri"; "Registering global shortcut '{}' with key '{new_shortcut}'.", id);
|
||||
|
||||
// Get the main window to access the global shortcut manager:
|
||||
let main_window_lock = MAIN_WINDOW.lock().unwrap();
|
||||
let main_window = match main_window_lock.as_ref() {
|
||||
Some(window) => window,
|
||||
None => {
|
||||
error!(Source = "Tauri"; "Cannot register shortcut: main window not available.");
|
||||
return Json(ShortcutResponse {
|
||||
success: false,
|
||||
error_message: "Main window not available".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let mut shortcut_manager = main_window.app_handle().global_shortcut_manager();
|
||||
let mut registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
|
||||
|
||||
// Unregister the old shortcut if one exists for this name:
|
||||
if let Some(old_shortcut) = registered_shortcuts.get(&id) {
|
||||
if !old_shortcut.is_empty() {
|
||||
match shortcut_manager.unregister(old_shortcut.as_str()) {
|
||||
Ok(_) => info!(Source = "Tauri"; "Unregistered old shortcut '{old_shortcut}' for '{}'.", id),
|
||||
Err(error) => warn!(Source = "Tauri"; "Failed to unregister old shortcut '{old_shortcut}': {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the new shortcut is empty, we're done (just unregistering):
|
||||
if new_shortcut.is_empty() {
|
||||
registered_shortcuts.remove(&id);
|
||||
info!(Source = "Tauri"; "Shortcut '{}' has been disabled.", id);
|
||||
return Json(ShortcutResponse {
|
||||
success: true,
|
||||
error_message: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Get the event broadcast sender for the shortcut callback:
|
||||
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
|
||||
let event_sender = match event_broadcast_lock.as_ref() {
|
||||
Some(sender) => sender.clone(),
|
||||
None => {
|
||||
error!(Source = "Tauri"; "Cannot register shortcut: event broadcast not initialized.");
|
||||
return Json(ShortcutResponse {
|
||||
success: false,
|
||||
error_message: "Event broadcast not initialized".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
drop(event_broadcast_lock);
|
||||
|
||||
// Register the new shortcut:
|
||||
match register_shortcut_with_callback(&mut shortcut_manager, &new_shortcut, id, event_sender) {
|
||||
Ok(_) => {
|
||||
info!(Source = "Tauri"; "Global shortcut '{new_shortcut}' registered successfully for '{}'.", id);
|
||||
registered_shortcuts.insert(id, new_shortcut);
|
||||
Json(ShortcutResponse {
|
||||
success: true,
|
||||
error_message: String::new(),
|
||||
})
|
||||
},
|
||||
|
||||
Err(error) => {
|
||||
let error_msg = format!("Failed to register shortcut: {error}");
|
||||
error!(Source = "Tauri"; "{error_msg}");
|
||||
Json(ShortcutResponse {
|
||||
success: false,
|
||||
error_message: error_msg,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request payload for validating a shortcut.
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct ValidateShortcutRequest {
|
||||
/// The shortcut string to validate (e.g., "CmdOrControl+1").
|
||||
shortcut: String,
|
||||
}
|
||||
|
||||
/// Response for shortcut validation.
|
||||
#[derive(Serialize)]
|
||||
pub struct ShortcutValidationResponse {
|
||||
is_valid: bool,
|
||||
error_message: String,
|
||||
has_conflict: bool,
|
||||
conflict_description: String,
|
||||
}
|
||||
|
||||
/// Validates a shortcut string without registering it.
|
||||
/// Checks if the shortcut syntax is valid and if it
|
||||
/// conflicts with existing shortcuts.
|
||||
#[post("/shortcuts/validate", data = "<payload>")]
|
||||
pub fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutRequest>) -> Json<ShortcutValidationResponse> {
|
||||
let shortcut = payload.shortcut.clone();
|
||||
|
||||
// Empty shortcuts are always valid (means "disabled"):
|
||||
if shortcut.is_empty() {
|
||||
return Json(ShortcutValidationResponse {
|
||||
is_valid: true,
|
||||
error_message: String::new(),
|
||||
has_conflict: false,
|
||||
conflict_description: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the shortcut is already registered:
|
||||
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
|
||||
for (name, registered_shortcut) in registered_shortcuts.iter() {
|
||||
if registered_shortcut.eq_ignore_ascii_case(&shortcut) {
|
||||
return Json(ShortcutValidationResponse {
|
||||
is_valid: true,
|
||||
error_message: String::new(),
|
||||
has_conflict: true,
|
||||
conflict_description: format!("Already used by: {}", name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drop(registered_shortcuts);
|
||||
|
||||
// Try to parse the shortcut to validate syntax.
|
||||
// We can't easily validate without registering in Tauri 1.x,
|
||||
// so we do basic syntax validation here:
|
||||
let is_valid = validate_shortcut_syntax(&shortcut);
|
||||
|
||||
if is_valid {
|
||||
Json(ShortcutValidationResponse {
|
||||
is_valid: true,
|
||||
error_message: String::new(),
|
||||
has_conflict: false,
|
||||
conflict_description: String::new(),
|
||||
})
|
||||
} else {
|
||||
Json(ShortcutValidationResponse {
|
||||
is_valid: false,
|
||||
error_message: format!("Invalid shortcut syntax: {}", shortcut),
|
||||
has_conflict: false,
|
||||
conflict_description: String::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Suspends shortcut processing by unregistering all shortcuts from the OS.
|
||||
/// The shortcuts remain in our internal map, so they can be re-registered on resume.
|
||||
/// This is useful when opening a dialog to configure shortcuts, so the user can
|
||||
/// press the current shortcut to re-enter it without triggering the action.
|
||||
#[post("/shortcuts/suspend")]
|
||||
pub fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
|
||||
// Get the main window to access the global shortcut manager:
|
||||
let main_window_lock = MAIN_WINDOW.lock().unwrap();
|
||||
let main_window = match main_window_lock.as_ref() {
|
||||
Some(window) => window,
|
||||
None => {
|
||||
error!(Source = "Tauri"; "Cannot suspend shortcuts: main window not available.");
|
||||
return Json(ShortcutResponse {
|
||||
success: false,
|
||||
error_message: "Main window not available".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let mut shortcut_manager = main_window.app_handle().global_shortcut_manager();
|
||||
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
|
||||
|
||||
// Unregister all shortcuts from the OS (but keep them in our map):
|
||||
for (name, shortcut) in registered_shortcuts.iter() {
|
||||
if !shortcut.is_empty() {
|
||||
match shortcut_manager.unregister(shortcut.as_str()) {
|
||||
Ok(_) => info!(Source = "Tauri"; "Temporarily unregistered shortcut '{shortcut}' for '{}'.", name),
|
||||
Err(error) => warn!(Source = "Tauri"; "Failed to unregister shortcut '{shortcut}' for '{}': {error}", name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(Source = "Tauri"; "Shortcut processing has been suspended ({} shortcuts unregistered).", registered_shortcuts.len());
|
||||
Json(ShortcutResponse {
|
||||
success: true,
|
||||
error_message: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resumes shortcut processing by re-registering all shortcuts with the OS.
|
||||
#[post("/shortcuts/resume")]
|
||||
pub fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
|
||||
// Get the main window to access the global shortcut manager:
|
||||
let main_window_lock = MAIN_WINDOW.lock().unwrap();
|
||||
let main_window = match main_window_lock.as_ref() {
|
||||
Some(window) => window,
|
||||
None => {
|
||||
error!(Source = "Tauri"; "Cannot resume shortcuts: main window not available.");
|
||||
return Json(ShortcutResponse {
|
||||
success: false,
|
||||
error_message: "Main window not available".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let mut shortcut_manager = main_window.app_handle().global_shortcut_manager();
|
||||
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
|
||||
|
||||
// Get the event broadcast sender for the shortcut callbacks:
|
||||
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
|
||||
let event_sender = match event_broadcast_lock.as_ref() {
|
||||
Some(sender) => sender.clone(),
|
||||
None => {
|
||||
error!(Source = "Tauri"; "Cannot resume shortcuts: event broadcast not initialized.");
|
||||
return Json(ShortcutResponse {
|
||||
success: false,
|
||||
error_message: "Event broadcast not initialized".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
drop(event_broadcast_lock);
|
||||
|
||||
// Re-register all shortcuts with the OS:
|
||||
let mut success_count = 0;
|
||||
for (shortcut_id, shortcut) in registered_shortcuts.iter() {
|
||||
if shortcut.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match register_shortcut_with_callback(&mut shortcut_manager, shortcut, *shortcut_id, event_sender.clone()) {
|
||||
Ok(_) => {
|
||||
info!(Source = "Tauri"; "Re-registered shortcut '{shortcut}' for '{}'.", shortcut_id);
|
||||
success_count += 1;
|
||||
},
|
||||
|
||||
Err(error) => warn!(Source = "Tauri"; "Failed to re-register shortcut '{shortcut}' for '{}': {error}", shortcut_id),
|
||||
}
|
||||
}
|
||||
|
||||
info!(Source = "Tauri"; "Shortcut processing has been resumed ({success_count} shortcuts re-registered).");
|
||||
Json(ShortcutResponse {
|
||||
success: true,
|
||||
error_message: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates the syntax of a shortcut string.
|
||||
fn validate_shortcut_syntax(shortcut: &str) -> bool {
|
||||
let parts: Vec<&str> = shortcut.split('+').collect();
|
||||
if parts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut has_key = false;
|
||||
for part in parts {
|
||||
let part_lower = part.to_lowercase();
|
||||
match part_lower.as_str() {
|
||||
// Modifiers
|
||||
"cmdorcontrol" | "commandorcontrol" | "ctrl" | "control" | "cmd" | "command" |
|
||||
"shift" | "alt" | "meta" | "super" | "option" => continue,
|
||||
|
||||
// Keys - letters
|
||||
"a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" |
|
||||
"n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" => has_key = true,
|
||||
|
||||
// Keys - numbers
|
||||
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" => has_key = true,
|
||||
|
||||
// Keys - function keys
|
||||
_ if part_lower.starts_with('f') && part_lower[1..].parse::<u32>().is_ok() => has_key = true,
|
||||
|
||||
// Keys - special
|
||||
"space" | "enter" | "tab" | "escape" | "backspace" | "delete" | "insert" |
|
||||
"home" | "end" | "pageup" | "pagedown" |
|
||||
"up" | "down" | "left" | "right" |
|
||||
"arrowup" | "arrowdown" | "arrowleft" | "arrowright" |
|
||||
"minus" | "equal" | "bracketleft" | "bracketright" | "backslash" |
|
||||
"semicolon" | "quote" | "backquote" | "comma" | "period" | "slash" => has_key = true,
|
||||
|
||||
// Keys - numpad
|
||||
_ if part_lower.starts_with("num") => has_key = true,
|
||||
|
||||
// Unknown
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
|
||||
has_key
|
||||
}
|
||||
|
||||
fn set_pdfium_path(path_resolver: PathResolver) {
|
||||
let pdfium_relative_source_path = String::from("resources/libraries/");
|
||||
let pdfium_source_path = path_resolver.resolve_resource(pdfium_relative_source_path);
|
||||
|
||||
@ -87,6 +87,10 @@ pub fn start_runtime_api() {
|
||||
crate::file_data::extract_data,
|
||||
crate::log::get_log_paths,
|
||||
crate::log::log_event,
|
||||
crate::app_window::register_shortcut,
|
||||
crate::app_window::validate_shortcut,
|
||||
crate::app_window::suspend_shortcuts,
|
||||
crate::app_window::resume_shortcuts,
|
||||
])
|
||||
.ignite().await.unwrap()
|
||||
.launch().await.unwrap();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user