Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions src/TableCloth.App/Dialogs/SkillMarketplaceWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:TableCloth.ViewModels"
xmlns:ai="using:TableCloth.ManagedAi"
xmlns:automation="using:Avalonia.Automation"
xmlns:ui="using:TableCloth.Theme.Controls"
x:Class="TableCloth.Dialogs.SkillMarketplaceWindow"
x:DataType="vm:SkillMarketplaceWindowViewModel"
Title="{x:Static ai:ManagedAiText.MarketplaceTitle}"
Expand All @@ -22,6 +24,26 @@
<TextBlock Text="{x:Static ai:ManagedAiText.MarketplaceDescription}" TextWrapping="Wrap" />
<TextBlock Text="{x:Static ai:ManagedAiText.MarketplaceScope}" FontSize="11"
Foreground="{DynamicResource ControlSecondaryForeground}" />
<Border x:Name="MarketplaceActivity" IsVisible="{Binding IsBusy}" Margin="0 7 0 0"
Background="{DynamicResource ControlFillColorSecondaryBrush}"
BorderBrush="{DynamicResource OptionsListBorderBrush}" BorderThickness="1" CornerRadius="4" Padding="12 10">
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
<ui:ProgressRing x:Name="MarketplaceProgress" IsIndeterminate="True" IsActive="{Binding IsBusy}"
Width="24" Height="24" VerticalAlignment="Center"
automation:AutomationProperties.Name="{x:Static ai:ManagedAiText.MarketplaceTitle}" />
<StackPanel Grid.Column="1" Spacing="6" VerticalAlignment="Center">
<TextBlock x:Name="CatalogActivity" Text="{Binding CatalogActivityText}" IsVisible="{Binding CatalogBusy}"
TextWrapping="Wrap" FontSize="12" automation:AutomationProperties.LiveSetting="Polite"
automation:AutomationProperties.Name="{Binding CatalogActivityText}" />
<TextBlock x:Name="LocalActivity" Text="{Binding LocalActivityText}" IsVisible="{Binding HasLocalActivity}"
TextWrapping="Wrap" FontSize="12" automation:AutomationProperties.LiveSetting="Polite"
automation:AutomationProperties.Name="{Binding LocalActivityText}" />
<TextBlock x:Name="RuntimeActivity" Text="{Binding SkillRuntimeNotice}" IsVisible="{Binding SkillRuntimeBusy}"
TextWrapping="Wrap" FontSize="12" automation:AutomationProperties.LiveSetting="Polite"
automation:AutomationProperties.Name="{Binding SkillRuntimeNotice}" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
<TabControl Grid.Row="1" x:Name="MarketplaceTabs">
<TabItem Header="{x:Static ai:ManagedAiText.MarketplaceBrowse}">
Expand Down Expand Up @@ -86,7 +108,7 @@
</WrapPanel>
</Grid>
</Grid>
<TextBlock Grid.Row="3" x:Name="CatalogNotice" Text="{Binding CatalogNotice}" TextWrapping="Wrap" FontSize="12"
<TextBlock Grid.Row="3" x:Name="CatalogNotice" Text="{Binding CatalogNotice}" TextWrapping="Wrap" FontSize="12" IsVisible="{Binding !CatalogBusy}"
Foreground="{DynamicResource ControlSecondaryForeground}" />
</Grid>
</TabItem>
Expand Down Expand Up @@ -141,12 +163,12 @@
<Button x:Name="InstalledRefresh" Content="{x:Static ai:ManagedAiText.SkillsRefresh}" Command="{Binding RefreshInstalledCommand}" />
<Button x:Name="InstalledRootFolder" Content="{x:Static ai:ManagedAiText.SkillsOpenFolder}" Command="{Binding OpenRootFolderCommand}" />
</WrapPanel>
<TextBlock Grid.Row="4" x:Name="InstalledNotice" Text="{Binding LocalNotice}" TextWrapping="Wrap" FontSize="12"
<TextBlock Grid.Row="4" x:Name="InstalledNotice" Text="{Binding LocalNotice}" TextWrapping="Wrap" FontSize="12" IsVisible="{Binding !HasLocalActivity}"
Foreground="{DynamicResource ControlSecondaryForeground}" />
</Grid>
</TabItem>
<TabItem Header="{x:Static ai:ManagedAiText.MarketplaceRuntimes}">
<Grid Margin="12 16" RowDefinitions="*,Auto,Auto,Auto" RowSpacing="10">
<Grid Margin="12 16" RowDefinitions="*,Auto,Auto" RowSpacing="10">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="14" MaxWidth="680" HorizontalAlignment="Left">
<TextBlock Text="{x:Static ai:ManagedAiText.SkillRuntimeDescription}" TextWrapping="Wrap" />
Expand Down Expand Up @@ -179,8 +201,7 @@
<Button x:Name="SkillRuntimeInstall" Content="{x:Static ai:ManagedAiText.SkillRuntimeInstall}" Command="{Binding InstallSkillRuntimesCommand}" />
<Button x:Name="SkillRuntimeRefresh" Content="{x:Static ai:ManagedAiText.SkillRuntimeRefresh}" Command="{Binding RefreshSkillRuntimeStatusCommand}" />
</WrapPanel>
<ProgressBar Grid.Row="2" x:Name="SkillRuntimeProgress" IsIndeterminate="True" IsVisible="{Binding SkillRuntimeBusy}" Height="4" />
<TextBlock Grid.Row="3" x:Name="SkillRuntimeNotice" Text="{Binding SkillRuntimeNotice}" TextWrapping="Wrap"
<TextBlock Grid.Row="2" x:Name="SkillRuntimeNotice" Text="{Binding SkillRuntimeNotice}" TextWrapping="Wrap" IsVisible="{Binding !SkillRuntimeBusy}"
Foreground="{DynamicResource ControlSecondaryForeground}" />
</Grid>
</TabItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public partial class SkillMarketplaceWindowViewModel

partial void OnSkillRuntimeBusyChanged(bool value)
{
OnPropertyChanged(nameof(IsBusy));
InstallSkillRuntimesCommand.NotifyCanExecuteChanged();
RefreshSkillRuntimeStatusCommand.NotifyCanExecuteChanged();
}
Expand Down
64 changes: 46 additions & 18 deletions src/TableCloth.App/ViewModels/SkillMarketplaceWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,17 @@ public SkillMarketplaceWindowViewModel(IManagedAiSkillManager manager, IManagedA
[ObservableProperty] private bool _localBusy;
[ObservableProperty] private string _catalogNotice = string.Empty;
[ObservableProperty] private string _localNotice = string.Empty;
[ObservableProperty] private string _localActivityText = string.Empty;

public bool HasSelectedCatalogSkill => SelectedCatalogSkill is not null;
public bool HasSelectedInstalledSkill => SelectedInstalledSkill is not null;
public bool CatalogEmpty => CatalogSkills.Count == 0;
public bool InstalledEmpty => InstalledSkills.Count == 0;
public string CatalogSummary => ManagedAiText.Select($"후보 {_catalog.Count}개, 표시 {CatalogSkills.Count}개", $"{_catalog.Count} candidates, {CatalogSkills.Count} shown");
public bool IsBusy => CatalogBusy || LocalBusy || SkillRuntimeBusy;
public bool HasLocalActivity => LocalBusy && LocalActivityText.Length > 0;
public string CatalogActivityText => ManagedAiText.Select("스킬 카탈로그를 불러오고 있습니다.", "Loading the skill catalog.");
public bool CatalogEmpty => !CatalogBusy && CatalogSkills.Count == 0;
public bool InstalledEmpty => !LocalBusy && InstalledSkills.Count == 0;
public string CatalogSummary => CatalogBusy ? CatalogActivityText
: ManagedAiText.Select($"후보 {_catalog.Count}개, 표시 {CatalogSkills.Count}개", $"{_catalog.Count} candidates, {CatalogSkills.Count} shown");
public string InstalledSummary => ManagedAiText.Select($"보유 스킬 {_installed.Count}개, 활성 스킬 {_installed.Count(x => x.Enabled)}개, 표시 {InstalledSkills.Count}개",
$"{_installed.Count} installed, {_installed.Count(x => x.Enabled)} enabled, {InstalledSkills.Count} shown");
public string ToggleLabel => SelectedInstalledSkill?.Enabled == true ? ManagedAiText.Select("비활성화", "Disable") : ManagedAiText.Select("활성화", "Enable");
Expand All @@ -114,9 +119,19 @@ partial void OnSelectedInstalledSkillChanged(SkillSettingsItem? value)
OnPropertyChanged(nameof(ToggleLabel));
NotifyLocalCommands();
}
partial void OnLocalBusyChanged(bool value) => NotifyLocalCommands();
partial void OnLocalActivityTextChanged(string value) => OnPropertyChanged(nameof(HasLocalActivity));
partial void OnLocalBusyChanged(bool value)
{
NotifyLocalCommands();
OnPropertyChanged(nameof(IsBusy));
OnPropertyChanged(nameof(HasLocalActivity));
OnPropertyChanged(nameof(InstalledEmpty));
}
partial void OnCatalogBusyChanged(bool value)
{
OnPropertyChanged(nameof(IsBusy));
OnPropertyChanged(nameof(CatalogEmpty));
OnPropertyChanged(nameof(CatalogSummary));
RefreshCatalogCommand.NotifyCanExecuteChanged();
InstallSkillCommand.NotifyCanExecuteChanged();
UpdateCatalogSkillCommand.NotifyCanExecuteChanged();
Expand Down Expand Up @@ -183,54 +198,63 @@ private async Task RefreshCatalog()
}

[RelayCommand(CanExecute = nameof(CanUseLocal))]
private Task RefreshInstalled() => RunLocalAsync(() => _manager.ListAsync(_token), ManagedAiText.Select("설치된 스킬을 새로 불러왔습니다.", "Installed skills refreshed."));
private Task RefreshInstalled() => RunLocalAsync(() => _manager.ListAsync(_token),
ManagedAiText.Select("설치된 스킬을 불러오고 있습니다.", "Loading installed skills."),
ManagedAiText.Select("설치된 스킬을 새로 불러왔습니다.", "Installed skills refreshed."));

[RelayCommand(CanExecute = nameof(CanUseLocal))]
private async Task ImportSkill()
{
if (!CanUseLocal() || PickFolderAsync is null) return;
LocalActivityText = ManagedAiText.Select("가져올 스킬 폴더를 선택하고 있습니다.", "Selecting a skill folder to import.");
LocalBusy = true;
string? source;
try { source = await PickFolderAsync(); }
catch { LocalNotice = ManagedAiText.Select("스킬 폴더를 선택하지 못했습니다.", "Could not select a skill folder."); return; }
finally { LocalBusy = false; }
finally { LocalBusy = false; LocalActivityText = string.Empty; }
if (!string.IsNullOrWhiteSpace(source)) await ImportFromDirectoryAsync(source);
}

public Task ImportFromDirectoryAsync(string source) => RunLocalAsync(() => _manager.ImportAsync(source, _token),
ManagedAiText.Select("스킬을 전용 폴더로 가져오고 있습니다.", "Importing the skill into the dedicated folder."),
ManagedAiText.Select("스킬을 전용 폴더로 가져왔습니다.", "Skill imported into the dedicated folder."));

[RelayCommand(CanExecute = nameof(CanInstall))]
private Task InstallSkill()
{
if (!CanInstall()) return Task.CompletedTask;
var id = SelectedCatalogSkill!.Id;
return RunMarketplaceActionAsync(() => _marketplace.InstallAsync(id, _token), ManagedAiText.Select("스킬을 설치했습니다.", "Skill installed."), true);
var selected = SelectedCatalogSkill!;
return RunMarketplaceActionAsync(() => _marketplace.InstallAsync(selected.Id, _token),
ManagedAiText.Select($"{selected.Name} 스킬을 설치하고 있습니다.", $"Installing {selected.Name}."),
ManagedAiText.Select("스킬을 설치했습니다.", "Skill installed."), true);
}

[RelayCommand(CanExecute = nameof(CanUpdateCatalog))]
private Task UpdateCatalogSkill()
{
if (!CanUpdateCatalog()) return Task.CompletedTask;
return UpdateAsync(SelectedCatalogSkill!.Id, true);
var selected = SelectedCatalogSkill!;
return UpdateAsync(selected.Id, selected.Name, true);
}

