소스 검색

Add OpenAI-powered grammar checking with detailed correction results.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 개월 전
부모
커밋
2b43625df7

+ 1 - 0
.gitignore

@@ -3,3 +3,4 @@
 xcuserdata/
 DerivedData/
 build/
+gramora/Resources/Secrets.plist

+ 33 - 0
gramora/Models/GrammarCheckModels.swift

@@ -0,0 +1,33 @@
+import Foundation
+
+struct GrammarIssue: Identifiable, Equatable {
+    let id = UUID()
+    let original: String
+    let suggestion: String
+    let explanation: String
+}
+
+struct GrammarCheckResult: Equatable {
+    let correctedText: String
+    let issues: [GrammarIssue]
+}
+
+enum GrammarCheckServiceError: LocalizedError {
+    case missingAPIKey
+    case emptyInput
+    case invalidResponse
+    case apiError(String)
+
+    var errorDescription: String? {
+        switch self {
+        case .missingAPIKey:
+            "OpenAI API key is missing. Add it to Secrets.plist."
+        case .emptyInput:
+            "Enter some text to check."
+        case .invalidResponse:
+            "Could not read the grammar check response."
+        case .apiError(let message):
+            message
+        }
+    }
+}

+ 8 - 0
gramora/Resources/Secrets.example.plist

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>OPENAI_API_KEY</key>
+	<string>your-openai-api-key-here</string>
+</dict>
+</plist>

+ 145 - 0
gramora/Services/GrammarCheckService.swift

@@ -0,0 +1,145 @@
+import Foundation
+
+protocol GrammarCheckServicing {
+    func checkGrammar(in text: String) async throws -> GrammarCheckResult
+}
+
+struct GrammarCheckService: GrammarCheckServicing {
+    private let session: URLSession
+    private let apiKeyProvider: () -> String?
+
+    init(
+        session: URLSession = .shared,
+        apiKeyProvider: @escaping () -> String? = { APIConfiguration.openAIAPIKey }
+    ) {
+        self.session = session
+        self.apiKeyProvider = apiKeyProvider
+    }
+
+    func checkGrammar(in text: String) async throws -> GrammarCheckResult {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            throw GrammarCheckServiceError.emptyInput
+        }
+
+        guard let apiKey = apiKeyProvider() else {
+            throw GrammarCheckServiceError.missingAPIKey
+        }
+
+        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+
+        let body = ChatCompletionRequest(
+            model: "gpt-4o-mini",
+            messages: [
+                .init(
+                    role: "system",
+                    content: """
+                    You are a professional grammar checker. Analyze the user's text for grammar, \
+                    punctuation, and clarity issues. Respond with JSON only using this schema:
+                    {
+                      "correctedText": "full corrected version of the input",
+                      "issues": [
+                        {
+                          "original": "problematic phrase or word",
+                          "suggestion": "corrected phrase or word",
+                          "explanation": "brief reason for the change"
+                        }
+                      ]
+                    }
+                    If there are no issues, return the original text in correctedText and an empty issues array.
+                    """
+                ),
+                .init(role: "user", content: trimmed)
+            ],
+            responseFormat: .init(type: "json_object")
+        )
+
+        request.httpBody = try JSONEncoder().encode(body)
+
+        let (data, response) = try await session.data(for: request)
+
+        if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
+            let message = Self.errorMessage(from: data) ?? "Request failed with status \(httpResponse.statusCode)."
+            throw GrammarCheckServiceError.apiError(message)
+        }
+
+        let completion = try JSONDecoder().decode(ChatCompletionResponse.self, from: data)
+        guard
+            let content = completion.choices.first?.message.content,
+            let contentData = content.data(using: .utf8)
+        else {
+            throw GrammarCheckServiceError.invalidResponse
+        }
+
+        let decoded = try JSONDecoder().decode(GrammarCheckResponse.self, from: contentData)
+        return GrammarCheckResult(
+            correctedText: decoded.correctedText,
+            issues: decoded.issues.map {
+                GrammarIssue(
+                    original: $0.original,
+                    suggestion: $0.suggestion,
+                    explanation: $0.explanation
+                )
+            }
+        )
+    }
+
+    private static func errorMessage(from data: Data) -> String? {
+        struct APIErrorResponse: Decodable {
+            struct APIError: Decodable {
+                let message: String
+            }
+
+            let error: APIError
+        }
+
+        return (try? JSONDecoder().decode(APIErrorResponse.self, from: data))?.error.message
+    }
+}
+
+private struct GrammarCheckResponse: Decodable {
+    struct Issue: Decodable {
+        let original: String
+        let suggestion: String
+        let explanation: String
+    }
+
+    let correctedText: String
+    let issues: [Issue]
+}
+
+private struct ChatCompletionRequest: Encodable {
+    struct Message: Encodable {
+        let role: String
+        let content: String
+    }
+
+    struct ResponseFormat: Encodable {
+        let type: String
+    }
+
+    let model: String
+    let messages: [Message]
+    let responseFormat: ResponseFormat
+
+    enum CodingKeys: String, CodingKey {
+        case model
+        case messages
+        case responseFormat = "response_format"
+    }
+}
+
+private struct ChatCompletionResponse: Decodable {
+    struct Choice: Decodable {
+        struct Message: Decodable {
+            let content: String
+        }
+
+        let message: Message
+    }
+
+    let choices: [Choice]
+}

