Explorar el Código

Enable bundled OpenAI across AI tools and fix Post Generator image layout.

Store the API key obfuscated in-app for Post Generator, Title Optimizer, and Comment Writer, and keep the compose view stable when selecting or uploading images.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 hace 1 mes
padre
commit
572649889b

+ 3 - 15
Reddit App/Managers/AIConfiguration.swift

@@ -1,30 +1,18 @@
 import Foundation
 
 enum AIConfiguration {
-    private static let apiKeyDefaultsKey = "openai_api_key"
-
+    /// OpenAI API key used by Post Generator, Title Optimizer, and Comment Writer.
+    /// Priority: `OPENAI_API_KEY` environment variable (dev builds), then bundled encrypted key.
     static var apiKey: String? {
         if let envKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"],
            !envKey.trimmingCharacters(in: .whitespaces).isEmpty {
             return envKey.trimmingCharacters(in: .whitespaces)
         }
 
-        let stored = UserDefaults.standard.string(forKey: apiKeyDefaultsKey)?
-            .trimmingCharacters(in: .whitespaces)
-        guard let stored, !stored.isEmpty else { return nil }
-        return stored
+        return EncryptedOpenAIKey.resolve()
     }
 
     static var usesLiveAI: Bool {
         apiKey != nil
     }
-
-    static func setAPIKey(_ key: String?) {
-        let trimmed = key?.trimmingCharacters(in: .whitespaces)
-        if let trimmed, !trimmed.isEmpty {
-            UserDefaults.standard.set(trimmed, forKey: apiKeyDefaultsKey)
-        } else {
-            UserDefaults.standard.removeObject(forKey: apiKeyDefaultsKey)
-        }
-    }
 }

+ 40 - 0
Reddit App/Managers/EncryptedOpenAIKey.swift

@@ -0,0 +1,40 @@
+import Foundation
+
+/// Runtime-decoded OpenAI credentials bundled with the app.
+/// The key is XOR-obfuscated so it does not appear as plain text in source or strings tables.
+enum EncryptedOpenAIKey {
+    private static let obfuscatedPayload: [UInt8] = [
+        33, 14, 73, 23, 25, 17, 0, 34, 42, 39, 72, 39, 6, 89, 13, 21, 34, 11, 40, 91,
+        120, 112, 89, 106, 63, 15, 30, 94, 23, 8, 37, 124, 10, 22, 62, 63, 47, 78, 21, 15,
+        55, 12, 104, 119, 7, 1, 57, 19, 28, 3, 48, 22, 87, 44, 61, 107, 43, 54, 46, 7, 91,
+        49, 30, 44, 29, 94, 96, 87, 93, 59, 7, 37, 10, 28, 65, 24, 4, 4, 25, 19, 25, 37, 25,
+        14, 43, 31, 86, 59, 94, 82, 89, 112, 24, 86, 22, 35, 30, 36, 53, 21, 38, 97, 81, 53,
+        69, 94, 32, 20, 127, 63, 53, 2, 103, 81, 64, 4, 38, 49, 12, 61, 70, 9, 22, 16, 11, 12,
+        56, 35, 8, 22, 40, 18, 93, 51, 122, 106, 115, 4, 27, 8, 13, 32, 91, 32, 62, 50, 46, 22,
+        48, 30, 3, 5, 6, 85, 44, 36, 46, 109, 65, 4, 105, 8, 40, 29, 60, 28, 51,
+    ]
+
+    private static var maskBytes: [UInt8] {
+        Array("ReddoraAI".utf8) + Array("ServiceKey".utf8) + Array("2026".utf8)
+    }
+
+    static func resolve() -> String? {
+        let mask = maskBytes
+        guard !mask.isEmpty else { return nil }
+
+        var decoded = [UInt8]()
+        decoded.reserveCapacity(obfuscatedPayload.count)
+
+        for (index, byte) in obfuscatedPayload.enumerated() {
+            decoded.append(byte ^ mask[index % mask.count])
+        }
+
+        guard let key = String(bytes: decoded, encoding: .utf8),
+              key.hasPrefix("sk-"),
+              !key.isEmpty else {
+            return nil
+        }
+
+        return key
+    }
+}

+ 1 - 1
Reddit App/Services/CommentGenerationService.swift

@@ -84,7 +84,7 @@ struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
         }
 
         if http.statusCode == 401 {
-            throw CommentGenerationError.apiUnavailable("Invalid OpenAI API key. Check AIConfiguration or OPENAI_API_KEY.")
+            throw CommentGenerationError.apiUnavailable("Invalid OpenAI API key.")
         }
         guard (200 ... 299).contains(http.statusCode) else {
             let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"

+ 1 - 1
Reddit App/Services/PostGenerationService.swift

@@ -79,7 +79,7 @@ struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
         }
 
         if http.statusCode == 401 {
-            throw PostGenerationError.apiUnavailable("Invalid OpenAI API key. Check AIConfiguration or OPENAI_API_KEY.")
+            throw PostGenerationError.apiUnavailable("Invalid OpenAI API key.")
         }
         guard (200 ... 299).contains(http.statusCode) else {
             let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"

+ 156 - 0
Reddit App/Services/TitleOptimizationService.swift

@@ -4,17 +4,173 @@ protocol TitleOptimizationServiceProtocol: Sendable {
     func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult
 }
 
+enum TitleOptimizationServiceFactory {
+    static func makeDefault() -> any TitleOptimizationServiceProtocol {
+        if let apiKey = AIConfiguration.apiKey {
+            return OpenAITitleOptimizationService(apiKey: apiKey)
+        }
+        return MockTitleOptimizationService()
+    }
+}
+
 enum TitleOptimizationError: LocalizedError {
     case emptyTopic
     case emptySubreddit
     case emptyTitle
+    case apiUnavailable(String)
+    case invalidResponse
 
     var errorDescription: String? {
         switch self {
         case .emptyTopic: "Enter a topic or keywords to guide title generation."
         case .emptySubreddit: "Enter a subreddit to tailor titles for that community."
         case .emptyTitle: "Enter a title to optimize, or provide a topic to generate from scratch."
+        case .apiUnavailable(let message): message
+        case .invalidResponse: "The AI returned an unexpected response. Please try again."
+        }
+    }
+}
+
+// MARK: - OpenAI
+
+struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
+    let apiKey: String
+    var model: String = "gpt-4o-mini"
+
+    func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult {
+        guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
+            throw TitleOptimizationError.emptySubreddit
+        }
+        guard !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty else {
+            throw TitleOptimizationError.emptyTopic
+        }
+        guard !draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty else {
+            throw TitleOptimizationError.emptyTitle
+        }
+
+        let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
+        let prompt = buildPrompt(draft: draft, subreddit: subreddit)
+
+        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
+        request.httpMethod = "POST"
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+        let body: [String: Any] = [
+            "model": model,
+            "temperature": 0.85,
+            "response_format": ["type": "json_object"],
+            "messages": [
+                [
+                    "role": "system",
+                    "content": """
+                    You optimize Reddit post titles. Respond with JSON only using keys:
+                    analysis (object with overallScore, lengthScore, engagementScore, clarityScore as integers 0-100, \
+                    and suggestions as string array),
+                    variants (array of exactly \(draft.variantCount.rawValue) objects with title, score 0-100, reasoning strings).
+                    Titles must be under 300 characters. Match subreddit culture and the requested goal/tone.
+                    """,
+                ],
+                ["role": "user", "content": prompt],
+            ],
+        ]
+        request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw TitleOptimizationError.apiUnavailable("Couldn't reach the AI service.")
+        }
+
+        if http.statusCode == 401 {
+            throw TitleOptimizationError.apiUnavailable("Invalid OpenAI API key.")
+        }
+        guard (200 ... 299).contains(http.statusCode) else {
+            let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
+            throw TitleOptimizationError.apiUnavailable("AI request failed: \(message)")
+        }
+
+        return try parseResponse(data)
+    }
+
+    private func buildPrompt(draft: TitleDraft, subreddit: String) -> String {
+        var lines = [
+            "Subreddit: r/\(subreddit)",
+            "Topic: \(draft.topic.trimmingCharacters(in: .whitespaces))",
+            "Original title: \(draft.originalTitle.trimmingCharacters(in: .whitespaces))",
+            "Goal: \(draft.titleGoal.title) — \(draft.titleGoal.subtitle)",
+            "Tone: \(draft.tone.title)",
+            "Post type: \(draft.postType.title)",
+        ]
+
+        if draft.isNSFW { lines.append("Tag: NSFW") }
+        if draft.isSpoiler { lines.append("Tag: Spoiler") }
+        if draft.isOC { lines.append("Tag: Original Content") }
+        if !draft.flair.isEmpty { lines.append("Flair: \(draft.flair)") }
+
+        return lines.joined(separator: "\n")
+    }
+
+    private func parseResponse(_ data: Data) throws -> TitleOptimizationResult {
+        struct APIEnvelope: Decodable {
+            struct Choice: Decodable {
+                struct Message: Decodable {
+                    let content: String
+                }
+                let message: Message
+            }
+            let choices: [Choice]
+        }
+
+        struct AnalysisPayload: Decodable {
+            let overallScore: Int
+            let lengthScore: Int
+            let engagementScore: Int
+            let clarityScore: Int
+            let suggestions: [String]
+        }
+
+        struct VariantPayload: Decodable {
+            let title: String
+            let score: Int
+            let reasoning: String
+        }
+
+        struct Payload: Decodable {
+            let analysis: AnalysisPayload
+            let variants: [VariantPayload]
+        }
+
+        let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
+        guard let content = envelope.choices.first?.message.content.data(using: .utf8) else {
+            throw TitleOptimizationError.invalidResponse
         }
+
+        let payload = try JSONDecoder().decode(Payload.self, from: content)
+        guard !payload.variants.isEmpty else {
+            throw TitleOptimizationError.invalidResponse
+        }
+
+        let analysis = TitleAnalysis(
+            overallScore: clampScore(payload.analysis.overallScore),
+            lengthScore: clampScore(payload.analysis.lengthScore),
+            engagementScore: clampScore(payload.analysis.engagementScore),
+            clarityScore: clampScore(payload.analysis.clarityScore),
+            suggestions: payload.analysis.suggestions
+        )
+
+        let variants = payload.variants.map {
+            TitleVariant(
+                title: String($0.title.prefix(PostDraftValidator.maxTitleLength)),
+                score: clampScore($0.score),
+                reasoning: $0.reasoning
+            )
+        }
+
+        return TitleOptimizationResult(analysis: analysis, variants: variants)
+    }
+
+    private func clampScore(_ value: Int) -> Int {
+        min(100, max(0, value))
     }
 }
 

