diff --git a/src/Everything2Everything.App/App.xaml.cs b/src/Everything2Everything.App/App.xaml.cs
index 6c03b33..3c7b93a 100644
--- a/src/Everything2Everything.App/App.xaml.cs
+++ b/src/Everything2Everything.App/App.xaml.cs
@@ -7,7 +7,15 @@ namespace Everything2Everything.App;
public partial class App : Application
{
- public ConversionEngine Engine { get; } = Everything2EverythingBootstrap.CreateDefault();
+ /// App·LlmProvider가 공유하는 설정 저장소 (키 저장 즉시 변환에 반영).
+ public ISettingsStore Settings { get; } = new DpapiSettingsStore();
+
+ public ConversionEngine Engine { get; }
+
+ public App()
+ {
+ Engine = Everything2EverythingBootstrap.CreateDefault(Settings);
+ }
protected override async void OnStartup(StartupEventArgs e)
{
diff --git a/src/Everything2Everything.App/Views/FormatShiftTheme.xaml b/src/Everything2Everything.App/Views/FormatShiftTheme.xaml
index 784011c..6aa50c0 100644
--- a/src/Everything2Everything.App/Views/FormatShiftTheme.xaml
+++ b/src/Everything2Everything.App/Views/FormatShiftTheme.xaml
@@ -338,4 +338,41 @@
+
+
+
+
+
+
diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml b/src/Everything2Everything.App/Views/MainWindow.xaml
index 31b5a84..3af73c6 100644
--- a/src/Everything2Everything.App/Views/MainWindow.xaml
+++ b/src/Everything2Everything.App/Views/MainWindow.xaml
@@ -334,6 +334,9 @@
+
diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs
index b3db981..31d1123 100644
--- a/src/Everything2Everything.App/Views/MainWindow.xaml.cs
+++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs
@@ -87,6 +87,14 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
CapabilityStatusText.Visibility = Visibility.Visible;
}
+ private void OnSettingsClick(object sender, RoutedEventArgs e)
+ {
+ var win = new SettingsWindow(((App)Application.Current).Settings) { Owner = this };
+ win.ShowDialog();
+ _ = RefreshCapabilityStatusAsync();
+ RefreshAvailableOutputFormats();
+ }
+
private void PickAndAddFiles()
{
var dlg = new Microsoft.Win32.OpenFileDialog
diff --git a/src/Everything2Everything.App/Views/SettingsWindow.xaml b/src/Everything2Everything.App/Views/SettingsWindow.xaml
new file mode 100644
index 0000000..ba9d8d7
--- /dev/null
+++ b/src/Everything2Everything.App/Views/SettingsWindow.xaml
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Everything2Everything.App/Views/SettingsWindow.xaml.cs b/src/Everything2Everything.App/Views/SettingsWindow.xaml.cs
new file mode 100644
index 0000000..7a5b4fb
--- /dev/null
+++ b/src/Everything2Everything.App/Views/SettingsWindow.xaml.cs
@@ -0,0 +1,157 @@
+using System.Diagnostics;
+using System.Threading;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Shapes;
+using Everything2Everything.Core;
+using Everything2Everything.Core.Converters;
+
+namespace Everything2Everything.App.Views;
+
+public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
+{
+ private readonly ISettingsStore _settings;
+
+ public SettingsWindow(ISettingsStore settings)
+ {
+ _settings = settings;
+ InitializeComponent();
+ LoadSettings();
+ RefreshToolStatus();
+ }
+
+ private void LoadSettings()
+ {
+ var backend = (_settings.Get("ai.backend") ?? "auto").ToLowerInvariant();
+ BackendCombo.SelectedIndex = backend switch
+ {
+ "openai" => 1,
+ "anthropic" => 2,
+ "codex" => 3,
+ _ => 0,
+ };
+ ModelBox.Text = _settings.Get("ai.model") ?? string.Empty;
+
+ SetKeyStatus(OpenAiDot, OpenAiStatus, _settings.Contains("openai.apikey"), HasEnv("OPENAI_API_KEY"));
+ SetKeyStatus(AnthropicDot, AnthropicStatus, _settings.Contains("anthropic.apikey"), HasEnv("ANTHROPIC_API_KEY"));
+ }
+
+ private static bool HasEnv(string name) => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name));
+
+ private void SetKeyStatus(Ellipse dot, TextBlock text, bool stored, bool env)
+ {
+ if (stored) { dot.Fill = Res("FsStatusSuccess"); text.Text = "저장됨 (변경하려면 새 키 입력)"; }
+ else if (env) { dot.Fill = Res("FsStatusInfo"); text.Text = "환경변수에서 감지됨"; }
+ else { dot.Fill = Res("FsTextTertiary"); text.Text = "키 미설정"; }
+ }
+
+ private static Brush Res(string key) => (Brush)Application.Current.Resources[key];
+
+ // --- 2단계 Verify: 형식 검사로 버튼 활성화 → 클릭 시에만 실제 핑 ---
+
+ private void OnOpenAiKeyChanged(object sender, RoutedEventArgs e)
+ => OpenAiVerifyBtn.IsEnabled = OpenAiKeyBox.Password.StartsWith("sk-", StringComparison.Ordinal);
+
+ private void OnAnthropicKeyChanged(object sender, RoutedEventArgs e)
+ => AnthropicVerifyBtn.IsEnabled = AnthropicKeyBox.Password.StartsWith("sk-ant-", StringComparison.Ordinal);
+
+ private async void OnVerifyOpenAi(object sender, RoutedEventArgs e)
+ => await VerifyAsync(OpenAiDot, OpenAiStatus, OpenAiVerifyBtn,
+ new OpenAiChatClient(OpenAiKeyBox.Password), ModelOr("gpt-4o-mini"));
+
+ private async void OnVerifyAnthropic(object sender, RoutedEventArgs e)
+ => await VerifyAsync(AnthropicDot, AnthropicStatus, AnthropicVerifyBtn,
+ new AnthropicChatClient(AnthropicKeyBox.Password), ModelOr("claude-3-5-sonnet-latest"));
+
+ private async void OnVerifyCodex(object sender, RoutedEventArgs e)
+ => await VerifyAsync(CodexDot, CodexStatus, CodexVerifyBtn, new CodexChatClient(), ModelOr(string.Empty));
+
+ private string ModelOr(string fallback)
+ => string.IsNullOrWhiteSpace(ModelBox.Text) ? fallback : ModelBox.Text.Trim();
+
+ private async Task VerifyAsync(Ellipse dot, TextBlock text, Button btn, IChatClient client, string model)
+ {
+ btn.IsEnabled = false;
+ dot.Fill = Res("FsStatusInfo");
+ text.Text = "확인 중…";
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
+ var reply = await client.CompleteAsync("Reply with exactly: OK", "ping", model, 8, cts.Token);
+ dot.Fill = Res("FsStatusSuccess");
+ text.Text = string.IsNullOrWhiteSpace(reply) ? $"확인됨 ({client.Name})" : $"확인됨 — {client.Name}";
+ }
+ catch (Exception ex)
+ {
+ dot.Fill = Res("FsStatusDanger");
+ text.Text = "실패: " + Trunc(ex.Message);
+ }
+ finally
+ {
+ btn.IsEnabled = true;
+ }
+ }
+
+ private static string Trunc(string s) => s.Length > 64 ? s[..64] + "…" : s;
+
+ private void RefreshToolStatus()
+ {
+ var ffmpeg = ExternalToolDetector.TryFindFfmpeg(out _);
+ FfmpegDot.Fill = Res(ffmpeg ? "FsStatusSuccess" : "FsStatusWarn");
+ FfmpegStatus.Text = ffmpeg ? "준비됨" : "미설치";
+
+ var libre = ExternalToolDetector.TryFindLibreOfficeSoffice(out _);
+ LibreDot.Fill = Res(libre ? "FsStatusSuccess" : "FsStatusWarn");
+ LibreStatus.Text = libre ? "준비됨" : "미설치";
+
+ var codex = ExternalToolDetector.IsCodexAvailable();
+ CodexDot.Fill = Res(codex ? "FsStatusSuccess" : "FsTextTertiary");
+ CodexStatus.Text = codex ? "설치됨 — 키 없이 사용 가능" : "미설치 (npm i -g @openai/codex)";
+ CodexVerifyBtn.IsEnabled = codex;
+ }
+
+ private void OnSave(object sender, RoutedEventArgs e)
+ {
+ var backend = BackendCombo.SelectedIndex switch
+ {
+ 1 => "openai",
+ 2 => "anthropic",
+ 3 => "codex",
+ _ => "auto",
+ };
+ _settings.Set("ai.backend", backend);
+
+ var model = ModelBox.Text?.Trim();
+ if (string.IsNullOrEmpty(model)) _settings.Remove("ai.model");
+ else _settings.Set("ai.model", model);
+
+ if (OpenAiKeyBox.Password.Length > 0) _settings.Set("openai.apikey", OpenAiKeyBox.Password);
+ if (AnthropicKeyBox.Password.Length > 0) _settings.Set("anthropic.apikey", AnthropicKeyBox.Password);
+
+ Close();
+ }
+
+ private void OnClose(object sender, RoutedEventArgs e) => Close();
+
+ private void OnDownloadFfmpeg(object sender, RoutedEventArgs e)
+ => OpenUrl("https://github.com/BtbN/FFmpeg-Builds/releases");
+
+ private void OnDownloadLibre(object sender, RoutedEventArgs e)
+ => OpenUrl("https://www.libreoffice.org/download/");
+
+ private void OnOpenFfmpegFolder(object sender, RoutedEventArgs e)
+ {
+ var dir = System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Everything2Everything", "ffmpeg");
+ Directory.CreateDirectory(dir);
+ OpenUrl(dir);
+ }
+
+ private static void OpenUrl(string target)
+ {
+ try { Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); }
+ catch { /* 브라우저/탐색기 실행 실패 무시 */ }
+ }
+}
diff --git a/src/Everything2Everything.Core/Converters/ChatClients.cs b/src/Everything2Everything.Core/Converters/ChatClients.cs
index df4fee1..b751beb 100644
--- a/src/Everything2Everything.Core/Converters/ChatClients.cs
+++ b/src/Everything2Everything.Core/Converters/ChatClients.cs
@@ -88,3 +88,52 @@ public sealed class AnthropicChatClient : IChatClient
private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
}
+
+///
+/// OpenAI Codex CLI를 non-interactive로 호출하는 백엔드. ChatGPT 구독 OAuth(auth.json)를 그대로
+/// 재사용하므로 API 키 없이 동작한다. `codex exec --skip-git-repo-check --ephemeral -o <file> -`
+/// 형태로 프롬프트를 stdin으로 전달하고, 최종 메시지를 파일에서 읽는다.
+///
+public sealed class CodexChatClient : IChatClient
+{
+ public string Name => "Codex CLI (OAuth)";
+
+ public async Task CompleteAsync(string systemPrompt, string userPrompt, string model, int maxTokens, CancellationToken ct)
+ {
+ var prompt = string.IsNullOrWhiteSpace(systemPrompt) ? userPrompt : systemPrompt + "\n\n---\n\n" + userPrompt;
+ var outFile = Path.Combine(Path.GetTempPath(), "e2e_codex_" + Guid.NewGuid().ToString("N") + ".txt");
+
+ var args = new List { "/c", "codex", "exec", "--skip-git-repo-check", "--ephemeral", "-o", outFile };
+ if (!string.IsNullOrWhiteSpace(model)) { args.Add("-m"); args.Add(model); }
+ args.Add("-"); // 프롬프트를 stdin으로 (인자 이스케이프 회피)
+
+ try
+ {
+ var r = await ExternalProcessRunner.RunAsync(
+ "cmd.exe", args, TimeSpan.FromMinutes(5), workingDirectory: null,
+ cancellationToken: ct, stdinText: prompt).ConfigureAwait(false);
+
+ if (r.TimedOut)
+ throw new InvalidOperationException("Codex CLI 응답이 시간 초과되었습니다 (5분).");
+
+ if (File.Exists(outFile))
+ {
+ var msg = await File.ReadAllTextAsync(outFile, ct).ConfigureAwait(false);
+ if (!string.IsNullOrWhiteSpace(msg)) return msg.Trim();
+ }
+
+ if (!r.Success)
+ {
+ var detail = !string.IsNullOrWhiteSpace(r.StdErr) ? r.StdErr : r.StdOut;
+ throw new InvalidOperationException($"Codex CLI 오류 (exit {r.ExitCode}): {Truncate(detail)}");
+ }
+ return r.StdOut.Trim();
+ }
+ finally
+ {
+ try { if (File.Exists(outFile)) File.Delete(outFile); } catch { }
+ }
+ }
+
+ private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
+}
diff --git a/src/Everything2Everything.Core/Converters/ExternalProcessRunner.cs b/src/Everything2Everything.Core/Converters/ExternalProcessRunner.cs
index 361dd22..6d5c24a 100644
--- a/src/Everything2Everything.Core/Converters/ExternalProcessRunner.cs
+++ b/src/Everything2Everything.Core/Converters/ExternalProcessRunner.cs
@@ -19,7 +19,8 @@ public static class ExternalProcessRunner
IEnumerable arguments,
TimeSpan? timeout = null,
string? workingDirectory = null,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ string? stdinText = null)
{
var psi = new ProcessStartInfo
{
@@ -28,7 +29,11 @@ public static class ExternalProcessRunner
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
+ RedirectStandardInput = stdinText is not null,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
};
+ if (stdinText is not null) psi.StandardInputEncoding = new UTF8Encoding(false);
foreach (var a in arguments) psi.ArgumentList.Add(a);
if (!string.IsNullOrEmpty(workingDirectory)) psi.WorkingDirectory = workingDirectory;
@@ -44,6 +49,17 @@ public static class ExternalProcessRunner
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
+ // 프롬프트 등 긴 입력을 인자 이스케이프 없이 stdin으로 안전하게 전달
+ if (stdinText is not null)
+ {
+ try
+ {
+ await proc.StandardInput.WriteAsync(stdinText.AsMemory(), cancellationToken).ConfigureAwait(false);
+ proc.StandardInput.Close();
+ }
+ catch { /* 프로세스가 stdin을 안 읽고 종료한 경우 무시 */ }
+ }
+
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (timeout is { } t) linked.CancelAfter(t);
diff --git a/src/Everything2Everything.Core/Converters/ExternalToolDetector.cs b/src/Everything2Everything.Core/Converters/ExternalToolDetector.cs
index 189c2e7..cfe24bd 100644
--- a/src/Everything2Everything.Core/Converters/ExternalToolDetector.cs
+++ b/src/Everything2Everything.Core/Converters/ExternalToolDetector.cs
@@ -2,7 +2,7 @@ using Microsoft.Win32;
namespace Everything2Everything.Core.Converters;
-internal static class ExternalToolDetector
+public static class ExternalToolDetector
{
public static bool TryFindLibreOfficeSoffice(out string sofficePath)
{
@@ -82,6 +82,34 @@ internal static class ExternalToolDetector
return false;
}
+ ///
+ /// codex CLI(OpenAI Codex, ChatGPT 구독 OAuth 재사용) 설치 여부. npm 글로벌 + PATH에서
+ /// codex.cmd/codex.exe/codex.ps1을 탐지한다.
+ ///
+ public static bool IsCodexAvailable()
+ {
+ var names = new[] { "codex.cmd", "codex.exe", "codex.ps1" };
+ var dirs = new List();
+
+ var appdata = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ if (!string.IsNullOrEmpty(appdata)) dirs.Add(Path.Combine(appdata, "npm"));
+
+ var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
+ foreach (var d in pathEnv.Split(Path.PathSeparator))
+ if (!string.IsNullOrWhiteSpace(d)) dirs.Add(d.Trim());
+
+ foreach (var dir in dirs.Distinct())
+ {
+ try
+ {
+ foreach (var n in names)
+ if (File.Exists(Path.Combine(dir, n))) return true;
+ }
+ catch { /* 잘못된 경로 무시 */ }
+ }
+ return false;
+ }
+
public static bool IsH2OrestartInstalled()
{
try
diff --git a/src/Everything2Everything.Core/Converters/LlmProvider.cs b/src/Everything2Everything.Core/Converters/LlmProvider.cs
index e8eba99..e95b351 100644
--- a/src/Everything2Everything.Core/Converters/LlmProvider.cs
+++ b/src/Everything2Everything.Core/Converters/LlmProvider.cs
@@ -22,7 +22,7 @@ public sealed class LlmProvider : IConverterProvider
new ConversionPair(".md", ".md", LossClass.Recode),
},
Status: ProviderStatus.RequiresExternal,
- Summary: "OpenAI 또는 Anthropic API로 텍스트를 요약·번역·교정합니다 (✨AI · 종량 과금 · 네트워크 필요). API 키가 없으면 비활성됩니다.",
+ Summary: "OpenAI/Anthropic API 또는 Codex CLI(ChatGPT 구독 OAuth, 키 불필요)로 텍스트를 요약·번역·교정합니다 (✨AI · 네트워크 필요). 키도 Codex도 없으면 비활성됩니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
@@ -96,10 +96,13 @@ public sealed class LlmProvider : IConverterProvider
return openaiKey is null ? (null, "") : (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini");
if (backend == "anthropic")
return anthropicKey is null ? (null, "") : (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest");
+ if (backend == "codex")
+ return ExternalToolDetector.IsCodexAvailable() ? (new CodexChatClient(), ai.Model ?? "") : (null, "");
- // auto: OpenAI 우선, 없으면 Anthropic
+ // auto: API 키 우선, 없으면 Codex CLI(ChatGPT 구독 OAuth)
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 (ExternalToolDetector.IsCodexAvailable()) return (new CodexChatClient(), ai.Model ?? "");
return (null, "");
}
diff --git a/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
index 993ef3c..b13cff2 100644
--- a/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
+++ b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
@@ -4,9 +4,9 @@ namespace Everything2Everything.Core;
public static class Everything2EverythingBootstrap
{
- public static ConversionEngine CreateDefault()
+ public static ConversionEngine CreateDefault(ISettingsStore? settings = null)
{
- var settings = new DpapiSettingsStore();
+ settings ??= new DpapiSettingsStore();
var magick = new Converters.MagickProvider();
var pdf = new Converters.PdfProvider();
var providers = new IConverterProvider[]
diff --git a/src/Everything2Everything.Tests/LlmProviderTests.cs b/src/Everything2Everything.Tests/LlmProviderTests.cs
index eecff8e..5673273 100644
--- a/src/Everything2Everything.Tests/LlmProviderTests.cs
+++ b/src/Everything2Everything.Tests/LlmProviderTests.cs
@@ -25,7 +25,8 @@ public class LlmProviderTests
[Fact]
public async Task NoKey_IsNotReady()
{
- if (EnvHasKey()) return; // 환경변수 키가 있으면 이 단언은 건너뜀
+ // 환경변수 키 또는 codex CLI(OAuth)가 있으면 AI가 활성화되므로 이 단언은 건너뜀
+ if (EnvHasKey() || ExternalToolDetector.IsCodexAvailable()) return;
var p = new LlmProvider(new FakeStore());
var a = await p.CheckAvailabilityAsync();
Assert.False(a.IsReady);