feat(tools): add first-run external tool installer wizard and catalog
첫 실행 시 외부 도구 설치 마법사를 띄우고 7종(FFmpeg·LibreOffice·Pandoc·ImageMagick·H2Orestart·agy·Codex)을 기본 전체 선택 상태로 순차 설치한다. - Core: ExternalToolCatalog(정적 카탈로그, winget ID 실검증), ExternalToolInstaller(winget 무인 실행·재감지 판정, IExternalCommandRunner 시드로 테스트 가능) - Core: ExternalToolDetector에 winget FFmpeg 스캔 + Pandoc/ImageMagick 감지 추가 - App: ToolSetupWindow + ToolSetupViewModel, 첫 실행 플래그(setup.tools.done), 설정 창 '외부 도구 설치/검증' 재진입 버튼 - Tests: ExternalToolInstallerTests 5건 - 기존 미커밋 작업(광고 Ads 지원: AdBannerControl/AdLargeCardControl/AdService, AI 백엔드 agy·switchboard·codex 채팅 클라이언트, 관련 테스트) 포함
This commit is contained in:
parent
f00f6567f4
commit
6a41c4a0dc
37 changed files with 2545 additions and 69 deletions
|
|
@ -76,15 +76,27 @@ public partial class App : Application
|
||||||
|
|
||||||
private void ShowMainWindow()
|
private void ShowMainWindow()
|
||||||
{
|
{
|
||||||
var window = new MainWindow(Engine, Settings);
|
RunFirstRunSetupIfNeeded();
|
||||||
|
var adService = _services.GetService<Everything2Everything.Core.Ads.IAdService>();
|
||||||
|
var window = new MainWindow(Engine, Settings, null, adService);
|
||||||
MainWindow = window;
|
MainWindow = window;
|
||||||
window.Show();
|
window.Show();
|
||||||
window.Activate();
|
window.Activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>첫 실행이면 외부 도구 설치 마법사를 한 번 띄우고 완료 플래그를 남긴다(설정 창에서 재진입 가능).</summary>
|
||||||
|
private void RunFirstRunSetupIfNeeded()
|
||||||
|
{
|
||||||
|
if (Settings.Get("setup.tools.done") == "1") return;
|
||||||
|
var wizard = new ToolSetupWindow();
|
||||||
|
wizard.ShowDialog();
|
||||||
|
Settings.Set("setup.tools.done", "1");
|
||||||
|
}
|
||||||
|
|
||||||
private void ShowConvertDialog(IReadOnlyList<string> files)
|
private void ShowConvertDialog(IReadOnlyList<string> files)
|
||||||
{
|
{
|
||||||
var window = new Views.MainWindow(Engine, Settings, files);
|
var adService = _services.GetService<Everything2Everything.Core.Ads.IAdService>();
|
||||||
|
var window = new Views.MainWindow(Engine, Settings, files, adService);
|
||||||
MainWindow = window;
|
MainWindow = window;
|
||||||
window.Show();
|
window.Show();
|
||||||
window.Activate();
|
window.Activate();
|
||||||
|
|
|
||||||
106
src/Everything2Everything.App/ViewModels/AdViewModel.cs
Normal file
106
src/Everything2Everything.App/ViewModels/AdViewModel.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using Everything2Everything.Core.Ads;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.ViewModels;
|
||||||
|
|
||||||
|
public class AdViewModel : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private readonly IAdService _adService;
|
||||||
|
private AdItem? _currentBannerAd;
|
||||||
|
private AdItem? _currentLargeAd;
|
||||||
|
private bool _isBannerVisible = true;
|
||||||
|
private bool _isLargeCardVisible = true;
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
public AdItem? CurrentBannerAd
|
||||||
|
{
|
||||||
|
get => _currentBannerAd;
|
||||||
|
set { _currentBannerAd = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdItem? CurrentLargeAd
|
||||||
|
{
|
||||||
|
get => _currentLargeAd;
|
||||||
|
set { _currentLargeAd = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBannerVisible
|
||||||
|
{
|
||||||
|
get => _isBannerVisible;
|
||||||
|
set { _isBannerVisible = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLargeCardVisible
|
||||||
|
{
|
||||||
|
get => _isLargeCardVisible;
|
||||||
|
set { _isLargeCardVisible = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand ClickBannerAdCommand { get; }
|
||||||
|
public ICommand ClickLargeAdCommand { get; }
|
||||||
|
public ICommand DismissBannerCommand { get; }
|
||||||
|
public ICommand DismissLargeCardCommand { get; }
|
||||||
|
|
||||||
|
public AdViewModel(IAdService adService)
|
||||||
|
{
|
||||||
|
_adService = adService ?? throw new ArgumentNullException(nameof(adService));
|
||||||
|
|
||||||
|
CurrentBannerAd = _adService.GetNextBannerAd();
|
||||||
|
CurrentLargeAd = _adService.GetNextLargeCardAd();
|
||||||
|
|
||||||
|
ClickBannerAdCommand = new AdCommand(_ =>
|
||||||
|
{
|
||||||
|
if (CurrentBannerAd != null)
|
||||||
|
_adService.OpenAdUrl(CurrentBannerAd);
|
||||||
|
});
|
||||||
|
|
||||||
|
ClickLargeAdCommand = new AdCommand(_ =>
|
||||||
|
{
|
||||||
|
if (CurrentLargeAd != null)
|
||||||
|
_adService.OpenAdUrl(CurrentLargeAd);
|
||||||
|
});
|
||||||
|
|
||||||
|
DismissBannerCommand = new AdCommand(_ => IsBannerVisible = false);
|
||||||
|
DismissLargeCardCommand = new AdCommand(_ => IsLargeCardVisible = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RotateBannerAd()
|
||||||
|
{
|
||||||
|
CurrentBannerAd = _adService.GetNextBannerAd();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RotateLargeCardAd()
|
||||||
|
{
|
||||||
|
CurrentLargeAd = _adService.GetNextLargeCardAd();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AdCommand : ICommand
|
||||||
|
{
|
||||||
|
private readonly Action<object?> _execute;
|
||||||
|
private readonly Func<object?, bool>? _canExecute;
|
||||||
|
|
||||||
|
public AdCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
|
||||||
|
{
|
||||||
|
_execute = execute;
|
||||||
|
_canExecute = canExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
||||||
|
public void Execute(object? parameter) => _execute(parameter);
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/Everything2Everything.App/ViewModels/ToolSetupViewModel.cs
Normal file
184
src/Everything2Everything.App/ViewModels/ToolSetupViewModel.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.ViewModels;
|
||||||
|
|
||||||
|
public enum ToolInstallState
|
||||||
|
{
|
||||||
|
NotInstalled,
|
||||||
|
Installed,
|
||||||
|
Busy,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>설치 마법사 목록의 한 행. 카탈로그 정의 + 선택/상태/진행 표현.</summary>
|
||||||
|
public sealed class ToolInstallItem : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public ExternalToolDefinition Definition { get; }
|
||||||
|
public ToolInstallItem(ExternalToolDefinition definition)
|
||||||
|
{
|
||||||
|
Definition = definition;
|
||||||
|
RefreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => Definition.DisplayName;
|
||||||
|
public string Description => Definition.Description;
|
||||||
|
|
||||||
|
private bool _isSelected = true;
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => _isSelected;
|
||||||
|
set { _isSelected = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToolInstallState _state;
|
||||||
|
public ToolInstallState State
|
||||||
|
{
|
||||||
|
get => _state;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
_state = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(IsBusy));
|
||||||
|
OnPropertyChanged(nameof(StatusText));
|
||||||
|
OnPropertyChanged(nameof(StatusBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string StatusText => State switch
|
||||||
|
{
|
||||||
|
ToolInstallState.Installed => "설치됨",
|
||||||
|
ToolInstallState.Busy => "설치 중…",
|
||||||
|
ToolInstallState.Failed => "실패",
|
||||||
|
_ => "미설치",
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool IsBusy => State == ToolInstallState.Busy;
|
||||||
|
|
||||||
|
public Brush StatusBrush => State switch
|
||||||
|
{
|
||||||
|
ToolInstallState.Installed => BrushOf("FsStatusSuccess"),
|
||||||
|
ToolInstallState.Busy => BrushOf("FsStatusInfo"),
|
||||||
|
ToolInstallState.Failed => BrushOf("FsStatusDanger"),
|
||||||
|
_ => BrushOf("FsStatusWarn"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private string? _detail;
|
||||||
|
public string? Detail
|
||||||
|
{
|
||||||
|
get => _detail;
|
||||||
|
private set { _detail = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshStatus()
|
||||||
|
=> State = Definition.IsInstalled() ? ToolInstallState.Installed : ToolInstallState.NotInstalled;
|
||||||
|
|
||||||
|
public void ApplyResult(bool success, string? detail)
|
||||||
|
{
|
||||||
|
Detail = detail;
|
||||||
|
State = success ? ToolInstallState.Installed : ToolInstallState.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginInstall() => State = ToolInstallState.Busy;
|
||||||
|
|
||||||
|
private static Brush BrushOf(string key)
|
||||||
|
=> (Application.Current?.TryFindResource(key) as Brush)
|
||||||
|
?? _fallbacks.GetValueOrDefault(key, _warn);
|
||||||
|
|
||||||
|
private static readonly Brush _info = Fixed(0x1565C0);
|
||||||
|
private static readonly Brush _success = Fixed(0x2E7D32);
|
||||||
|
private static readonly Brush _warn = Fixed(0xC77700);
|
||||||
|
private static readonly Brush _danger = Fixed(0xC62828);
|
||||||
|
private static readonly Dictionary<string, Brush> _fallbacks = new()
|
||||||
|
{
|
||||||
|
["FsStatusInfo"] = _info,
|
||||||
|
["FsStatusSuccess"] = _success,
|
||||||
|
["FsStatusWarn"] = _warn,
|
||||||
|
["FsStatusDanger"] = _danger,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Brush Fixed(uint rgb)
|
||||||
|
{
|
||||||
|
var c = (int)rgb;
|
||||||
|
return new SolidColorBrush(Color.FromRgb((byte)(c >> 16), (byte)(c >> 8), (byte)c));
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>설치 마법사의 루트 뷰모델: 목록 + 전체 선택 + 선택분 순차 설치.</summary>
|
||||||
|
public sealed class ToolSetupViewModel : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private readonly ExternalToolInstaller _installer;
|
||||||
|
private bool _isInstalling;
|
||||||
|
private bool _selectAll = true;
|
||||||
|
|
||||||
|
public ObservableCollection<ToolInstallItem> Tools { get; } = new();
|
||||||
|
|
||||||
|
public ToolSetupViewModel(IEnumerable<ExternalToolDefinition>? definitions = null, ExternalToolInstaller? installer = null)
|
||||||
|
{
|
||||||
|
_installer = installer ?? new ExternalToolInstaller();
|
||||||
|
foreach (var def in definitions ?? ExternalToolCatalog.All)
|
||||||
|
Tools.Add(new ToolInstallItem(def));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsInstalling
|
||||||
|
{
|
||||||
|
get => _isInstalling;
|
||||||
|
private set { _isInstalling = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool SelectAll
|
||||||
|
{
|
||||||
|
get => _selectAll;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_selectAll = value;
|
||||||
|
foreach (var t in Tools) t.IsSelected = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshStatus()
|
||||||
|
{
|
||||||
|
foreach (var t in Tools) t.RefreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InstallSelectedAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (IsInstalling) return;
|
||||||
|
IsInstalling = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var item in Tools.Where(t => t.IsSelected).ToList())
|
||||||
|
await InstallOneAsync(item, ct).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsInstalling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InstallOneAsync(ToolInstallItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
item.BeginInstall();
|
||||||
|
var result = await _installer.InstallAsync(item.Definition, ct).ConfigureAwait(true);
|
||||||
|
var detail = string.IsNullOrWhiteSpace(result.Output)
|
||||||
|
? result.Message
|
||||||
|
: Tail(result.Output, 600);
|
||||||
|
item.ApplyResult(result.Success, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Tail(string s, int max)
|
||||||
|
=> s.Length <= max ? s : "…" + s[^max..];
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
99
src/Everything2Everything.App/Views/AdBannerControl.xaml
Normal file
99
src/Everything2Everything.App/Views/AdBannerControl.xaml
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<UserControl x:Class="Everything2Everything.App.Views.AdBannerControl"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignHeight="56" d:DesignWidth="800">
|
||||||
|
<UserControl.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Border Visibility="{Binding IsBannerVisible, Converter={StaticResource BoolToVis}}"
|
||||||
|
Style="{StaticResource FsDoubleBezelShellStyle}"
|
||||||
|
Margin="0,4,0,0">
|
||||||
|
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="12,8">
|
||||||
|
<Grid VerticalAlignment="Center">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- 1. [AD] / [스폰서] 배지 -->
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Margin="0,0,10,0"
|
||||||
|
Padding="6,2"
|
||||||
|
CornerRadius="4"
|
||||||
|
Background="#1A00E5FF"
|
||||||
|
BorderBrush="{StaticResource FsAccentCyan}"
|
||||||
|
BorderThickness="1"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CurrentBannerAd.BadgeText, FallbackValue=AD}"
|
||||||
|
FontSize="10"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource FsAccentCyan}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 2. 광고 타이틀 및 설명 -->
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="{Binding CurrentBannerAd.Title, FallbackValue=스폰서십 안내}"
|
||||||
|
Style="{StaticResource FsLabelStyle}"
|
||||||
|
Foreground="{StaticResource FsTextPrimary}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding CurrentBannerAd.Description, FallbackValue=Everything2Everything을 후원하고 최신 기능을 가장 먼저 만나보세요.}"
|
||||||
|
Style="{StaticResource FsCaptionStyle}"
|
||||||
|
Foreground="{StaticResource FsTextSecondary}"
|
||||||
|
Margin="0,2,0,0"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 3. CTA 액션 버튼 -->
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
Padding="12,4"
|
||||||
|
Height="28"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||||
|
Command="{Binding ClickBannerAdCommand}"
|
||||||
|
ToolTip="광고 및 안내 링크 열기">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CurrentBannerAd.CtaText, FallbackValue='자세히 보기'}"
|
||||||
|
FontSize="11"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<ui:SymbolIcon Symbol="Open24"
|
||||||
|
FontSize="11"
|
||||||
|
Margin="4,0,0,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- 4. 광고 닫기 버튼 -->
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Width="24" Height="24"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Style="{StaticResource FsIconButtonStyle}"
|
||||||
|
Command="{Binding DismissBannerCommand}"
|
||||||
|
ToolTip="광고 닫기">
|
||||||
|
<ui:SymbolIcon Symbol="Dismiss24"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{StaticResource FsTextTertiary}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
11
src/Everything2Everything.App/Views/AdBannerControl.xaml.cs
Normal file
11
src/Everything2Everything.App/Views/AdBannerControl.xaml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.Views;
|
||||||
|
|
||||||
|
public partial class AdBannerControl : UserControl
|
||||||
|
{
|
||||||
|
public AdBannerControl()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/Everything2Everything.App/Views/AdLargeCardControl.xaml
Normal file
117
src/Everything2Everything.App/Views/AdLargeCardControl.xaml
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<UserControl x:Class="Everything2Everything.App.Views.AdLargeCardControl"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignHeight="220" d:DesignWidth="320">
|
||||||
|
<UserControl.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Border Visibility="{Binding IsLargeCardVisible, Converter={StaticResource BoolToVis}}"
|
||||||
|
Style="{StaticResource FsDoubleBezelShellStyle}"
|
||||||
|
Margin="0,16,0,0">
|
||||||
|
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="16">
|
||||||
|
<StackPanel>
|
||||||
|
<!-- 1. 헤더: 스폰서 배지 + 닫기 버튼 -->
|
||||||
|
<Grid Margin="0,0,0,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Border Padding="6,2"
|
||||||
|
CornerRadius="4"
|
||||||
|
Background="#1A3B82F6"
|
||||||
|
BorderBrush="{StaticResource FsAccentBlue}"
|
||||||
|
BorderThickness="1"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CurrentLargeAd.BadgeText, FallbackValue='스폰서 추천'}"
|
||||||
|
FontSize="10"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource FsAccentBlue}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Width="22" Height="22"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Style="{StaticResource FsIconButtonStyle}"
|
||||||
|
Command="{Binding DismissLargeCardCommand}"
|
||||||
|
ToolTip="광고 닫기">
|
||||||
|
<ui:SymbolIcon Symbol="Dismiss24"
|
||||||
|
FontSize="10"
|
||||||
|
Foreground="{StaticResource FsTextTertiary}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 2. 본문: 아이콘/이미지 + 타이틀 + 설명 -->
|
||||||
|
<Grid Margin="0,0,0,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Width="36" Height="36"
|
||||||
|
CornerRadius="8"
|
||||||
|
Background="{StaticResource FsBgInput}"
|
||||||
|
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||||
|
BorderThickness="1"
|
||||||
|
Margin="0,0,12,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Image Width="20" Height="20"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality"
|
||||||
|
Source="pack://application:,,,/Everything2Everything;component/Assets/logo-mark.png"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CurrentLargeAd.Title, FallbackValue='비즈니스 미디어 솔루션'}"
|
||||||
|
Style="{StaticResource FsLabelStyle}"
|
||||||
|
Foreground="{StaticResource FsTextPrimary}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="13"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding CurrentLargeAd.Description, FallbackValue='고성능 무손실 일괄 변환을 업무 환경에 적용하세요.'}"
|
||||||
|
Style="{StaticResource FsCaptionStyle}"
|
||||||
|
Foreground="{StaticResource FsTextSecondary}"
|
||||||
|
Margin="0,3,0,0"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 3. 하단 전면 CTA 버튼 -->
|
||||||
|
<Button Style="{StaticResource FsIslandPrimaryButtonStyle}"
|
||||||
|
Height="34"
|
||||||
|
Command="{Binding ClickLargeAdCommand}"
|
||||||
|
ToolTip="자세히 보기">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CurrentLargeAd.CtaText, FallbackValue='솔루션 살펴보기'}"
|
||||||
|
FontSize="12"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<ui:SymbolIcon Symbol="ArrowRight24"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="6,0,0,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.Views;
|
||||||
|
|
||||||
|
public partial class AdLargeCardControl : UserControl
|
||||||
|
{
|
||||||
|
public AdLargeCardControl()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
|
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
|
||||||
|
xmlns:local="clr-namespace:Everything2Everything.App.Views"
|
||||||
Title="Everything2Everything"
|
Title="Everything2Everything"
|
||||||
Icon="pack://application:,,,/Everything2Everything;component/Assets/app-icon.png"
|
Icon="pack://application:,,,/Everything2Everything;component/Assets/app-icon.png"
|
||||||
Width="1280" Height="960"
|
Width="1280" Height="960"
|
||||||
|
|
@ -327,6 +328,26 @@
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- E. AI 텍스트 변환 작업 선택기 (TXT/MD) -->
|
||||||
|
<StackPanel x:Name="AiQuickPanel" Margin="0,0,0,6" Visibility="Collapsed">
|
||||||
|
<TextBlock Text="AI 변환 작업" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,4"/>
|
||||||
|
<ComboBox x:Name="AiTaskQuickCombo"
|
||||||
|
AutomationProperties.AutomationId="AiTaskQuickCombo"
|
||||||
|
SelectedIndex="{Binding Options.AiTaskIndex, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay}"
|
||||||
|
SelectionChanged="OnAiTaskSelectionChanged">
|
||||||
|
<ComboBoxItem Content="핵심 요약 (Summarize)"/>
|
||||||
|
<ComboBoxItem Content="다국어 번역 (Translate)"/>
|
||||||
|
<ComboBoxItem Content="오탈자 및 문법 교정 (Proofread)"/>
|
||||||
|
</ComboBox>
|
||||||
|
<StackPanel x:Name="AiTargetLanguagePanel" Margin="0,8,0,0" Visibility="Collapsed">
|
||||||
|
<TextBlock Text="번역 대상 언어" Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox x:Name="AiTargetLanguageBox"
|
||||||
|
Style="{StaticResource FsPathInputStyle}"
|
||||||
|
Padding="10,6"
|
||||||
|
Text="{Binding Options.TargetLanguage, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- 5. 상세 인코딩 설정 (폴드아웃 Expander: 프리셋 직하단 위치) -->
|
<!-- 5. 상세 인코딩 설정 (폴드아웃 Expander: 프리셋 직하단 위치) -->
|
||||||
<Expander x:Name="AdvancedOptionsExpander"
|
<Expander x:Name="AdvancedOptionsExpander"
|
||||||
Margin="0,6,0,6"
|
Margin="0,6,0,6"
|
||||||
|
|
@ -666,17 +687,18 @@
|
||||||
<Border Grid.Row="0" Grid.Column="0"
|
<Border Grid.Row="0" Grid.Column="0"
|
||||||
Background="{StaticResource FsBgPanel}"
|
Background="{StaticResource FsBgPanel}"
|
||||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||||
BorderThickness="0,0,1,1" Padding="16,8">
|
BorderThickness="0,0,1,1" Padding="12,8">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="175"/>
|
<ColumnDefinition Width="*" MinWidth="110"/>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<!-- Search Box with Live Watermark & Clear Button -->
|
<!-- Search Box with Live Watermark & Clear Button (Flex Width) -->
|
||||||
<Grid Grid.Column="0" Width="175" VerticalAlignment="Center">
|
<Grid Grid.Column="0" Margin="0,0,8,0" VerticalAlignment="Center">
|
||||||
<TextBox x:Name="SearchBox" Style="{StaticResource FsPathInputStyle}"
|
<TextBox x:Name="SearchBox" Style="{StaticResource FsPathInputStyle}"
|
||||||
Padding="28,5,24,5" Text="{Binding SearchText, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=PropertyChanged}"
|
Padding="28,5,24,5" Text="{Binding SearchText, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=PropertyChanged}"
|
||||||
ToolTip="파일명 또는 확장자로 검색" VerticalAlignment="Center"/>
|
ToolTip="파일명 또는 확장자로 검색" VerticalAlignment="Center" HorizontalAlignment="Stretch"/>
|
||||||
<ui:SymbolIcon Symbol="Search24" FontSize="13" Foreground="{StaticResource FsTextTertiary}"
|
<ui:SymbolIcon Symbol="Search24" FontSize="13" Foreground="{StaticResource FsTextTertiary}"
|
||||||
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="8,0,0,0" IsHitTestVisible="False"/>
|
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="8,0,0,0" IsHitTestVisible="False"/>
|
||||||
<TextBlock x:Name="SearchPlaceholderText"
|
<TextBlock x:Name="SearchPlaceholderText"
|
||||||
|
|
@ -720,49 +742,26 @@
|
||||||
<ui:SymbolIcon Symbol="Dismiss24" FontSize="10" Foreground="{StaticResource FsTextTertiary}" VerticalAlignment="Center"/>
|
<ui:SymbolIcon Symbol="Dismiss24" FontSize="10" Foreground="{StaticResource FsTextTertiary}" VerticalAlignment="Center"/>
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
<!-- Category Filter Chips & Inspector Toggle -->
|
<!-- 카테고리 필터 폴드아웃 드롭다운 -->
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
<ComboBox x:Name="CategoryFilterCombo" Grid.Column="1"
|
||||||
<Button Margin="0,0,3,0" Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
|
AutomationProperties.AutomationId="CategoryFilterCombo"
|
||||||
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
MinWidth="90" Height="28" Padding="10,4"
|
||||||
CommandParameter="All">
|
FontSize="11" VerticalAlignment="Center"
|
||||||
<TextBlock Text="전체" FontSize="11" VerticalAlignment="Center"/>
|
ToolTip="카테고리 필터 (전체 · 이미지 · 문서 · 미디어 · 데이터)"
|
||||||
</Button>
|
SelectionChanged="OnCategoryFilterSelectionChanged">
|
||||||
<Button Margin="0,0,3,0" Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
|
<ComboBoxItem Tag="All" IsSelected="True" Content="전체"/>
|
||||||
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
<ComboBoxItem Tag="Image" Content="이미지"/>
|
||||||
CommandParameter="Image">
|
<ComboBoxItem Tag="Document" Content="문서"/>
|
||||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
<ComboBoxItem Tag="Media" Content="미디어"/>
|
||||||
<ui:SymbolIcon Symbol="Image24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
<ComboBoxItem Tag="Data" Content="데이터"/>
|
||||||
<TextBlock Text="이미지" FontSize="11" VerticalAlignment="Center"/>
|
</ComboBox>
|
||||||
</StackPanel>
|
<!-- 구분선 & 미리보기 토글 버튼 -->
|
||||||
</Button>
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="6,0,0,0">
|
||||||
<Button Margin="0,0,3,0" Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
|
<Border Width="1" Height="14" Background="{StaticResource FsBorderSubtle}" Margin="2,0,6,0"/>
|
||||||
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
<Button Padding="7,4" Height="28" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||||
CommandParameter="Document">
|
|
||||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
|
||||||
<ui:SymbolIcon Symbol="Document24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
|
||||||
<TextBlock Text="문서" FontSize="11" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button Margin="0,0,3,0" Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
|
|
||||||
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
|
||||||
CommandParameter="Media">
|
|
||||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
|
||||||
<ui:SymbolIcon Symbol="Video24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
|
||||||
<TextBlock Text="미디어" FontSize="11" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button Margin="0,0,3,0" Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
|
|
||||||
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
|
||||||
CommandParameter="Data">
|
|
||||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
|
||||||
<ui:SymbolIcon Symbol="Database24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
|
||||||
<TextBlock Text="데이터" FontSize="11" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Border Width="1" Height="14" Background="{StaticResource FsBorderSubtle}" Margin="5,0"/>
|
|
||||||
<Button Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
|
|
||||||
Command="{Binding ToggleInspectorCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
Command="{Binding ToggleInspectorCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
ToolTip="미리보기 패널 접기/펼치기">
|
ToolTip="미리보기 패널 접기/펼치기"
|
||||||
|
VerticalAlignment="Center">
|
||||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
<ui:SymbolIcon Symbol="PreviewLink24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
<ui:SymbolIcon Symbol="PreviewLink24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||||
<TextBlock Text="미리보기" FontSize="11" VerticalAlignment="Center"/>
|
<TextBlock Text="미리보기" FontSize="11" VerticalAlignment="Center"/>
|
||||||
|
|
@ -774,8 +773,13 @@
|
||||||
|
|
||||||
<!-- 좌측: Active Queue OR Past Results -->
|
<!-- 좌측: Active Queue OR Past Results -->
|
||||||
<Grid Grid.Row="1" Grid.Column="0">
|
<Grid Grid.Row="1" Grid.Column="0">
|
||||||
<!-- Active Queue view (default) -->
|
<Grid.RowDefinitions>
|
||||||
<Grid x:Name="ActiveQueueView" Visibility="Visible">
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Active Queue view (default) -->
|
||||||
|
<Grid x:Name="ActiveQueueView" Grid.Row="0" Visibility="Visible">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
|
|
@ -1012,7 +1016,7 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Past Results view -->
|
<!-- Past Results view -->
|
||||||
<Grid x:Name="PastResultsContainer" Visibility="Collapsed">
|
<Grid x:Name="PastResultsContainer" Grid.Row="0" Visibility="Collapsed">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
|
|
@ -1229,6 +1233,9 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 가로 슬림 배너 광고 슬롯 -->
|
||||||
|
<local:AdBannerControl Grid.Row="1" Margin="12,4,12,8"
|
||||||
|
DataContext="{Binding AdVm, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<!-- /좌측 컬럼 끝 -->
|
<!-- /좌측 컬럼 끝 -->
|
||||||
|
|
||||||
|
|
@ -1468,6 +1475,10 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 대형 카드 광고 슬롯 (스폰서십 및 솔루션 안내) -->
|
||||||
|
<local:AdLargeCardControl Margin="0,12,0,0"
|
||||||
|
DataContext="{Binding AdVm, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ using Everything2Everything.App.Shell;
|
||||||
using Everything2Everything.App.ViewModels;
|
using Everything2Everything.App.ViewModels;
|
||||||
using Everything2Everything.Core;
|
using Everything2Everything.Core;
|
||||||
using Everything2Everything.Core.Filters;
|
using Everything2Everything.Core.Filters;
|
||||||
|
using Everything2Everything.Core.Ads;
|
||||||
using Everything2Everything.Core.Inspector;
|
using Everything2Everything.Core.Inspector;
|
||||||
using Everything2Everything.Core.Presets;
|
using Everything2Everything.Core.Presets;
|
||||||
using LossClass = Everything2Everything.Core.Providers.LossClass;
|
using LossClass = Everything2Everything.Core.Providers.LossClass;
|
||||||
|
|
@ -134,15 +135,22 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC
|
||||||
if (_selectedCategory != value)
|
if (_selectedCategory != value)
|
||||||
{
|
{
|
||||||
_selectedCategory = value;
|
_selectedCategory = value;
|
||||||
|
if (CategoryFilterCombo != null && CategoryFilterCombo.SelectedIndex != (int)value)
|
||||||
|
{
|
||||||
|
CategoryFilterCombo.SelectedIndex = (int)value;
|
||||||
|
}
|
||||||
ApplyQueueFilters();
|
ApplyQueueFilters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList<string>? initialFiles = null)
|
public AdViewModel AdVm { get; }
|
||||||
|
|
||||||
|
public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList<string>? initialFiles = null, IAdService? adService = null)
|
||||||
{
|
{
|
||||||
_engine = engine;
|
_engine = engine;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
AdVm = new AdViewModel(adService ?? new AdService());
|
||||||
|
|
||||||
AddFilesCommand = new RelayCommand(_ => PickAndAddFiles());
|
AddFilesCommand = new RelayCommand(_ => PickAndAddFiles());
|
||||||
ProcessQueueCommand = new RelayCommand(_ => OnProcessQueueClick(this, new RoutedEventArgs()),
|
ProcessQueueCommand = new RelayCommand(_ => OnProcessQueueClick(this, new RoutedEventArgs()),
|
||||||
|
|
@ -828,6 +836,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnCategoryFilterSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (CategoryFilterCombo?.SelectedItem is ComboBoxItem item && item.Tag is string tag)
|
||||||
|
{
|
||||||
|
ApplyFilterCategory(tag);
|
||||||
|
}
|
||||||
|
else if (CategoryFilterCombo != null && CategoryFilterCombo.SelectedIndex >= 0)
|
||||||
|
{
|
||||||
|
SelectedCategory = (FilterCategory)CategoryFilterCombo.SelectedIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyFilterCategory(string? categoryName)
|
private void ApplyFilterCategory(string? categoryName)
|
||||||
{
|
{
|
||||||
if (Enum.TryParse<FilterCategory>(categoryName, true, out var cat))
|
if (Enum.TryParse<FilterCategory>(categoryName, true, out var cat))
|
||||||
|
|
@ -1540,9 +1560,25 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC
|
||||||
if (PdfQuickPanel is not null)
|
if (PdfQuickPanel is not null)
|
||||||
PdfQuickPanel.Visibility = isPdf ? Visibility.Visible : Visibility.Collapsed;
|
PdfQuickPanel.Visibility = isPdf ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
|
var isText = ext is ".txt" or ".md";
|
||||||
|
if (AiQuickPanel is not null)
|
||||||
|
AiQuickPanel.Visibility = isText ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
if (AiTargetLanguagePanel is not null && AiTaskQuickCombo is not null)
|
||||||
|
AiTargetLanguagePanel.Visibility = (isText && AiTaskQuickCombo.SelectedIndex == 1)
|
||||||
|
? Visibility.Visible
|
||||||
|
: Visibility.Collapsed;
|
||||||
|
|
||||||
UpdateMediaPanelForFormat(extension);
|
UpdateMediaPanelForFormat(extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnAiTaskSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (AiTargetLanguagePanel is null || AiTaskQuickCombo is null) return;
|
||||||
|
AiTargetLanguagePanel.Visibility = AiTaskQuickCombo.SelectedIndex == 1
|
||||||
|
? Visibility.Visible
|
||||||
|
: Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>출력이 영상/오디오/PDF/이미지일 때 상세 인코딩 폴드아웃 서브패널 노출(Fluent 2 점진적 공개 패턴).</summary>
|
/// <summary>출력이 영상/오디오/PDF/이미지일 때 상세 인코딩 폴드아웃 서브패널 노출(Fluent 2 점진적 공개 패턴).</summary>
|
||||||
private void UpdateMediaPanelForFormat(string? extension)
|
private void UpdateMediaPanelForFormat(string? extension)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,20 @@
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- AI 텍스트 처리 (txt/md) -->
|
||||||
|
<StackPanel x:Name="AiQuickPanel" Margin="0,18,0,0" Visibility="Collapsed">
|
||||||
|
<TextBlock Text="AI 작업 선택" Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
|
||||||
|
<ComboBox SelectedIndex="{Binding AiTaskIndex, Mode=TwoWay}">
|
||||||
|
<ComboBoxItem Content="핵심 요약 (Summarize)"/>
|
||||||
|
<ComboBoxItem Content="자연스러운 번역 (Translate)"/>
|
||||||
|
<ComboBoxItem Content="오탈자 및 문법 교정 (Proofread)"/>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBox Margin="0,8,0,0" Style="{StaticResource FsPathInputStyle}"
|
||||||
|
Padding="10,6"
|
||||||
|
Text="{Binding TargetLanguage, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
ToolTip="번역 시 대상 언어 (예: 영어, 일본어, 중국어)"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- 버튼 -->
|
<!-- 버튼 -->
|
||||||
<Grid Margin="0,22,0,0">
|
<Grid Margin="0,22,0,0">
|
||||||
<Button HorizontalAlignment="Left" Content="자세히 옵션…"
|
<Button HorizontalAlignment="Left" Content="자세히 옵션…"
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,12 @@ public partial class QuickOptionsWindow : FluentWindow
|
||||||
var isVideo = ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi";
|
var isVideo = ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi";
|
||||||
var isLossyAudio = ext is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg";
|
var isLossyAudio = ext is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg";
|
||||||
var isImageQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif";
|
var isImageQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif";
|
||||||
|
var isText = ext is ".txt" or ".md";
|
||||||
|
|
||||||
VideoQuickPanel.Visibility = isVideo ? Visibility.Visible : Visibility.Collapsed;
|
VideoQuickPanel.Visibility = isVideo ? Visibility.Visible : Visibility.Collapsed;
|
||||||
AudioQuickPanel.Visibility = (isVideo || isLossyAudio) ? Visibility.Visible : Visibility.Collapsed;
|
AudioQuickPanel.Visibility = (isVideo || isLossyAudio) ? Visibility.Visible : Visibility.Collapsed;
|
||||||
ImageQualityPanel.Visibility = isImageQuality ? Visibility.Visible : Visibility.Collapsed;
|
ImageQualityPanel.Visibility = isImageQuality ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
AiQuickPanel.Visibility = isText ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
// 영상의 오디오 트랙임을 구분
|
// 영상의 오디오 트랙임을 구분
|
||||||
if (isVideo) AudioPanelLabel.Text = "오디오 비트레이트";
|
if (isVideo) AudioPanelLabel.Text = "오디오 비트레이트";
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,48 @@
|
||||||
|
|
||||||
<TextBlock Text="기본 백엔드" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
<TextBlock Text="기본 백엔드" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
||||||
<ComboBox x:Name="BackendCombo" Margin="0,0,0,16">
|
<ComboBox x:Name="BackendCombo" Margin="0,0,0,16">
|
||||||
<ComboBoxItem Content="자동 (API 키 우선, 없으면 Codex)"/>
|
<ComboBoxItem Content="자동 (API 키 우선 · Switchboard · Antigravity · Codex)"/>
|
||||||
|
<ComboBoxItem Content="Switchboard 게이트웨이 (http://127.0.0.1:8787 · 로컬/LAN)"/>
|
||||||
|
<ComboBoxItem Content="Antigravity CLI (agy · Gemini OAuth · 무료/구독)"/>
|
||||||
<ComboBoxItem Content="OpenAI (API 키)"/>
|
<ComboBoxItem Content="OpenAI (API 키)"/>
|
||||||
<ComboBoxItem Content="Anthropic (API 키)"/>
|
<ComboBoxItem Content="Anthropic (API 키)"/>
|
||||||
<ComboBoxItem Content="Codex CLI (ChatGPT 구독 OAuth)"/>
|
<ComboBoxItem Content="Codex CLI (ChatGPT 구독 OAuth)"/>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
|
||||||
|
<!-- Switchboard Gateway -->
|
||||||
|
<TextBlock Text="Switchboard 게이트웨이 (로컬/LAN 멀티에이전트 오케스트레이션)" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
||||||
|
<Grid Margin="0,0,0,6">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox x:Name="SwitchboardEndpointBox" Grid.Column="0"
|
||||||
|
Style="{StaticResource FsPathInputStyle}"
|
||||||
|
TextChanged="OnSwitchboardEndpointChanged"/>
|
||||||
|
<Button x:Name="SwitchboardVerifyBtn" Grid.Column="1" Content="테스트"
|
||||||
|
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
|
||||||
|
Padding="12,6" Click="OnVerifySwitchboard"/>
|
||||||
|
<Button x:Name="SwitchboardStartBtn" Grid.Column="2" Content="게이트웨이 기동"
|
||||||
|
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
|
||||||
|
Padding="12,6" Click="OnStartSwitchboardGateway"/>
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
|
||||||
|
<Ellipse x:Name="SwitchboardDot" Style="{StaticResource FsLossDotStyle}"
|
||||||
|
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="SwitchboardStatus" Text="확인 중…" Style="{StaticResource FsCaptionStyle}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Antigravity CLI (OAuth) -->
|
||||||
|
<TextBlock Text="Antigravity CLI (agy · Google OAuth · API 키 불필요)" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
|
||||||
|
<Ellipse x:Name="AgyDot" Style="{StaticResource FsLossDotStyle}"
|
||||||
|
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="AgyStatus" Text="확인 중…" Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
|
||||||
|
<Button x:Name="AgyVerifyBtn" Content="테스트" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||||
|
Margin="12,0,0,0" Padding="10,4" IsEnabled="False" Click="OnVerifyAgy" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- OpenAI -->
|
<!-- OpenAI -->
|
||||||
<TextBlock Text="OpenAI API Key" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
<TextBlock Text="OpenAI API Key" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
||||||
<Grid Margin="0,0,0,4">
|
<Grid Margin="0,0,0,4">
|
||||||
|
|
@ -111,6 +147,9 @@
|
||||||
<TextBlock Text="외부 도구" Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="15"/>
|
<TextBlock Text="외부 도구" Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="15"/>
|
||||||
<TextBlock Text="설치하면 영상/오디오·한글/Word 변환이 자동 활성화됩니다."
|
<TextBlock Text="설치하면 영상/오디오·한글/Word 변환이 자동 활성화됩니다."
|
||||||
Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,12"/>
|
Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,12"/>
|
||||||
|
<Button Content="외부 도구 설치 / 검증" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||||
|
Padding="14,6" HorizontalAlignment="Right" Margin="0,0,0,12"
|
||||||
|
Click="OnOpenToolInstaller"/>
|
||||||
<CheckBox x:Name="GpuToggle" Content="영상 변환 시 GPU 가속(NVENC) 시도 — 없으면 CPU 자동 전환"
|
<CheckBox x:Name="GpuToggle" Content="영상 변환 시 GPU 가속(NVENC) 시도 — 없으면 CPU 자동 전환"
|
||||||
Foreground="{StaticResource FsTextSecondary}" IsChecked="True" Margin="0,0,0,16"/>
|
Foreground="{StaticResource FsTextSecondary}" IsChecked="True" Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,15 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
var backend = (_settings.Get("ai.backend") ?? "auto").ToLowerInvariant();
|
var backend = (_settings.Get("ai.backend") ?? "auto").ToLowerInvariant();
|
||||||
BackendCombo.SelectedIndex = backend switch
|
BackendCombo.SelectedIndex = backend switch
|
||||||
{
|
{
|
||||||
"openai" => 1,
|
"switchboard" or "gateway" => 1,
|
||||||
"anthropic" => 2,
|
"agy" => 2,
|
||||||
"codex" => 3,
|
"openai" => 3,
|
||||||
|
"anthropic" => 4,
|
||||||
|
"codex" => 5,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
ModelBox.Text = _settings.Get("ai.model") ?? string.Empty;
|
ModelBox.Text = _settings.Get("ai.model") ?? string.Empty;
|
||||||
|
SwitchboardEndpointBox.Text = _settings.Get("switchboard.endpoint") ?? "http://127.0.0.1:8787";
|
||||||
|
|
||||||
SetKeyStatus(OpenAiDot, OpenAiStatus, _settings.Contains("openai.apikey"), HasEnv("OPENAI_API_KEY"));
|
SetKeyStatus(OpenAiDot, OpenAiStatus, _settings.Contains("openai.apikey"), HasEnv("OPENAI_API_KEY"));
|
||||||
SetKeyStatus(AnthropicDot, AnthropicStatus, _settings.Contains("anthropic.apikey"), HasEnv("ANTHROPIC_API_KEY"));
|
SetKeyStatus(AnthropicDot, AnthropicStatus, _settings.Contains("anthropic.apikey"), HasEnv("ANTHROPIC_API_KEY"));
|
||||||
|
|
@ -58,6 +61,9 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
private void OnAnthropicKeyChanged(object sender, RoutedEventArgs e)
|
private void OnAnthropicKeyChanged(object sender, RoutedEventArgs e)
|
||||||
=> AnthropicVerifyBtn.IsEnabled = AnthropicKeyBox.Password.StartsWith("sk-ant-", StringComparison.Ordinal);
|
=> AnthropicVerifyBtn.IsEnabled = AnthropicKeyBox.Password.StartsWith("sk-ant-", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private void OnSwitchboardEndpointChanged(object sender, TextChangedEventArgs e)
|
||||||
|
=> _ = CheckSwitchboardGatewayAsync();
|
||||||
|
|
||||||
private async void OnVerifyOpenAi(object sender, RoutedEventArgs e)
|
private async void OnVerifyOpenAi(object sender, RoutedEventArgs e)
|
||||||
=> await VerifyAsync(OpenAiDot, OpenAiStatus, OpenAiVerifyBtn,
|
=> await VerifyAsync(OpenAiDot, OpenAiStatus, OpenAiVerifyBtn,
|
||||||
new OpenAiChatClient(OpenAiKeyBox.Password), ModelOr("gpt-4o-mini"));
|
new OpenAiChatClient(OpenAiKeyBox.Password), ModelOr("gpt-4o-mini"));
|
||||||
|
|
@ -66,6 +72,63 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
=> await VerifyAsync(AnthropicDot, AnthropicStatus, AnthropicVerifyBtn,
|
=> await VerifyAsync(AnthropicDot, AnthropicStatus, AnthropicVerifyBtn,
|
||||||
new AnthropicChatClient(AnthropicKeyBox.Password), ModelOr("claude-3-5-sonnet-latest"));
|
new AnthropicChatClient(AnthropicKeyBox.Password), ModelOr("claude-3-5-sonnet-latest"));
|
||||||
|
|
||||||
|
private async void OnVerifySwitchboard(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var ep = string.IsNullOrWhiteSpace(SwitchboardEndpointBox.Text)
|
||||||
|
? (_settings.Get("switchboard.endpoint") ?? "http://127.0.0.1:8787")
|
||||||
|
: SwitchboardEndpointBox.Text.Trim();
|
||||||
|
await VerifyAsync(SwitchboardDot, SwitchboardStatus, SwitchboardVerifyBtn,
|
||||||
|
new SwitchboardChatClient(ep), ModelOr(string.Empty));
|
||||||
|
await CheckSwitchboardGatewayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStartSwitchboardGateway(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
@"D:\workspace\Switchboard\start-gateway-lan.cmd",
|
||||||
|
@"D:\workspace\Switchboard\start-gateway-agy.cmd",
|
||||||
|
@"D:\workspace\Switchboard\start-gateway.cmd",
|
||||||
|
};
|
||||||
|
string? cmdPath = candidates.FirstOrDefault(System.IO.File.Exists);
|
||||||
|
if (cmdPath is null)
|
||||||
|
{
|
||||||
|
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
var relativeCandidates = new[]
|
||||||
|
{
|
||||||
|
System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "..", "..", "..", "..", "Switchboard", "start-gateway-lan.cmd")),
|
||||||
|
System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "..", "..", "..", "..", "Switchboard", "start-gateway-agy.cmd")),
|
||||||
|
System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "..", "..", "..", "..", "Switchboard", "start-gateway.cmd")),
|
||||||
|
};
|
||||||
|
cmdPath = relativeCandidates.FirstOrDefault(System.IO.File.Exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmdPath is not null)
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = cmdPath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
WorkingDirectory = System.IO.Path.GetDirectoryName(cmdPath)!,
|
||||||
|
});
|
||||||
|
SwitchboardStatus.Text = "게이트웨이 기동 중… 잠시 후 테스트를 누르세요.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SwitchboardStatus.Text = "런처 스크립트를 찾을 수 없습니다 (start-gateway-lan.cmd).";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SwitchboardStatus.Text = "기동 실패: " + Trunc(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnVerifyAgy(object sender, RoutedEventArgs e)
|
||||||
|
=> await VerifyAsync(AgyDot, AgyStatus, AgyVerifyBtn, new AgyChatClient(), ModelOr(string.Empty));
|
||||||
|
|
||||||
private async void OnVerifyCodex(object sender, RoutedEventArgs e)
|
private async void OnVerifyCodex(object sender, RoutedEventArgs e)
|
||||||
=> await VerifyAsync(CodexDot, CodexStatus, CodexVerifyBtn, new CodexChatClient(), ModelOr(string.Empty));
|
=> await VerifyAsync(CodexDot, CodexStatus, CodexVerifyBtn, new CodexChatClient(), ModelOr(string.Empty));
|
||||||
|
|
||||||
|
|
@ -107,23 +170,59 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
LibreDot.Fill = Res(libre ? "FsStatusSuccess" : "FsStatusWarn");
|
LibreDot.Fill = Res(libre ? "FsStatusSuccess" : "FsStatusWarn");
|
||||||
LibreStatus.Text = libre ? "준비됨" : "미설치";
|
LibreStatus.Text = libre ? "준비됨" : "미설치";
|
||||||
|
|
||||||
|
_ = CheckSwitchboardGatewayAsync();
|
||||||
|
|
||||||
|
var agy = ExternalToolDetector.IsAgyAvailable(out _);
|
||||||
|
AgyDot.Fill = Res(agy ? "FsStatusSuccess" : "FsTextTertiary");
|
||||||
|
AgyStatus.Text = agy ? "설치됨 — 키 없이 사용 가능" : "미설치 (winget install Google.AntigravityCLI)";
|
||||||
|
AgyVerifyBtn.IsEnabled = agy;
|
||||||
|
|
||||||
var codex = ExternalToolDetector.IsCodexAvailable();
|
var codex = ExternalToolDetector.IsCodexAvailable();
|
||||||
CodexDot.Fill = Res(codex ? "FsStatusSuccess" : "FsTextTertiary");
|
CodexDot.Fill = Res(codex ? "FsStatusSuccess" : "FsTextTertiary");
|
||||||
CodexStatus.Text = codex ? "설치됨 — 키 없이 사용 가능" : "미설치 (npm i -g @openai/codex)";
|
CodexStatus.Text = codex ? "설치됨 — 키 없이 사용 가능" : "미설치 (npm i -g @openai/codex)";
|
||||||
CodexVerifyBtn.IsEnabled = codex;
|
CodexVerifyBtn.IsEnabled = codex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task CheckSwitchboardGatewayAsync()
|
||||||
|
{
|
||||||
|
if (SwitchboardDot is null || SwitchboardStatus is null) return;
|
||||||
|
var ep = string.IsNullOrWhiteSpace(SwitchboardEndpointBox?.Text)
|
||||||
|
? (_settings.Get("switchboard.endpoint") ?? "http://127.0.0.1:8787")
|
||||||
|
: SwitchboardEndpointBox.Text.Trim();
|
||||||
|
var (ok, profile, resolvedEp) = await ExternalToolDetector.CheckSwitchboardGatewayHealthAsync(ep);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
SwitchboardDot.Fill = Res("FsStatusSuccess");
|
||||||
|
SwitchboardStatus.Text = string.IsNullOrWhiteSpace(profile)
|
||||||
|
? $"실행 중 ({resolvedEp})"
|
||||||
|
: $"실행 중 ({resolvedEp} · {profile})";
|
||||||
|
if (SwitchboardStartBtn is not null) SwitchboardStartBtn.IsEnabled = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SwitchboardDot.Fill = Res("FsTextTertiary");
|
||||||
|
SwitchboardStatus.Text = $"미실행 ({ep})";
|
||||||
|
if (SwitchboardStartBtn is not null) SwitchboardStartBtn.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnSave(object sender, RoutedEventArgs e)
|
private void OnSave(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var backend = BackendCombo.SelectedIndex switch
|
var backend = BackendCombo.SelectedIndex switch
|
||||||
{
|
{
|
||||||
1 => "openai",
|
1 => "switchboard",
|
||||||
2 => "anthropic",
|
2 => "agy",
|
||||||
3 => "codex",
|
3 => "openai",
|
||||||
|
4 => "anthropic",
|
||||||
|
5 => "codex",
|
||||||
_ => "auto",
|
_ => "auto",
|
||||||
};
|
};
|
||||||
_settings.Set("ai.backend", backend);
|
_settings.Set("ai.backend", backend);
|
||||||
|
|
||||||
|
var sbEndpoint = SwitchboardEndpointBox.Text?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(sbEndpoint))
|
||||||
|
_settings.Set("switchboard.endpoint", sbEndpoint);
|
||||||
|
|
||||||
var model = ModelBox.Text?.Trim();
|
var model = ModelBox.Text?.Trim();
|
||||||
if (string.IsNullOrEmpty(model)) _settings.Remove("ai.model");
|
if (string.IsNullOrEmpty(model)) _settings.Remove("ai.model");
|
||||||
else _settings.Set("ai.model", model);
|
else _settings.Set("ai.model", model);
|
||||||
|
|
@ -137,6 +236,13 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
|
|
||||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private void OnOpenToolInstaller(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var win = new ToolSetupWindow { Owner = this };
|
||||||
|
win.ShowDialog();
|
||||||
|
RefreshToolStatus();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDownloadFfmpeg(object sender, RoutedEventArgs e)
|
private void OnDownloadFfmpeg(object sender, RoutedEventArgs e)
|
||||||
=> OpenUrl("https://github.com/BtbN/FFmpeg-Builds/releases");
|
=> OpenUrl("https://github.com/BtbN/FFmpeg-Builds/releases");
|
||||||
|
|
||||||
|
|
|
||||||
88
src/Everything2Everything.App/Views/ToolSetupWindow.xaml
Normal file
88
src/Everything2Everything.App/Views/ToolSetupWindow.xaml
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<ui:FluentWindow x:Class="Everything2Everything.App.Views.ToolSetupWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
Title="처음 설정 — 외부 도구 설치"
|
||||||
|
Width="640" Height="680"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ExtendsContentIntoTitleBar="True"
|
||||||
|
WindowBackdropType="Mica"
|
||||||
|
Background="{DynamicResource FsBgBase}">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ui:ControlsDictionary/>
|
||||||
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid Background="{StaticResource FsBgBase}">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ui:TitleBar Grid.Row="0" Title="처음 설정" ShowMaximize="False" ShowMinimize="False"/>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="24,8,24,16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="필요한 외부 도구를 설치합니다."
|
||||||
|
Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="16"/>
|
||||||
|
<TextBlock Text="변환에 필요한 외부 도구 목록입니다. 기본값은 모두 선택된 상태이며, 아래 버튼으로 한 번에 순차 설치합니다. 이미 설치된 도구는 설치 단계에서 확인만 됩니다. (H2Orestart는 라이선스상 자동 설치 대신 직접 추가가 필요합니다.)"
|
||||||
|
Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,16"/>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Tools}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Style="{StaticResource FsDoubleBezelShellStyle}" Margin="0,0,0,10" HorizontalAlignment="Stretch">
|
||||||
|
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="16,12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||||
|
<StackPanel Grid.Column="1" Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="{Binding Name}" Style="{StaticResource FsBodyStyle}" FontWeight="Medium"/>
|
||||||
|
<TextBlock Text="{Binding Description}" Style="{StaticResource FsCaptionStyle}"
|
||||||
|
TextWrapping="Wrap" Margin="0,3,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Ellipse Width="9" Height="9" Style="{StaticResource FsLossDotStyle}"
|
||||||
|
Fill="{Binding StatusBrush}" VerticalAlignment="Center" Margin="0,0,7,0"/>
|
||||||
|
<TextBlock Text="{Binding StatusText}" Style="{StaticResource FsCaptionStyle}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Border Grid.Row="2" Background="{StaticResource FsBgPanel}"
|
||||||
|
BorderBrush="{StaticResource FsBorderSubtle}" BorderThickness="0,1,0,0" Padding="24,14">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding SelectAll}" Content="전체 선택"
|
||||||
|
Foreground="{StaticResource FsTextSecondary}" VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Content="나중에" Style="{StaticResource FsSecondaryButtonStyle}" Padding="20,8" Click="OnLater"/>
|
||||||
|
<Button x:Name="InstallButton" Content="설치 시작"
|
||||||
|
Style="{StaticResource FsPrimaryButtonStyle}" Padding="20,8" Margin="8,0,0,0" Click="OnInstall"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</ui:FluentWindow>
|
||||||
46
src/Everything2Everything.App/Views/ToolSetupWindow.xaml.cs
Normal file
46
src/Everything2Everything.App/Views/ToolSetupWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows;
|
||||||
|
using Everything2Everything.App.ViewModels;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.Views;
|
||||||
|
|
||||||
|
/// <summary>첫 실행 외부 도구 설치 마법사. 선택된(기본 전체) 도구를 순차 설치하고 상태를 갱신한다.</summary>
|
||||||
|
public partial class ToolSetupWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
|
{
|
||||||
|
private readonly ToolSetupViewModel _vm;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
public ToolSetupWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_vm = new ToolSetupViewModel();
|
||||||
|
DataContext = _vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnInstall(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_vm.IsInstalling) return;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
InstallButton.IsEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _vm.InstallSelectedAsync(_cts.Token);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
InstallButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLater(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
base.OnClosed(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Everything2Everything.Core/Ads/AdItem.cs
Normal file
21
src/Everything2Everything.Core/Ads/AdItem.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace Everything2Everything.Core.Ads;
|
||||||
|
|
||||||
|
public enum AdType
|
||||||
|
{
|
||||||
|
Banner,
|
||||||
|
LargeCard
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AdItem
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string BadgeText { get; set; } = "AD";
|
||||||
|
public string ImageUrl { get; set; } = string.Empty;
|
||||||
|
public string TargetUrl { get; set; } = string.Empty;
|
||||||
|
public string CtaText { get; set; } = "자세히 보기";
|
||||||
|
public AdType Type { get; set; } = AdType.Banner;
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public int DisplayOrder { get; set; } = 0;
|
||||||
|
}
|
||||||
34
src/Everything2Everything.Core/Ads/AdMobConfig.cs
Normal file
34
src/Everything2Everything.Core/Ads/AdMobConfig.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
namespace Everything2Everything.Core.Ads;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Google AdMob 공식 앱 ID, 광고 단위 ID 및 app-ads.txt 검증 구성 모델
|
||||||
|
/// </summary>
|
||||||
|
public class AdMobConfig
|
||||||
|
{
|
||||||
|
/// <summary>Google AdMob 게시자 ID (예: pub-XXXXXXXXXXXXXXXX)</summary>
|
||||||
|
public string PublisherId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>AdMob 공식 앱 ID (형식: ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY)</summary>
|
||||||
|
public string AppId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>배너 광고 단위 ID (형식: ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY)</summary>
|
||||||
|
public string BannerAdUnitId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>전면/대형 카드 광고 단위 ID (형식: ca-app-pub-XXXXXXXXXXXXXXXX/ZZZZZZZZZZ)</summary>
|
||||||
|
public string InterstitialAdUnitId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Google 공식 규격의 app-ads.txt 문자열 생성
|
||||||
|
/// 형식: google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0
|
||||||
|
/// </summary>
|
||||||
|
public string GenerateAppAdsTxt()
|
||||||
|
{
|
||||||
|
var pubId = PublisherId.Trim();
|
||||||
|
if (!pubId.StartsWith("pub-", System.StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(pubId))
|
||||||
|
{
|
||||||
|
pubId = "pub-" + pubId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"google.com, {pubId}, DIRECT, f08c47fec0942fa0";
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/Everything2Everything.Core/Ads/AdService.cs
Normal file
221
src/Everything2Everything.Core/Ads/AdService.cs
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Core.Ads;
|
||||||
|
|
||||||
|
public class AdService : IAdService
|
||||||
|
{
|
||||||
|
private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(5) };
|
||||||
|
private readonly List<AdItem> _ads = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private int _bannerIndex = 0;
|
||||||
|
private int _largeCardIndex = 0;
|
||||||
|
|
||||||
|
public AdService()
|
||||||
|
{
|
||||||
|
InitializeDefaultAds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeDefaultAds()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_ads.Clear();
|
||||||
|
|
||||||
|
// Default House Banner Ads
|
||||||
|
_ads.Add(new AdItem
|
||||||
|
{
|
||||||
|
Id = "house-banner-sponsor-1",
|
||||||
|
Title = "초고속 무손실 미디어 솔루션",
|
||||||
|
Description = "Everything2Everything 공식 후원 및 제휴 파트너십을 확인하세요.",
|
||||||
|
BadgeText = "스폰서",
|
||||||
|
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/logo-mark.png",
|
||||||
|
TargetUrl = "https://github.com/yunchan8804/Everything2Everthing",
|
||||||
|
CtaText = "자세히 보기",
|
||||||
|
Type = AdType.Banner,
|
||||||
|
IsActive = true,
|
||||||
|
DisplayOrder = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
_ads.Add(new AdItem
|
||||||
|
{
|
||||||
|
Id = "house-banner-tip-2",
|
||||||
|
Title = "대용량 일괄 변환 최적화",
|
||||||
|
Description = "FFmpeg 및 LibRaw 가속 엔진으로 수천 장의 이미지를 순식간에 처리합니다.",
|
||||||
|
BadgeText = "AD",
|
||||||
|
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/glyph-video.png",
|
||||||
|
TargetUrl = "https://github.com/yunchan8804/Everything2Everthing#features",
|
||||||
|
CtaText = "가이드 보기",
|
||||||
|
Type = AdType.Banner,
|
||||||
|
IsActive = true,
|
||||||
|
DisplayOrder = 2
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default Large Card Ads
|
||||||
|
_ads.Add(new AdItem
|
||||||
|
{
|
||||||
|
Id = "house-large-card-1",
|
||||||
|
Title = "비즈니스 미디어 변환 엔진",
|
||||||
|
Description = "개인 및 기업을 위한 고성능 무손실 일괄 변환. 워터마크 없이 100% 무료로 자유롭게 활용하세요.",
|
||||||
|
BadgeText = "스폰서 추천",
|
||||||
|
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/illus-done.png",
|
||||||
|
TargetUrl = "https://github.com/yunchan8804/Everything2Everthing",
|
||||||
|
CtaText = "파트너십 문의",
|
||||||
|
Type = AdType.LargeCard,
|
||||||
|
IsActive = true,
|
||||||
|
DisplayOrder = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
_ads.Add(new AdItem
|
||||||
|
{
|
||||||
|
Id = "house-large-card-2",
|
||||||
|
Title = "스마트 AI 요약 & OCR 가속",
|
||||||
|
Description = "로컬 LLM과 Tesseract OCR을 결합하여 스캔 문서와 PDF를 즉시 검색 가능한 텍스트로 변환합니다.",
|
||||||
|
BadgeText = "AD",
|
||||||
|
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/glyph-ai.png",
|
||||||
|
TargetUrl = "https://github.com/yunchan8804/Everything2Everthing",
|
||||||
|
CtaText = "기능 살펴보기",
|
||||||
|
Type = AdType.LargeCard,
|
||||||
|
IsActive = true,
|
||||||
|
DisplayOrder = 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<AdItem> GetBannerAds()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _ads.Where(a => a.Type == AdType.Banner && a.IsActive).OrderBy(a => a.DisplayOrder).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<AdItem> GetLargeCardAds()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _ads.Where(a => a.Type == AdType.LargeCard && a.IsActive).OrderBy(a => a.DisplayOrder).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdItem? GetNextBannerAd()
|
||||||
|
{
|
||||||
|
var banners = GetBannerAds();
|
||||||
|
if (banners.Count == 0) return null;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var ad = banners[_bannerIndex % banners.Count];
|
||||||
|
_bannerIndex = (_bannerIndex + 1) % banners.Count;
|
||||||
|
return ad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdItem? GetNextLargeCardAd()
|
||||||
|
{
|
||||||
|
var cards = GetLargeCardAds();
|
||||||
|
if (cards.Count == 0) return null;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var ad = cards[_largeCardIndex % cards.Count];
|
||||||
|
_largeCardIndex = (_largeCardIndex + 1) % cards.Count;
|
||||||
|
return ad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int LoadAdsFromJson(string json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
|
var items = JsonSerializer.Deserialize<List<AdItem>>(json, options);
|
||||||
|
if (items == null || items.Count == 0) return 0;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.Id))
|
||||||
|
{
|
||||||
|
item.Id = Guid.NewGuid().ToString("N");
|
||||||
|
}
|
||||||
|
var existingIdx = _ads.FindIndex(a => a.Id == item.Id);
|
||||||
|
if (existingIdx >= 0)
|
||||||
|
{
|
||||||
|
_ads[existingIdx] = item;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_ads.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.Count;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> RefreshAdsFromFeedAsync(string feedUrl, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(feedUrl) || !Uri.TryCreate(feedUrl, UriKind.Absolute, out var uri) ||
|
||||||
|
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await HttpClient.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||||
|
return LoadAdsFromJson(response);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool OpenAdUrl(AdItem ad)
|
||||||
|
{
|
||||||
|
if (ad == null || string.IsNullOrWhiteSpace(ad.TargetUrl))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(ad.TargetUrl, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = uri.AbsoluteUri,
|
||||||
|
UseShellExecute = true
|
||||||
|
};
|
||||||
|
Process.Start(psi);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/Everything2Everything.Core/Ads/IAdService.cs
Normal file
16
src/Everything2Everything.Core/Ads/IAdService.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Core.Ads;
|
||||||
|
|
||||||
|
public interface IAdService
|
||||||
|
{
|
||||||
|
IReadOnlyList<AdItem> GetBannerAds();
|
||||||
|
IReadOnlyList<AdItem> GetLargeCardAds();
|
||||||
|
AdItem? GetNextBannerAd();
|
||||||
|
AdItem? GetNextLargeCardAd();
|
||||||
|
Task<int> RefreshAdsFromFeedAsync(string feedUrl, CancellationToken cancellationToken = default);
|
||||||
|
int LoadAdsFromJson(string json);
|
||||||
|
bool OpenAdUrl(AdItem ad);
|
||||||
|
}
|
||||||
|
|
@ -137,3 +137,108 @@ public sealed class CodexChatClient : IChatClient
|
||||||
|
|
||||||
private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
|
private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class AgyChatClient : IChatClient
|
||||||
|
{
|
||||||
|
private readonly string _agyPath;
|
||||||
|
|
||||||
|
public AgyChatClient(string? agyPath = null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(agyPath))
|
||||||
|
{
|
||||||
|
_agyPath = agyPath;
|
||||||
|
}
|
||||||
|
else if (ExternalToolDetector.IsAgyAvailable(out var detected))
|
||||||
|
{
|
||||||
|
_agyPath = detected;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_agyPath = "agy";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "Antigravity CLI (agy)";
|
||||||
|
|
||||||
|
public async Task<string> CompleteAsync(string systemPrompt, string userPrompt, string model, int maxTokens, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var prompt = string.IsNullOrWhiteSpace(systemPrompt) ? userPrompt : systemPrompt + "\n\n---\n\n" + userPrompt;
|
||||||
|
|
||||||
|
var args = new List<string>
|
||||||
|
{
|
||||||
|
"-p", prompt,
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"--disable-slash-commands",
|
||||||
|
"--output-format", "text"
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(model))
|
||||||
|
{
|
||||||
|
args.Add("--model");
|
||||||
|
args.Add(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = await ExternalProcessRunner.RunAsync(
|
||||||
|
_agyPath, args, TimeSpan.FromMinutes(5), workingDirectory: null,
|
||||||
|
cancellationToken: ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (r.TimedOut)
|
||||||
|
throw new InvalidOperationException("Antigravity CLI 응답이 시간 초과되었습니다 (5분).");
|
||||||
|
|
||||||
|
if (!r.Success)
|
||||||
|
{
|
||||||
|
var detail = !string.IsNullOrWhiteSpace(r.StdErr) ? r.StdErr : r.StdOut;
|
||||||
|
throw new InvalidOperationException($"Antigravity CLI 오류 (exit {r.ExitCode}): {Truncate(detail)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.StdOut.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 로컬 또는 네트워크에 기동된 Switchboard Gateway (http://127.0.0.1:8787)를 호출하는 백엔드.
|
||||||
|
/// 게이트웨이의 /chat 엔드포인트와 통신하며, 게이트웨이가 관리하는 에이전트(agy, codex 등)를 통해 텍스트를 완성한다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SwitchboardChatClient : IChatClient
|
||||||
|
{
|
||||||
|
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(5) };
|
||||||
|
private readonly string _endpoint;
|
||||||
|
|
||||||
|
public SwitchboardChatClient(string? endpoint = null)
|
||||||
|
{
|
||||||
|
var raw = string.IsNullOrWhiteSpace(endpoint) ? "http://127.0.0.1:8787" : endpoint.Trim();
|
||||||
|
_endpoint = raw.TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "Switchboard Gateway";
|
||||||
|
public string Endpoint => _endpoint;
|
||||||
|
|
||||||
|
public async Task<string> CompleteAsync(string systemPrompt, string userPrompt, string model, int maxTokens, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var message = string.IsNullOrWhiteSpace(systemPrompt) ? userPrompt : systemPrompt + "\n\n---\n\n" + userPrompt;
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
message,
|
||||||
|
sessionId = "e2e-" + Guid.NewGuid().ToString("N")[..8],
|
||||||
|
model = string.IsNullOrWhiteSpace(model) ? null : model,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, $"{_endpoint}/chat");
|
||||||
|
req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
using var resp = await Http.SendAsync(req, ct).ConfigureAwait(false);
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
throw new InvalidOperationException($"Switchboard 게이트웨이 오류 ({(int)resp.StatusCode}): {Truncate(json)}");
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.TryGetProperty("reply", out var replyProp))
|
||||||
|
return replyProp.GetString() ?? "";
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Switchboard 게이트웨이 응답에 'reply' 필드가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
namespace Everything2Everything.Core.Converters;
|
||||||
|
|
||||||
|
/// <summary>설치 방식 분류. Winget = 자동 설치 가능, Manual = 사용자 안내만(라이선스/URL 제약).</summary>
|
||||||
|
public enum ExternalToolInstallKind
|
||||||
|
{
|
||||||
|
Winget,
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>첫 실행 설치 마법사가 다루는 외부 도구 한 항목의 정의. 감지 함수는 설치 여부 판정을 담당한다.</summary>
|
||||||
|
public sealed class ExternalToolDefinition
|
||||||
|
{
|
||||||
|
public required string Key { get; init; }
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
public required string Description { get; init; }
|
||||||
|
public required ExternalToolInstallKind Kind { get; init; }
|
||||||
|
public string? WingetId { get; init; }
|
||||||
|
public string? ManualNote { get; init; }
|
||||||
|
public required Func<bool> IsInstalled { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 앱이 의존하는 외부 도구의 정적 카탈로그. winget 패키지 ID는 기기에서 실검증된 값이다.
|
||||||
|
/// (Gyan.FFmpeg / TheDocumentFoundation.LibreOffice / JohnMacFarlane.Pandoc /
|
||||||
|
/// ImageMagick.ImageMagick / Google.AntigravityCLI / OpenAI.Codex)
|
||||||
|
/// H2Orestart는 GPL이고 프로젝트 원칙(번들 금지·외부 조달)상 자동 설치하지 않고 안내만 한다.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExternalToolCatalog
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<ExternalToolDefinition> All { get; } = new[]
|
||||||
|
{
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "ffmpeg",
|
||||||
|
DisplayName = "FFmpeg",
|
||||||
|
Description = "영상/오디오 변환 (mp4·webm·mp3·flac…). ffmpeg·ffprobe 포함.",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "Gyan.FFmpeg",
|
||||||
|
IsInstalled = () => ExternalToolDetector.TryFindFfmpeg(out _),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "libreoffice",
|
||||||
|
DisplayName = "LibreOffice",
|
||||||
|
Description = "한글/Word/문서 변환 (DOCX→PDF·이미지).",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "TheDocumentFoundation.LibreOffice",
|
||||||
|
IsInstalled = () => ExternalToolDetector.TryFindLibreOfficeSoffice(out _),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "pandoc",
|
||||||
|
DisplayName = "Pandoc",
|
||||||
|
Description = "마크업 변환 (md·rst·latex·epub…).",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "JohnMacFarlane.Pandoc",
|
||||||
|
IsInstalled = () => ExternalToolDetector.TryFindPandoc(out _),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "imagemagick",
|
||||||
|
DisplayName = "ImageMagick",
|
||||||
|
Description = "HEIC 등 이미지 처리 변환.",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "ImageMagick.ImageMagick",
|
||||||
|
IsInstalled = () => ExternalToolDetector.TryFindMagick(out _),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "h2orestart",
|
||||||
|
DisplayName = "H2Orestart (한글 확장)",
|
||||||
|
Description = "LibreOffice에서 .hwp/.hwpx 열기용 UNO 확장.",
|
||||||
|
Kind = ExternalToolInstallKind.Manual,
|
||||||
|
ManualNote = "LibreOffice 설치 후 확장 관리자(Tools → Extension Manager)에서 H2Orestart.oxt를 직접 추가하세요.",
|
||||||
|
IsInstalled = () => ExternalToolDetector.IsH2OrestartInstalled(),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "agy",
|
||||||
|
DisplayName = "Antigravity CLI (agy)",
|
||||||
|
Description = "AI 변환 백엔드 (Google OAuth · API 키 불필요).",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "Google.AntigravityCLI",
|
||||||
|
IsInstalled = () => ExternalToolDetector.IsAgyAvailable(out _),
|
||||||
|
},
|
||||||
|
new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "codex",
|
||||||
|
DisplayName = "Codex CLI",
|
||||||
|
Description = "AI 변환 백엔드 (ChatGPT 구독 OAuth).",
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = "OpenAI.Codex",
|
||||||
|
IsInstalled = () => ExternalToolDetector.IsCodexAvailable(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ExternalToolDefinition? Find(string key)
|
||||||
|
=> All.FirstOrDefault(t => string.Equals(t.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,22 @@ public static class ExternalToolDetector
|
||||||
if (!string.IsNullOrWhiteSpace(dir))
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
candidates.Add(dir.Trim());
|
candidates.Add(dir.Trim());
|
||||||
|
|
||||||
|
// winget(Gyan.FFmpeg)은 zip 해제본을 %LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin에 둔다.
|
||||||
|
// PATH에 없으므로 설치 후에도 감지되도록 패키지 폴더를 직접 스캔한다.
|
||||||
|
var winGetPackages = Path.Combine(local, "Microsoft", "WinGet", "Packages");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(winGetPackages))
|
||||||
|
{
|
||||||
|
foreach (var pkgDir in Directory.EnumerateDirectories(winGetPackages, "Gyan.FFmpeg*"))
|
||||||
|
{
|
||||||
|
foreach (var bin in Directory.EnumerateDirectories(pkgDir, "bin", SearchOption.AllDirectories))
|
||||||
|
candidates.Add(bin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* WinGet 패키지 디렉터리 스캔 실패 무시 */ }
|
||||||
|
|
||||||
foreach (var dir in candidates.Distinct())
|
foreach (var dir in candidates.Distinct())
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -82,6 +98,78 @@ public static class ExternalToolDetector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pandoc 바이너리를 찾는다. (1) %LOCALAPPDATA%\Pandoc (winget 기본 설치 위치),
|
||||||
|
/// (2) WinGet Links, (3) 시스템 PATH 순.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryFindPandoc(out string pandocPath)
|
||||||
|
{
|
||||||
|
pandocPath = "";
|
||||||
|
var candidates = new List<string>();
|
||||||
|
|
||||||
|
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (!string.IsNullOrEmpty(local))
|
||||||
|
{
|
||||||
|
candidates.Add(Path.Combine(local, "Pandoc", "pandoc.exe"));
|
||||||
|
candidates.Add(Path.Combine(local, "Microsoft", "WinGet", "Links", "pandoc.exe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
|
foreach (var dir in pathEnv.Split(Path.PathSeparator))
|
||||||
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
|
candidates.Add(Path.Combine(dir.Trim(), "pandoc.exe"));
|
||||||
|
|
||||||
|
foreach (var p in candidates.Distinct())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(p)) { pandocPath = p; return true; }
|
||||||
|
}
|
||||||
|
catch { /* 잘못된 경로 무시 */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ImageMagick(magick.exe) 바이너리를 찾는다. (1) %ProgramFiles%/ProgramFiles(x86) 아래
|
||||||
|
/// ImageMagick-* 폴더, (2) 실제 PATH 순.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryFindMagick(out string magickPath)
|
||||||
|
{
|
||||||
|
magickPath = "";
|
||||||
|
var candidates = new List<string>();
|
||||||
|
|
||||||
|
foreach (var root in new[]
|
||||||
|
{
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(root)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var dir in Directory.EnumerateDirectories(root, "ImageMagick-*"))
|
||||||
|
candidates.Add(Path.Combine(dir, "magick.exe"));
|
||||||
|
}
|
||||||
|
catch { /* 디렉터리 열람 실패 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
|
foreach (var dir in pathEnv.Split(Path.PathSeparator))
|
||||||
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
|
candidates.Add(Path.Combine(dir.Trim(), "magick.exe"));
|
||||||
|
|
||||||
|
foreach (var p in candidates.Distinct())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(p)) { magickPath = p; return true; }
|
||||||
|
}
|
||||||
|
catch { /* 잘못된 경로 무시 */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// codex CLI(OpenAI Codex, ChatGPT 구독 OAuth 재사용) 설치 여부. npm 글로벌 + PATH에서
|
/// codex CLI(OpenAI Codex, ChatGPT 구독 OAuth 재사용) 설치 여부. npm 글로벌 + PATH에서
|
||||||
/// codex.cmd/codex.exe/codex.ps1을 탐지한다.
|
/// codex.cmd/codex.exe/codex.ps1을 탐지한다.
|
||||||
|
|
@ -110,6 +198,38 @@ public static class ExternalToolDetector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Antigravity CLI (agy, Google 개인 OAuth 재사용) 설치 여부. WinGet Links 및 PATH에서 agy.exe를 탐지한다.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsAgyAvailable(out string agyPath)
|
||||||
|
{
|
||||||
|
agyPath = "";
|
||||||
|
var candidates = new List<string>();
|
||||||
|
|
||||||
|
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (!string.IsNullOrEmpty(local))
|
||||||
|
candidates.Add(Path.Combine(local, "Microsoft", "WinGet", "Links", "agy.exe"));
|
||||||
|
|
||||||
|
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
|
foreach (var d in pathEnv.Split(Path.PathSeparator))
|
||||||
|
if (!string.IsNullOrWhiteSpace(d))
|
||||||
|
candidates.Add(Path.Combine(d.Trim(), "agy.exe"));
|
||||||
|
|
||||||
|
foreach (var p in candidates.Distinct())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(p))
|
||||||
|
{
|
||||||
|
agyPath = p;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* 잘못된 경로 무시 */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsH2OrestartInstalled()
|
public static bool IsH2OrestartInstalled()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -143,4 +263,56 @@ public static class ExternalToolDetector
|
||||||
catch { }
|
catch { }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 로컬 루프백 또는 지정된 LAN의 Switchboard Gateway 청취 여부를 100ms 이내에 신속히 감지한다.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsSwitchboardGatewayAvailable(out string endpoint, string? configuredEndpoint = null)
|
||||||
|
{
|
||||||
|
endpoint = string.IsNullOrWhiteSpace(configuredEndpoint) ? "http://127.0.0.1:8787" : configuredEndpoint.Trim().TrimEnd('/');
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(endpoint);
|
||||||
|
using var tcp = new System.Net.Sockets.TcpClient();
|
||||||
|
var host = uri.Host;
|
||||||
|
var port = uri.Port > 0 ? uri.Port : 8787;
|
||||||
|
var result = tcp.BeginConnect(host, port, null, null);
|
||||||
|
var success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromMilliseconds(100));
|
||||||
|
if (!success || !tcp.Connected) return false;
|
||||||
|
tcp.EndConnect(result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switchboard Gateway (기본 http://127.0.0.1:8787) 헬스체크 및 에이전트 프로필을 비동기로 정밀 검증한다.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<(bool available, string? agentProfile, string endpoint)> CheckSwitchboardGatewayHealthAsync(
|
||||||
|
string? endpoint = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ep = string.IsNullOrWhiteSpace(endpoint) ? "http://127.0.0.1:8787" : endpoint.Trim().TrimEnd('/');
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
cts.CancelAfter(TimeSpan.FromMilliseconds(1500));
|
||||||
|
using var client = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromMilliseconds(1500) };
|
||||||
|
using var resp = await client.GetAsync($"{ep}/health", cts.Token).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode) return (false, null, ep);
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var ok = root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean();
|
||||||
|
var profile = root.TryGetProperty("agentProfile", out var pProp) ? pProp.GetString() : null;
|
||||||
|
return (ok, profile, ep);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return (false, null, ep);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Core.Converters;
|
||||||
|
|
||||||
|
/// <summary>외부 명령 한 번을 실행하는 시드. 프로세스 실행을 추상화해 설치 로직을 단위 테스트 가능하게 한다.</summary>
|
||||||
|
public interface IExternalCommandRunner
|
||||||
|
{
|
||||||
|
Task<ExternalCommandResult> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExternalCommandResult(int ExitCode, string Output);
|
||||||
|
|
||||||
|
/// <summary>System.Diagnostics.Process 기반 기본 구현. 숨김 실행 + 출력(꼬리) 캡처 + 취소 시 Kill.</summary>
|
||||||
|
public sealed class SystemCommandRunner : IExternalCommandRunner
|
||||||
|
{
|
||||||
|
public async Task<ExternalCommandResult> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = fileName,
|
||||||
|
Arguments = arguments,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
WorkingDirectory = workingDirectory ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
};
|
||||||
|
|
||||||
|
using var proc = new Process { StartInfo = psi };
|
||||||
|
var outTail = new StringBuilder();
|
||||||
|
var errTail = new StringBuilder();
|
||||||
|
proc.OutputDataReceived += (_, e) => { if (e.Data is not null) AppendCapped(outTail, e.Data); };
|
||||||
|
proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) AppendCapped(errTail, e.Data); };
|
||||||
|
|
||||||
|
proc.Start();
|
||||||
|
proc.BeginOutputReadLine();
|
||||||
|
proc.BeginErrorReadLine();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await proc.WaitForExitAsync(ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Kill(proc);
|
||||||
|
return new ExternalCommandResult(-1, "취소됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
var joined = (outTail + "\n" + errTail).Trim();
|
||||||
|
return new ExternalCommandResult(proc.ExitCode, joined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Kill(Process proc)
|
||||||
|
{
|
||||||
|
try { proc.Kill(entireProcessTree: true); }
|
||||||
|
catch { }
|
||||||
|
try { proc.WaitForExit(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>출력이 폭주하지 않도록 꼬리 일부만 유지한다.</summary>
|
||||||
|
private static void AppendCapped(StringBuilder sb, string line, int max = 4000)
|
||||||
|
{
|
||||||
|
lock (sb)
|
||||||
|
{
|
||||||
|
if (sb.Length > max * 3) return;
|
||||||
|
sb.AppendLine(line);
|
||||||
|
if (sb.Length > max)
|
||||||
|
{
|
||||||
|
var keep = sb.ToString(sb.Length - max, max);
|
||||||
|
sb.Clear();
|
||||||
|
sb.Append('…');
|
||||||
|
sb.Append(keep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExternalToolInstallResult(bool Success, int ExitCode, string Output, string? Message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 외부 도구 설치 실행기. Winget 도구는 winget 무인 설치 명령을 만들고 실행한 뒤,
|
||||||
|
/// 설치 여부를 다시 감지(IsInstalled)해 실제 성공 여부를 판정한다.
|
||||||
|
/// Manual(수동) 도구는 실행하지 않고 안내 문구만 돌려준다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExternalToolInstaller
|
||||||
|
{
|
||||||
|
private readonly IExternalCommandRunner _runner;
|
||||||
|
|
||||||
|
public ExternalToolInstaller(IExternalCommandRunner? runner = null)
|
||||||
|
=> _runner = runner ?? new SystemCommandRunner();
|
||||||
|
|
||||||
|
public static bool IsWingetAvailable()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo("winget", "--version")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
};
|
||||||
|
using var p = Process.Start(psi);
|
||||||
|
if (p is null) return false;
|
||||||
|
if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; }
|
||||||
|
return p.ExitCode == 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExternalToolInstallResult> InstallAsync(ExternalToolDefinition tool, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 수동 도구(H2Orestart 등)는 자동 설치를 시도하지 않고 안내문을 돌려준다.
|
||||||
|
if (tool.Kind != ExternalToolInstallKind.Winget || string.IsNullOrWhiteSpace(tool.WingetId))
|
||||||
|
return new ExternalToolInstallResult(tool.IsInstalled(), 0, "", tool.ManualNote);
|
||||||
|
|
||||||
|
var args = $"install --id {tool.WingetId} -e --source winget --silent " +
|
||||||
|
"--accept-package-agreements --accept-source-agreements --disable-interactivity";
|
||||||
|
|
||||||
|
ExternalCommandResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await _runner.RunAsync("winget", args, null, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new ExternalToolInstallResult(false, -1, ex.Message, "winget 실행 실패 — 설치할 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 판정은 exit code가 아니라 '설치 후 실제 감지'로 한다(앱 입장의 실질 기준).
|
||||||
|
var installed = tool.IsInstalled();
|
||||||
|
return new ExternalToolInstallResult(
|
||||||
|
installed,
|
||||||
|
result.ExitCode,
|
||||||
|
result.Output,
|
||||||
|
installed ? null : $"설치 실패 또는 아직 감지 안 됨 (코드 {result.ExitCode})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -96,16 +96,28 @@ public sealed class LlmProvider : IConverterProvider
|
||||||
return openaiKey is null ? (null, "") : (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini");
|
return openaiKey is null ? (null, "") : (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini");
|
||||||
if (backend == "anthropic")
|
if (backend == "anthropic")
|
||||||
return anthropicKey is null ? (null, "") : (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest");
|
return anthropicKey is null ? (null, "") : (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest");
|
||||||
|
if (backend == "agy")
|
||||||
|
return ExternalToolDetector.IsAgyAvailable(out var agyPath) ? (new AgyChatClient(agyPath), ai.Model ?? "") : (null, "");
|
||||||
|
if (backend == "switchboard" || backend == "gateway")
|
||||||
|
{
|
||||||
|
var ep = _settings.Get("switchboard.endpoint");
|
||||||
|
return (new SwitchboardChatClient(ep), ai.Model ?? "");
|
||||||
|
}
|
||||||
if (backend == "codex")
|
if (backend == "codex")
|
||||||
return ExternalToolDetector.IsCodexAvailable() ? (new CodexChatClient(), ai.Model ?? "") : (null, "");
|
return ExternalToolDetector.IsCodexAvailable() ? (new CodexChatClient(), ai.Model ?? "") : (null, "");
|
||||||
|
|
||||||
// auto: API 키 우선, 없으면 Codex CLI(ChatGPT 구독 OAuth)
|
// auto: API 키 우선, 없으면 Antigravity CLI(Gemini OAuth) 또는 Switchboard Gateway 또는 Codex CLI(ChatGPT 구독 OAuth)
|
||||||
if (openaiKey is not null) return (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini");
|
if (openaiKey is not null) return (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini");
|
||||||
if (anthropicKey is not null) return (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest");
|
if (anthropicKey is not null) return (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest");
|
||||||
|
if (ExternalToolDetector.IsAgyAvailable(out var defaultAgyPath)) return (new AgyChatClient(defaultAgyPath), ai.Model ?? "");
|
||||||
|
var sbEp = _settings.Get("switchboard.endpoint");
|
||||||
|
if (ExternalToolDetector.IsSwitchboardGatewayAvailable(out var defaultEp, sbEp)) return (new SwitchboardChatClient(defaultEp), ai.Model ?? "");
|
||||||
if (ExternalToolDetector.IsCodexAvailable()) return (new CodexChatClient(), ai.Model ?? "");
|
if (ExternalToolDetector.IsCodexAvailable()) return (new CodexChatClient(), ai.Model ?? "");
|
||||||
return (null, "");
|
return (null, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal (IChatClient? client, string model) ResolveClientForTesting(AiOptions ai) => ResolveClient(ai);
|
||||||
|
|
||||||
private string? GetKey(string provider)
|
private string? GetKey(string provider)
|
||||||
{
|
{
|
||||||
var stored = _settings.Get($"{provider}.apikey");
|
var stored = _settings.Get($"{provider}.apikey");
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ public static class ServiceCollectionExtensions
|
||||||
else
|
else
|
||||||
services.TryAddSingleton<ISettingsStore, DpapiSettingsStore>();
|
services.TryAddSingleton<ISettingsStore, DpapiSettingsStore>();
|
||||||
|
|
||||||
|
services.TryAddSingleton<Everything2Everything.Core.Ads.IAdService, Everything2Everything.Core.Ads.AdService>();
|
||||||
|
|
||||||
// IConverterProvider 구현 전수 자동 등록. AsSelfWithInterfaces =
|
// IConverterProvider 구현 전수 자동 등록. AsSelfWithInterfaces =
|
||||||
// 구체 타입을 단일 싱글턴으로 등록 + IConverterProvider는 그 인스턴스로 포워드한다.
|
// 구체 타입을 단일 싱글턴으로 등록 + IConverterProvider는 그 인스턴스로 포워드한다.
|
||||||
// → Heic(MagickProvider)/Docx·Ocr·Hwpx(PdfProvider)/Llm(ISettingsStore) 생성자 의존이
|
// → Heic(MagickProvider)/Docx·Ocr·Hwpx(PdfProvider)/Llm(ISettingsStore) 생성자 의존이
|
||||||
|
|
|
||||||
105
src/Everything2Everything.Tests/AdDesignAuditTests.cs
Normal file
105
src/Everything2Everything.Tests/AdDesignAuditTests.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class AdDesignAuditTests
|
||||||
|
{
|
||||||
|
private static readonly string SolutionRoot = FindSolutionRoot();
|
||||||
|
private static readonly string ViewsDir = Path.Combine(SolutionRoot, "src", "Everything2Everything.App", "Views");
|
||||||
|
|
||||||
|
private static string FindSolutionRoot()
|
||||||
|
{
|
||||||
|
var dir = Directory.GetCurrentDirectory();
|
||||||
|
while (dir != null && !File.Exists(Path.Combine(dir, "Everything2Everything.slnx")))
|
||||||
|
{
|
||||||
|
dir = Directory.GetParent(dir)?.FullName;
|
||||||
|
}
|
||||||
|
return dir ?? throw new DirectoryNotFoundException("솔루션 루트를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdControls_MustExistAndBeWellFormed()
|
||||||
|
{
|
||||||
|
var bannerPath = Path.Combine(ViewsDir, "AdBannerControl.xaml");
|
||||||
|
var largeCardPath = Path.Combine(ViewsDir, "AdLargeCardControl.xaml");
|
||||||
|
|
||||||
|
Assert.True(File.Exists(bannerPath), "AdBannerControl.xaml 이 존재해야 합니다.");
|
||||||
|
Assert.True(File.Exists(largeCardPath), "AdLargeCardControl.xaml 이 존재해야 합니다.");
|
||||||
|
|
||||||
|
var bannerDoc = XDocument.Parse(File.ReadAllText(bannerPath));
|
||||||
|
var largeCardDoc = XDocument.Parse(File.ReadAllText(largeCardPath));
|
||||||
|
|
||||||
|
Assert.NotNull(bannerDoc.Root);
|
||||||
|
Assert.NotNull(largeCardDoc.Root);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdControls_MustComplyWithStrictDesignAudit()
|
||||||
|
{
|
||||||
|
var targetFiles = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(ViewsDir, "AdBannerControl.xaml"),
|
||||||
|
Path.Combine(ViewsDir, "AdLargeCardControl.xaml")
|
||||||
|
};
|
||||||
|
|
||||||
|
var rawEmojis = new[] { "⚙", "📁", "🗑", "✖", "🔧", "✨", "🚀", "⚡" };
|
||||||
|
var violations = new List<string>();
|
||||||
|
|
||||||
|
foreach (var file in targetFiles)
|
||||||
|
{
|
||||||
|
if (!File.Exists(file)) continue;
|
||||||
|
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
var fileName = Path.GetFileName(file);
|
||||||
|
|
||||||
|
// 1. 유니코드 이모지 금지 검증
|
||||||
|
var buttons = doc.Descendants().Where(e => e.Name.LocalName is "Button" or "ToggleButton");
|
||||||
|
foreach (var btn in buttons)
|
||||||
|
{
|
||||||
|
var content = btn.Attribute("Content")?.Value;
|
||||||
|
if (!string.IsNullOrEmpty(content) && rawEmojis.Any(emoji => content.Contains(emoji)))
|
||||||
|
{
|
||||||
|
violations.Add($"[{fileName}] 버튼에 이모지 사용 감지: {content}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 가로 StackPanel 내 아이콘/텍스트 VerticalAlignment="Center" 검증
|
||||||
|
var stackPanels = doc.Descendants().Where(e => e.Name.LocalName == "StackPanel" &&
|
||||||
|
e.Attribute("Orientation")?.Value == "Horizontal");
|
||||||
|
foreach (var sp in stackPanels)
|
||||||
|
{
|
||||||
|
foreach (var child in sp.Elements())
|
||||||
|
{
|
||||||
|
var va = child.Attribute("VerticalAlignment")?.Value;
|
||||||
|
if (va != "Center")
|
||||||
|
{
|
||||||
|
violations.Add($"[{fileName}] 가로 StackPanel의 자식 <{child.Name.LocalName}>에 VerticalAlignment=\"Center\" 누락");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. TextBlock 오버플로 방지 (TextTrimming 또는 TextWrapping) 검증
|
||||||
|
var textBlocks = doc.Descendants().Where(e => e.Name.LocalName == "TextBlock");
|
||||||
|
foreach (var tb in textBlocks)
|
||||||
|
{
|
||||||
|
var text = tb.Attribute("Text")?.Value ?? "";
|
||||||
|
// 바인딩된 동적 텍스트는 Trimming이나 Wrapping 필수
|
||||||
|
if (text.Contains("{Binding") || text.Contains("{x:Bind"))
|
||||||
|
{
|
||||||
|
var trimming = tb.Attribute("TextTrimming")?.Value;
|
||||||
|
var wrapping = tb.Attribute("TextWrapping")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(trimming) && string.IsNullOrEmpty(wrapping))
|
||||||
|
{
|
||||||
|
violations.Add($"[{fileName}] 바인딩 TextBlock에 TextTrimming 또는 TextWrapping 누락: {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(violations.Count == 0,
|
||||||
|
"광고 컨트롤 디자인 감사 위반이 발견되었습니다:\n" + string.Join("\n", violations));
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Everything2Everything.Tests/AdMobConfigTests.cs
Normal file
30
src/Everything2Everything.Tests/AdMobConfigTests.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using Everything2Everything.Core.Ads;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class AdMobConfigTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AdMobConfig_DefaultState_IsValid()
|
||||||
|
{
|
||||||
|
var config = new AdMobConfig();
|
||||||
|
|
||||||
|
Assert.NotNull(config.AppId);
|
||||||
|
Assert.NotNull(config.BannerAdUnitId);
|
||||||
|
Assert.NotNull(config.InterstitialAdUnitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateAppAdsTxt_ReturnsValidGoogleSpecification()
|
||||||
|
{
|
||||||
|
var config = new AdMobConfig
|
||||||
|
{
|
||||||
|
PublisherId = "pub-1234567890123456"
|
||||||
|
};
|
||||||
|
|
||||||
|
string appAdsTxt = config.GenerateAppAdsTxt();
|
||||||
|
|
||||||
|
Assert.Contains("google.com, pub-1234567890123456, DIRECT, f08c47fec0942fa0", appAdsTxt);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/Everything2Everything.Tests/AdServiceTests.cs
Normal file
115
src/Everything2Everything.Tests/AdServiceTests.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Everything2Everything.Core.Ads;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class AdServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetBannerAds_ReturnsDefaultHouseAds_NotEmpty()
|
||||||
|
{
|
||||||
|
var service = new AdService();
|
||||||
|
var bannerAds = service.GetBannerAds();
|
||||||
|
|
||||||
|
Assert.NotNull(bannerAds);
|
||||||
|
Assert.NotEmpty(bannerAds);
|
||||||
|
Assert.All(bannerAds, ad =>
|
||||||
|
{
|
||||||
|
Assert.Equal(AdType.Banner, ad.Type);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.Title));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.Description));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.TargetUrl));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLargeCardAds_ReturnsDefaultLargeCardAds_NotEmpty()
|
||||||
|
{
|
||||||
|
var service = new AdService();
|
||||||
|
var largeAds = service.GetLargeCardAds();
|
||||||
|
|
||||||
|
Assert.NotNull(largeAds);
|
||||||
|
Assert.NotEmpty(largeAds);
|
||||||
|
Assert.All(largeAds, ad =>
|
||||||
|
{
|
||||||
|
Assert.Equal(AdType.LargeCard, ad.Type);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.Title));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.Description));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(ad.TargetUrl));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetNextBannerAd_RotatesThroughAds()
|
||||||
|
{
|
||||||
|
var service = new AdService();
|
||||||
|
var banners = service.GetBannerAds();
|
||||||
|
|
||||||
|
if (banners.Count > 1)
|
||||||
|
{
|
||||||
|
var first = service.GetNextBannerAd();
|
||||||
|
var second = service.GetNextBannerAd();
|
||||||
|
|
||||||
|
Assert.NotNull(first);
|
||||||
|
Assert.NotNull(second);
|
||||||
|
Assert.NotEqual(first.Id, second.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ad = service.GetNextBannerAd();
|
||||||
|
Assert.NotNull(ad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenAdUrl_InvalidOrEmptyUrl_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var service = new AdService();
|
||||||
|
|
||||||
|
Assert.False(service.OpenAdUrl(new AdItem { TargetUrl = "" }));
|
||||||
|
Assert.False(service.OpenAdUrl(new AdItem { TargetUrl = "javascript:alert(1)" }));
|
||||||
|
Assert.False(service.OpenAdUrl(new AdItem { TargetUrl = "not-a-valid-url" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadAdsFromJson_ValidJson_PopulatesCustomAds()
|
||||||
|
{
|
||||||
|
var service = new AdService();
|
||||||
|
string json = @"[
|
||||||
|
{
|
||||||
|
""Id"": ""test-banner-1"",
|
||||||
|
""Title"": ""커스텀 파트너스 배너"",
|
||||||
|
""Description"": ""안전하고 빠른 클라우드 스토리지"",
|
||||||
|
""BadgeText"": ""스폰서"",
|
||||||
|
""ImageUrl"": ""https://example.com/banner.png"",
|
||||||
|
""TargetUrl"": ""https://example.com/promo"",
|
||||||
|
""CtaText"": ""지금 확인"",
|
||||||
|
""Type"": 0,
|
||||||
|
""IsActive"": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
""Id"": ""test-large-1"",
|
||||||
|
""Title"": ""대용량 미디어 솔루션"",
|
||||||
|
""Description"": ""초고속 무손실 변환을 비즈니스에 도입하세요"",
|
||||||
|
""BadgeText"": ""AD"",
|
||||||
|
""ImageUrl"": ""https://example.com/large.png"",
|
||||||
|
""TargetUrl"": ""https://example.com/b2b"",
|
||||||
|
""CtaText"": ""솔루션 보기"",
|
||||||
|
""Type"": 1,
|
||||||
|
""IsActive"": true
|
||||||
|
}
|
||||||
|
]";
|
||||||
|
|
||||||
|
int count = service.LoadAdsFromJson(json);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
var banners = service.GetBannerAds();
|
||||||
|
var largeCards = service.GetLargeCardAds();
|
||||||
|
|
||||||
|
Assert.Contains(banners, b => b.Id == "test-banner-1");
|
||||||
|
Assert.Contains(largeCards, l => l.Id == "test-large-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/Everything2Everything.Tests/AdViewModelTests.cs
Normal file
102
src/Everything2Everything.Tests/AdViewModelTests.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Everything2Everything.App.ViewModels;
|
||||||
|
using Everything2Everything.Core.Ads;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class AdViewModelTests
|
||||||
|
{
|
||||||
|
private class FakeAdService : IAdService
|
||||||
|
{
|
||||||
|
public List<AdItem> Banners { get; } = new()
|
||||||
|
{
|
||||||
|
new AdItem { Id = "b1", Title = "배너 1", TargetUrl = "https://example.com/b1", Type = AdType.Banner },
|
||||||
|
new AdItem { Id = "b2", Title = "배너 2", TargetUrl = "https://example.com/b2", Type = AdType.Banner }
|
||||||
|
};
|
||||||
|
|
||||||
|
public List<AdItem> Cards { get; } = new()
|
||||||
|
{
|
||||||
|
new AdItem { Id = "c1", Title = "카드 1", TargetUrl = "https://example.com/c1", Type = AdType.LargeCard },
|
||||||
|
new AdItem { Id = "c2", Title = "카드 2", TargetUrl = "https://example.com/c2", Type = AdType.LargeCard }
|
||||||
|
};
|
||||||
|
|
||||||
|
public AdItem? LastOpenedAd { get; private set; }
|
||||||
|
private int _bIndex = 0;
|
||||||
|
private int _cIndex = 0;
|
||||||
|
|
||||||
|
public IReadOnlyList<AdItem> GetBannerAds() => Banners;
|
||||||
|
public IReadOnlyList<AdItem> GetLargeCardAds() => Cards;
|
||||||
|
|
||||||
|
public AdItem? GetNextBannerAd()
|
||||||
|
{
|
||||||
|
var ad = Banners[_bIndex % Banners.Count];
|
||||||
|
_bIndex++;
|
||||||
|
return ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdItem? GetNextLargeCardAd()
|
||||||
|
{
|
||||||
|
var ad = Cards[_cIndex % Cards.Count];
|
||||||
|
_cIndex++;
|
||||||
|
return ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
public System.Threading.Tasks.Task<int> RefreshAdsFromFeedAsync(string feedUrl, System.Threading.CancellationToken cancellationToken = default) => System.Threading.Tasks.Task.FromResult(0);
|
||||||
|
public int LoadAdsFromJson(string json) => 0;
|
||||||
|
|
||||||
|
public bool OpenAdUrl(AdItem ad)
|
||||||
|
{
|
||||||
|
LastOpenedAd = ad;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_InitializesAdsAndVisibility()
|
||||||
|
{
|
||||||
|
var fake = new FakeAdService();
|
||||||
|
var vm = new AdViewModel(fake);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.CurrentBannerAd);
|
||||||
|
Assert.NotNull(vm.CurrentLargeAd);
|
||||||
|
Assert.True(vm.IsBannerVisible);
|
||||||
|
Assert.True(vm.IsLargeCardVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClickBannerAdCommand_OpensAdUrl()
|
||||||
|
{
|
||||||
|
var fake = new FakeAdService();
|
||||||
|
var vm = new AdViewModel(fake);
|
||||||
|
|
||||||
|
vm.ClickBannerAdCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(fake.LastOpenedAd);
|
||||||
|
Assert.Equal(vm.CurrentBannerAd?.Id, fake.LastOpenedAd.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DismissBannerCommand_HidesBanner()
|
||||||
|
{
|
||||||
|
var fake = new FakeAdService();
|
||||||
|
var vm = new AdViewModel(fake);
|
||||||
|
|
||||||
|
Assert.True(vm.IsBannerVisible);
|
||||||
|
vm.DismissBannerCommand.Execute(null);
|
||||||
|
Assert.False(vm.IsBannerVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RotateBannerAd_ChangesCurrentBanner()
|
||||||
|
{
|
||||||
|
var fake = new FakeAdService();
|
||||||
|
var vm = new AdViewModel(fake);
|
||||||
|
|
||||||
|
var firstId = vm.CurrentBannerAd?.Id;
|
||||||
|
vm.RotateBannerAd();
|
||||||
|
var secondId = vm.CurrentBannerAd?.Id;
|
||||||
|
|
||||||
|
Assert.NotEqual(firstId, secondId);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/Everything2Everything.Tests/AgyChatClientTests.cs
Normal file
57
src/Everything2Everything.Tests/AgyChatClientTests.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Everything2Everything.Core;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class AgyChatClientTests
|
||||||
|
{
|
||||||
|
private sealed class FakeStore : ISettingsStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string> _d = new();
|
||||||
|
public string? Get(string key) => _d.TryGetValue(key, out var v) ? v : null;
|
||||||
|
public void Set(string key, string value) => _d[key] = value;
|
||||||
|
public void Remove(string key) => _d.Remove(key);
|
||||||
|
public bool Contains(string key) => _d.ContainsKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExternalToolDetector_CanDetectAgy()
|
||||||
|
{
|
||||||
|
var available = ExternalToolDetector.IsAgyAvailable(out var path);
|
||||||
|
if (!available) return;
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(path));
|
||||||
|
Assert.EndsWith("agy.exe", path, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AgyChatClient_HasCorrectName()
|
||||||
|
{
|
||||||
|
var client = new AgyChatClient();
|
||||||
|
Assert.Equal("Antigravity CLI (agy)", client.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LlmProvider_ResolveClient_AutoSelectsAgyWhenNoKey()
|
||||||
|
{
|
||||||
|
var store = new FakeStore();
|
||||||
|
var provider = new LlmProvider(store);
|
||||||
|
var (client, model) = provider.ResolveClientForTesting(new AiOptions { Backend = "agy" });
|
||||||
|
Assert.NotNull(client);
|
||||||
|
Assert.IsType<AgyChatClient>(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LlmProvider_CheckAvailability_IsReadyWhenAgyAvailable()
|
||||||
|
{
|
||||||
|
if (!ExternalToolDetector.IsAgyAvailable(out _)) return;
|
||||||
|
var store = new FakeStore();
|
||||||
|
var provider = new LlmProvider(store);
|
||||||
|
var availability = await provider.CheckAvailabilityAsync();
|
||||||
|
Assert.True(availability.IsReady);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -26,6 +26,7 @@ public class DependencyInjectionTests
|
||||||
|
|
||||||
Assert.NotNull(sp.GetRequiredService<ProviderRegistry>());
|
Assert.NotNull(sp.GetRequiredService<ProviderRegistry>());
|
||||||
Assert.NotNull(sp.GetRequiredService<ConversionEngine>());
|
Assert.NotNull(sp.GetRequiredService<ConversionEngine>());
|
||||||
|
Assert.NotNull(sp.GetRequiredService<Everything2Everything.Core.Ads.IAdService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -944,8 +944,8 @@ public class DesignAuditAstTests
|
||||||
Assert.NotNull(searchBoxCol);
|
Assert.NotNull(searchBoxCol);
|
||||||
|
|
||||||
var widthVal = searchBoxCol.Attribute("Width")?.Value;
|
var widthVal = searchBoxCol.Attribute("Width")?.Value;
|
||||||
Assert.True(int.TryParse(widthVal, out var w) && w <= 180,
|
Assert.True(widthVal == "*" || (int.TryParse(widthVal, out var w) && w <= 180),
|
||||||
$"SearchBox 컬럼 폭은 180px 이하여야 필터 버튼들이 잘리지 않습니다. 현재: {widthVal}");
|
$"SearchBox 컬럼 폭은 가변(*)이거나 180px 이하여야 필터 컨트롤이 잘리지 않습니다. 현재: {widthVal}");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -1104,6 +1104,33 @@ public class DesignAuditAstTests
|
||||||
Assert.Equal("0", row);
|
Assert.Equal("0", row);
|
||||||
Assert.Equal("2", rowSpan);
|
Assert.Equal("2", rowSpan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_Toolbar_MustHave_FlexibleSearch_And_CategoryFilterDropdown_Without_Overflow()
|
||||||
|
{
|
||||||
|
// 파일명 검색창과 카테고리 필터가 좁은 창 가로폭(360px)에서도 씹힘/겹침(Overflow Collision)이 발생하지 않도록
|
||||||
|
// 1) 5개 개별 가로 버튼 대신 단일 폴드아웃 드롭다운(CategoryFilterCombo)으로 정합되어야 하며
|
||||||
|
// 2) 검색창은 Width="*" 컬럼에 위치하여 가변 너비를 가져야 하고
|
||||||
|
// 3) CategoryFilterCombo는 VerticalAlignment="Center"를 준수해야 한다.
|
||||||
|
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||||
|
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||||
|
|
||||||
|
// CategoryFilterCombo ComboBox 존재 검증
|
||||||
|
var combo = doc.Descendants().FirstOrDefault(e =>
|
||||||
|
e.Name.LocalName == "ComboBox" &&
|
||||||
|
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "CategoryFilterCombo");
|
||||||
|
Assert.NotNull(combo);
|
||||||
|
|
||||||
|
var vAlign = combo.Attribute("VerticalAlignment")?.Value;
|
||||||
|
Assert.Equal("Center", vAlign);
|
||||||
|
|
||||||
|
// 이전 5개 개별 가로 버튼(CommandParameter="All" 등)이 툴바에서 제거되어 드롭다운으로 대체되었는지 검증
|
||||||
|
var individualFilterButtons = doc.Descendants().Where(e =>
|
||||||
|
e.Name.LocalName == "Button" &&
|
||||||
|
e.Attribute("Command")?.Value?.Contains("FilterCategoryCommand") == true &&
|
||||||
|
e.Attribute("CommandParameter")?.Value is "All" or "Image" or "Document" or "Media" or "Data").ToList();
|
||||||
|
Assert.Empty(individualFilterButtons);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using System.Windows.Controls.Primitives;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using Everything2Everything.App.Views;
|
using Everything2Everything.App.Views;
|
||||||
using Everything2Everything.Core;
|
using Everything2Everything.Core;
|
||||||
|
using Everything2Everything.Core.Filters;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Everything2Everything.Tests;
|
namespace Everything2Everything.Tests;
|
||||||
|
|
@ -398,8 +399,9 @@ public class DesignAuditVisualTreeTests
|
||||||
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||||
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(rtb));
|
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(rtb));
|
||||||
|
|
||||||
var outDir = @"C:\Users\encep\.gemini\antigravity\brain\a5abaf02-dd4b-45f7-8890-144e9da36bcc";
|
var outDir = @"C:\Users\encep\.gemini\antigravity\brain\b58fd023-a52b-4f4b-aa23-e6df654aa1fb";
|
||||||
var outPath = System.IO.Path.Combine(outDir, "app_rendered_preview.png");
|
if (!System.IO.Directory.Exists(outDir)) System.IO.Directory.CreateDirectory(outDir);
|
||||||
|
var outPath = System.IO.Path.Combine(outDir, "rendered_toolbar_fixed.png");
|
||||||
using (var fs = new System.IO.FileStream(outPath, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.ReadWrite))
|
using (var fs = new System.IO.FileStream(outPath, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.ReadWrite))
|
||||||
{
|
{
|
||||||
encoder.Save(fs);
|
encoder.Save(fs);
|
||||||
|
|
@ -410,6 +412,86 @@ public class DesignAuditVisualTreeTests
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainWindow_CategoryFilterCombo_ChangesCategory_And_FiltersQueue()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||||
|
var settings = new FakeSettingsStore();
|
||||||
|
var window = new MainWindow(engine, settings);
|
||||||
|
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\test.png", FileName = "test.png" });
|
||||||
|
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\doc.pdf", FileName = "doc.pdf" });
|
||||||
|
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\clip.mp4", FileName = "clip.mp4" });
|
||||||
|
window.ActiveQueue.Add(new QueueItem { SourcePath = @"C:\sheet.xlsx", FileName = "sheet.xlsx" });
|
||||||
|
|
||||||
|
var combo = (ComboBox)window.FindName("CategoryFilterCombo");
|
||||||
|
Assert.NotNull(combo);
|
||||||
|
Assert.Equal(5, combo.Items.Count);
|
||||||
|
|
||||||
|
// 0: 전체 (All)
|
||||||
|
Assert.Equal(FilterCategory.All, window.SelectedCategory);
|
||||||
|
|
||||||
|
// 1: 이미지 (Image)
|
||||||
|
combo.SelectedIndex = 1;
|
||||||
|
Assert.Equal(FilterCategory.Image, window.SelectedCategory);
|
||||||
|
|
||||||
|
var view = System.Windows.Data.CollectionViewSource.GetDefaultView(window.ActiveQueue);
|
||||||
|
var filteredItems = view.Cast<QueueItem>().ToList();
|
||||||
|
Assert.Single(filteredItems);
|
||||||
|
Assert.Equal("test.png", filteredItems[0].FileName);
|
||||||
|
|
||||||
|
// 0: 전체 복귀 (All)
|
||||||
|
combo.SelectedIndex = 0;
|
||||||
|
Assert.Equal(FilterCategory.All, window.SelectedCategory);
|
||||||
|
filteredItems = view.Cast<QueueItem>().ToList();
|
||||||
|
Assert.Equal(4, filteredItems.Count);
|
||||||
|
|
||||||
|
// 가상 레이아웃 검증 (너비 360px 환경에서도 DesiredSize가 정상 계산되는지)
|
||||||
|
var content = (UIElement)window.Content;
|
||||||
|
content.Measure(new Size(360, 600));
|
||||||
|
content.Arrange(new Rect(0, 0, 360, 600));
|
||||||
|
Assert.True(content.DesiredSize.Width > 0);
|
||||||
|
Assert.False(double.IsNaN(content.DesiredSize.Width));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdBannerControl_MeasureAndArrange_HasValidLayoutBounds()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var adService = new Everything2Everything.Core.Ads.AdService();
|
||||||
|
var vm = new Everything2Everything.App.ViewModels.AdViewModel(adService);
|
||||||
|
var banner = new AdBannerControl { DataContext = vm };
|
||||||
|
banner.Measure(new Size(800, 100));
|
||||||
|
banner.Arrange(new Rect(0, 0, 800, 100));
|
||||||
|
|
||||||
|
Assert.True(banner.DesiredSize.Width > 0);
|
||||||
|
Assert.True(banner.DesiredSize.Height > 0);
|
||||||
|
Assert.False(double.IsNaN(banner.DesiredSize.Width));
|
||||||
|
Assert.False(double.IsNaN(banner.DesiredSize.Height));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdLargeCardControl_MeasureAndArrange_HasValidLayoutBounds()
|
||||||
|
{
|
||||||
|
RunOnSta(() =>
|
||||||
|
{
|
||||||
|
var adService = new Everything2Everything.Core.Ads.AdService();
|
||||||
|
var vm = new Everything2Everything.App.ViewModels.AdViewModel(adService);
|
||||||
|
var card = new AdLargeCardControl { DataContext = vm };
|
||||||
|
card.Measure(new Size(340, 300));
|
||||||
|
card.Arrange(new Rect(0, 0, 340, 300));
|
||||||
|
|
||||||
|
Assert.True(card.DesiredSize.Width > 0);
|
||||||
|
Assert.True(card.DesiredSize.Height > 0);
|
||||||
|
Assert.False(double.IsNaN(card.DesiredSize.Width));
|
||||||
|
Assert.False(double.IsNaN(card.DesiredSize.Height));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static IEnumerable<T> FindLogicalChildren<T>(object parent) where T : DependencyObject
|
private static IEnumerable<T> FindLogicalChildren<T>(object parent) where T : DependencyObject
|
||||||
{
|
{
|
||||||
if (parent is ContentControl cc && cc.Content != null)
|
if (parent is ContentControl cc && cc.Content != null)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Everything2Everything.App.ViewModels;
|
using Everything2Everything.App.ViewModels;
|
||||||
using Everything2Everything.Core;
|
using Everything2Everything.Core;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
using Everything2Everything.Core.Providers;
|
using Everything2Everything.Core.Providers;
|
||||||
using Everything2Everything.Core.Filters;
|
using Everything2Everything.Core.Filters;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -147,5 +148,56 @@ public class E2EUserScenarioTests
|
||||||
Assert.False(MediaConversionNegotiator.CanConvert(".xlsx", ".png"));
|
Assert.False(MediaConversionNegotiator.CanConvert(".xlsx", ".png"));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
[Theory]
|
||||||
|
[InlineData("summarize", null, "요약")]
|
||||||
|
[InlineData("translate", "일본어", "일본어")]
|
||||||
|
[InlineData("proofread", null, "교정")]
|
||||||
|
public void Scenario8_AiQuickPanel_Options_BuildPrompt_FormatsCorrectlyForAllTasks(string task, string? targetLang, string expectedKeyword)
|
||||||
|
{
|
||||||
|
var options = new AiOptions { Task = task, TargetLanguage = targetLang };
|
||||||
|
var (system, user) = LlmProvider.BuildPrompt(options, "테스트 입력 문장");
|
||||||
|
Assert.Contains(expectedKeyword, system);
|
||||||
|
Assert.Equal("테스트 입력 문장", user);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Scenario9_AiConversion_EndToEnd_SwitchboardResolution()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "E2E_AiConversion_" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var srcFile = Path.Combine(tempDir, "sample.md");
|
||||||
|
await File.WriteAllTextAsync(srcFile, "# Hello\nEverything2Everything test content.");
|
||||||
|
|
||||||
|
var store = new FakeSettingsStore();
|
||||||
|
store.Set("switchboard.endpoint", "http://192.168.0.225:8787");
|
||||||
|
var provider = new LlmProvider(store);
|
||||||
|
|
||||||
|
var options = new ConvertOptions
|
||||||
|
{
|
||||||
|
Ai = new AiOptions { Task = "summarize", Backend = "switchboard" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var (client, _) = provider.ResolveClientForTesting(options.Ai);
|
||||||
|
Assert.NotNull(client);
|
||||||
|
Assert.Equal("Switchboard Gateway", client.Name);
|
||||||
|
var sbClient = Assert.IsType<SwitchboardChatClient>(client);
|
||||||
|
Assert.Equal("http://192.168.0.225:8787", sbClient.Endpoint);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(tempDir))
|
||||||
|
Directory.Delete(tempDir, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSettingsStore : ISettingsStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string> _d = new();
|
||||||
|
public string? Get(string key) => _d.TryGetValue(key, out var v) ? v : null;
|
||||||
|
public void Set(string key, string value) => _d[key] = value;
|
||||||
|
public void Remove(string key) => _d.Remove(key);
|
||||||
|
public bool Contains(string key) => _d.ContainsKey(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
117
src/Everything2Everything.Tests/ExternalToolInstallerTests.cs
Normal file
117
src/Everything2Everything.Tests/ExternalToolInstallerTests.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
using Everything2Everything.App.ViewModels;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class ExternalToolInstallerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Catalog_CoversAllKnownTools_WithVerifiedWingetIds()
|
||||||
|
{
|
||||||
|
var byKey = ExternalToolCatalog.All.ToDictionary(t => t.Key, StringComparer.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(7, byKey.Count);
|
||||||
|
|
||||||
|
Assert.Equal("Gyan.FFmpeg", byKey["ffmpeg"].WingetId);
|
||||||
|
Assert.Equal("TheDocumentFoundation.LibreOffice", byKey["libreoffice"].WingetId);
|
||||||
|
Assert.Equal("JohnMacFarlane.Pandoc", byKey["pandoc"].WingetId);
|
||||||
|
Assert.Equal("ImageMagick.ImageMagick", byKey["imagemagick"].WingetId);
|
||||||
|
Assert.Equal("Google.AntigravityCLI", byKey["agy"].WingetId);
|
||||||
|
Assert.Equal("OpenAI.Codex", byKey["codex"].WingetId);
|
||||||
|
|
||||||
|
Assert.Equal(ExternalToolInstallKind.Manual, byKey["h2orestart"].Kind);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(byKey["h2orestart"].ManualNote));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InstallAsync_WingetTool_RunsSilentWinget_AndReportsInstalled()
|
||||||
|
{
|
||||||
|
var runner = new CapturingRunner();
|
||||||
|
var installer = new ExternalToolInstaller(runner);
|
||||||
|
var tool = WingetTool("test-tool", "Contoso.Demo", () => true);
|
||||||
|
|
||||||
|
var result = await installer.InstallAsync(tool, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("winget", runner.LastFileName);
|
||||||
|
Assert.Contains("--id Contoso.Demo", runner.LastArgs);
|
||||||
|
Assert.Contains("--silent", runner.LastArgs);
|
||||||
|
Assert.Contains("--accept-package-agreements", runner.LastArgs);
|
||||||
|
Assert.Contains("--accept-source-agreements", runner.LastArgs);
|
||||||
|
Assert.Contains("--disable-interactivity", runner.LastArgs);
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Null(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InstallAsync_WhenStillNotDetectedAfterRun_ReportsFailure()
|
||||||
|
{
|
||||||
|
var runner = new CapturingRunner { ExitCode = 1, Output = "installer failed" };
|
||||||
|
var installer = new ExternalToolInstaller(runner);
|
||||||
|
var tool = WingetTool("test-tool", "Contoso.Demo", () => false);
|
||||||
|
|
||||||
|
var result = await installer.InstallAsync(tool, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("실패", result.Message);
|
||||||
|
Assert.Equal(1, result.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InstallAsync_ManualKind_DoesNotRunAnything_ReturnsGuidance()
|
||||||
|
{
|
||||||
|
var runner = new CapturingRunner();
|
||||||
|
var installer = new ExternalToolInstaller(runner);
|
||||||
|
var tool = new ExternalToolDefinition
|
||||||
|
{
|
||||||
|
Key = "manual",
|
||||||
|
DisplayName = "Manual",
|
||||||
|
Description = "d",
|
||||||
|
Kind = ExternalToolInstallKind.Manual,
|
||||||
|
ManualNote = "직접 추가하세요",
|
||||||
|
IsInstalled = () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await installer.InstallAsync(tool, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(runner.LastFileName);
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal("직접 추가하세요", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToolSetupViewModel_DefaultsToAllSelected_AndCoversCatalog()
|
||||||
|
{
|
||||||
|
var vm = new ToolSetupViewModel(
|
||||||
|
ExternalToolCatalog.All,
|
||||||
|
new ExternalToolInstaller(new CapturingRunner()));
|
||||||
|
|
||||||
|
Assert.Equal(ExternalToolCatalog.All.Count, vm.Tools.Count);
|
||||||
|
Assert.All(vm.Tools, t => Assert.True(t.IsSelected));
|
||||||
|
Assert.True(vm.SelectAll);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExternalToolDefinition WingetTool(string key, string id, Func<bool> installed) => new()
|
||||||
|
{
|
||||||
|
Key = key,
|
||||||
|
DisplayName = key,
|
||||||
|
Description = key,
|
||||||
|
Kind = ExternalToolInstallKind.Winget,
|
||||||
|
WingetId = id,
|
||||||
|
IsInstalled = installed,
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class CapturingRunner : IExternalCommandRunner
|
||||||
|
{
|
||||||
|
public string? LastFileName;
|
||||||
|
public string? LastArgs;
|
||||||
|
public int ExitCode { get; set; }
|
||||||
|
public string Output { get; set; } = "";
|
||||||
|
|
||||||
|
public Task<ExternalCommandResult> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct)
|
||||||
|
{
|
||||||
|
LastFileName = fileName;
|
||||||
|
LastArgs = arguments;
|
||||||
|
return Task.FromResult(new ExternalCommandResult(ExitCode, Output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,8 +25,8 @@ public class LlmProviderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task NoKey_IsNotReady()
|
public async Task NoKey_IsNotReady()
|
||||||
{
|
{
|
||||||
// 환경변수 키 또는 codex CLI(OAuth)가 있으면 AI가 활성화되므로 이 단언은 건너뜀
|
// 환경변수 키 또는 codex/agy CLI(OAuth) 또는 Switchboard Gateway가 있으면 AI가 활성화되므로 이 단언은 건너뜀
|
||||||
if (EnvHasKey() || ExternalToolDetector.IsCodexAvailable()) return;
|
if (EnvHasKey() || ExternalToolDetector.IsCodexAvailable() || ExternalToolDetector.IsAgyAvailable(out _) || ExternalToolDetector.IsSwitchboardGatewayAvailable(out _)) return;
|
||||||
var p = new LlmProvider(new FakeStore());
|
var p = new LlmProvider(new FakeStore());
|
||||||
var a = await p.CheckAvailabilityAsync();
|
var a = await p.CheckAvailabilityAsync();
|
||||||
Assert.False(a.IsReady);
|
Assert.False(a.IsReady);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Everything2Everything.Core;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class SwitchboardChatClientTests
|
||||||
|
{
|
||||||
|
private sealed class FakeStore : ISettingsStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string> _d = new();
|
||||||
|
public string? Get(string key) => _d.TryGetValue(key, out var v) ? v : null;
|
||||||
|
public void Set(string key, string value) => _d[key] = value;
|
||||||
|
public void Remove(string key) => _d.Remove(key);
|
||||||
|
public bool Contains(string key) => _d.ContainsKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SwitchboardChatClient_HasCorrectNameAndEndpoint()
|
||||||
|
{
|
||||||
|
var client = new SwitchboardChatClient();
|
||||||
|
Assert.Equal("Switchboard Gateway", client.Name);
|
||||||
|
Assert.Equal("http://127.0.0.1:8787", client.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SwitchboardChatClient_CustomEndpoint_IsPreserved()
|
||||||
|
{
|
||||||
|
var client = new SwitchboardChatClient("http://192.168.1.100:8787/");
|
||||||
|
Assert.Equal("http://192.168.1.100:8787", client.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LlmProvider_ResolveClient_SelectsSwitchboardWhenRequested()
|
||||||
|
{
|
||||||
|
var store = new FakeStore();
|
||||||
|
store.Set("switchboard.endpoint", "http://127.0.0.1:8787");
|
||||||
|
var provider = new LlmProvider(store);
|
||||||
|
var (client, model) = provider.ResolveClientForTesting(new AiOptions { Backend = "switchboard" });
|
||||||
|
Assert.NotNull(client);
|
||||||
|
Assert.IsType<SwitchboardChatClient>(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckSwitchboardGatewayHealthAsync_ReturnsFalseForUnreachablePort()
|
||||||
|
{
|
||||||
|
var (available, _, _) = await ExternalToolDetector.CheckSwitchboardGatewayHealthAsync("http://127.0.0.1:59999");
|
||||||
|
Assert.False(available);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExternalToolDetector_IsSwitchboardGatewayAvailable_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var available = ExternalToolDetector.IsSwitchboardGatewayAvailable(out var ep);
|
||||||
|
Assert.Equal("http://127.0.0.1:8787", ep);
|
||||||
|
// Returns bool without hanging or throwing
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LlmProvider_CustomEndpoint_IsUsedWhenConfigured()
|
||||||
|
{
|
||||||
|
var store = new FakeStore();
|
||||||
|
store.Set("switchboard.endpoint", "http://192.168.0.25:8787");
|
||||||
|
var provider = new LlmProvider(store);
|
||||||
|
var (client, _) = provider.ResolveClientForTesting(new AiOptions { Backend = "switchboard" });
|
||||||
|
Assert.NotNull(client);
|
||||||
|
var sbClient = Assert.IsType<SwitchboardChatClient>(client);
|
||||||
|
Assert.Equal("http://192.168.0.25:8787", sbClient.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExternalToolDetector_IsSwitchboardGatewayAvailable_WithCustomEndpoint_PreservesEndpoint()
|
||||||
|
{
|
||||||
|
var available = ExternalToolDetector.IsSwitchboardGatewayAvailable(out var ep, "http://192.168.0.25:8787/");
|
||||||
|
Assert.Equal("http://192.168.0.25:8787", ep);
|
||||||
|
Assert.False(available);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue