TitleOptimizerViewModel.swift 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import AppKit
  2. import SwiftUI
  3. @MainActor
  4. @Observable
  5. final class TitleOptimizerViewModel {
  6. var draft = TitleDraft()
  7. var selectedTab: TitleOptimizerTab = .optimize
  8. var isOptimizing = false
  9. var errorMessage: String?
  10. var successMessage: String?
  11. var analysis: TitleAnalysis?
  12. var variants: [TitleVariant] = []
  13. var selectedVariantID: UUID?
  14. private var hasPremiumAccess = false
  15. private var hasEverPurchasedPremium = false
  16. private var optimizationService: any TitleOptimizationServiceProtocol
  17. private let injectedOptimizationService: (any TitleOptimizationServiceProtocol)?
  18. private let historyManager: AIHistoryManager
  19. private let freeUsageManager: AIFreeUsageManager
  20. var onPaywallRequired: (() -> Void)?
  21. private(set) var remainingFreeAIUses: Int
  22. init(
  23. hasPremiumAccess: Bool = false,
  24. optimizationService: (any TitleOptimizationServiceProtocol)? = nil,
  25. historyManager: AIHistoryManager = .shared,
  26. freeUsageManager: AIFreeUsageManager = .shared
  27. ) {
  28. self.hasPremiumAccess = hasPremiumAccess
  29. self.injectedOptimizationService = optimizationService
  30. self.historyManager = historyManager
  31. self.freeUsageManager = freeUsageManager
  32. self.remainingFreeAIUses = freeUsageManager.remainingUses
  33. self.hasEverPurchasedPremium = false
  34. self.optimizationService = optimizationService
  35. ?? TitleOptimizationServiceFactory.make(usesLiveAI: Self.liveAIEnabled(
  36. hasPremiumAccess: hasPremiumAccess,
  37. hasEverPurchasedPremium: false,
  38. freeUsageManager: freeUsageManager
  39. ))
  40. }
  41. func setSubscriptionState(hasPremiumAccess: Bool, hasEverPurchasedPremium: Bool) {
  42. self.hasPremiumAccess = hasPremiumAccess
  43. self.hasEverPurchasedPremium = hasEverPurchasedPremium
  44. refreshFreeUsageState()
  45. }
  46. func setPremiumAccess(_ hasPremiumAccess: Bool) {
  47. setSubscriptionState(
  48. hasPremiumAccess: hasPremiumAccess,
  49. hasEverPurchasedPremium: hasEverPurchasedPremium
  50. )
  51. }
  52. func refreshFreeUsageState() {
  53. remainingFreeAIUses = freeUsageManager.remainingUses
  54. refreshOptimizationService()
  55. }
  56. private func refreshOptimizationService() {
  57. if injectedOptimizationService == nil {
  58. optimizationService = TitleOptimizationServiceFactory.make(usesLiveAI: usesLiveAI)
  59. }
  60. }
  61. private static func liveAIEnabled(
  62. hasPremiumAccess: Bool,
  63. hasEverPurchasedPremium: Bool,
  64. freeUsageManager: AIFreeUsageManager
  65. ) -> Bool {
  66. AIConfiguration.usesLiveAI(
  67. hasPremiumAccess: hasPremiumAccess,
  68. canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
  69. hasEverPurchasedPremium: hasEverPurchasedPremium
  70. )
  71. )
  72. }
  73. var usesLiveAI: Bool {
  74. AIConfiguration.usesLiveAI(
  75. hasPremiumAccess: hasPremiumAccess,
  76. canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
  77. hasEverPurchasedPremium: hasEverPurchasedPremium
  78. )
  79. )
  80. }
  81. var generationEngineSubtitle: String {
  82. if usesLiveAI {
  83. if hasPremiumAccess {
  84. return "Craft viral Reddit titles with AI"
  85. }
  86. let remaining = remainingFreeAIUses
  87. let useLabel = remaining == 1 ? "use" : "uses"
  88. return "Craft viral Reddit titles with AI (\(remaining) free \(useLabel) left)"
  89. }
  90. if hasEverPurchasedPremium {
  91. return "Upgrade to Premium for unlimited AI generations"
  92. }
  93. return "Craft viral Reddit titles with AI templates"
  94. }
  95. var formattedSubreddit: String {
  96. let trimmed = draft.subreddit.trimmingCharacters(in: .whitespaces)
  97. guard !trimmed.isEmpty else { return "r/subreddit" }
  98. return trimmed.hasPrefix("r/") ? trimmed : "r/\(trimmed)"
  99. }
  100. var canOptimize: Bool {
  101. !isOptimizing && missingOptimizeFields.isEmpty
  102. }
  103. var missingOptimizeFields: [String] {
  104. var missing: [String] = []
  105. if draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty {
  106. missing.append("subreddit")
  107. }
  108. if draft.topic.trimmingCharacters(in: .whitespaces).isEmpty {
  109. missing.append("topic")
  110. }
  111. if draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty {
  112. missing.append("title")
  113. }
  114. return missing
  115. }
  116. var activeTitle: String {
  117. if let selectedID = selectedVariantID,
  118. let variant = variants.first(where: { $0.id == selectedID }) {
  119. return variant.title
  120. }
  121. return draft.originalTitle
  122. }
  123. var bestVariant: TitleVariant? {
  124. variants.max(by: { $0.score < $1.score })
  125. }
  126. var previewDraft: PostDraft {
  127. PostDraft(
  128. postType: draft.postType,
  129. subreddit: draft.subreddit,
  130. topic: draft.topic,
  131. tone: draft.tone,
  132. title: activeTitle,
  133. isNSFW: draft.isNSFW,
  134. isSpoiler: draft.isSpoiler,
  135. isOC: draft.isOC,
  136. flair: draft.flair
  137. )
  138. }
  139. func selectPostType(_ type: RedditPostType) {
  140. draft.postType = type
  141. clearMessages()
  142. }
  143. func selectVariant(_ variant: TitleVariant) {
  144. selectedVariantID = variant.id
  145. clearMessages()
  146. }
  147. func applyVariant(_ variant: TitleVariant) {
  148. draft.originalTitle = variant.title
  149. selectedVariantID = variant.id
  150. successMessage = "Title applied."
  151. errorMessage = nil
  152. }
  153. func optimizeTitles() async {
  154. let missing = missingOptimizeFields
  155. guard missing.isEmpty else {
  156. errorMessage = "Fill in \(missing.joined(separator: ", ")) before optimizing."
  157. successMessage = nil
  158. return
  159. }
  160. if requiresPaywallForGeneration() {
  161. onPaywallRequired?()
  162. return
  163. }
  164. isOptimizing = true
  165. errorMessage = nil
  166. successMessage = nil
  167. await Task.yield()
  168. defer { isOptimizing = false }
  169. let usedLiveAI = usesLiveAI
  170. do {
  171. let result = try await optimizationService.optimizeTitles(from: draft)
  172. analysis = result.analysis
  173. variants = result.variants
  174. selectedVariantID = result.variants.first?.id
  175. if usedLiveAI, !hasPremiumAccess, !hasEverPurchasedPremium {
  176. freeUsageManager.recordUse(for: .titleOptimizer)
  177. refreshFreeUsageState()
  178. }
  179. let engine = usedLiveAI ? "AI" : "local AI templates"
  180. successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
  181. let entry = AIHistoryEntry.fromTitleOptimizer(
  182. draft: draft,
  183. analysis: analysis,
  184. variants: variants,
  185. selectedVariantID: selectedVariantID
  186. )
  187. Task { @MainActor in
  188. historyManager.save(entry)
  189. }
  190. } catch {
  191. errorMessage = UserFacingError.message(for: error)
  192. }
  193. }
  194. func copyToClipboard() {
  195. let title = activeTitle.trimmingCharacters(in: .whitespaces)
  196. guard !title.isEmpty else { return }
  197. NSPasteboard.general.clearContents()
  198. NSPasteboard.general.setString(title, forType: .string)
  199. successMessage = "Copied to clipboard."
  200. errorMessage = nil
  201. }
  202. func resetDraft() {
  203. draft = TitleDraft()
  204. selectedTab = .optimize
  205. analysis = nil
  206. variants = []
  207. selectedVariantID = nil
  208. clearMessages()
  209. }
  210. func restore(from entry: AIHistoryEntry) {
  211. guard case .titleOptimizer(let storedDraft, let storedAnalysis, let storedVariants, let selectedID) = entry.payload else {
  212. return
  213. }
  214. draft = storedDraft.titleDraft
  215. analysis = storedAnalysis?.titleAnalysis
  216. variants = storedVariants.map(\.titleVariant)
  217. selectedVariantID = selectedID
  218. selectedTab = variants.isEmpty ? .optimize : .compare
  219. clearMessages()
  220. successMessage = "Restored from history."
  221. }
  222. func exportText() -> String {
  223. var lines: [String] = []
  224. lines.append("Subreddit: \(formattedSubreddit)")
  225. lines.append("Topic: \(draft.topic)")
  226. lines.append("Goal: \(draft.titleGoal.title)")
  227. lines.append("Tone: \(draft.tone.title)")
  228. lines.append("")
  229. lines.append("Original: \(draft.originalTitle)")
  230. if let analysis {
  231. lines.append("")
  232. lines.append("Analysis Score: \(analysis.overallScore)/100")
  233. lines.append(" Length: \(analysis.lengthScore)")
  234. lines.append(" Engagement: \(analysis.engagementScore)")
  235. lines.append(" Clarity: \(analysis.clarityScore)")
  236. }
  237. if !variants.isEmpty {
  238. lines.append("")
  239. lines.append("Suggestions:")
  240. for (index, variant) in variants.enumerated() {
  241. lines.append("\(index + 1). [\(variant.score)] \(variant.title)")
  242. }
  243. }
  244. return lines.joined(separator: "\n")
  245. }
  246. private func clearMessages() {
  247. errorMessage = nil
  248. successMessage = nil
  249. }
  250. private func requiresPaywallForGeneration() -> Bool {
  251. !hasPremiumAccess && (hasEverPurchasedPremium || remainingFreeAIUses == 0)
  252. }
  253. }