+ 16 - 0
gramora/Utilities/APIConfiguration.swift

@@ -0,0 +1,16 @@
+import Foundation
+
+enum APIConfiguration {
+    static var openAIAPIKey: String? {
+        guard
+            let path = Bundle.main.path(forResource: "Secrets", ofType: "plist"),
+            let dictionary = NSDictionary(contentsOfFile: path),
+            let key = dictionary["OPENAI_API_KEY"] as? String,
+            !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+        else {
+            return nil
+        }
+
+        return key
+    }
+}

+ 56 - 1
gramora/ViewModels/GrammarCheckerViewModel.swift

@@ -7,6 +7,17 @@ final class GrammarCheckerViewModel: ObservableObject {
     static let maxCharacters = 10_000
 
     @Published var text: String = ""
+    @Published private(set) var issues: [GrammarIssue] = []
+    @Published private(set) var correctedText: String = ""
+    @Published private(set) var hasResults = false
+    @Published private(set) var isChecking = false
+    @Published private(set) var errorMessage: String?
+
+    private let grammarCheckService: any GrammarCheckServicing
+
+    init(grammarCheckService: any GrammarCheckServicing = GrammarCheckService()) {
+        self.grammarCheckService = grammarCheckService
+    }
 
     var characterCount: Int { text.count }
 
@@ -14,16 +25,60 @@ final class GrammarCheckerViewModel: ObservableObject {
         "\(characterCount)/\(Self.maxCharacters)"
     }
 
+    var issueCountLabel: String {
+        let count = issues.count
+        return "\(count) Issue\(count == 1 ? "" : "s")"
+    }
+
+    var canCheck: Bool {
+        !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isChecking
+    }
+
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
         text = String(clipboard.prefix(Self.maxCharacters))
+        resetResults()
     }
 
     func clearText() {
         text = ""
+        resetResults()
+    }
+
+    func resetResults() {
+        issues = []
+        correctedText = ""
+        hasResults = false
+        errorMessage = nil
     }
 
     func checkGrammar() {
-        // Grammar check service integration point
+        let content = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !content.isEmpty else {
+            resetResults()
+            return
+        }
+
+        isChecking = true
+        errorMessage = nil
+
+        Task {
+            do {
+                let result = try await grammarCheckService.checkGrammar(in: content)
+                issues = result.issues
+                correctedText = result.correctedText
+                hasResults = true
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+
+            isChecking = false
+        }
+    }
+
+    func copyCorrectedText() {
+        guard !correctedText.isEmpty else { return }
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(correctedText, forType: .string)
     }
 }

+ 191 - 40
gramora/Views/GrammarCheckerView.swift

