Переглянути джерело

Speed up grammar checks and enforce a 1,000-word input cap across tools.

Chunk grammar checking into parallel issues-only requests, block typing and pasting past the limit in the editor, and lower tool limits from 10,000 to 1,000 words.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 місяць тому
батько
коміт
93ae68fac0

+ 64 - 25
gramora/Services/GrammarCheckService.swift

@@ -5,6 +5,24 @@ protocol GrammarCheckServicing {
 }
 
 struct GrammarCheckService: GrammarCheckServicing {
+    private static let chunkMaxWords = 350
+    private static let maxConcurrentChunks = 4
+    private static let issuesOnlySystemPrompt = """
+    You are a professional grammar checker. Analyze the user's text for grammar, \
+    punctuation, and clarity issues only. Respond with JSON only using this schema:
+    {
+      "issues": [
+        {
+          "original": "exact problematic phrase from the input",
+          "suggestion": "corrected phrase",
+          "explanation": "brief reason for the change"
+        }
+      ]
+    }
+    Return only real issues in the issues array. Do not rewrite or return the full text. \
+    Preserve markdown, tables, code, file paths, and technical terms unchanged.
+    """
+
     private let openAI: OpenAIChatServicing
 
     init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
@@ -18,46 +36,67 @@ struct GrammarCheckService: GrammarCheckServicing {
         }
 
         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": [
-                    {
-                      "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.
-                """,
-                userPrompt: trimmed,
-                as: GrammarCheckResponse.self
-            )
+            let chunks = trimmed.chunked(maxWords: Self.chunkMaxWords)
+            let issues = try await checkChunks(chunks)
+            let correctedText = trimmed.applyingGrammarCorrections(issues)
 
             return GrammarCheckResult(
-                correctedText: response.correctedText,
-                issues: response.issues.map {
-                    GrammarIssue(original: $0.original, suggestion: $0.suggestion, explanation: $0.explanation)
-                }
+                correctedText: correctedText,
+                issues: issues
             )
         } catch let error as OpenAIServiceError {
             throw GrammarCheckServiceError.fromOpenAI(error)
         }
     }
+
+    private func checkChunks(_ chunks: [String]) async throws -> [GrammarIssue] {
+        var allIssues: [GrammarIssue] = []
+
+        for batchStart in stride(from: 0, to: chunks.count, by: Self.maxConcurrentChunks) {
+            let batchEnd = min(batchStart + Self.maxConcurrentChunks, chunks.count)
+            let batch = Array(chunks[batchStart..<batchEnd])
+
+            let batchIssues = try await withThrowingTaskGroup(of: [GrammarIssue].self) { group in
+                for chunk in batch {
+                    group.addTask {
+                        try await self.checkSingleChunk(chunk)
+                    }
+                }
+
+                var issues: [GrammarIssue] = []
+                for try await chunkIssues in group {
+                    issues.append(contentsOf: chunkIssues)
+                }
+                return issues
+            }
+
+            allIssues.append(contentsOf: batchIssues)
+        }
+
+        return allIssues
+    }
+
+    private func checkSingleChunk(_ text: String) async throws -> [GrammarIssue] {
+        let response: GrammarIssuesResponse = try await openAI.completeJSON(
+            systemPrompt: Self.issuesOnlySystemPrompt,
+            userPrompt: text,
+            maxTokens: 1_500,
+            as: GrammarIssuesResponse.self
+        )
+
+        return response.issues.map {
+            GrammarIssue(original: $0.original, suggestion: $0.suggestion, explanation: $0.explanation)
+        }
+    }
 }
 
-private struct GrammarCheckResponse: Decodable {
+private struct GrammarIssuesResponse: Decodable {
     struct Issue: Decodable {
         let original: String
         let suggestion: String
         let explanation: String
     }
 
-    let correctedText: String
     let issues: [Issue]
 }
 

+ 27 - 6
gramora/Services/OpenAIService.swift

@@ -21,14 +21,29 @@ enum OpenAIServiceError: LocalizedError {
 }
 
 protocol OpenAIChatServicing {
-    func completeText(systemPrompt: String, userPrompt: String) async throws -> String
+    func completeText(systemPrompt: String, userPrompt: String, maxTokens: Int?) async throws -> String
     func completeJSON<T: Decodable>(
         systemPrompt: String,
         userPrompt: String,
+        maxTokens: Int?,
         as type: T.Type
     ) async throws -> T
 }
 
+extension OpenAIChatServicing {
+    func completeText(systemPrompt: String, userPrompt: String) async throws -> String {
+        try await completeText(systemPrompt: systemPrompt, userPrompt: userPrompt, maxTokens: nil)
+    }
+
+    func completeJSON<T: Decodable>(
+        systemPrompt: String,
+        userPrompt: String,
+        as type: T.Type
+    ) async throws -> T {
+        try await completeJSON(systemPrompt: systemPrompt, userPrompt: userPrompt, maxTokens: nil, as: type)
+    }
+}
+
 struct OpenAIChatService: OpenAIChatServicing {
     static let shared = OpenAIChatService()
 
@@ -43,16 +58,17 @@ struct OpenAIChatService: OpenAIChatServicing {
         self.model = model
     }
 
-    func completeText(systemPrompt: String, userPrompt: String) async throws -> String {
-        try await performRequest(systemPrompt: systemPrompt, userPrompt: userPrompt, jsonMode: false)
+    func completeText(systemPrompt: String, userPrompt: String, maxTokens: Int? = nil) async throws -> String {
+        try await performRequest(systemPrompt: systemPrompt, userPrompt: userPrompt, jsonMode: false, maxTokens: maxTokens)
     }
 
     func completeJSON<T: Decodable>(
         systemPrompt: String,
         userPrompt: String,
+        maxTokens: Int? = nil,
         as type: T.Type
     ) async throws -> T {
-        let content = try await performRequest(systemPrompt: systemPrompt, userPrompt: userPrompt, jsonMode: true)
+        let content = try await performRequest(systemPrompt: systemPrompt, userPrompt: userPrompt, jsonMode: true, maxTokens: maxTokens)
         guard let data = content.data(using: .utf8) else {
             throw OpenAIServiceError.invalidResponse
         }
@@ -62,7 +78,8 @@ struct OpenAIChatService: OpenAIChatServicing {
     private func performRequest(
         systemPrompt: String,
         userPrompt: String,
-        jsonMode: Bool
+        jsonMode: Bool,
+        maxTokens: Int?
     ) async throws -> String {
         guard let apiKey = apiKeyProvider() else {
             throw OpenAIServiceError.missingAPIKey
@@ -80,7 +97,8 @@ struct OpenAIChatService: OpenAIChatServicing {
                 .init(role: "system", content: systemPrompt),
                 .init(role: "user", content: userPrompt)
             ],
-            responseFormat: jsonMode ? .init(type: "json_object") : nil
+            responseFormat: jsonMode ? .init(type: "json_object") : nil,
+            maxTokens: maxTokens
         )
 
         request.httpBody = try JSONEncoder().encode(body)
@@ -146,11 +164,13 @@ private struct ChatCompletionRequest: Encodable {
     let model: String
     let messages: [Message]
     let responseFormat: ResponseFormat?
+    let maxTokens: Int?
 
     enum CodingKeys: String, CodingKey {
         case model
         case messages
         case responseFormat = "response_format"
+        case maxTokens = "max_tokens"
     }
 
     func encode(to encoder: Encoder) throws {
@@ -158,6 +178,7 @@ private struct ChatCompletionRequest: Encodable {
         try container.encode(model, forKey: .model)
         try container.encode(messages, forKey: .messages)
         try container.encodeIfPresent(responseFormat, forKey: .responseFormat)
+        try container.encodeIfPresent(maxTokens, forKey: .maxTokens)
     }
 }
 

+ 64 - 0
gramora/Utilities/String+Chunking.swift

@@ -0,0 +1,64 @@
+import Foundation
+
+extension String {
+    func chunked(maxWords: Int) -> [String] {
+        guard maxWords > 0 else { return [self] }
+        guard wordCount > maxWords else { return [self] }
+
+        var chunks: [String] = []
+        var current = ""
+        var currentWordCount = 0
+
+        for paragraph in components(separatedBy: "\n\n") {
+            let trimmedParagraph = paragraph.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !trimmedParagraph.isEmpty else { continue }
+
+            if trimmedParagraph.wordCount > maxWords {
+                if !current.isEmpty {
+                    chunks.append(current)
+                    current = ""
+                    currentWordCount = 0
+                }
+                chunks.append(contentsOf: trimmedParagraph.splitByWordCount(maxWords))
+                continue
+            }
+
+            if currentWordCount + trimmedParagraph.wordCount > maxWords, !current.isEmpty {
+                chunks.append(current)
+                current = trimmedParagraph
+                currentWordCount = trimmedParagraph.wordCount
+            } else if current.isEmpty {
+                current = trimmedParagraph
+                currentWordCount = trimmedParagraph.wordCount
+            } else {
+                current += "\n\n" + trimmedParagraph
+                currentWordCount += trimmedParagraph.wordCount
+            }
+        }
+
+        if !current.isEmpty {
+            chunks.append(current)
+        }
+
+        return chunks.isEmpty ? [self] : chunks
+    }
+
+    private func splitByWordCount(_ maxWords: Int) -> [String] {
+        var chunks: [String] = []
+        var wordBuffer: [Substring] = []
+
+        for word in split(whereSeparator: \.isWhitespace) {
+            wordBuffer.append(word)
+            if wordBuffer.count >= maxWords {
+                chunks.append(wordBuffer.joined(separator: " "))
+                wordBuffer = []
+            }
+        }
+
+        if !wordBuffer.isEmpty {
+            chunks.append(wordBuffer.joined(separator: " "))
+        }
+
+        return chunks.isEmpty ? [self] : chunks
+    }
+}

+ 21 - 0
gramora/Utilities/String+GrammarCorrections.swift

@@ -0,0 +1,21 @@
+import Foundation
+
+extension String {
+    func applyingGrammarCorrections(_ issues: [GrammarIssue]) -> String {
+        guard !issues.isEmpty else { return self }
+
+        var result = self
+        var replacements: [(Range<String.Index>, String)] = []
+
+        for issue in issues {
+            guard let range = result.range(of: issue.original) else { continue }
+            replacements.append((range, issue.suggestion))
+        }
+
+        for (range, suggestion) in replacements.sorted(by: { $0.0.lowerBound > $1.0.lowerBound }) {
+            result.replaceSubrange(range, with: suggestion)
+        }
+
+        return result
+    }
+}

+ 21 - 0
gramora/Utilities/String+WordCount.swift

@@ -1,5 +1,26 @@
 import Foundation
 
+enum WordLimit {
+    static func message(maxWords: Int) -> String {
+        "Maximum \(maxWords) words allowed."
+    }
+
+    static func apply(_ text: String, maxWords: Int) -> (text: String, exceeded: Bool) {
+        guard text.wordCount > maxWords else { return (text, false) }
+        return (text.truncated(toMaxWords: maxWords), true)
+    }
+
+    static func applyImported(
+        _ text: String,
+        maxWords: Int,
+        setError: (String?) -> Void
+    ) -> String {
+        let (limited, exceeded) = apply(text, maxWords: maxWords)
+        setError(exceeded ? message(maxWords: maxWords) : nil)
+        return limited
+    }
+}
+
 extension String {
     var wordCount: Int {
         let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)

+ 19 - 3
gramora/ViewModels/EmailWriterViewModel.swift

@@ -4,7 +4,7 @@ import Foundation
 
 @MainActor
 final class EmailWriterViewModel: ObservableObject {
-    static let maxWords = 5_000
+    static let maxWords = 1_000
 
     @Published var subject: String = ""
     @Published var prompt: String = ""
@@ -39,9 +39,25 @@ final class EmailWriterViewModel: ObservableObject {
 
     func pastePrompt() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
-        prompt = clipboard.truncated(toMaxWords: Self.maxWords)
+        prompt = WordLimit.applyImported(clipboard, maxWords: Self.maxWords, setError: { errorMessage = $0 })
         generatedEmail = ""
-        errorMessage = nil
+    }
+
+    func notifyWordLimitReached() {
+        errorMessage = WordLimit.message(maxWords: Self.maxWords)
+    }
+
+    func handlePromptChange() {
+        let (limited, exceeded) = WordLimit.apply(prompt, maxWords: Self.maxWords)
+        if exceeded {
+            prompt = limited
+            errorMessage = WordLimit.message(maxWords: Self.maxWords)
+        } else if errorMessage == WordLimit.message(maxWords: Self.maxWords) {
+            errorMessage = nil
+        }
+        if !generatedEmail.isEmpty {
+            generatedEmail = ""
+        }
     }
 
     func clearAll() {

+ 20 - 3
gramora/ViewModels/GrammarCheckerViewModel.swift

@@ -4,7 +4,7 @@ import Foundation
 
 @MainActor
 final class GrammarCheckerViewModel: ObservableObject {
-    static let maxWords = 10_000
+    static let maxWords = 1_000
 
     @Published var text: String = ""
     @Published private(set) var issues: [GrammarIssue] = []
@@ -42,7 +42,7 @@ final class GrammarCheckerViewModel: ObservableObject {
 
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
-        text = clipboard.truncated(toMaxWords: Self.maxWords)
+        text = WordLimit.applyImported(clipboard, maxWords: Self.maxWords, setError: { errorMessage = $0 })
         resetResults()
     }
 
@@ -50,7 +50,7 @@ final class GrammarCheckerViewModel: ObservableObject {
         Task {
             do {
                 guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
-                text = extracted.truncated(toMaxWords: Self.maxWords)
+                text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 resetResults()
             } catch {
                 errorMessage = error.localizedDescription
@@ -58,6 +58,23 @@ final class GrammarCheckerViewModel: ObservableObject {
         }
     }
 
+    func notifyWordLimitReached() {
+        errorMessage = WordLimit.message(maxWords: Self.maxWords)
+    }
+
+    func handleTextChange() {
+        let (limited, exceeded) = WordLimit.apply(text, maxWords: Self.maxWords)
+        if exceeded {
+            text = limited
+            errorMessage = WordLimit.message(maxWords: Self.maxWords)
+        } else if errorMessage == WordLimit.message(maxWords: Self.maxWords) {
+            errorMessage = nil
+        }
+        if hasResults {
+            resetResults()
+        }
+    }
+
     func clearText() {
         text = ""
         resetResults()

+ 19 - 2
gramora/ViewModels/JournalFinderViewModel.swift

@@ -5,7 +5,7 @@ import Foundation
 @MainActor
 final class JournalFinderViewModel: ObservableObject {
     static let maxTitleLength = 300
-    static let maxAbstractWords = 10_000
+    static let maxAbstractWords = 1_000
     static let maxKeywordsLength = 500
 
     @Published var title: String = ""
@@ -54,10 +54,27 @@ final class JournalFinderViewModel: ObservableObject {
 
     func pasteAbstract() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
-        abstractText = clipboard.truncated(toMaxWords: Self.maxAbstractWords)
+        abstractText = WordLimit.applyImported(clipboard, maxWords: Self.maxAbstractWords, setError: { errorMessage = $0 })
         resetResults()
     }
 
+    func notifyAbstractWordLimitReached() {
+        errorMessage = WordLimit.message(maxWords: Self.maxAbstractWords)
+    }
+
+    func handleAbstractTextChange() {
+        let (limited, exceeded) = WordLimit.apply(abstractText, maxWords: Self.maxAbstractWords)
+        if exceeded {
+            abstractText = limited
+            errorMessage = WordLimit.message(maxWords: Self.maxAbstractWords)
+        } else if errorMessage == WordLimit.message(maxWords: Self.maxAbstractWords) {
+            errorMessage = nil
+        }
+        if hasResults {
+            resetResults()
+        }
+    }
+
     func clearAll() {
         title = ""
         abstractText = ""

+ 20 - 5
gramora/ViewModels/LanguageTranslatorViewModel.swift

@@ -4,7 +4,7 @@ import Foundation
 
 @MainActor
 final class LanguageTranslatorViewModel: ObservableObject {
-    static let maxWords = 10_000
+    static let maxWords = 1_000
 
     @Published var sourceText: String = ""
     @Published var translatedText: String = ""
@@ -45,24 +45,39 @@ final class LanguageTranslatorViewModel: ObservableObject {
 
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
-        sourceText = clipboard.truncated(toMaxWords: Self.maxWords)
+        sourceText = WordLimit.applyImported(clipboard, maxWords: Self.maxWords, setError: { errorMessage = $0 })
         translatedText = ""
-        errorMessage = nil
     }
 
     func uploadFile() {
         Task {
             do {
                 guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
-                sourceText = extracted.truncated(toMaxWords: Self.maxWords)
+                sourceText = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 translatedText = ""
-                errorMessage = nil
             } catch {
                 errorMessage = error.localizedDescription
             }
         }
     }
 
+    func notifyWordLimitReached() {
+        errorMessage = WordLimit.message(maxWords: Self.maxWords)
+    }
+
+    func handleSourceTextChange() {
+        let (limited, exceeded) = WordLimit.apply(sourceText, maxWords: Self.maxWords)
+        if exceeded {
+            sourceText = limited
+            errorMessage = WordLimit.message(maxWords: Self.maxWords)
+        } else if errorMessage == WordLimit.message(maxWords: Self.maxWords) {
+            errorMessage = nil
+        }
+        if !translatedText.isEmpty {
+            translatedText = ""
+        }
+    }
+
     func clearText() {
         sourceText = ""
         translatedText = ""

+ 20 - 5
gramora/ViewModels/ParaphrasingViewModel.swift

@@ -4,7 +4,7 @@ import Foundation
 
 @MainActor
 final class ParaphrasingViewModel: ObservableObject {
-    static let maxWords = 10_000
+    static let maxWords = 1_000
 
     @Published var text: String = ""
     @Published private(set) var paraphrasedText: String = ""
@@ -32,24 +32,39 @@ final class ParaphrasingViewModel: ObservableObject {
 
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
-        text = clipboard.truncated(toMaxWords: Self.maxWords)
+        text = WordLimit.applyImported(clipboard, maxWords: Self.maxWords, setError: { errorMessage = $0 })
         paraphrasedText = ""
-        errorMessage = nil
     }
 
     func uploadFile() {
         Task {
             do {
                 guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
-                text = extracted.truncated(toMaxWords: Self.maxWords)
+                text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 paraphrasedText = ""
-                errorMessage = nil
             } catch {
                 errorMessage = error.localizedDescription
             }
         }
     }
 
+    func notifyWordLimitReached() {
+        errorMessage = WordLimit.message(maxWords: Self.maxWords)
+    }
+
+    func handleTextChange() {
+        let (limited, exceeded) = WordLimit.apply(text, maxWords: Self.maxWords)
+        if exceeded {
+            text = limited
+            errorMessage = WordLimit.message(maxWords: Self.maxWords)
+        } else if errorMessage == WordLimit.message(maxWords: Self.maxWords) {
+            errorMessage = nil
+        }
+        if !paraphrasedText.isEmpty {
+            paraphrasedText = ""
+        }
+    }
+
     func clearText() {
         text = ""
         paraphrasedText = ""

+ 20 - 3
gramora/ViewModels/PunctuationCheckerViewModel.swift

@@ -4,7 +4,7 @@ import Foundation
 
 @MainActor
 final class PunctuationCheckerViewModel: ObservableObject {
-    static let maxWords = 10_000
+    static let maxWords = 1_000
 
     @Published var text: String = ""
     @Published private(set) var correctedText: String = ""
@@ -34,7 +34,7 @@ final class PunctuationCheckerViewModel: ObservableObject {
 
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
-        text = clipboard.truncated(toMaxWords: Self.maxWords)
+        text = WordLimit.applyImported(clipboard, maxWords: Self.maxWords, setError: { errorMessage = $0 })
         resetResults()
     }
 
@@ -42,7 +42,7 @@ final class PunctuationCheckerViewModel: ObservableObject {
         Task {
             do {
                 guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
-                text = extracted.truncated(toMaxWords: Self.maxWords)
+                text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 resetResults()
             } catch {
                 errorMessage = error.localizedDescription
@@ -50,6 +50,23 @@ final class PunctuationCheckerViewModel: ObservableObject {
         }
     }
 
+    func notifyWordLimitReached() {
+        errorMessage = WordLimit.message(maxWords: Self.maxWords)
+    }
+
+    func handleTextChange() {
+        let (limited, exceeded) = WordLimit.apply(text, maxWords: Self.maxWords)
+        if exceeded {
+            text = limited
+            errorMessage = WordLimit.message(maxWords: Self.maxWords)
+        } else if errorMessage == WordLimit.message(maxWords: Self.maxWords) {
+            errorMessage = nil
+        }
+        if hasResults {
+            resetResults()
+        }
+    }
+
     func clearText() {
         text = ""
         resetResults()

+ 20 - 3
gramora/ViewModels/SpellCheckerViewModel.swift

@@ -4,7 +4,7 @@ import Foundation
 
 @MainActor
 final class SpellCheckerViewModel: ObservableObject {
-    static let maxWords = 10_000
+    static let maxWords = 1_000
 
     @Published var text: String = ""
     @Published private(set) var mistakes: [SpellingMistake] = []
@@ -42,7 +42,7 @@ final class SpellCheckerViewModel: ObservableObject {
 
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
-        text = clipboard.truncated(toMaxWords: Self.maxWords)
+        text = WordLimit.applyImported(clipboard, maxWords: Self.maxWords, setError: { errorMessage = $0 })
         resetResults()
     }
 
@@ -50,7 +50,7 @@ final class SpellCheckerViewModel: ObservableObject {
         Task {
             do {
                 guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
-                text = extracted.truncated(toMaxWords: Self.maxWords)
+                text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 resetResults()
             } catch {
                 errorMessage = error.localizedDescription
@@ -58,6 +58,23 @@ final class SpellCheckerViewModel: ObservableObject {
         }
     }
 
+    func notifyWordLimitReached() {
+        errorMessage = WordLimit.message(maxWords: Self.maxWords)
+    }
+
+    func handleTextChange() {
+        let (limited, exceeded) = WordLimit.apply(text, maxWords: Self.maxWords)
+        if exceeded {
+            text = limited
+            errorMessage = WordLimit.message(maxWords: Self.maxWords)
+        } else if errorMessage == WordLimit.message(maxWords: Self.maxWords) {
+            errorMessage = nil
+        }
+        if hasResults {
+            resetResults()
+        }
+    }
+
     func clearText() {
         text = ""
         resetResults()

+ 20 - 5
gramora/ViewModels/SummarizerViewModel.swift

@@ -4,7 +4,7 @@ import Foundation
 
 @MainActor
 final class SummarizerViewModel: ObservableObject {
-    static let maxWords = 10_000
+    static let maxWords = 1_000
 
     @Published var text: String = ""
     @Published private(set) var summary: String = ""
@@ -32,24 +32,39 @@ final class SummarizerViewModel: ObservableObject {
 
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
-        text = clipboard.truncated(toMaxWords: Self.maxWords)
+        text = WordLimit.applyImported(clipboard, maxWords: Self.maxWords, setError: { errorMessage = $0 })
         summary = ""
-        errorMessage = nil
     }
 
     func uploadFile() {
         Task {
             do {
                 guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
-                text = extracted.truncated(toMaxWords: Self.maxWords)
+                text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 summary = ""
-                errorMessage = nil
             } catch {
                 errorMessage = error.localizedDescription
             }
         }
     }
 
+    func notifyWordLimitReached() {
+        errorMessage = WordLimit.message(maxWords: Self.maxWords)
+    }
+
+    func handleTextChange() {
+        let (limited, exceeded) = WordLimit.apply(text, maxWords: Self.maxWords)
+        if exceeded {
+            text = limited
+            errorMessage = WordLimit.message(maxWords: Self.maxWords)
+        } else if errorMessage == WordLimit.message(maxWords: Self.maxWords) {
+            errorMessage = nil
+        }
+        if !summary.isEmpty {
+            summary = ""
+        }
+    }
+
     func clearText() {
         text = ""
         summary = ""

+ 17 - 4
gramora/ViewModels/WordsCountViewModel.swift

@@ -4,7 +4,7 @@ import Foundation
 
 @MainActor
 final class WordsCountViewModel: ObservableObject {
-    static let maxWords = 50_000
+    static let maxWords = 1_000
 
     @Published var text: String = ""
     @Published private(set) var errorMessage: String?
@@ -48,21 +48,34 @@ final class WordsCountViewModel: ObservableObject {
 
     func pasteText() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
-        text = clipboard.truncated(toMaxWords: Self.maxWords)
+        text = WordLimit.applyImported(clipboard, maxWords: Self.maxWords, setError: { errorMessage = $0 })
     }
 
     func uploadFile() {
         Task {
             do {
                 guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
-                text = extracted.truncated(toMaxWords: Self.maxWords)
-                errorMessage = nil
+                text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
             } catch {
                 errorMessage = error.localizedDescription
             }
         }
     }
 
+    func notifyWordLimitReached() {
+        errorMessage = WordLimit.message(maxWords: Self.maxWords)
+    }
+
+    func handleTextChange() {
+        let (limited, exceeded) = WordLimit.apply(text, maxWords: Self.maxWords)
+        if exceeded {
+            text = limited
+            errorMessage = WordLimit.message(maxWords: Self.maxWords)
+        } else if errorMessage == WordLimit.message(maxWords: Self.maxWords) {
+            errorMessage = nil
+        }
+    }
+
     func clearText() {
         text = ""
         errorMessage = nil

+ 20 - 0
gramora/Views/Components/ThinCaretTextEditor.swift

@@ -73,6 +73,8 @@ struct ThinCaretTextEditor: NSViewRepresentable {
     @Binding var text: String
     var placeholder: String = ""
     var isEditable: Bool = true
+    var maxWords: Int?
+    var onWordLimitReached: (() -> Void)?
     var onSubmit: (() -> Void)?
 
     func makeCoordinator() -> Coordinator {
@@ -152,6 +154,24 @@ struct ThinCaretTextEditor: NSViewRepresentable {
             textView.needsDisplay = true
         }
 
+        func textView(
+            _ textView: NSTextView,
+            shouldChangeTextIn affectedCharRange: NSRange,
+            replacementString: String?
+        ) -> Bool {
+            guard let maxWords = parent.maxWords else { return true }
+
+            let current = textView.string as NSString
+            let replacement = replacementString ?? ""
+            let newString = current.replacingCharacters(in: affectedCharRange, with: replacement)
+
+            guard newString.wordCount > maxWords, !replacement.isEmpty else { return true }
+
+            NSSound.beep()
+            parent.onWordLimitReached?()
+            return false
+        }
+
         func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
             guard commandSelector == #selector(NSResponder.insertNewline(_:)),
                   let event = textView.window?.currentEvent,

+ 4 - 4
gramora/Views/EmailWriterView.swift

@@ -43,10 +43,8 @@ struct EmailWriterView: View {
             .padding(.top, AppTheme.brandLabelTopInset)
             .padding(.bottom, dynamicBottomPadding)
         }
-        .onChange(of: viewModel.prompt) { newValue in
-            if newValue.wordCount > EmailWriterViewModel.maxWords {
-                viewModel.prompt = newValue.truncated(toMaxWords: EmailWriterViewModel.maxWords)
-            }
+        .onChange(of: viewModel.prompt) { _ in
+            viewModel.handlePromptChange()
         }
         .onAppear(perform: restoreIfNeeded)
     }
@@ -176,6 +174,8 @@ struct EmailWriterView: View {
                 ThinCaretTextEditor(
                     text: $viewModel.prompt,
                     placeholder: "Describe the email you need — key points, recipient context, or a rough draft...",
+                    maxWords: EmailWriterViewModel.maxWords,
+                    onWordLimitReached: viewModel.notifyWordLimitReached,
                     onSubmit: viewModel.generateEmail
                 )
                     .font(.system(size: 14))

+ 5 - 8
gramora/Views/GrammarCheckerView.swift

@@ -61,13 +61,8 @@ struct GrammarCheckerView: View {
                 }
             }
         }
-        .onChange(of: viewModel.text) { newValue in
-            if newValue.wordCount > GrammarCheckerViewModel.maxWords {
-                viewModel.text = newValue.truncated(toMaxWords: GrammarCheckerViewModel.maxWords)
-            }
-            if viewModel.hasResults {
-                viewModel.resetResults()
-            }
+        .onChange(of: viewModel.text) { _ in
+            viewModel.handleTextChange()
         }
         .onAppear(perform: restoreIfNeeded)
     }
@@ -127,7 +122,9 @@ struct GrammarCheckerView: View {
             VStack(alignment: .leading, spacing: 6) {
                 ThinCaretTextEditor(
                     text: $viewModel.text,
-                    placeholder: "Type or paste your text here and press the check button..."
+                    placeholder: "Type or paste your text here and press the check button...",
+                    maxWords: GrammarCheckerViewModel.maxWords,
+                    onWordLimitReached: viewModel.notifyWordLimitReached
                 )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)

+ 5 - 6
gramora/Views/JournalFinderView.swift

@@ -42,11 +42,8 @@ struct JournalFinderView: View {
             }
             if viewModel.hasResults { viewModel.resetResults() }
         }
-        .onChange(of: viewModel.abstractText) { newValue in
-            if newValue.wordCount > JournalFinderViewModel.maxAbstractWords {
-                viewModel.abstractText = newValue.truncated(toMaxWords: JournalFinderViewModel.maxAbstractWords)
-            }
-            if viewModel.hasResults { viewModel.resetResults() }
+        .onChange(of: viewModel.abstractText) { _ in
+            viewModel.handleAbstractTextChange()
         }
         .onChange(of: viewModel.keywords) { newValue in
             if newValue.count > JournalFinderViewModel.maxKeywordsLength {
@@ -252,7 +249,9 @@ struct JournalFinderView: View {
             ZStack(alignment: .topLeading) {
                 ThinCaretTextEditor(
                     text: $viewModel.abstractText,
-                    placeholder: "Paste your abstract, introduction, or manuscript summary here..."
+                    placeholder: "Paste your abstract, introduction, or manuscript summary here...",
+                    maxWords: JournalFinderViewModel.maxAbstractWords,
+                    onWordLimitReached: viewModel.notifyAbstractWordLimitReached
                 )
                     .frame(maxWidth: .infinity, minHeight: minHeight)
             }

+ 4 - 4
gramora/Views/LanguageTranslatorView.swift

@@ -29,10 +29,8 @@ struct LanguageTranslatorView: View {
             .padding(.top, AppTheme.brandLabelTopInset)
             .padding(.bottom, dynamicBottomPadding)
         }
-        .onChange(of: viewModel.sourceText) { newValue in
-            if newValue.wordCount > LanguageTranslatorViewModel.maxWords {
-                viewModel.sourceText = newValue.truncated(toMaxWords: LanguageTranslatorViewModel.maxWords)
-            }
+        .onChange(of: viewModel.sourceText) { _ in
+            viewModel.handleSourceTextChange()
         }
         .onChange(of: viewModel.sourceLanguage) { _ in
             viewModel.syncTargetLanguageIfNeeded()
@@ -146,6 +144,8 @@ struct LanguageTranslatorView: View {
                 ThinCaretTextEditor(
                     text: $viewModel.sourceText,
                     placeholder: "Start writing...",
+                    maxWords: LanguageTranslatorViewModel.maxWords,
+                    onWordLimitReached: viewModel.notifyWordLimitReached,
                     onSubmit: viewModel.translate
                 )
                     .font(.system(size: 14))

+ 5 - 5
gramora/Views/ParaphrasingView.swift

@@ -68,10 +68,8 @@ struct ParaphrasingView: View {
                 }
             }
         }
-        .onChange(of: viewModel.text) { newValue in
-            if newValue.wordCount > ParaphrasingViewModel.maxWords {
-                viewModel.text = newValue.truncated(toMaxWords: ParaphrasingViewModel.maxWords)
-            }
+        .onChange(of: viewModel.text) { _ in
+            viewModel.handleTextChange()
         }
     }
 
@@ -115,7 +113,9 @@ struct ParaphrasingView: View {
             VStack(alignment: .leading, spacing: 6) {
                 ThinCaretTextEditor(
                     text: $viewModel.text,
-                    placeholder: "Type or paste your text here and press the paraphrase button..."
+                    placeholder: "Type or paste your text here and press the paraphrase button...",
+                    maxWords: ParaphrasingViewModel.maxWords,
+                    onWordLimitReached: viewModel.notifyWordLimitReached
                 )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)

+ 5 - 5
gramora/Views/PunctuationCheckerView.swift

@@ -68,10 +68,8 @@ struct PunctuationCheckerView: View {
                 }
             }
         }
-        .onChange(of: viewModel.text) { newValue in
-            if newValue.wordCount > PunctuationCheckerViewModel.maxWords {
-                viewModel.text = newValue.truncated(toMaxWords: PunctuationCheckerViewModel.maxWords)
-            }
+        .onChange(of: viewModel.text) { _ in
+            viewModel.handleTextChange()
         }
     }
 
@@ -115,7 +113,9 @@ struct PunctuationCheckerView: View {
             VStack(alignment: .leading, spacing: 6) {
                 ThinCaretTextEditor(
                     text: $viewModel.text,
-                    placeholder: "Type or paste your text here and press the check button..."
+                    placeholder: "Type or paste your text here and press the check button...",
+                    maxWords: PunctuationCheckerViewModel.maxWords,
+                    onWordLimitReached: viewModel.notifyWordLimitReached
                 )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)

+ 5 - 8
gramora/Views/SpellCheckerView.swift

@@ -61,13 +61,8 @@ struct SpellCheckerView: View {
                 }
             }
         }
-        .onChange(of: viewModel.text) { newValue in
-            if newValue.wordCount > SpellCheckerViewModel.maxWords {
-                viewModel.text = newValue.truncated(toMaxWords: SpellCheckerViewModel.maxWords)
-            }
-            if viewModel.hasResults {
-                viewModel.resetResults()
-            }
+        .onChange(of: viewModel.text) { _ in
+            viewModel.handleTextChange()
         }
         .onAppear(perform: restoreIfNeeded)
     }
@@ -127,7 +122,9 @@ struct SpellCheckerView: View {
             VStack(alignment: .leading, spacing: 6) {
                 ThinCaretTextEditor(
                     text: $viewModel.text,
-                    placeholder: "Type or paste your text here and press the check button..."
+                    placeholder: "Type or paste your text here and press the check button...",
+                    maxWords: SpellCheckerViewModel.maxWords,
+                    onWordLimitReached: viewModel.notifyWordLimitReached
                 )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)

+ 5 - 5
gramora/Views/SummarizerView.swift

@@ -68,10 +68,8 @@ struct SummarizerView: View {
                 }
             }
         }
-        .onChange(of: viewModel.text) { newValue in
-            if newValue.wordCount > SummarizerViewModel.maxWords {
-                viewModel.text = newValue.truncated(toMaxWords: SummarizerViewModel.maxWords)
-            }
+        .onChange(of: viewModel.text) { _ in
+            viewModel.handleTextChange()
         }
     }
 
@@ -115,7 +113,9 @@ struct SummarizerView: View {
             VStack(alignment: .leading, spacing: 6) {
                 ThinCaretTextEditor(
                     text: $viewModel.text,
-                    placeholder: "Type or paste your text here and press the summarize button..."
+                    placeholder: "Type or paste your text here and press the summarize button...",
+                    maxWords: SummarizerViewModel.maxWords,
+                    onWordLimitReached: viewModel.notifyWordLimitReached
                 )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)

+ 5 - 5
gramora/Views/WordsCountView.swift

@@ -28,10 +28,8 @@ struct WordsCountView: View {
             .padding(.top, AppTheme.brandLabelTopInset)
             .padding(.bottom, dynamicBottomPadding)
         }
-        .onChange(of: viewModel.text) { newValue in
-            if newValue.wordCount > WordsCountViewModel.maxWords {
-                viewModel.text = newValue.truncated(toMaxWords: WordsCountViewModel.maxWords)
-            }
+        .onChange(of: viewModel.text) { _ in
+            viewModel.handleTextChange()
         }
     }
 
@@ -81,7 +79,9 @@ struct WordsCountView: View {
             VStack(alignment: .leading, spacing: 6) {
                 ThinCaretTextEditor(
                     text: $viewModel.text,
-                    placeholder: "Type or paste your text here to count words..."
+                    placeholder: "Type or paste your text here to count words...",
+                    maxWords: WordsCountViewModel.maxWords,
+                    onWordLimitReached: viewModel.notifyWordLimitReached
                 )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)