[RelayCommand(CanExecute = nameof(CanUpdateInstalled))]
private Task UpdateInstalledSkill()
{
if (!CanUpdateInstalled()) return Task.CompletedTask;
return UpdateAsync(SelectedInstalledSkill!.Id);
var selected = SelectedInstalledSkill!;
return UpdateAsync(selected.Id, selected.Name);
}

private Task UpdateAsync(string id, bool fromCatalog = false)
private Task UpdateAsync(string id, string name, bool fromCatalog = false)
{
var success = ManagedAiText.Select("스킬 파일을 카탈로그의 커밋으로 업데이트했습니다.", "Skill files updated to the catalog commit.");
return RunMarketplaceActionAsync(() => _marketplace.UpdateAsync(id, _token), success, fromCatalog);
return RunMarketplaceActionAsync(() => _marketplace.UpdateAsync(id, _token),
ManagedAiText.Select($"{name} 스킬을 업데이트하고 있습니다.", $"Updating {name}."), success, fromCatalog);
}

private async Task RunMarketplaceActionAsync(Func<Task<IReadOnlyList<AiSkill>>> action, string success, bool fromCatalog)
private async Task RunMarketplaceActionAsync(Func<Task<IReadOnlyList<AiSkill>>> action, string activity, string success, bool fromCatalog)
{
if (await RunLocalAsync(action, success))
if (await RunLocalAsync(action, activity, success))
{
await RefreshCatalog();
LocalNotice = _catalogAvailable ? success : success + " " + CatalogNotice;
Expand All @@ -244,6 +268,7 @@ private Task ToggleSkill()
if (!CanToggleSelected()) return Task.CompletedTask;
var selected = SelectedInstalledSkill!;
return RunLocalAsync(() => _manager.SetEnabledAsync(selected.Id, !selected.Enabled, _token),
ManagedAiText.Select($"{selected.Name} 스킬 사용 설정을 변경하고 있습니다.", $"Updating the enabled state of {selected.Name}."),
ManagedAiText.Select("스킬 사용 설정을 변경했습니다. 다음 대화부터 적용합니다.", "Skill state updated. The next chat uses this setting."));
}

Expand All @@ -257,17 +282,20 @@ private Task RemoveSkill()
return Task.CompletedTask;
// A modal confirmation can run a nested UI loop. Recheck the captured selection before mutation.
if (!CanUseSelectedLocal() || !ReferenceEquals(selected, SelectedInstalledSkill)) return Task.CompletedTask;
return RunLocalAsync(() => _manager.RemoveAsync(selected.Id, _token), ManagedAiText.Select("스킬을 제거했습니다.", "Skill removed."));
return RunLocalAsync(() => _manager.RemoveAsync(selected.Id, _token),
ManagedAiText.Select($"{selected.Name} 스킬을 제거하고 있습니다.", $"Removing {selected.Name}."),
ManagedAiText.Select("스킬을 제거했습니다.", "Skill removed."));
}

private async Task<bool> RunLocalAsync(Func<Task<IReadOnlyList<AiSkill>>> action, string success)
private async Task<bool> RunLocalAsync(Func<Task<IReadOnlyList<AiSkill>>> action, string activity, string success)
{
if (!CanUseLocal()) return false;
var succeeded = false;
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_localCompletion = completion;
LocalActivityText = activity;
LocalBusy = true;
LocalNotice = ManagedAiText.Select("전용 스킬을 확인하고 있습니다.", "Checking dedicated skills.");
LocalNotice = activity;
try
{
_installed = await Task.Run(action, _token);
Expand All @@ -290,7 +318,7 @@ private async Task<bool> RunLocalAsync(Func<Task<IReadOnlyList<AiSkill>>> action
};
}
catch { LocalNotice = ManagedAiText.Select("스킬 작업을 완료하지 못했습니다. 잠시 후 다시 시도할 수 있습니다.", "Could not complete the skill operation. Try again shortly."); }
finally { LocalBusy = false; completion.TrySetResult(); }
finally { LocalBusy = false; LocalActivityText = string.Empty; completion.TrySetResult(); }
return succeeded;
}

Expand Down
Loading
Loading