@@ -3,69 +3,108 @@ import SwiftUI
 struct GrammarCheckerView: View {
     @StateObject private var viewModel = GrammarCheckerViewModel()
 
+    private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
+
     var body: some View {
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
-            let dynamicTopPadding = min(max(proxy.size.height * 0.05, 24), 44)
-            let dynamicBottomPadding = min(max(proxy.size.height * 0.06, 28), 56)
+            let dynamicTopPadding = min(max(proxy.size.height * 0.015, 8), 16)
+            let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
             let dynamicEditorHeight = min(max(proxy.size.height * 0.43, 260), 560)
 
-            ScrollView(.vertical, showsIndicators: false) {
-                VStack(alignment: .leading, spacing: 0) {
-                    header
-                        .padding(.bottom, 28)
+            Group {
+                if viewModel.hasResults {
+                    VStack(alignment: .leading, spacing: 0) {
+                        header
+                            .padding(.bottom, 20)
+
+                        resultsLayout
+                            .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    }
+                    .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+                    .padding(.horizontal, dynamicHorizontalPadding)
+                    .padding(.top, dynamicTopPadding)
+                    .padding(.bottom, dynamicBottomPadding)
+                } else {
+                    ScrollView(.vertical, showsIndicators: false) {
+                        VStack(alignment: .leading, spacing: 0) {
+                            header
+                                .padding(.bottom, 24)
+
+                            contentCard(editorHeight: dynamicEditorHeight)
+                                .padding(.bottom, 24)
 
-                    contentCard(editorHeight: dynamicEditorHeight)
-                        .padding(.bottom, 24)
+                            if let errorMessage = viewModel.errorMessage {
+                                Text(errorMessage)
+                                    .font(.system(size: 14))
+                                    .foregroundStyle(.red)
+                                    .padding(.bottom, 12)
+                            }
 
-                    GradientButton(title: "Check Grammar") {
-                        viewModel.checkGrammar()
+                            GradientButton(title: viewModel.isChecking ? "Checking..." : "Check Grammar") {
+                                viewModel.checkGrammar()
+                            }
+                            .disabled(!viewModel.canCheck)
+                        }
+                        .frame(maxWidth: 1200, alignment: .topLeading)
+                        .frame(maxWidth: .infinity, alignment: .topLeading)
+                        .padding(.horizontal, dynamicHorizontalPadding)
+                        .padding(.top, dynamicTopPadding)
+                        .padding(.bottom, dynamicBottomPadding)
                     }
                 }
-                .frame(maxWidth: 1200, alignment: .topLeading)
-                .frame(maxWidth: .infinity, alignment: .topLeading)
-                .padding(.horizontal, dynamicHorizontalPadding)
-                .padding(.top, dynamicTopPadding)
-                .padding(.bottom, dynamicBottomPadding)
             }
         }
         .onChange(of: viewModel.text) { newValue in
             if newValue.count > GrammarCheckerViewModel.maxCharacters {
                 viewModel.text = String(newValue.prefix(GrammarCheckerViewModel.maxCharacters))
             }
+            if viewModel.hasResults {
+                viewModel.resetResults()
+            }
         }
     }
 
     private var header: some View {
-        HStack(alignment: .top) {
-            VStack(alignment: .leading, spacing: 8) {
-                Text("Grammar Checker")
-                    .font(.system(size: 30, weight: .bold))
-                    .foregroundStyle(AppTheme.textPrimary)
-
-                HStack(spacing: 0) {
-                    Text("Write ")
-                        .foregroundStyle(AppTheme.textSecondary)
-                    Text("better")
-                        .fontWeight(.semibold)
-                        .foregroundStyle(AppTheme.teal)
-                    Text(". Sound ")
-                        .foregroundStyle(AppTheme.textSecondary)
-                    Text("smarter")
-                        .fontWeight(.semibold)
-                        .foregroundStyle(AppTheme.teal)
-                    Text(".")
-                        .foregroundStyle(AppTheme.textSecondary)
+        VStack(alignment: .leading, spacing: 16) {
+            if viewModel.hasResults {
+                IconButton(iconName: "arrow.left") {
+                    viewModel.resetResults()
                 }
-                .font(.system(size: 15))
+                .accessibilityLabel("Go back")
             }
 
-            Spacer()
+            HStack(alignment: .top) {
+                VStack(alignment: .leading, spacing: 8) {
+                    Text("Grammar Checker")
+                        .font(.system(size: 30, weight: .bold))
+                        .foregroundStyle(AppTheme.textPrimary)
+
+                    HStack(spacing: 0) {
+                        Text("Write ")
+                            .foregroundStyle(AppTheme.textSecondary)
+                        Text("better")
+                            .fontWeight(.semibold)
+                            .foregroundStyle(AppTheme.teal)
+                        Text(". Sound ")
+                            .foregroundStyle(AppTheme.textSecondary)
+                        Text("smarter")
+                            .fontWeight(.semibold)
+                            .foregroundStyle(AppTheme.teal)
+                        Text(".")
+                            .foregroundStyle(AppTheme.textSecondary)
+                    }
+                    .font(.system(size: 15))
+                }
+
+                Spacer()
 
-            IconButton {
-                SettingsGearIcon()
-            } action: {}
-            .accessibilityLabel("Settings")
+                IconButton {
+                    SettingsGearIcon()
+                } action: {}
+                .accessibilityLabel("Settings")
+            }
         }
     }
 
@@ -131,4 +170,116 @@ struct GrammarCheckerView: View {
         )
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
     }
+
+    private var resultsLayout: some View {
+        HStack(alignment: .top, spacing: 20) {
+            correctedContentPanel
+            grammarReportPanel
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+    }
+
+    private var correctedContentPanel: some View {
+        VStack(alignment: .leading, spacing: 14) {
+            Text("Your Content")
+                .font(.system(size: 18, weight: .bold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            VStack(alignment: .leading, spacing: 0) {
+                Text(viewModel.correctedText)
+                    .font(.system(size: 14))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+                    .padding(20)
+
+                HStack {
+                    ToolbarButton(title: "Copy Text", iconName: "doc.on.doc") {
+                        viewModel.copyCorrectedText()
+                    }
+                    Spacer()
+                }
+                .padding(.horizontal, 16)
+                .padding(.bottom, 16)
+            }
+            .frame(maxHeight: .infinity)
+            .background(panelBackground)
+            .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+            .overlay(
+                RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                    .stroke(AppTheme.border, lineWidth: 1)
+            )
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+    }
+
+    private var grammarReportPanel: some View {
+        VStack(alignment: .leading, spacing: 14) {
+            Text("Grammar Report")
+                .font(.system(size: 18, weight: .bold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            VStack(alignment: .leading, spacing: 14) {
+                Text(viewModel.issueCountLabel)
+                    .font(.system(size: 15, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                if viewModel.issues.isEmpty {
+                    Text("No grammar issues found.")
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textSecondary)
+                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+                } else {
+                    ScrollView(.vertical, showsIndicators: false) {
+                        VStack(spacing: 10) {
+                            ForEach(viewModel.issues) { issue in
+                                issueCard(issue)
+                            }
+                        }
+                    }
+                    .frame(maxHeight: .infinity)
+                }
+            }
+            .padding(16)
+            .frame(width: 300)
+            .frame(maxHeight: .infinity, alignment: .topLeading)
+            .background(panelBackground)
+            .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+            .overlay(
+                RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                    .stroke(AppTheme.border, lineWidth: 1)
+            )
+        }
+        .frame(maxHeight: .infinity, alignment: .topLeading)
+    }
+
+    private func issueCard(_ issue: GrammarIssue) -> some View {
+        VStack(alignment: .leading, spacing: 10) {
+            Text("Correct the grammar error")
+                .font(.system(size: 13, weight: .bold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            HStack(spacing: 18) {
+                Text(issue.original)
+                    .font(.system(size: 14, weight: .medium))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Text(issue.suggestion)
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(AppTheme.teal)
+            }
+
+            Text(issue.explanation)
+                .font(.system(size: 12))
+                .foregroundStyle(AppTheme.textSecondary)
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(.horizontal, 14)
+        .padding(.vertical, 12)
+        .background(Color.white)
+        .clipShape(RoundedRectangle(cornerRadius: 10))
+        .overlay(
+            RoundedRectangle(cornerRadius: 10)
+                .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
+        )
+    }
 }