Просмотр исходного кода

Wire all AI tools through a shared OpenAI client using Secrets.plist.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 месяц назад
Родитель
Сommit
fd93b56f8e

+ 3 - 0
gramora/Models/DictionaryModels.swift

@@ -23,6 +23,7 @@ struct DictionaryDefinition: Identifiable, Equatable {
 }
 
 enum DictionaryServiceError: LocalizedError {
+    case missingAPIKey
     case emptyInput
     case wordNotFound
     case invalidResponse
@@ -30,6 +31,8 @@ enum DictionaryServiceError: LocalizedError {
 
     var errorDescription: String? {
         switch self {
+        case .missingAPIKey:
+            "OpenAI API key is missing. Add it to Secrets.plist."
         case .emptyInput:
             "Enter a word to look up."
         case .wordNotFound:

+ 66 - 56
gramora/Services/DictionaryService.swift

@@ -5,10 +5,10 @@ protocol DictionaryServicing {
 }
 
 struct DictionaryService: DictionaryServicing {
-    private let session: URLSession
+    private let openAI: OpenAIChatServicing
 
-    init(session: URLSession = .shared) {
-        self.session = session
+    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+        self.openAI = openAI
     }
 
     func lookUp(word: String) async throws -> DictionaryEntry {
@@ -17,79 +17,89 @@ struct DictionaryService: DictionaryServicing {
             throw DictionaryServiceError.emptyInput
         }
 
-        let encodedWord = trimmed.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? trimmed
-        guard let url = URL(string: "https://api.dictionaryapi.dev/api/v2/entries/en/\(encodedWord)") else {
-            throw DictionaryServiceError.invalidResponse
-        }
-
-        let (data, response) = try await session.data(from: url)
+        do {
+            let response: DictionaryAPIResponse = try await openAI.completeJSON(
+                systemPrompt: """
+                You are an English dictionary. Define the requested word. Respond with JSON only using this schema:
+                {
+                  "word": "the word",
+                  "phonetic": "/phonetic transcription/ or null",
+                  "meanings": [
+                    {
+                      "partOfSpeech": "noun",
+                      "definitions": [
+                        {
+                          "text": "definition text",
+                          "example": "example sentence or null"
+                        }
+                      ],
+                      "synonyms": ["synonym"],
+                      "antonyms": ["antonym"]
+                    }
+                  ]
+                }
+                If the word is not a valid English word, return an empty meanings array.
+                """,
+                userPrompt: trimmed,
+                as: DictionaryAPIResponse.self
+            )
 
-        if let httpResponse = response as? HTTPURLResponse {
-            if httpResponse.statusCode == 404 {
+            guard !response.meanings.isEmpty else {
                 throw DictionaryServiceError.wordNotFound
             }
-            if !(200...299).contains(httpResponse.statusCode) {
-                throw DictionaryServiceError.networkError("Request failed with status \(httpResponse.statusCode).")
-            }
-        }
 
-        let entries = try JSONDecoder().decode([DictionaryAPIEntry].self, from: data)
-        guard let entry = entries.first else {
-            throw DictionaryServiceError.wordNotFound
+            return DictionaryEntry(
+                word: response.word,
+                phonetic: response.phonetic,
+                audioURL: nil,
+                meanings: response.meanings.map { meaning in
+                    DictionaryMeaning(
+                        partOfSpeech: meaning.partOfSpeech,
+                        definitions: meaning.definitions.map {
+                            DictionaryDefinition(text: $0.text, example: $0.example)
+                        },
+                        synonyms: meaning.synonyms,
+                        antonyms: meaning.antonyms
+                    )
+                }
+            )
+        } catch let error as OpenAIServiceError {
+            throw DictionaryServiceError.fromOpenAI(error)
+        } catch let error as DictionaryServiceError {
+            throw error
+        } catch {
+            throw DictionaryServiceError.invalidResponse
         }
-
-        return entry.toDictionaryEntry()
     }
 }
 
-private struct DictionaryAPIEntry: Decodable {
-    struct Phonetic: Decodable {
-        let text: String?
-        let audio: String?
-    }
-
+private struct DictionaryAPIResponse: Decodable {
     struct Meaning: Decodable {
         struct Definition: Decodable {
-            let definition: String
+            let text: String
             let example: String?
-            let synonyms: [String]?
-            let antonyms: [String]?
         }
 
         let partOfSpeech: String
         let definitions: [Definition]
-        let synonyms: [String]?
-        let antonyms: [String]?
+        let synonyms: [String]
+        let antonyms: [String]
     }
 
     let word: String
-    let phonetics: [Phonetic]
+    let phonetic: String?
     let meanings: [Meaning]
+}
 
-    func toDictionaryEntry() -> DictionaryEntry {
-        let phonetic = phonetics.first(where: { !($0.text ?? "").isEmpty })?.text
-        let audioString = phonetics.first(where: { !($0.audio ?? "").isEmpty })?.audio
-        let audioURL = audioString.flatMap { URL(string: $0) }
-
-        let mappedMeanings = meanings.map { meaning in
-            DictionaryMeaning(
-                partOfSpeech: meaning.partOfSpeech,
-                definitions: meaning.definitions.map {
-                    DictionaryDefinition(
-                        text: $0.definition,
-                        example: $0.example
-                    )
-                },
-                synonyms: Array(Set((meaning.synonyms ?? []) + meaning.definitions.flatMap { $0.synonyms ?? [] })).sorted(),
-                antonyms: Array(Set((meaning.antonyms ?? []) + meaning.definitions.flatMap { $0.antonyms ?? [] })).sorted()
-            )
+private extension DictionaryServiceError {
+    static func fromOpenAI(_ error: OpenAIServiceError) -> DictionaryServiceError {
+        switch error {
+        case .missingAPIKey:
+            .missingAPIKey
+        case .invalidResponse:
+            .invalidResponse
+        case .apiError(let message):
+            .networkError(message)
         }
-
-        return DictionaryEntry(
-            word: word,
-            phonetic: phonetic,
-            audioURL: audioURL,
-            meanings: mappedMeanings
-        )
     }
 }

+ 46 - 0
gramora/Services/EmailWriterService.swift

@@ -0,0 +1,46 @@
+import Foundation
+
+protocol EmailWriterServicing {
+    func generateEmail(
+        prompt: String,
+        subject: String,
+        emailType: EmailType,
+        tone: EmailTone
+    ) async throws -> String
+}
+
+struct EmailWriterService: EmailWriterServicing {
+    private let openAI: OpenAIChatServicing
+
+    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+        self.openAI = openAI
+    }
+
+    func generateEmail(
+        prompt: String,
+        subject: String,
+        emailType: EmailType,
+        tone: EmailTone
+    ) async throws -> String {
+        let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            throw OpenAIServiceError.apiError("Describe what the email should say.")
+        }
+
+        var userPrompt = "Email type: \(emailType.title)\nTone: \(tone.title)\n"
+        let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+        if !trimmedSubject.isEmpty {
+            userPrompt += "Subject: \(trimmedSubject)\n"
+        }
+        userPrompt += "\nContent instructions:\n\(trimmed)"
+
+        return try await openAI.completeText(
+            systemPrompt: """
+            You are a professional email writer. Write a complete, ready-to-send email based on the \
+            user's instructions. Include a subject line only if one was not already provided. \
+            Return only the email text with no preamble or explanation.
+            """,
+            userPrompt: userPrompt
+        )
+    }
+}

+ 39 - 109
gramora/Services/GrammarCheckService.swift

@@ -5,15 +5,10 @@ protocol GrammarCheckServicing {
 }
 
 struct GrammarCheckService: GrammarCheckServicing {
-    private let session: URLSession
-    private let apiKeyProvider: () -> String?
+    private let openAI: OpenAIChatServicing
 
-    init(
-        session: URLSession = .shared,
-        apiKeyProvider: @escaping () -> String? = { APIConfiguration.openAIAPIKey }
-    ) {
-        self.session = session
-        self.apiKeyProvider = apiKeyProvider
+    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+        self.openAI = openAI
     }
 
     func checkGrammar(in text: String) async throws -> GrammarCheckResult {
@@ -22,81 +17,36 @@ struct GrammarCheckService: GrammarCheckServicing {
             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:
+        do {
+            let response: GrammarCheckResponse = try await openAI.completeJSON(
+                systemPrompt: """
+                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": [
                     {
-                      "correctedText": "full corrected version of the input",
-                      "issues": [
-                        {
-                          "original": "problematic phrase or word",
-                          "suggestion": "corrected phrase or word",
-                          "explanation": "brief reason for the change"
-                        }
-                      ]
+                      "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
+                  ]
+                }
+                If there are no issues, return the original text in correctedText and an empty issues array.
+                """,
+                userPrompt: trimmed,
+                as: GrammarCheckResponse.self
+            )
+
+            return GrammarCheckResult(
+                correctedText: response.correctedText,
+                issues: response.issues.map {
+                    GrammarIssue(original: $0.original, suggestion: $0.suggestion, explanation: $0.explanation)
+                }
+            )
+        } catch let error as OpenAIServiceError {
+            throw GrammarCheckServiceError.fromOpenAI(error)
         }
-
-        return (try? JSONDecoder().decode(APIErrorResponse.self, from: data))?.error.message
     }
 }
 
@@ -111,35 +61,15 @@ private struct GrammarCheckResponse: Decodable {
     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
+private extension GrammarCheckServiceError {
+    static func fromOpenAI(_ error: OpenAIServiceError) -> GrammarCheckServiceError {
+        switch error {
+        case .missingAPIKey:
+            .missingAPIKey
+        case .invalidResponse:
+            .invalidResponse
+        case .apiError(let message):
+            .apiError(message)
         }
-
-        let message: Message
     }
-
-    let choices: [Choice]
 }

+ 56 - 122
gramora/Services/JournalFinderService.swift

@@ -5,15 +5,10 @@ protocol JournalFinderServicing {
 }
 
 struct JournalFinderService: JournalFinderServicing {
-    private let session: URLSession
-    private let apiKeyProvider: () -> String?
+    private let openAI: OpenAIChatServicing
 
-    init(
-        session: URLSession = .shared,
-        apiKeyProvider: @escaping () -> String? = { APIConfiguration.openAIAPIKey }
-    ) {
-        self.session = session
-        self.apiKeyProvider = apiKeyProvider
+    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+        self.openAI = openAI
     }
 
     func findJournals(for criteria: JournalFinderSearchCriteria) async throws -> JournalFinderResult {
@@ -25,87 +20,58 @@ struct JournalFinderService: JournalFinderServicing {
             throw JournalFinderServiceError.emptyInput
         }
 
-        guard let apiKey = apiKeyProvider() else {
-            throw JournalFinderServiceError.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 userPrompt = Self.buildUserPrompt(from: criteria, title: title, abstract: abstract, keywords: keywords)
 
-        let body = JournalFinderChatRequest(
-            model: "gpt-4o-mini",
-            messages: [
-                .init(
-                    role: "system",
-                    content: """
-                    You are an academic publishing advisor. Recommend real, well-known scholarly \
-                    journals that fit the user's research. Respond with JSON only using this schema:
+        do {
+            let decoded: JournalFinderAPIResponse = try await openAI.completeJSON(
+                systemPrompt: """
+                You are an academic publishing advisor. Recommend real, well-known scholarly \
+                journals that fit the user's research. Respond with JSON only using this schema:
+                {
+                  "summary": "one sentence overview of the search",
+                  "recommendations": [
                     {
-                      "summary": "one sentence overview of the search",
-                      "recommendations": [
-                        {
-                          "name": "journal title",
-                          "publisher": "publisher name",
-                          "matchScore": 85,
-                          "scope": "brief scope description",
-                          "fitReason": "why this journal fits the manuscript",
-                          "isOpenAccess": true,
-                          "indexes": ["Scopus", "Web of Science"],
-                          "websiteURL": "https://example.com",
-                          "averageReviewWeeks": 12
-                        }
-                      ]
+                      "name": "journal title",
+                      "publisher": "publisher name",
+                      "matchScore": 85,
+                      "scope": "brief scope description",
+                      "fitReason": "why this journal fits the manuscript",
+                      "isOpenAccess": true,
+                      "indexes": ["Scopus", "Web of Science"],
+                      "websiteURL": "https://example.com",
+                      "averageReviewWeeks": 12
                     }
-                    Return 5 to 8 journals sorted by matchScore descending. \
-                    matchScore must be 0-100. Use only real journals. \
-                    averageReviewWeeks may be null if unknown.
-                    """
-                ),
-                .init(role: "user", content: userPrompt)
-            ],
-            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 JournalFinderServiceError.apiError(message)
-        }
+                  ]
+                }
+                Return 5 to 8 journals sorted by matchScore descending. \
+                matchScore must be 0-100. Use only real journals. \
+                averageReviewWeeks may be null if unknown.
+                """,
+                userPrompt: userPrompt,
+                as: JournalFinderAPIResponse.self
+            )
 
-        let completion = try JSONDecoder().decode(JournalFinderChatResponse.self, from: data)
-        guard
-            let content = completion.choices.first?.message.content,
-            let contentData = content.data(using: .utf8)
-        else {
-            throw JournalFinderServiceError.invalidResponse
-        }
+            let recommendations = decoded.recommendations.map {
+                JournalRecommendation(
+                    name: $0.name,
+                    publisher: $0.publisher,
+                    matchScore: min(100, max(0, $0.matchScore)),
+                    scope: $0.scope,
+                    fitReason: $0.fitReason,
+                    isOpenAccess: $0.isOpenAccess,
+                    indexes: $0.indexes,
+                    websiteURL: $0.websiteURL,
+                    averageReviewWeeks: $0.averageReviewWeeks
+                )
+            }
 
-        let decoded = try JSONDecoder().decode(JournalFinderAPIResponse.self, from: contentData)
-        let recommendations = decoded.recommendations.map {
-            JournalRecommendation(
-                name: $0.name,
-                publisher: $0.publisher,
-                matchScore: min(100, max(0, $0.matchScore)),
-                scope: $0.scope,
-                fitReason: $0.fitReason,
-                isOpenAccess: $0.isOpenAccess,
-                indexes: $0.indexes,
-                websiteURL: $0.websiteURL,
-                averageReviewWeeks: $0.averageReviewWeeks
+            return JournalFinderResult(
+                recommendations: recommendations.sorted { $0.matchScore > $1.matchScore },
+                summary: decoded.summary
             )
+        } catch let error as OpenAIServiceError {
+            throw JournalFinderServiceError.fromOpenAI(error)
         }
-
-        return JournalFinderResult(
-            recommendations: recommendations.sorted { $0.matchScore > $1.matchScore },
-            summary: decoded.summary
-        )
     }
 
     private static func buildUserPrompt(
@@ -138,18 +104,6 @@ struct JournalFinderService: JournalFinderServicing {
 
         return lines.joined(separator: "\n")
     }
-
-    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 JournalFinderAPIResponse: Decodable {
@@ -169,35 +123,15 @@ private struct JournalFinderAPIResponse: Decodable {
     let recommendations: [Recommendation]
 }
 
-private struct JournalFinderChatRequest: 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 JournalFinderChatResponse: Decodable {
-    struct Choice: Decodable {
-        struct Message: Decodable {
-            let content: String
+private extension JournalFinderServiceError {
+    static func fromOpenAI(_ error: OpenAIServiceError) -> JournalFinderServiceError {
+        switch error {
+        case .missingAPIKey:
+            .missingAPIKey
+        case .invalidResponse:
+            .invalidResponse
+        case .apiError(let message):
+            .apiError(message)
         }
-
-        let message: Message
     }
-
-    let choices: [Choice]
 }

+ 144 - 0
gramora/Services/OpenAIService.swift

@@ -0,0 +1,144 @@
+import Foundation
+
+enum OpenAIServiceError: LocalizedError {
+    case missingAPIKey
+    case invalidResponse
+    case apiError(String)
+
+    var errorDescription: String? {
+        switch self {
+        case .missingAPIKey:
+            "OpenAI API key is missing. Add it to Secrets.plist."
+        case .invalidResponse:
+            "Could not read the response from OpenAI."
+        case .apiError(let message):
+            message
+        }
+    }
+}
+
+protocol OpenAIChatServicing {
+    func completeText(systemPrompt: String, userPrompt: String) async throws -> String
+    func completeJSON<T: Decodable>(
+        systemPrompt: String,
+        userPrompt: String,
+        as type: T.Type
+    ) async throws -> T
+}
+
+struct OpenAIChatService: OpenAIChatServicing {
+    private let session: URLSession
+    private let apiKeyProvider: () -> String?
+    private let model: String
+
+    init(
+        session: URLSession = .shared,
+        apiKeyProvider: @escaping () -> String? = { APIConfiguration.openAIAPIKey },
+        model: String = "gpt-4o-mini"
+    ) {
+        self.session = session
+        self.apiKeyProvider = apiKeyProvider
+        self.model = model
+    }
+
+    func completeText(systemPrompt: String, userPrompt: String) async throws -> String {
+        try await performRequest(systemPrompt: systemPrompt, userPrompt: userPrompt, jsonMode: false)
+    }
+
+    func completeJSON<T: Decodable>(
+        systemPrompt: String,
+        userPrompt: String,
+        as type: T.Type
+    ) async throws -> T {
+        let content = try await performRequest(systemPrompt: systemPrompt, userPrompt: userPrompt, jsonMode: true)
+        guard let data = content.data(using: .utf8) else {
+            throw OpenAIServiceError.invalidResponse
+        }
+        return try JSONDecoder().decode(T.self, from: data)
+    }
+
+    private func performRequest(
+        systemPrompt: String,
+        userPrompt: String,
+        jsonMode: Bool
+    ) async throws -> String {
+        guard let apiKey = apiKeyProvider() else {
+            throw OpenAIServiceError.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: model,
+            messages: [
+                .init(role: "system", content: systemPrompt),
+                .init(role: "user", content: userPrompt)
+            ],
+            responseFormat: jsonMode ? .init(type: "json_object") : nil
+        )
+
+        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 OpenAIServiceError.apiError(message)
+        }
+
+        let completion = try JSONDecoder().decode(ChatCompletionResponse.self, from: data)
+        guard let content = completion.choices.first?.message.content else {
+            throw OpenAIServiceError.invalidResponse
+        }
+
+        return content
+    }
+
+    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 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]
+}

+ 28 - 0
gramora/Services/ParaphrasingService.swift

@@ -0,0 +1,28 @@
+import Foundation
+
+protocol ParaphrasingServicing {
+    func paraphrase(_ text: String) async throws -> String
+}
+
+struct ParaphrasingService: ParaphrasingServicing {
+    private let openAI: OpenAIChatServicing
+
+    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+        self.openAI = openAI
+    }
+
+    func paraphrase(_ text: String) async throws -> String {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            throw OpenAIServiceError.apiError("Enter some text to paraphrase.")
+        }
+
+        return try await openAI.completeText(
+            systemPrompt: """
+            You are a professional writing assistant. Paraphrase the user's text while preserving \
+            the original meaning and tone. Return only the paraphrased text with no preamble or explanation.
+            """,
+            userPrompt: trimmed
+        )
+    }
+}

+ 58 - 0
gramora/Services/PunctuationCheckService.swift

@@ -0,0 +1,58 @@
+import Foundation
+
+protocol PunctuationCheckServicing {
+    func checkPunctuation(in text: String) async throws -> GrammarCheckResult
+}
+
+struct PunctuationCheckService: PunctuationCheckServicing {
+    private let openAI: OpenAIChatServicing
+
+    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+        self.openAI = openAI
+    }
+
+    func checkPunctuation(in text: String) async throws -> GrammarCheckResult {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            throw GrammarCheckServiceError.emptyInput
+        }
+
+        let response: TextIssuesResponse = try await openAI.completeJSON(
+            systemPrompt: """
+            You are a professional punctuation checker. Analyze the user's text for punctuation \
+            issues only. Respond with JSON only using this schema:
+            {
+              "correctedText": "full corrected version of the input",
+              "issues": [
+                {
+                  "original": "problematic phrase",
+                  "suggestion": "corrected phrase",
+                  "explanation": "brief reason for the change"
+                }
+              ]
+            }
+            If there are no issues, return the original text in correctedText and an empty issues array.
+            """,
+            userPrompt: trimmed,
+            as: TextIssuesResponse.self
+        )
+
+        return GrammarCheckResult(
+            correctedText: response.correctedText,
+            issues: response.issues.map {
+                GrammarIssue(original: $0.original, suggestion: $0.suggestion, explanation: $0.explanation)
+            }
+        )
+    }
+}
+
+private struct TextIssuesResponse: Decodable {
+    struct Issue: Decodable {
+        let original: String
+        let suggestion: String
+        let explanation: String
+    }
+
+    let correctedText: String
+    let issues: [Issue]
+}

+ 61 - 0
gramora/Services/SpellCheckService.swift

@@ -0,0 +1,61 @@
+import Foundation
+
+struct SpellCheckResult: Equatable {
+    let correctedText: String
+    let mistakes: [SpellingMistake]
+}
+
+protocol SpellCheckServicing {
+    func checkSpelling(in text: String) async throws -> SpellCheckResult
+}
+
+struct SpellCheckService: SpellCheckServicing {
+    private let openAI: OpenAIChatServicing
+
+    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+        self.openAI = openAI
+    }
+
+    func checkSpelling(in text: String) async throws -> SpellCheckResult {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            throw OpenAIServiceError.apiError("Enter some text to check.")
+        }
+
+        let response: SpellCheckResponse = try await openAI.completeJSON(
+            systemPrompt: """
+            You are a professional spelling checker. Analyze the user's text for spelling mistakes only. \
+            Respond with JSON only using this schema:
+            {
+              "correctedText": "full corrected version of the input",
+              "mistakes": [
+                {
+                  "original": "misspelled word",
+                  "suggestion": "correct spelling"
+                }
+              ]
+            }
+            If there are no mistakes, return the original text in correctedText and an empty mistakes array.
+            """,
+            userPrompt: trimmed,
+            as: SpellCheckResponse.self
+        )
+
+        return SpellCheckResult(
+            correctedText: response.correctedText,
+            mistakes: response.mistakes.map {
+                SpellingMistake(original: $0.original, suggestion: $0.suggestion)
+            }
+        )
+    }
+}
+
+private struct SpellCheckResponse: Decodable {
+    struct Mistake: Decodable {
+        let original: String
+        let suggestion: String
+    }
+
+    let correctedText: String
+    let mistakes: [Mistake]
+}

+ 28 - 0
gramora/Services/SummarizerService.swift

@@ -0,0 +1,28 @@
+import Foundation
+
+protocol SummarizerServicing {
+    func summarize(_ text: String) async throws -> String
+}
+
+struct SummarizerService: SummarizerServicing {
+    private let openAI: OpenAIChatServicing
+
+    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+        self.openAI = openAI
+    }
+
+    func summarize(_ text: String) async throws -> String {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            throw OpenAIServiceError.apiError("Enter some text to summarize.")
+        }
+
+        return try await openAI.completeText(
+            systemPrompt: """
+            You are a professional summarizer. Produce a clear, concise summary of the user's text. \
+            Return only the summary with no preamble or explanation.
+            """,
+            userPrompt: trimmed
+        )
+    }
+}

+ 36 - 0
gramora/Services/TranslationService.swift

@@ -0,0 +1,36 @@
+import Foundation
+
+protocol TranslationServicing {
+    func translate(
+        _ text: String,
+        from source: TranslationLanguage,
+        to target: TranslationLanguage
+    ) async throws -> String
+}
+
+struct TranslationService: TranslationServicing {
+    private let openAI: OpenAIChatServicing
+
+    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+        self.openAI = openAI
+    }
+
+    func translate(
+        _ text: String,
+        from source: TranslationLanguage,
+        to target: TranslationLanguage
+    ) async throws -> String {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            throw OpenAIServiceError.apiError("Enter some text to translate.")
+        }
+
+        return try await openAI.completeText(
+            systemPrompt: """
+            You are a professional translator. Translate the user's text from \(source.name) to \
+            \(target.name). Return only the translated text with no preamble or explanation.
+            """,
+            userPrompt: trimmed
+        )
+    }
+}

+ 32 - 19
gramora/ViewModels/EmailWriterViewModel.swift

@@ -11,11 +11,17 @@ final class EmailWriterViewModel: ObservableObject {
     @Published var generatedEmail: String = ""
     @Published var emailType: EmailType = .followUp
     @Published var tone: EmailTone = .professional
-    @Published var isGenerating = false
+    @Published private(set) var isGenerating = false
+    @Published private(set) var errorMessage: String?
 
+    private let emailWriterService: any EmailWriterServicing
     private let historyStorage: HistoryStoring
 
-    init(historyStorage: HistoryStoring = HistoryStorageService.shared) {
+    init(
+        emailWriterService: any EmailWriterServicing = EmailWriterService(),
+        historyStorage: HistoryStoring = HistoryStorageService.shared
+    ) {
+        self.emailWriterService = emailWriterService
         self.historyStorage = historyStorage
     }
 
@@ -36,12 +42,15 @@ final class EmailWriterViewModel: ObservableObject {
     func pastePrompt() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
         prompt = String(clipboard.prefix(Self.maxCharacters))
+        generatedEmail = ""
+        errorMessage = nil
     }
 
     func clearAll() {
         subject = ""
         prompt = ""
         generatedEmail = ""
+        errorMessage = nil
     }
 
     func copyGeneratedEmail() {
@@ -56,27 +65,30 @@ final class EmailWriterViewModel: ObservableObject {
 
         isGenerating = true
         generatedEmail = ""
+        errorMessage = nil
 
-        // Email generation service integration point
         let subjectLine = subject.trimmingCharacters(in: .whitespacesAndNewlines)
-        let greeting = tone == .formal ? "Dear Recipient," : "Hi there,"
-
-        var email = "\(greeting)\n\n"
-        email += "[\(emailType.title) · \(tone.title)]\n\n"
-        email += trimmedPrompt
-        email += "\n\nBest regards,\n[Your Name]"
 
-        if !subjectLine.isEmpty {
-            email = "Subject: \(subjectLine)\n\n" + email
+        Task {
+            do {
+                let email = try await emailWriterService.generateEmail(
+                    prompt: trimmedPrompt,
+                    subject: subjectLine,
+                    emailType: emailType,
+                    tone: tone
+                )
+                generatedEmail = email
+                saveToHistory(
+                    subject: subjectLine,
+                    prompt: trimmedPrompt,
+                    generatedEmail: email
+                )
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+
+            isGenerating = false
         }
-
-        generatedEmail = email
-        isGenerating = false
-        saveToHistory(
-            subject: subjectLine,
-            prompt: trimmedPrompt,
-            generatedEmail: email
-        )
     }
 
     func restore(from entry: HistoryEntry) {
@@ -87,6 +99,7 @@ final class EmailWriterViewModel: ObservableObject {
         generatedEmail = data.generatedEmail
         emailType = data.emailType
         tone = data.tone
+        errorMessage = nil
     }
 
     private func saveToHistory(subject: String, prompt: String, generatedEmail: String) {

+ 31 - 8
gramora/ViewModels/LanguageTranslatorViewModel.swift

@@ -11,11 +11,17 @@ final class LanguageTranslatorViewModel: ObservableObject {
     @Published var translatedText: String = ""
     @Published var sourceLanguage: TranslationLanguage = .defaultSource
     @Published var targetLanguage: TranslationLanguage = .defaultTarget
-    @Published var isTranslating = false
+    @Published private(set) var isTranslating = false
+    @Published private(set) var errorMessage: String?
 
+    private let translationService: any TranslationServicing
     private let historyStorage: HistoryStoring
 
-    init(historyStorage: HistoryStoring = HistoryStorageService.shared) {
+    init(
+        translationService: any TranslationServicing = TranslationService(),
+        historyStorage: HistoryStoring = HistoryStorageService.shared
+    ) {
+        self.translationService = translationService
         self.historyStorage = historyStorage
     }
 
@@ -40,6 +46,8 @@ final class LanguageTranslatorViewModel: ObservableObject {
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
         sourceText = String(clipboard.prefix(Self.maxCharacters))
+        translatedText = ""
+        errorMessage = nil
     }
 
     func uploadFile() {
@@ -54,14 +62,17 @@ final class LanguageTranslatorViewModel: ObservableObject {
         do {
             let contents = try String(contentsOf: url, encoding: .utf8)
             sourceText = String(contents.prefix(Self.maxCharacters))
+            translatedText = ""
+            errorMessage = nil
         } catch {
-            // File read integration point
+            errorMessage = "Could not read the selected file."
         }
     }
 
     func clearText() {
         sourceText = ""
         translatedText = ""
+        errorMessage = nil
     }
 
     func swapLanguages() {
@@ -80,11 +91,22 @@ final class LanguageTranslatorViewModel: ObservableObject {
 
         isTranslating = true
         translatedText = ""
-
-        // Translation service integration point
-        translatedText = "[\(sourceLanguage.name) → \(targetLanguage.name)] \(trimmed)"
-        isTranslating = false
-        saveToHistory(sourceText: trimmed, translatedText: translatedText)
+        errorMessage = nil
+
+        Task {
+            do {
+                translatedText = try await translationService.translate(
+                    trimmed,
+                    from: sourceLanguage,
+                    to: targetLanguage
+                )
+                saveToHistory(sourceText: trimmed, translatedText: translatedText)
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+
+            isTranslating = false
+        }
     }
 
     func restore(from entry: HistoryEntry) {
@@ -94,6 +116,7 @@ final class LanguageTranslatorViewModel: ObservableObject {
         translatedText = data.translatedText
         sourceLanguage = TranslationLanguage.all.first { $0.id == data.sourceLanguageID } ?? .defaultSource
         targetLanguage = TranslationLanguage.all.first { $0.id == data.targetLanguageID } ?? .defaultTarget
+        errorMessage = nil
     }
 
     private func saveToHistory(sourceText: String, translatedText: String) {

+ 39 - 1
gramora/ViewModels/ParaphrasingViewModel.swift

@@ -7,6 +7,15 @@ final class ParaphrasingViewModel: ObservableObject {
     static let maxCharacters = 10_000
 
     @Published var text: String = ""
+    @Published private(set) var paraphrasedText: String = ""
+    @Published private(set) var isParaphrasing = false
+    @Published private(set) var errorMessage: String?
+
+    private let paraphrasingService: any ParaphrasingServicing
+
+    init(paraphrasingService: any ParaphrasingServicing = ParaphrasingService()) {
+        self.paraphrasingService = paraphrasingService
+    }
 
     var characterCount: Int { text.count }
 
@@ -14,16 +23,45 @@ final class ParaphrasingViewModel: ObservableObject {
         "\(characterCount)/\(Self.maxCharacters)"
     }
 
+    var canParaphrase: Bool {
+        !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isParaphrasing
+    }
+
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
         text = String(clipboard.prefix(Self.maxCharacters))
+        paraphrasedText = ""
+        errorMessage = nil
     }
 
     func clearText() {
         text = ""
+        paraphrasedText = ""
+        errorMessage = nil
     }
 
     func paraphrase() {
-        // Paraphrasing service integration point
+        let content = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !content.isEmpty else { return }
+
+        isParaphrasing = true
+        paraphrasedText = ""
+        errorMessage = nil
+
+        Task {
+            do {
+                paraphrasedText = try await paraphrasingService.paraphrase(content)
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+
+            isParaphrasing = false
+        }
+    }
+
+    func copyParaphrasedText() {
+        guard !paraphrasedText.isEmpty else { return }
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(paraphrasedText, forType: .string)
     }
 }

+ 51 - 1
gramora/ViewModels/PunctuationCheckerViewModel.swift

@@ -7,6 +7,17 @@ final class PunctuationCheckerViewModel: ObservableObject {
     static let maxCharacters = 10_000
 
     @Published var text: String = ""
+    @Published private(set) var correctedText: String = ""
+    @Published private(set) var issues: [GrammarIssue] = []
+    @Published private(set) var hasResults = false
+    @Published private(set) var isChecking = false
+    @Published private(set) var errorMessage: String?
+
+    private let punctuationCheckService: any PunctuationCheckServicing
+
+    init(punctuationCheckService: any PunctuationCheckServicing = PunctuationCheckService()) {
+        self.punctuationCheckService = punctuationCheckService
+    }
 
     var characterCount: Int { text.count }
 
@@ -14,16 +25,55 @@ final class PunctuationCheckerViewModel: ObservableObject {
         "\(characterCount)/\(Self.maxCharacters)"
     }
 
+    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() {
+        correctedText = ""
+        issues = []
+        hasResults = false
+        errorMessage = nil
     }
 
     func checkPunctuation() {
-        // Punctuation 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 punctuationCheckService.checkPunctuation(in: content)
+                correctedText = result.correctedText
+                issues = result.issues
+                hasResults = true
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+
+            isChecking = false
+        }
+    }
+
+    func copyCorrectedText() {
+        guard !correctedText.isEmpty else { return }
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(correctedText, forType: .string)
     }
 }

+ 27 - 41
gramora/ViewModels/SpellCheckerViewModel.swift

@@ -10,10 +10,17 @@ final class SpellCheckerViewModel: ObservableObject {
     @Published private(set) var mistakes: [SpellingMistake] = []
     @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 spellCheckService: any SpellCheckServicing
     private let historyStorage: HistoryStoring
 
-    init(historyStorage: HistoryStoring = HistoryStorageService.shared) {
+    init(
+        spellCheckService: any SpellCheckServicing = SpellCheckService(),
+        historyStorage: HistoryStoring = HistoryStorageService.shared
+    ) {
+        self.spellCheckService = spellCheckService
         self.historyStorage = historyStorage
     }
 
@@ -28,6 +35,10 @@ final class SpellCheckerViewModel: ObservableObject {
         return "\(count) Mistake\(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))
@@ -43,6 +54,7 @@ final class SpellCheckerViewModel: ObservableObject {
         mistakes = []
         correctedText = ""
         hasResults = false
+        errorMessage = nil
     }
 
     func checkSpelling() {
@@ -52,49 +64,22 @@ final class SpellCheckerViewModel: ObservableObject {
             return
         }
 
-        let checker = NSSpellChecker.shared
-        let language = checker.language()
-        let nsContent = content as NSString
-        var location = 0
-        var foundMistakes: [SpellingMistake] = []
-        var replacements: [(range: NSRange, suggestion: String)] = []
-
-        while location < nsContent.length {
-            let misspelledRange = checker.checkSpelling(
-                of: content,
-                startingAt: location
-            )
-
-            guard misspelledRange.location != NSNotFound else { break }
-
-            let original = nsContent.substring(with: misspelledRange)
-            let guesses = checker.guesses(
-                forWordRange: misspelledRange,
-                in: content,
-                language: language,
-                inSpellDocumentWithTag: 0
-            )
-            let suggestion = guesses?.first ?? original
-
-            foundMistakes.append(SpellingMistake(original: original, suggestion: suggestion))
-            if original != suggestion {
-                replacements.append((misspelledRange, suggestion))
+        isChecking = true
+        errorMessage = nil
+
+        Task {
+            do {
+                let result = try await spellCheckService.checkSpelling(in: content)
+                mistakes = result.mistakes
+                correctedText = result.correctedText
+                hasResults = true
+                saveToHistory(text: content, mistakes: result.mistakes, correctedText: result.correctedText)
+            } catch {
+                errorMessage = error.localizedDescription
             }
 
-            let nextLocation = misspelledRange.location + max(misspelledRange.length, 1)
-            if nextLocation <= location { break }
-            location = nextLocation
-        }
-
-        let mutable = NSMutableString(string: content)
-        for replacement in replacements.reversed() {
-            mutable.replaceCharacters(in: replacement.range, with: replacement.suggestion)
+            isChecking = false
         }
-
-        mistakes = foundMistakes
-        correctedText = mutable as String
-        hasResults = true
-        saveToHistory(text: content, mistakes: foundMistakes, correctedText: mutable as String)
     }
 
     func restore(from entry: HistoryEntry) {
@@ -106,6 +91,7 @@ final class SpellCheckerViewModel: ObservableObject {
         }
         correctedText = data.correctedText
         hasResults = true
+        errorMessage = nil
     }
 
     private func saveToHistory(text: String, mistakes: [SpellingMistake], correctedText: String) {

+ 39 - 1
gramora/ViewModels/SummarizerViewModel.swift

@@ -7,6 +7,15 @@ final class SummarizerViewModel: ObservableObject {
     static let maxCharacters = 10_000
 
     @Published var text: String = ""
+    @Published private(set) var summary: String = ""
+    @Published private(set) var isSummarizing = false
+    @Published private(set) var errorMessage: String?
+
+    private let summarizerService: any SummarizerServicing
+
+    init(summarizerService: any SummarizerServicing = SummarizerService()) {
+        self.summarizerService = summarizerService
+    }
 
     var characterCount: Int { text.count }
 
@@ -14,16 +23,45 @@ final class SummarizerViewModel: ObservableObject {
         "\(characterCount)/\(Self.maxCharacters)"
     }
 
+    var canSummarize: Bool {
+        !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSummarizing
+    }
+
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
         text = String(clipboard.prefix(Self.maxCharacters))
+        summary = ""
+        errorMessage = nil
     }
 
     func clearText() {
         text = ""
+        summary = ""
+        errorMessage = nil
     }
 
     func summarize() {
-        // Summarization service integration point
+        let content = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !content.isEmpty else { return }
+
+        isSummarizing = true
+        summary = ""
+        errorMessage = nil
+
+        Task {
+            do {
+                summary = try await summarizerService.summarize(content)
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+
+            isSummarizing = false
+        }
+    }
+
+    func copySummary() {
+        guard !summary.isEmpty else { return }
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(summary, forType: .string)
     }
 }

+ 8 - 1
gramora/Views/EmailWriterView.swift

@@ -25,7 +25,14 @@ struct EmailWriterView: View {
                     .frame(maxHeight: .infinity, alignment: .topLeading)
                     .padding(.bottom, 24)
 
-                GradientButton(title: "Generate Email") {
+                if let errorMessage = viewModel.errorMessage {
+                    Text(errorMessage)
+                        .font(.system(size: 14))
+                        .foregroundStyle(.red)
+                        .padding(.bottom, 12)
+                }
+
+                GradientButton(title: viewModel.isGenerating ? "Generating..." : "Generate Email") {
                     viewModel.generateEmail()
                 }
                 .disabled(!viewModel.canGenerate)

+ 7 - 0
gramora/Views/LanguageTranslatorView.swift

@@ -96,6 +96,13 @@ struct LanguageTranslatorView: View {
             targetPanel
                 .frame(maxHeight: .infinity)
 
+            if let errorMessage = viewModel.errorMessage {
+                Text(errorMessage)
+                    .font(.system(size: 14))
+                    .foregroundStyle(.red)
+                    .fixedSize(horizontal: false, vertical: true)
+            }
+
             Text("Press ⌘ + Return to translate")
                 .font(.system(size: 11))
                 .foregroundStyle(AppTheme.textMuted)

+ 50 - 1
gramora/Views/ParaphrasingView.swift

@@ -18,9 +18,22 @@ struct ParaphrasingView: View {
                     contentCard(editorHeight: dynamicEditorHeight)
                         .padding(.bottom, 24)
 
-                    GradientButton(title: "Paraphrase") {
+                    if !viewModel.paraphrasedText.isEmpty {
+                        outputCard
+                            .padding(.bottom, 24)
+                    }
+
+                    if let errorMessage = viewModel.errorMessage {
+                        Text(errorMessage)
+                            .font(.system(size: 14))
+                            .foregroundStyle(.red)
+                            .padding(.bottom, 12)
+                    }
+
+                    GradientButton(title: viewModel.isParaphrasing ? "Paraphrasing..." : "Paraphrase") {
                         viewModel.paraphrase()
                     }
+                    .disabled(!viewModel.canParaphrase)
                 }
                 .frame(maxWidth: 1200, alignment: .topLeading)
                 .frame(maxWidth: .infinity, alignment: .topLeading)
@@ -131,4 +144,40 @@ struct ParaphrasingView: View {
         )
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
     }
+
+    private var outputCard: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            HStack {
+                Text("Paraphrased Text")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Spacer()
+
+                ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
+                    viewModel.copyParaphrasedText()
+                }
+            }
+
+            Text(viewModel.paraphrasedText)
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.textPrimary)
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .padding(16)
+                .background(AppTheme.tealLight.opacity(0.35))
+                .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                        .stroke(AppTheme.teal.opacity(0.2), lineWidth: 1)
+                )
+        }
+        .padding(22)
+        .background(AppTheme.cardBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                .stroke(AppTheme.border, lineWidth: 1)
+        )
+        .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
+    }
 }

+ 50 - 1
gramora/Views/PunctuationCheckerView.swift

@@ -18,9 +18,22 @@ struct PunctuationCheckerView: View {
                     contentCard(editorHeight: dynamicEditorHeight)
                         .padding(.bottom, 24)
 
-                    GradientButton(title: "Check Punctuation") {
+                    if !viewModel.correctedText.isEmpty {
+                        outputCard
+                            .padding(.bottom, 24)
+                    }
+
+                    if let errorMessage = viewModel.errorMessage {
+                        Text(errorMessage)
+                            .font(.system(size: 14))
+                            .foregroundStyle(.red)
+                            .padding(.bottom, 12)
+                    }
+
+                    GradientButton(title: viewModel.isChecking ? "Checking..." : "Check Punctuation") {
                         viewModel.checkPunctuation()
                     }
+                    .disabled(!viewModel.canCheck)
                 }
                 .frame(maxWidth: 1200, alignment: .topLeading)
                 .frame(maxWidth: .infinity, alignment: .topLeading)
@@ -131,4 +144,40 @@ struct PunctuationCheckerView: View {
         )
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
     }
+
+    private var outputCard: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            HStack {
+                Text("Corrected Text")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Spacer()
+
+                ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
+                    viewModel.copyCorrectedText()
+                }
+            }
+
+            Text(viewModel.correctedText)
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.textPrimary)
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .padding(16)
+                .background(AppTheme.tealLight.opacity(0.35))
+                .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                        .stroke(AppTheme.teal.opacity(0.2), lineWidth: 1)
+                )
+        }
+        .padding(22)
+        .background(AppTheme.cardBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                .stroke(AppTheme.border, lineWidth: 1)
+        )
+        .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
+    }
 }

+ 9 - 1
gramora/Views/SpellCheckerView.swift

@@ -43,9 +43,17 @@ struct SpellCheckerView: View {
                             contentCard(editorHeight: dynamicEditorHeight)
                                 .padding(.bottom, 24)
 
-                            GradientButton(title: "Check Spelling") {
+                            if let errorMessage = viewModel.errorMessage {
+                                Text(errorMessage)
+                                    .font(.system(size: 14))
+                                    .foregroundStyle(.red)
+                                    .padding(.bottom, 12)
+                            }
+
+                            GradientButton(title: viewModel.isChecking ? "Checking..." : "Check Spelling") {
                                 viewModel.checkSpelling()
                             }
+                            .disabled(!viewModel.canCheck)
                         }
                         .frame(maxWidth: 1200, alignment: .topLeading)
                         .frame(maxWidth: .infinity, alignment: .topLeading)

+ 50 - 1
gramora/Views/SummarizerView.swift

@@ -18,9 +18,22 @@ struct SummarizerView: View {
                     contentCard(editorHeight: dynamicEditorHeight)
                         .padding(.bottom, 24)
 
-                    GradientButton(title: "Summarize") {
+                    if !viewModel.summary.isEmpty {
+                        outputCard
+                            .padding(.bottom, 24)
+                    }
+
+                    if let errorMessage = viewModel.errorMessage {
+                        Text(errorMessage)
+                            .font(.system(size: 14))
+                            .foregroundStyle(.red)
+                            .padding(.bottom, 12)
+                    }
+
+                    GradientButton(title: viewModel.isSummarizing ? "Summarizing..." : "Summarize") {
                         viewModel.summarize()
                     }
+                    .disabled(!viewModel.canSummarize)
                 }
                 .frame(maxWidth: 1200, alignment: .topLeading)
                 .frame(maxWidth: .infinity, alignment: .topLeading)
@@ -131,4 +144,40 @@ struct SummarizerView: View {
         )
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
     }
+
+    private var outputCard: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            HStack {
+                Text("Summary")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Spacer()
+
+                ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
+                    viewModel.copySummary()
+                }
+            }
+
+            Text(viewModel.summary)
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.textPrimary)
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .padding(16)
+                .background(AppTheme.tealLight.opacity(0.35))
+                .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                        .stroke(AppTheme.teal.opacity(0.2), lineWidth: 1)
+                )
+        }
+        .padding(22)
+        .background(AppTheme.cardBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                .stroke(AppTheme.border, lineWidth: 1)
+        )
+        .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
+    }
 }