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:
parent
faffb8f205
commit
2a3d220db4
12 changed files with 480 additions and 8 deletions
|
|
@ -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 <file> -`
|
||||
/// 형태로 프롬프트를 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, "");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue