TitleOptimizerViewModel.swift 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  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 optimizationService: any TitleOptimizationServiceProtocol
  16. private let injectedOptimizationService: (any TitleOptimizationServiceProtocol)?
  17. private let historyManager: AIHistoryManager
  18. init(
  19. hasPremiumAccess: Bool = false,
  20. optimizationService: (any TitleOptimizationServiceProtocol)? = nil,
  21. historyManager: AIHistoryManager = .shared
  22. ) {
  23. self.hasPremiumAccess = hasPremiumAccess
  24. self.injectedOptimizationService = optimizationService
  25. self.historyManager = historyManager
  26. self.optimizationService = optimizationService
  27. ?? TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
  28. }
  29. func setPremiumAccess(_ hasPremiumAccess: Bool) {
  30. self.hasPremiumAccess = hasPremiumAccess
  31. if injectedOptimizationService == nil {
  32. optimizationService = TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
  33. }
  34. }
  35. var usesLiveAI: Bool {
  36. AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
  37. }
  38. var formattedSubreddit: String {
  39. let trimmed = draft.subreddit.trimmingCharacters(in: .whitespaces)
  40. guard !trimmed.isEmpty else { return "r/subreddit" }
  41. return trimmed.hasPrefix("r/") ? trimmed : "r/\(trimmed)"
  42. }
  43. var canOptimize: Bool {
  44. !isOptimizing && missingOptimizeFields.isEmpty
  45. }
  46. var missingOptimizeFields: [String] {
  47. var missing: [String] = []
  48. if draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty {
  49. missing.append("subreddit")
  50. }
  51. if draft.topic.trimmingCharacters(in: .whitespaces).isEmpty {
  52. missing.append("topic")
  53. }
  54. if draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty {
  55. missing.append("title")
  56. }
  57. return missing
  58. }
  59. var activeTitle: String {
  60. if let selectedID = selectedVariantID,
  61. let variant = variants.first(where: { $0.id == selectedID }) {
  62. return variant.title
  63. }
  64. return draft.originalTitle
  65. }
  66. var bestVariant: TitleVariant? {
  67. variants.max(by: { $0.score < $1.score })
  68. }
  69. var previewDraft: PostDraft {
  70. PostDraft(
  71. postType: draft.postType,
  72. subreddit: draft.subreddit,
  73. topic: draft.topic,
  74. tone: draft.tone,
  75. title: activeTitle,
  76. isNSFW: draft.isNSFW,
  77. isSpoiler: draft.isSpoiler,
  78. isOC: draft.isOC,
  79. flair: draft.flair
  80. )
  81. }
  82. func selectPostType(_ type: RedditPostType) {
  83. draft.postType = type
  84. clearMessages()
  85. }
  86. func selectVariant(_ variant: TitleVariant) {
  87. selectedVariantID = variant.id
  88. clearMessages()
  89. }
  90. func applyVariant(_ variant: TitleVariant) {
  91. draft.originalTitle = variant.title
  92. selectedVariantID = variant.id
  93. successMessage = "Title applied."
  94. errorMessage = nil
  95. }
  96. func optimizeTitles() async {
  97. let missing = missingOptimizeFields
  98. guard missing.isEmpty else {
  99. errorMessage = "Fill in \(missing.joined(separator: ", ")) before optimizing."
  100. successMessage = nil
  101. return
  102. }
  103. isOptimizing = true
  104. errorMessage = nil
  105. successMessage = nil
  106. await Task.yield()
  107. defer { isOptimizing = false }
  108. do {
  109. let result = try await optimizationService.optimizeTitles(from: draft)
  110. analysis = result.analysis
  111. variants = result.variants
  112. selectedVariantID = result.variants.first?.id
  113. let engine = usesLiveAI ? "AI" : "local AI templates"
  114. successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
  115. let entry = AIHistoryEntry.fromTitleOptimizer(
  116. draft: draft,
  117. analysis: analysis,
  118. variants: variants,
  119. selectedVariantID: selectedVariantID
  120. )
  121. Task { @MainActor in
  122. historyManager.save(entry)
  123. }
  124. } catch {
  125. errorMessage = UserFacingError.message(for: error)
  126. }
  127. }
  128. func copyToClipboard() {
  129. let title = activeTitle.trimmingCharacters(in: .whitespaces)
  130. guard !title.isEmpty else { return }
  131. NSPasteboard.general.clearContents()
  132. NSPasteboard.general.setString(title, forType: .string)
  133. successMessage = "Copied to clipboard."
  134. errorMessage = nil
  135. }
  136. func resetDraft() {
  137. draft = TitleDraft()
  138. selectedTab = .optimize
  139. analysis = nil
  140. variants = []
  141. selectedVariantID = nil
  142. clearMessages()
  143. }
  144. func restore(from entry: AIHistoryEntry) {
  145. guard case .titleOptimizer(let storedDraft, let storedAnalysis, let storedVariants, let selectedID) = entry.payload else {
  146. return
  147. }
  148. draft = storedDraft.titleDraft
  149. analysis = storedAnalysis?.titleAnalysis
  150. variants = storedVariants.map(\.titleVariant)
  151. selectedVariantID = selectedID
  152. selectedTab = variants.isEmpty ? .optimize : .compare
  153. clearMessages()
  154. successMessage = "Restored from history."
  155. }
  156. func exportText() -> String {
  157. var lines: [String] = []
  158. lines.append("Subreddit: \(formattedSubreddit)")
  159. lines.append("Topic: \(draft.topic)")
  160. lines.append("Goal: \(draft.titleGoal.title)")
  161. lines.append("Tone: \(draft.tone.title)")
  162. lines.append("")
  163. lines.append("Original: \(draft.originalTitle)")
  164. if let analysis {
  165. lines.append("")
  166. lines.append("Analysis Score: \(analysis.overallScore)/100")
  167. lines.append(" Length: \(analysis.lengthScore)")
  168. lines.append(" Engagement: \(analysis.engagementScore)")
  169. lines.append(" Clarity: \(analysis.clarityScore)")
  170. }
  171. if !variants.isEmpty {
  172. lines.append("")
  173. lines.append("Suggestions:")
  174. for (index, variant) in variants.enumerated() {
  175. lines.append("\(index + 1). [\(variant.score)] \(variant.title)")
  176. }
  177. }
  178. return lines.joined(separator: "\n")
  179. }
  180. private func clearMessages() {
  181. errorMessage = nil
  182. successMessage = nil
  183. }
  184. }