+ 6 - 2
Reddit App/ViewModels/TitleOptimizerViewModel.swift

@@ -15,8 +15,12 @@ final class TitleOptimizerViewModel {
 
     private let optimizationService: any TitleOptimizationServiceProtocol
 
-    init(optimizationService: any TitleOptimizationServiceProtocol = MockTitleOptimizationService()) {
-        self.optimizationService = optimizationService
+    init(optimizationService: (any TitleOptimizationServiceProtocol)? = nil) {
+        self.optimizationService = optimizationService ?? TitleOptimizationServiceFactory.makeDefault()
+    }
+
+    var usesLiveAI: Bool {
+        AIConfiguration.usesLiveAI
     }
 
     var formattedSubreddit: String {

+ 4 - 2
Reddit App/Views/Components/PostGeneratorComponents.swift

@@ -429,8 +429,10 @@ struct RedditPostPreviewCard: View {
         if let url = draft.imageFileURL, let nsImage = NSImage(contentsOf: url) {
             Image(nsImage: nsImage)
                 .resizable()
-                .aspectRatio(contentMode: .fit)
-                .frame(maxHeight: 200)
+                .scaledToFit()
+                .frame(maxWidth: .infinity, maxHeight: 200)
+                .frame(height: 200)
+                .clipped()
                 .clipShape(RoundedRectangle(cornerRadius: 8))
         } else {
             imagePlaceholder(icon: "photo", label: "Image preview")

+ 54 - 57
Reddit App/Views/PostGeneratorView.swift

@@ -12,6 +12,7 @@ struct PostGeneratorView: View {
         static let columnWidth: CGFloat = 300
         static let columnSpacing: CGFloat = 20
         static let sectionSpacing: CGFloat = 12
+        static let imageUploadHeight: CGFloat = 140
     }
 
     var body: some View {
@@ -217,18 +218,7 @@ struct PostGeneratorView: View {
     // MARK: - Compose
 
     private var composeContent: some View {
-        ViewThatFits(in: .horizontal) {
-            composeColumns
-
-            VStack(alignment: .leading, spacing: Layout.columnSpacing) {
-                composeColumn(title: "Configuration", icon: "slider.horizontal.3") {
-                    configurationPanel
-                }
-                composeColumn(title: "Post Content", icon: "doc.text") {
-                    editorPanel
-                }
-            }
-        }
+        composeColumns
     }
 
     private var composeColumns: some View {
@@ -237,6 +227,7 @@ struct PostGeneratorView: View {
                 configurationPanel
             }
             .frame(width: Layout.columnWidth)
+            .layoutPriority(1)
 
             Rectangle()
                 .fill(AppTheme.border)
@@ -246,7 +237,8 @@ struct PostGeneratorView: View {
             composeColumn(title: "Post Content", icon: "doc.text") {
                 editorPanel
             }
-            .frame(maxWidth: .infinity)
+            .frame(minWidth: 0, maxWidth: .infinity)
+            .layoutPriority(0)
         }
     }
 
@@ -365,6 +357,7 @@ struct PostGeneratorView: View {
             }
 
             postTypeEditor
+                .frame(minHeight: 260, alignment: .top)
 
             characterCountFooter
         }
@@ -550,57 +543,61 @@ struct PostGeneratorView: View {
 
     @ViewBuilder
     private var imageUploadArea: some View {
-        if let url = viewModel.draft.imageFileURL, let nsImage = NSImage(contentsOf: url) {
-            ZStack(alignment: .topTrailing) {
-                Image(nsImage: nsImage)
-                    .resizable()
-                    .aspectRatio(contentMode: .fit)
-                    .frame(maxHeight: 180)
-                    .frame(maxWidth: .infinity)
-                    .clipShape(RoundedRectangle(cornerRadius: 8))
+        Group {
+            if let url = viewModel.draft.imageFileURL, let nsImage = NSImage(contentsOf: url) {
+                ZStack(alignment: .topTrailing) {
+                    Image(nsImage: nsImage)
+                        .resizable()
+                        .scaledToFit()
+                        .frame(maxWidth: .infinity, maxHeight: Layout.imageUploadHeight)
+                        .frame(maxWidth: .infinity, maxHeight: Layout.imageUploadHeight)
+                        .clipped()
 
+                    Button {
+                        viewModel.removeImage()
+                    } label: {
+                        Image(systemName: "xmark.circle.fill")
+                            .font(.system(size: 18))
+                            .foregroundStyle(.white)
+                            .shadow(radius: 2)
+                    }
+                    .buttonStyle(AppPlainButtonStyle())
+                    .hoverOverlay(cornerRadius: 8)
+                    .padding(8)
+                }
+            } else {
                 Button {
-                    viewModel.removeImage()
+                    viewModel.showImageImporter = true
                 } label: {
-                    Image(systemName: "xmark.circle.fill")
-                        .font(.system(size: 18))
-                        .foregroundStyle(.white)
-                        .shadow(radius: 2)
+                    VStack(spacing: 8) {
+                        Image(systemName: "photo.badge.plus")
+                            .font(.system(size: 24))
+                            .foregroundStyle(AppTheme.accentPurpleLight)
+                        Text("Click to upload image")
+                            .font(.system(size: 11, weight: .medium))
+                            .foregroundStyle(AppTheme.textSecondary)
+                        Text("PNG, JPG, GIF, WebP")
+                            .font(.system(size: 9))
+                            .foregroundStyle(AppTheme.textTertiary)
+                    }
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    .background(AppTheme.panelBackground)
+                    .clipShape(RoundedRectangle(cornerRadius: 8))
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 8)
+                            .strokeBorder(
+                                AppTheme.accentPurple.opacity(0.3),
+                                style: StrokeStyle(lineWidth: 1, dash: [6, 4])
+                            )
+                    )
                 }
                 .buttonStyle(AppPlainButtonStyle())
-                .hoverOverlay(cornerRadius: 8)
-                .padding(8)
-            }
-        } else {
-            Button {
-                viewModel.showImageImporter = true
-            } label: {
-                VStack(spacing: 8) {
-                    Image(systemName: "photo.badge.plus")
-                        .font(.system(size: 24))
-                        .foregroundStyle(AppTheme.accentPurpleLight)
-                    Text("Click to upload image")
-                        .font(.system(size: 11, weight: .medium))
-                        .foregroundStyle(AppTheme.textSecondary)
-                    Text("PNG, JPG, GIF, WebP")
-                        .font(.system(size: 9))
-                        .foregroundStyle(AppTheme.textTertiary)
-                }
-                .frame(maxWidth: .infinity)
-                .frame(height: 140)
-                .background(AppTheme.panelBackground)
-                .clipShape(RoundedRectangle(cornerRadius: 8))
-                .overlay(
-                    RoundedRectangle(cornerRadius: 8)
-                        .strokeBorder(
-                            AppTheme.accentPurple.opacity(0.3),
-                            style: StrokeStyle(lineWidth: 1, dash: [6, 4])
-                        )
-                )
+                .hoverCard(cornerRadius: 8)
             }
-            .buttonStyle(AppPlainButtonStyle())
-            .hoverCard(cornerRadius: 8)
         }
+        .frame(maxWidth: .infinity)
+        .frame(height: Layout.imageUploadHeight)
+        .clipShape(RoundedRectangle(cornerRadius: 8))
     }
 
     private var characterCountFooter: some View {

+ 1 - 1
Reddit App/Views/TitleOptimizerView.swift

@@ -49,7 +49,7 @@ struct TitleOptimizerView: View {
                 Text("Title Optimizer")
                     .font(.system(size: 18, weight: .semibold))
                     .foregroundStyle(AppTheme.textPrimary)
-                Text("Craft viral Reddit titles with AI")
+                Text(viewModel.usesLiveAI ? "Craft viral Reddit titles with OpenAI" : "Craft viral Reddit titles with AI templates")
                     .font(.system(size: 11))
                     .foregroundStyle(AppTheme.textSecondary)
             }