1
0
Fork 0

feat(ui): AI 설정창 + Codex CLI OAuth 백엔드 + ⚙ 진입점

사용자 원래 요구였던 Codex non-interactive OAuth를 실제 동작까지 검증해 통합. ChatGPT 구독으로 API 키 없이 AI 변환.

- CodexChatClient: codex exec --skip-git-repo-check --ephemeral -o <file> - (stdin 프롬프트). 실제 한글→영어 번역 검증 완료
- ExternalProcessRunner: stdin 지원 + UTF-8 인코딩(한글 깨짐 해결)
- ExternalToolDetector.IsCodexAvailable() + public 승격
- LlmProvider: codex 백엔드 + auto 폴백(키 없으면 Codex)
- SettingsWindow: OpenAI/Anthropic 키(2단계 Verify) + Codex(OAuth) + 외부도구 상태/다운로드 CTA + 모델 선택
- App.Settings를 LlmProvider와 공유(키 저장 즉시 반영)
- MainWindow 네비바 ⚙ 설정 진입점
- 디자인: Cursor/Zed/Raycast 설정 UX + Radix 상태 토큰 영감
This commit is contained in:
Yun Chan 2026-06-01 14:46:28 +09:00
parent faffb8f205
commit 2a3d220db4
12 changed files with 480 additions and 8 deletions

View file

@ -88,3 +88,52 @@ public sealed class AnthropicChatClient : IChatClient
private static string Truncate(string s) => s.Length > 400 ? s[..400] : s;
}
/// <summary>
/// OpenAI Codex CLI를 non-interactive로 호출하는 백엔드. ChatGPT 구독 OAuth(auth.json)를 그대로
/// 재사용하므로 API 키 없이 동작한다. `codex exec --skip-git-repo-check --ephemeral -o &lt;file&gt; -`
/// 형태로 프롬프트를 stdin으로 전달하고, 최종 메시지를 파일에서 읽는다.
/// </summary>
public sealed class CodexChatClient : IChatClient
{
public string Name => "Codex CLI (OAuth)";
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 outFile = Path.Combine(Path.GetTempPath(), "e2e_codex_" + Guid.NewGuid().ToString("N") + ".txt");
var args = new List<string> { "/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;
}

View file

@ -19,7 +19,8 @@ public static class ExternalProcessRunner
IEnumerable<string> 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);

View file

@ -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;
}
/// <summary>
/// codex CLI(OpenAI Codex, ChatGPT 구독 OAuth 재사용) 설치 여부. npm 글로벌 + PATH에서
/// codex.cmd/codex.exe/codex.ps1을 탐지한다.
/// </summary>
public static bool IsCodexAvailable()
{
var names = new[] { "codex.cmd", "codex.exe", "codex.ps1" };
var dirs = new List<string>();
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

View file

@ -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, "");
}

View file

@ -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[]