Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions Modules/Sources/WordPressShared/Utility/LanguageModelHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation

#if canImport(FoundationModels)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The canImport checks can be removed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude added it for some reason and I kept it – removed.

import FoundationModels
#endif

public enum LanguageModelHelper {
public static var isSupported: Bool {
guard #available(iOS 26, *) else { return false }
switch SystemLanguageModel.default.availability {
case .available:
return true
case .unavailable(let reason):
switch reason {
case .appleIntelligenceNotEnabled, .modelNotReady:
return true
case .deviceNotEligible:
return false
@unknown default:
return false
}
}
}

public static func makeGenerateExcerptPrompt(
content: String,
length: GeneratedContentLength,
style: GenerationStyle
) -> String {
"""
Task: Create exactly 3 excerpts for a blog post.

CRITICAL CONSTRAINTS:
• Each excerpt MUST be \(length.promptModifier) (\(length.wordRange) words)
• Style: \(style.promptDescription)

EXCERPT REQUIREMENTS:
* Follow the best practices for post excerpts esteblished in the WordPress ecosystem
• First sentence: Strong hook that creates curiosity
• Include the post's main value proposition
• Use active voice (avoid "is", "are", "was", "were" when possible)
• End with implicit promise of more information
• Do not use ellipsis (...) at the end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated result is English when the content is non-English. Adding a requirement here saying that the excerpt should be in the same language as the source content?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't going to test different locales but ran out of time.

If the post content is not supported, it will show this:

Screenshot 2025-09-18 at 8 20 34 AM

I can't get it to produce the results in anything other than English. I opened a ticket –https://linear.app/a8c/issue/CMM-762/excerpts-are-created-in-the-system-language-not-the-content-language. Help is welcome.


VARIATION GUIDELINES:
Excerpt 1: Open with a question that addresses reader's problem
Excerpt 2: Start with a bold statement or surprising fact
Excerpt 3: Lead with the primary benefit or outcome

SOURCE CONTENT:
\(content)
Copy link
Contributor

@crazytonyli crazytonyli Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The content is in HTML format. That does not seem to bother the LLM, because the result is pretty on point. But I wonder if we should still explicitly mention that the source content is in HTML format in the prompt.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially wanted to trim HTML, but I wasn't sure it'll work well with Gutenberg blocks. Ideally, we want a rendered version of the post, but we don't have that. Based on my testing, it just figures it out, so I think we are good.

"""
}
}

public enum GenerationStyle: String, CaseIterable, RawRepresentable {
case engaging
case conversational
case witty
case formal
case professional

public var displayName: String {
switch self {
case .engaging:
NSLocalizedString("generation.style.engaging", value: "Engaging", comment: "AI generation style")
case .conversational:
NSLocalizedString("generation.style.conversational", value: "Conversational", comment: "AI generation style")
case .witty:
NSLocalizedString("generation.style.witty", value: "Witty", comment: "AI generation style")
case .formal:
NSLocalizedString("generation.style.formal", value: "Formal", comment: "AI generation style")
case .professional:
NSLocalizedString("generation.style.professional", value: "Professional", comment: "AI generation style")
}
}

public var promptDescription: String {
switch self {
case .engaging: "engaging and compelling tone"
case .witty: "witty, creative, entertaining"
case .conversational: "friendly and conversational tone"
case .formal: "formal and academic tone"
case .professional: "professional and polished tone"
}
}
}

public enum GeneratedContentLength: Int, CaseIterable, RawRepresentable {
case short
case medium
case long

public var displayName: String {
switch self {
case .short:
NSLocalizedString("generation.length.short", value: "Short", comment: "Generated content length (needs to be short)")
case .medium:
NSLocalizedString("generation.length.medium", value: "Medium", comment: "Generated content length (needs to be short)")
case .long:
NSLocalizedString("generation.length.long", value: "Long", comment: "Generated content length (needs to be short)")
}
}

public var trackingName: String {
switch self {
case .short: "short"
case .medium: "medium"
case .long: "long"
}
}

public var promptModifier: String {
switch self {
case .short: "short"
case .medium: "medium"
case .long: "long"
}
}

public var wordRange: String {
switch self {
case .short: "30-60"
case .medium: "60-90"
case .long: "120-150"
}
}
}
103 changes: 103 additions & 0 deletions Modules/Sources/WordPressUI/Views/LanguageModelUnavailableView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import SwiftUI

#if canImport(FoundationModels)
import FoundationModels
#endif

@available(iOS 26, *)
public struct LanguageModelUnavailableView: View {
public let reason: SystemLanguageModel.Availability.UnavailableReason

public var body: some View {
makeUnavailableView(for: reason)
}

public init(reason: SystemLanguageModel.Availability.UnavailableReason) {
self.reason = reason
}

@ViewBuilder
private func makeUnavailableView(for reason: SystemLanguageModel.Availability.UnavailableReason) -> some View {
switch reason {
case .appleIntelligenceNotEnabled:
EmptyStateView {
Label(Strings.appleIntelligenceDisabledTitle, systemImage: "apple.intelligence")
} description: {
Text(Strings.appleIntelligenceDisabledMessage)
} actions: {
if let settingURL = URL(string: UIApplication.openSettingsURLString) {
Button(Strings.openAppleIntelligenceSettings) {
UIApplication.shared.open(settingURL)
}
.buttonStyle(.borderedProminent)
.tint(AppColor.primary)
}
}
case .modelNotReady:
EmptyStateView {
Label(Strings.preparingModel, systemImage: "apple.intelligence")
} description: {
Text(Strings.preparingModelDescription)
} actions: {
EmptyView()
}
default:
EmptyStateView {
Label(Strings.appleIntelligenceUnavailableTitle, systemImage: "apple.intelligence")
} description: {
Text(Strings.appleIntelligenceUnavailableTitle)
} actions: {
EmptyView()
}
}
}
}

@available(iOS 26, *)
#Preview {
LanguageModelUnavailableView(reason: .appleIntelligenceNotEnabled)
}

private enum Strings {
static let appleIntelligenceDisabledTitle = NSLocalizedString(
"intelligence.unavailableView.appleIntelligenceDisabled.title",
value: "Apple Intelligence Required",
comment: "Title shown when Apple Intelligence is disabled"
)

static let appleIntelligenceDisabledMessage = NSLocalizedString(
"intelligence.unavailableView.appleIntelligenceDisabled.message",
value: "To generate excerpts with AI, please enable Apple Intelligence in Settings. This feature uses on-device processing to protect your privacy.",
comment: "Message shown when Apple Intelligence is disabled"
)

static let openAppleIntelligenceSettings = NSLocalizedString(
"intelligence.unavailableView.appleIntelligenceDisabled.openSettings",
value: "Open Settings",
comment: "Button to open Apple Intelligence settings"
)

static let preparingModel = NSLocalizedString(
"intelligence.unavailableView.preparingModel.title",
value: "Preparing model...",
comment: "Title shown when the AI model is not ready"
)

static let preparingModelDescription = NSLocalizedString(
"intelligence.unavailableView.preparingModel.description",
value: "The AI model is downloading or being prepared. Please try again in a moment.",
comment: "Description shown when the AI model is not ready"
)

static let appleIntelligenceUnavailableTitle = NSLocalizedString(
"intelligence.unavailableView.appleIntelligenceUnvailable.title",
value: "Apple Intelligence Unvailable",
comment: "Title shown when Apple Intelligence is unavailable"
)

static let appleIntelligenceUnavailableMessage = NSLocalizedString(
"intelligence.unavailableView.appleIntelligenceUnvailable.message",
value: "Apple Intelligence is not available on this device",
comment: "Message shown when Apple Intelligence is unavailable"
)
}
2 changes: 1 addition & 1 deletion RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
26.4
-----

* [*] [Intelligence] Add support for generating excerpts for posts [#24852]

26.3
-----
Expand Down
17 changes: 16 additions & 1 deletion WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import Foundation
import WordPressData
import WordPressShared

// WPiOS-only events
@objc public enum WPAnalyticsEvent: Int {

case createSheetShown
Expand Down Expand Up @@ -669,6 +668,12 @@ import WordPressShared
case jetpackConnectCompleted
case jetpackConnectStepRetried

// Intelligence
case intelligenceExcerptGeneratorOpened
case intelligenceExcerptSelected
case intelligenceExcerptOptionsGenerated
case intelligenceUnavailableViewShown

/// A String that represents the event
var value: String {
switch self {
Expand Down Expand Up @@ -1808,6 +1813,16 @@ import WordPressShared
return "jetpack_rest_connect_completed"
case .jetpackConnectStepRetried:
return "jetpack_rest_connect_step_retried"

// Intelligence
case .intelligenceExcerptGeneratorOpened:
return "intelligence_excerpt_generator_opened"
case .intelligenceExcerptSelected:
return "intelligence_excerpt_selected"
case .intelligenceExcerptOptionsGenerated:
return "intelligence_excerpt_options_generated"
case .intelligenceUnavailableViewShown:
return "intelligence_unavailable_view_shown"
} // END OF SWITCH
}

Expand Down
39 changes: 39 additions & 0 deletions WordPress/Classes/Utility/Button+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import SwiftUI

extension Button where Label == Text {
@ViewBuilder
public static func make(role: BackportButtonRole, _ action: @escaping () -> Void) -> some View {
if #available(iOS 26, *) {
SwiftUI.Button(role: ButtonRole(role), action: action)
} else {
SwiftUI.Button(role.title) {
action()
}
}
}
}

public enum BackportButtonRole {
case cancel
case close
case confirm

var title: String {
switch self {
case .cancel: SharedStrings.Button.cancel
case .close: SharedStrings.Button.close
case .confirm: SharedStrings.Button.done
}
}
}

@available(iOS 26, *)
private extension ButtonRole {
init(_ role: BackportButtonRole) {
switch role {
case .cancel: self = .cancel
case .close: self = .close
case .confirm: self = .confirm
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,11 @@ private struct PostSettingsView: View {
private var excerptSection: some View {
Section {
NavigationLink {
PostSettingsExcerptEditor(text: $viewModel.settings.excerpt)
.navigationTitle(Strings.excerptHeader)
PostSettingsExcerptEditor(
postContent: (viewModel.post.content ?? ""),
text: $viewModel.settings.excerpt
)
.navigationTitle(Strings.excerptHeader)
} label: {
PostSettingExcerptRow(text: viewModel.settings.excerpt)
}
Expand Down
Loading