feat(ai): unify reply center and refresh keyboard AI features

- Merge invitation, task, blessing, clarification, and empathy actions
  into a single Reply flow, with three fixed, clearly labeled stance
  choices whenever user intent must not be guessed.
- Refine clipboard semantic routing with bilingual schedule,
  confirmation, and follow-up models, conservative language thresholds,
  and explicit-assignment guard for complaint-only text.
- Persist Apple account refresh state, harden session recovery, and
  surface durable account diagnostics across keyboard and app.
- Derive personal-style prompts through two-stage corpus evidence and
  apply real low-confidence ASR tendencies instead of neutral templates.
- Localize the new reply center, clipboard semantics, and personal-style
  surfaces in both English and Simplified Chinese.
This commit is contained in:
Rocky
2026-08-29 11:51:21 +08:00
parent f91a8f2456
commit 3c10d73d7f
30 changed files with 2090 additions and 447 deletions
@@ -13,6 +13,7 @@ struct AssistantKeyboardUITestHarness: View {
case pending case pending
case skillFailure case skillFailure
case skills case skills
case semanticBadge
case search case search
} }
@@ -55,6 +56,7 @@ struct AssistantKeyboardUITestHarness: View {
.background(backgroundColor.ignoresSafeArea()) .background(backgroundColor.ignoresSafeArea())
.onDisappear { .onDisappear {
AIKeyboardView.debugPreviewSkills = nil AIKeyboardView.debugPreviewSkills = nil
AIKeyboardView.debugPreviewSemanticBadgeKeys = nil
AIKeyboardView.debugSkipsLongPressCoach = false AIKeyboardView.debugSkipsLongPressCoach = false
AIKeyboardView.debugKeepsSkillTip = false AIKeyboardView.debugKeepsSkillTip = false
} }
@@ -74,6 +76,7 @@ struct AssistantKeyboardUITestHarness: View {
state.aiServiceAvailable = true state.aiServiceAvailable = true
state.micDisabled = false state.micDisabled = false
state.returnKeyRole = .send state.returnKeyRole = .send
AIKeyboardView.debugPreviewSemanticBadgeKeys = nil
let keyboardState = state let keyboardState = state
state.tapMic = { [weak keyboardState] in state.tapMic = { [weak keyboardState] in
@@ -190,6 +193,12 @@ struct AssistantKeyboardUITestHarness: View {
AIKeyboardView.debugPreviewSkills = previewSkills AIKeyboardView.debugPreviewSkills = previewSkills
state.undoAvailable = true state.undoAvailable = true
state.editAvailable = true state.editAvailable = true
case .semanticBadge:
AIKeyboardView.debugPreviewSkills = nil
AIKeyboardView.debugPreviewSemanticBadgeKeys = (
intent: "keyboard.semantic.intent.informationQuery",
domain: "keyboard.semantic.domain.weather"
)
case .search: case .search:
AIKeyboardView.debugPreviewSkills = nil AIKeyboardView.debugPreviewSkills = nil
state.returnKeyRole = .search state.returnKeyRole = .search
+188 -27
View File
@@ -13,6 +13,11 @@ typealias LearnedStyleGenerator = @MainActor @Sendable (
AppUILanguage AppUILanguage
) async throws -> PolishStylePack ) async throws -> PolishStylePack
private struct PolishStyleErrorAlert {
let title: String
let message: String
}
@MainActor @MainActor
struct PolishStylesView: View { struct PolishStylesView: View {
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@@ -26,11 +31,14 @@ struct PolishStylesView: View {
/// receives a concrete pack (avoids `isPresented` + nil race showing defaults). /// receives a concrete pack (avoids `isPresented` + nil race showing defaults).
@State private var editingPack: PolishStylePack? @State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack? @State private var viewingPack: PolishStylePack?
@State private var errorMessage: String? @State private var errorAlert: PolishStyleErrorAlert?
@State private var isGeneratingLearnedStyle = false @State private var isGeneratingLearnedStyle = false
@State private var learnedStyleGenerationTask: Task<Void, Never>?
@State private var learnedStyleGenerationID: UUID?
private let store = AppGroupStore() private let store = AppGroupStore()
private let learnedStyleGenerator: LearnedStyleGenerator private let learnedStyleGenerator: LearnedStyleGenerator
private let pullsCloudStylesOnAppear: Bool
private let columns = [ private let columns = [
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing), GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing),
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing) GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing)
@@ -38,16 +46,23 @@ struct PolishStylesView: View {
init( init(
initialEditingPack: PolishStylePack? = nil, initialEditingPack: PolishStylePack? = nil,
pullsCloudStylesOnAppear: Bool = true,
learnedStyleGenerator: @escaping LearnedStyleGenerator = { corpus, replyExamples, language in learnedStyleGenerator: @escaping LearnedStyleGenerator = { corpus, replyExamples, language in
try await PolishStyleLearningService(store: AppGroupStore()) try await PolishStyleLearningService(store: AppGroupStore())
.generateStyle( .generateStyle(
from: corpus, from: corpus,
replyExamples: replyExamples, replyExamples: replyExamples,
outputLanguage: language outputLanguage: language,
minimumEffectiveCharacterCount:
AppDistributionChannel.allowsInternalTools
? 0
: PolishStyleLearningCorpusBuilder
.requiredEffectiveCharacterCount
) )
} }
) { ) {
_editingPack = State(initialValue: initialEditingPack) _editingPack = State(initialValue: initialEditingPack)
self.pullsCloudStylesOnAppear = pullsCloudStylesOnAppear
self.learnedStyleGenerator = learnedStyleGenerator self.learnedStyleGenerator = learnedStyleGenerator
} }
@@ -92,7 +107,10 @@ struct PolishStylesView: View {
Image(systemName: "plus") Image(systemName: "plus")
} }
.tint(palette.textPrimary) .tint(palette.textPrimary)
.disabled(catalog.entries.count >= PolishStyleLimits.maximumUserPacks) .disabled(
catalog.entries.count >= PolishStyleLimits.maximumUserPacks
|| isGeneratingLearnedStyle
)
.accessibilityLabel(Text("polishStyles.add")) .accessibilityLabel(Text("polishStyles.add"))
} }
} }
@@ -109,18 +127,19 @@ struct PolishStylesView: View {
PolishStylePromptDetailSheet(pack: pack, language: config.uiLanguage) PolishStylePromptDetailSheet(pack: pack, language: config.uiLanguage)
} }
.alert( .alert(
Text("polishStyles.error.title"), Text(errorAlert?.title ?? ""),
isPresented: Binding( isPresented: Binding(
get: { errorMessage != nil }, get: { errorAlert != nil },
set: { if !$0 { errorMessage = nil } } set: { if !$0 { errorAlert = nil } }
) )
) { ) {
Button("common.done") { errorMessage = nil } Button("common.done") { errorAlert = nil }
} message: { } message: {
Text(errorMessage ?? "") Text(errorAlert?.message ?? "")
} }
.task { .task {
reload() reload()
guard pullsCloudStylesOnAppear else { return }
await PolishStyleCloudSync.shared.pullAndMergeIfEnabled() await PolishStyleCloudSync.shared.pullAndMergeIfEnabled()
reload() reload()
} }
@@ -130,12 +149,28 @@ struct PolishStylesView: View {
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in .onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
reload() reload()
} }
.onDisappear {
cancelLearnedStyleGeneration()
}
} }
private var styleLearningCorpus: PolishStyleLearningCorpus { private var styleLearningCorpus: PolishStyleLearningCorpus {
PolishStyleLearningCorpusBuilder.build(from: history.snapshot()) PolishStyleLearningCorpusBuilder.build(from: history.snapshot())
} }
/// Debug and TestFlight builds may exercise the complete generation
/// pipeline before enough personal corpus exists. App Store builds keep
/// the production 2,500-character gate.
private var bypassesStyleLearningCharacterGate: Bool {
AppDistributionChannel.allowsInternalTools
}
private func isEligibleForStyleGeneration(
_ corpus: PolishStyleLearningCorpus
) -> Bool {
bypassesStyleLearningCharacterGate || corpus.isReady
}
private var learnedStylePack: PolishStylePack? { private var learnedStylePack: PolishStylePack? {
catalog.entries catalog.entries
.filter { $0.learningMetadata != nil } .filter { $0.learningMetadata != nil }
@@ -152,7 +187,7 @@ struct PolishStylesView: View {
let corpus = styleLearningCorpus let corpus = styleLearningCorpus
let required = PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount let required = PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
let isActionAvailable = corpus.isReady && !reachedLimit let isActionAvailable = isEligibleForStyleGeneration(corpus) && !reachedLimit
let canGenerate = isActionAvailable && !isGeneratingLearnedStyle let canGenerate = isActionAvailable && !isGeneratingLearnedStyle
let completedCharacterCount = min(corpus.effectiveCharacterCount, required) let completedCharacterCount = min(corpus.effectiveCharacterCount, required)
let learnedFraction = required > 0 let learnedFraction = required > 0
@@ -205,7 +240,9 @@ struct PolishStylesView: View {
Spacer() Spacer()
Text( Text(
corpus.isReady bypassesStyleLearningCharacterGate && !corpus.isReady
? AppL10n.string("polishStyles.learn.testBuildReady")
: corpus.isReady
? AppL10n.string("polishStyles.learn.ready") ? AppL10n.string("polishStyles.learn.ready")
: AppL10n.format( : AppL10n.format(
"polishStyles.learn.remaining", "polishStyles.learn.remaining",
@@ -213,7 +250,11 @@ struct PolishStylesView: View {
) )
) )
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(corpus.isReady ? palette.accent : palette.textTertiary) .foregroundStyle(
isEligibleForStyleGeneration(corpus)
? palette.accent
: palette.textTertiary
)
} }
Button { Button {
@@ -266,7 +307,9 @@ struct PolishStylesView: View {
corpus: PolishStyleLearningCorpus corpus: PolishStyleLearningCorpus
) -> some View { ) -> some View {
let isSelected = pack.id == activeID let isSelected = pack.id == activeID
let canRegenerate = corpus.isReady && !isGeneratingLearnedStyle let canRegenerate = isEligibleForStyleGeneration(corpus)
&& !isGeneratingLearnedStyle
let hasInsufficientEvidence = Self.hasInsufficientEvidence(pack.learningMetadata)
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
let actionShape = RoundedRectangle( let actionShape = RoundedRectangle(
cornerRadius: Radius.medium, cornerRadius: Radius.medium,
@@ -303,9 +346,18 @@ struct PolishStylesView: View {
Text(pack.displayName(language: config.uiLanguage)) Text(pack.displayName(language: config.uiLanguage))
.font(TypeStyle.bodyEmph) .font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Text("polishStyles.learn.generated.description") Text(
hasInsufficientEvidence
? AppL10n.string("polishStyles.learn.lowConfidence")
: AppL10n.string("polishStyles.learn.generated.description")
)
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary) .foregroundStyle(
hasInsufficientEvidence
? palette.danger
: palette.textSecondary
)
.fixedSize(horizontal: false, vertical: true)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -319,6 +371,7 @@ struct PolishStylesView: View {
.background(palette.surfaceElevated, in: Circle()) .background(palette.surfaceElevated, in: Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(isGeneratingLearnedStyle)
.accessibilityLabel(Text("polishStyles.edit")) .accessibilityLabel(Text("polishStyles.edit"))
} }
@@ -340,6 +393,12 @@ struct PolishStylesView: View {
Int64(metadata.replyFinalEditCount) Int64(metadata.replyFinalEditCount)
) )
) )
Text(
AppL10n.format(
"polishStyles.learn.confidence",
Self.confidencePercentage(metadata.confidence)
)
)
} }
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
@@ -398,6 +457,8 @@ struct PolishStylesView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(!canRegenerate) .disabled(!canRegenerate)
.accessibilityIdentifier("polishStyles.learn.regenerate")
.accessibilityValue(Text(pack.id))
} }
} }
.padding(Spacing.lg) .padding(Spacing.lg)
@@ -486,6 +547,7 @@ struct PolishStylesView: View {
} }
.padding(Spacing.sm) .padding(Spacing.sm)
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(isGeneratingLearnedStyle)
.accessibilityLabel(Text("polishStyles.edit")) .accessibilityLabel(Text("polishStyles.edit"))
if isSelected { if isSelected {
@@ -515,10 +577,12 @@ struct PolishStylesView: View {
Button("polishStyles.duplicate") { Button("polishStyles.duplicate") {
duplicate(pack) duplicate(pack)
} }
.disabled(isGeneratingLearnedStyle)
if pack.kind == .user { if pack.kind == .user {
Button("common.delete", role: .destructive) { Button("common.delete", role: .destructive) {
delete(pack) delete(pack)
} }
.disabled(isGeneratingLearnedStyle)
} }
} }
} }
@@ -527,16 +591,24 @@ struct PolishStylesView: View {
from corpus: PolishStyleLearningCorpus, from corpus: PolishStyleLearningCorpus,
replacing existingPack: PolishStylePack? = nil replacing existingPack: PolishStylePack? = nil
) { ) {
guard corpus.isReady, !isGeneratingLearnedStyle else { return } guard isEligibleForStyleGeneration(corpus),
!isGeneratingLearnedStyle,
learnedStyleGenerationTask == nil else { return }
let generationID = UUID()
learnedStyleGenerationID = generationID
isGeneratingLearnedStyle = true isGeneratingLearnedStyle = true
Task { learnedStyleGenerationTask = Task { @MainActor in
defer { isGeneratingLearnedStyle = false } defer { finishLearnedStyleGeneration(id: generationID) }
do { do {
let generated = try await learnedStyleGenerator( let generated = try await learnedStyleGenerator(
corpus, corpus,
ClipboardReplyFeedbackStore.shared.learningExamples(), ClipboardReplyFeedbackStore.shared.learningExamples(),
config.uiLanguage config.uiLanguage
) )
try Task.checkCancellation()
guard learnedStyleGenerationID == generationID,
editingPack == nil,
viewingPack == nil else { return }
// Always let the user inspect and edit the learned prompt before // Always let the user inspect and edit the learned prompt before
// it is saved, synced, or made active. // it is saved, synced, or made active.
if let existingPack { if let existingPack {
@@ -554,11 +626,31 @@ struct PolishStylesView: View {
editingPack = generated editingPack = generated
} }
} catch { } catch {
errorMessage = localizedLearningError(error) guard learnedStyleGenerationID == generationID,
!Task.isCancelled,
!Self.isCancellation(error) else { return }
errorAlert = PolishStyleErrorAlert(
title: AppL10n.string("polishStyles.learn.error.title"),
message: localizedLearningError(error)
)
} }
} }
} }
private func cancelLearnedStyleGeneration() {
learnedStyleGenerationTask?.cancel()
learnedStyleGenerationTask = nil
learnedStyleGenerationID = nil
isGeneratingLearnedStyle = false
}
private func finishLearnedStyleGeneration(id: UUID) {
guard learnedStyleGenerationID == id else { return }
learnedStyleGenerationTask = nil
learnedStyleGenerationID = nil
isGeneratingLearnedStyle = false
}
private func descriptionKey(for pack: PolishStylePack) -> LocalizedStringKey { private func descriptionKey(for pack: PolishStylePack) -> LocalizedStringKey {
guard pack.kind == .builtin else { return "polishStyles.custom.description" } guard pack.kind == .builtin else { return "polishStyles.custom.description" }
switch pack.id { switch pack.id {
@@ -587,9 +679,11 @@ struct PolishStylesView: View {
} }
} }
private func save(_ pack: PolishStylePack) { private func save(_ pack: PolishStylePack) -> Bool {
var updatedCatalog = catalog
do { do {
try catalog.upsert(pack) try updatedCatalog.upsert(pack)
catalog = updatedCatalog
store.setPolishStyleCatalog(catalog) store.setPolishStyleCatalog(catalog)
store.setActivePolishStyleId(pack.id) store.setActivePolishStyleId(pack.id)
activeID = pack.id activeID = pack.id
@@ -597,14 +691,23 @@ struct PolishStylesView: View {
try? await PolishStyleCloudSync.shared.pushLocalIfEnabled(catalog) try? await PolishStyleCloudSync.shared.pushLocalIfEnabled(catalog)
try? await AppCloudSync.shared.settingsSyncService.pushLocalIfEnabled() try? await AppCloudSync.shared.settingsSyncService.pushLocalIfEnabled()
} }
return true
} catch { } catch {
errorMessage = localized(error) errorAlert = PolishStyleErrorAlert(
title: AppL10n.string("polishStyles.error.title"),
message: localized(error)
)
return false
} }
} }
private func duplicate(_ pack: PolishStylePack) { private func duplicate(_ pack: PolishStylePack) {
guard !isGeneratingLearnedStyle else { return }
guard catalog.entries.count < PolishStyleLimits.maximumUserPacks else { guard catalog.entries.count < PolishStyleLimits.maximumUserPacks else {
errorMessage = AppL10n.string("polishStyles.error.limit") errorAlert = PolishStyleErrorAlert(
title: AppL10n.string("polishStyles.error.title"),
message: AppL10n.string("polishStyles.error.limit")
)
return return
} }
editingPack = PolishStylePack( editingPack = PolishStylePack(
@@ -625,7 +728,7 @@ struct PolishStylesView: View {
} }
private func delete(_ pack: PolishStylePack) { private func delete(_ pack: PolishStylePack) {
guard pack.kind == .user else { return } guard pack.kind == .user, !isGeneratingLearnedStyle else { return }
catalog.recordDeletion(of: pack.id) catalog.recordDeletion(of: pack.id)
store.setPolishStyleCatalog(catalog) store.setPolishStyleCatalog(catalog)
if activeID == pack.id { if activeID == pack.id {
@@ -649,7 +752,10 @@ struct PolishStylesView: View {
case .requestTooLarge: case .requestTooLarge:
return AppL10n.string("polishStyles.learn.error.requestTooLarge") return AppL10n.string("polishStyles.learn.error.requestTooLarge")
case nil: case nil:
return AppL10n.string("polishStyles.learn.error.request") return PolishStyleLearningFailureMessage.localized(
for: error,
language: config.uiLanguage
) ?? AppL10n.string("polishStyles.learn.error.request")
} }
} }
@@ -663,6 +769,20 @@ struct PolishStylesView: View {
case nil: return AppL10n.string("polishStyles.error.generic") case nil: return AppL10n.string("polishStyles.error.generic")
} }
} }
private static func hasInsufficientEvidence(
_ metadata: PolishStylePack.LearningMetadata?
) -> Bool {
metadata?.evidenceStatus.caseInsensitiveCompare("insufficient") == .orderedSame
}
private static func confidencePercentage(_ confidence: Double) -> Int64 {
Int64((min(max(confidence, 0), 1) * 100).rounded())
}
private static func isCancellation(_ error: Error) -> Bool {
error is CancellationError || (error as? LLMError) == .cancelled
}
} }
private struct PolishStylePromptDetailSheet: View { private struct PolishStylePromptDetailSheet: View {
@@ -701,7 +821,7 @@ private struct PolishStylePromptDetailSheet: View {
private struct PolishStyleEditorSheet: View { private struct PolishStyleEditorSheet: View {
let pack: PolishStylePack let pack: PolishStylePack
let isNew: Bool let isNew: Bool
let onSave: (PolishStylePack) -> Void let onSave: (PolishStylePack) -> Bool
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@@ -712,7 +832,7 @@ private struct PolishStyleEditorSheet: View {
init( init(
pack: PolishStylePack, pack: PolishStylePack,
isNew: Bool, isNew: Bool,
onSave: @escaping (PolishStylePack) -> Void onSave: @escaping (PolishStylePack) -> Bool
) { ) {
self.pack = pack self.pack = pack
self.isNew = isNew self.isNew = isNew
@@ -725,6 +845,35 @@ private struct PolishStyleEditorSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
if let metadata = pack.learningMetadata {
Section {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(
Self.hasInsufficientEvidence(metadata)
? AppL10n.string("polishStyles.learn.lowConfidence")
: AppL10n.string("polishStyles.learn.generated.description")
)
.font(TypeStyle.caption2)
.foregroundStyle(
Self.hasInsufficientEvidence(metadata)
? palette.danger
: palette.textSecondary
)
Text(
AppL10n.format(
"polishStyles.learn.confidence",
Self.confidencePercentage(metadata.confidence)
)
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
.fixedSize(horizontal: false, vertical: true)
.settingsListRow()
.cardListRow(elevated: false)
.accessibilityIdentifier("polishStyles.editor.learningEvidence")
}
}
Section("polishStyles.editor.name") { Section("polishStyles.editor.name") {
TextField("polishStyles.editor.namePlaceholder", text: $name) TextField("polishStyles.editor.namePlaceholder", text: $name)
.settingsListRow() .settingsListRow()
@@ -743,6 +892,7 @@ private struct PolishStyleEditorSheet: View {
.frame(minHeight: 320) .frame(minHeight: 320)
.padding(Spacing.md) .padding(Spacing.md)
.cardListRow(elevated: false) .cardListRow(elevated: false)
.accessibilityIdentifier("polishStyles.editor.prompt")
.onChange(of: prompt) { _, newValue in .onChange(of: prompt) { _, newValue in
// Paste-only custom prompts that declare emoji opt-in // Paste-only custom prompts that declare emoji opt-in
// should flip the toggle so post-processing keeps them. // should flip the toggle so post-processing keeps them.
@@ -787,9 +937,10 @@ private struct PolishStyleEditorSheet: View {
createdAt: pack.createdAt, createdAt: pack.createdAt,
updatedAt: Date() updatedAt: Date()
) )
onSave(result) if onSave(result) {
dismiss() dismiss()
} }
}
.disabled( .disabled(
name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
@@ -801,4 +952,14 @@ private struct PolishStyleEditorSheet: View {
} }
} }
} }
private static func hasInsufficientEvidence(
_ metadata: PolishStylePack.LearningMetadata
) -> Bool {
metadata.evidenceStatus.caseInsensitiveCompare("insufficient") == .orderedSame
}
private static func confidencePercentage(_ confidence: Double) -> Int64 {
Int64((min(max(confidence, 0), 1) * 100).rounded())
}
} }
@@ -14,21 +14,54 @@ struct PolishStylesScreenshotHarness: View {
private let generatedPack: PolishStylePack? private let generatedPack: PolishStylePack?
private let showsSavedGeneratedStyle: Bool private let showsSavedGeneratedStyle: Bool
private let simulatesGeneration: Bool private let simulatesGeneration: Bool
private let usesServiceBackedGeneration: Bool
private let usesInsufficientEvidence: Bool
private let failsServiceBackedSynthesis: Bool
private let delaysServiceBackedGeneration: Bool
init() { init() {
let arguments = ProcessInfo.processInfo.arguments
language = ReleaseNotesScreenshotFixture.language language = ReleaseNotesScreenshotFixture.language
ProviderConfig.shared.uiLanguage = language ProviderConfig.shared.uiLanguage = language
ReleaseNotesScreenshotFixture.seedReadyStyleCorpus(language: language) ReleaseNotesScreenshotFixture.seedReadyStyleCorpus(language: language)
simulatesGeneration = ProcessInfo.processInfo.arguments.contains( let testsWithoutCorpus = arguments.contains(
"--polish-styles-service-ui-test-no-corpus"
)
if testsWithoutCorpus {
SpeechHistoryStore.shared.clearAll()
}
simulatesGeneration = arguments.contains(
"--polish-styles-generation-demo" "--polish-styles-generation-demo"
) )
if simulatesGeneration { usesInsufficientEvidence = arguments.contains(
"--polish-styles-service-ui-test-insufficient"
)
failsServiceBackedSynthesis = arguments.contains(
"--polish-styles-service-ui-test-failure"
)
delaysServiceBackedGeneration = arguments.contains(
"--polish-styles-service-ui-test-cancel"
)
let testsRegeneration = arguments.contains(
"--polish-styles-service-ui-test-regenerate"
)
usesServiceBackedGeneration = usesInsufficientEvidence
|| failsServiceBackedSynthesis
|| delaysServiceBackedGeneration
|| testsRegeneration
|| arguments.contains(
"--polish-styles-service-ui-test"
)
if simulatesGeneration || usesServiceBackedGeneration {
ReleaseNotesScreenshotFixture.resetStyleCatalog() ReleaseNotesScreenshotFixture.resetStyleCatalog()
} }
showsSavedGeneratedStyle = ProcessInfo.processInfo.arguments.contains( if testsRegeneration || failsServiceBackedSynthesis {
ReleaseNotesScreenshotFixture.seedGeneratedStyle(language: language)
}
showsSavedGeneratedStyle = arguments.contains(
"--polish-styles-generated-saved" "--polish-styles-generated-saved"
) )
generatedPack = ProcessInfo.processInfo.arguments.contains( generatedPack = arguments.contains(
"--polish-styles-generated-review" "--polish-styles-generated-review"
) )
? ReleaseNotesScreenshotFixture.generatedStyle(language: language) ? ReleaseNotesScreenshotFixture.generatedStyle(language: language)
@@ -40,7 +73,13 @@ struct PolishStylesScreenshotHarness: View {
var body: some View { var body: some View {
ThemedRoot { ThemedRoot {
if simulatesGeneration { if usesServiceBackedGeneration {
PolishStylesServiceUITestHarness(
usesInsufficientEvidence: usesInsufficientEvidence,
failsSynthesis: failsServiceBackedSynthesis,
delaysGeneration: delaysServiceBackedGeneration
)
} else if simulatesGeneration {
PolishStylesView( PolishStylesView(
learnedStyleGenerator: { _, _, language in learnedStyleGenerator: { _, _, language in
try await Task.sleep(for: .seconds(1.8)) try await Task.sleep(for: .seconds(1.8))
@@ -58,6 +97,136 @@ struct PolishStylesScreenshotHarness: View {
} }
} }
/// Exercises the production learning service without network access. Unlike
/// release-note fixtures, this host scripts only the LLM boundary and lets the
/// real two-stage extractor/synthesizer pipeline build the review pack.
@MainActor
private struct PolishStylesServiceUITestHarness: View {
let usesInsufficientEvidence: Bool
let failsSynthesis: Bool
let delaysGeneration: Bool
@StateObject private var recorder = PolishStylesUITestRecorder()
@State private var showsStyles = true
var body: some View {
ZStack(alignment: .topTrailing) {
if showsStyles {
PolishStylesView(
pullsCloudStylesOnAppear: false,
learnedStyleGenerator: { corpus, replyExamples, language in
let client = PolishStylesUITestScriptedLLMClient(
responses: ReleaseNotesScreenshotFixture.serviceResponses(
language: language,
usesInsufficientEvidence: usesInsufficientEvidence,
failsSynthesis: failsSynthesis
),
delay: delaysGeneration ? 5 : 0.15,
recorder: recorder
)
return try await PolishStyleLearningService(
store: AppGroupStore(),
client: client
)
.generateStyle(
from: corpus,
replyExamples: replyExamples,
outputLanguage: language,
minimumEffectiveCharacterCount:
AppDistributionChannel.allowsInternalTools
? 0
: PolishStyleLearningCorpusBuilder
.requiredEffectiveCharacterCount
)
}
)
} else {
VStack(spacing: 16) {
Text("Style view closed")
.accessibilityIdentifier("polishStyles.test.closed")
if recorder.didObserveCancellation {
Text("Generation cancellation observed")
.accessibilityIdentifier("polishStyles.test.cancelled")
}
Button("Return to styles") {
showsStyles = true
}
.accessibilityIdentifier("polishStyles.test.return")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if delaysGeneration, showsStyles {
Button("Leave styles") {
showsStyles = false
}
.accessibilityIdentifier("polishStyles.test.leave")
.padding()
}
}
}
}
@MainActor
private final class PolishStylesUITestRecorder: ObservableObject {
@Published var didObserveCancellation = false
func markCancellationObserved() {
didObserveCancellation = true
}
}
private final class PolishStylesUITestScriptedLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
private let responses: [String]
private let delay: TimeInterval
private let recorder: PolishStylesUITestRecorder
private var responseIndex = 0
init(
responses: [String],
delay: TimeInterval,
recorder: PolishStylesUITestRecorder
) {
self.responses = responses
self.delay = delay
self.recorder = recorder
}
func polish(
_: String,
systemPrompt: String,
timeout _: TimeInterval?
) async throws -> String {
try await response(for: systemPrompt)
}
func polish(
_: String,
systemPrompt: String,
timeout _: TimeInterval?,
options _: LLMGenerationOptions
) async throws -> String {
try await response(for: systemPrompt)
}
private func response(for _: String) async throws -> String {
do {
try Task.checkCancellation()
try await Task.sleep(for: .seconds(delay))
try Task.checkCancellation()
} catch is CancellationError {
await recorder.markCancellationObserved()
throw CancellationError()
}
guard !responses.isEmpty else { return "{}" }
let index = min(responseIndex, responses.count - 1)
responseIndex += 1
return responses[index]
}
}
@MainActor @MainActor
struct HomeDictionaryScreenshotHarness: View { struct HomeDictionaryScreenshotHarness: View {
@StateObject private var flowManager = FlowSessionManager() @StateObject private var flowManager = FlowSessionManager()
@@ -148,6 +317,94 @@ private enum ReleaseNotesScreenshotFixture {
) )
} }
static func serviceResponses(
language: AppUILanguage,
usesInsufficientEvidence: Bool,
failsSynthesis: Bool
) -> [String] {
if failsSynthesis {
return [
sufficientEvidenceResponse,
"invalid synthesis response",
"invalid synthesis repair response"
]
}
if usesInsufficientEvidence {
return [
insufficientEvidenceResponse,
generatedStyleResponse(language: language)
]
}
return [
sufficientEvidenceResponse,
generatedStyleResponse(language: language)
]
}
private static let sufficientEvidenceResponse = ##"""
{
"status":"sufficient",
"confidence":0.86,
"asr":{
"traits":[
{"name":"concise and direct","description":"The user repeatedly preserves concise direct wording","confidence":0.9,"supportCount":4}
],
"evidence":[
{"source":"asrUserEdit","summary":"User edits preserve direct wording","supportCount":2},
{"source":"asrRepeatedBefore","summary":"Short direct phrases recur in dictation","supportCount":4}
],
"contradictions":[]
},
"reply":{
"traits":[
{"name":"relaxed replies","description":"The user prefers relaxed replies without invented information","confidence":0.7,"supportCount":2}
],
"evidence":[
{"source":"replyFinalEdit","summary":"Final edits preserve natural short sentences","supportCount":1},
{"source":"replyCrossContextSelection","summary":"Relaxed tone is preferred across contexts","supportCount":2},
{"source":"replyAcceptance","summary":"A single acceptance remains weak evidence","supportCount":1}
],
"contradictions":[]
}
}
"""##
private static let insufficientEvidenceResponse = ##"""
{
"status":"insufficient",
"confidence":0.2,
"asr":{
"traits":[
{"name":"retention:direct short phrasing","description":"The raw ASR uses a direct short-message rhythm","confidence":0.2,"supportCount":1}
],
"evidence":[
{"source":"asrObservedBefore","summary":"A raw before sample uses direct short phrasing","supportCount":1}
],
"contradictions":[]
},
"reply":{"traits":[],"evidence":[],"contradictions":[]}
}
"""##
private static func generatedStyleResponse(language: AppUILanguage) -> String {
if language == .chinese {
return ##"""
{
"name":"",
"prompt":"# \n保留用户自然\n# \nASR preserve mode\nAI reply active-transfer mode\n# \n原文\n输出",
"allowsAddedEmoji":false
}
"""##
}
return ##"""
{
"name":"My Speaking Style",
"prompt":"# \nPreserve the user's natural, direct voice.\n# \nASR preserve mode: correct recognition and punctuation without changing intent.\nAI reply active-transfer mode: respond first, add only necessary detail, and invent no facts or commitments.\n# \nDraft: I will check later\nOutput: I'll check later.",
"allowsAddedEmoji":false
}
"""##
}
static func seedGeneratedStyle(language: AppUILanguage) { static func seedGeneratedStyle(language: AppUILanguage) {
let pack = generatedStyle(language: language) let pack = generatedStyle(language: language)
var catalog = PolishStyleCatalog() var catalog = PolishStyleCatalog()
+5 -1
View File
@@ -313,7 +313,7 @@
"settings.aiAgent.responseLength.section" = "Answers"; "settings.aiAgent.responseLength.section" = "Answers";
"settings.aiAgent.responseLength.title" = "Response length"; "settings.aiAgent.responseLength.title" = "Response length";
"settings.aiAgent.multipleReplies.title" = "Provide multiple replies"; "settings.aiAgent.multipleReplies.title" = "Provide multiple replies";
"settings.aiAgent.multipleReplies.description" = "Show several natural reply options when using Reply."; "settings.aiAgent.multipleReplies.description" = "Show natural, formal, and playful options for ordinary chats. Invitations and tasks always offer separate decisions so AI never chooses your stance.";
"settings.clipboard.title" = "Clipboard"; "settings.clipboard.title" = "Clipboard";
"settings.clipboard.section" = "Features"; "settings.clipboard.section" = "Features";
"settings.clipboard.subtitle.on" = "On"; "settings.clipboard.subtitle.on" = "On";
@@ -683,17 +683,21 @@
"polishStyles.learn.progress" = "%lld / %lld characters"; "polishStyles.learn.progress" = "%lld / %lld characters";
"polishStyles.learn.remaining" = "%lld to go"; "polishStyles.learn.remaining" = "%lld to go";
"polishStyles.learn.ready" = "Ready"; "polishStyles.learn.ready" = "Ready";
"polishStyles.learn.testBuildReady" = "Test build: 2,500-character limit disabled";
"polishStyles.learn.action" = "Generate Style"; "polishStyles.learn.action" = "Generate Style";
"polishStyles.learn.generating" = "Generating…"; "polishStyles.learn.generating" = "Generating…";
"polishStyles.learn.generated.description" = "Your personal style learned from reviewed evidence."; "polishStyles.learn.generated.description" = "Your personal style learned from reviewed evidence.";
"polishStyles.learn.lowConfidence" = "Low confidence. The prompt was generated from limited evidence; please review it.";
"polishStyles.learn.generatedAt" = "Generated"; "polishStyles.learn.generatedAt" = "Generated";
"polishStyles.learn.evidenceSummary" = "%lld ASR characters · %lld reply choices · %lld final edits"; "polishStyles.learn.evidenceSummary" = "%lld ASR characters · %lld reply choices · %lld final edits";
"polishStyles.learn.confidence" = "Confidence: %lld%%";
"polishStyles.learn.select" = "Use this style"; "polishStyles.learn.select" = "Use this style";
"polishStyles.learn.selected" = "In use"; "polishStyles.learn.selected" = "In use";
"polishStyles.learn.regenerate" = "Regenerate"; "polishStyles.learn.regenerate" = "Regenerate";
"polishStyles.learn.regenerating" = "Regenerating…"; "polishStyles.learn.regenerating" = "Regenerating…";
"polishStyles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving."; "polishStyles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
"polishStyles.learn.limit" = "Delete a custom style before generating another one."; "polishStyles.learn.limit" = "Delete a custom style before generating another one.";
"polishStyles.learn.error.title" = "Couldnt Generate Style";
"polishStyles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available."; "polishStyles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available.";
"polishStyles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again."; "polishStyles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again.";
"polishStyles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again."; "polishStyles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again.";
@@ -313,7 +313,7 @@
"settings.aiAgent.responseLength.section" = "回答"; "settings.aiAgent.responseLength.section" = "回答";
"settings.aiAgent.responseLength.title" = "回复篇幅"; "settings.aiAgent.responseLength.title" = "回复篇幅";
"settings.aiAgent.multipleReplies.title" = "提供多种回复"; "settings.aiAgent.multipleReplies.title" = "提供多种回复";
"settings.aiAgent.multipleReplies.description" = "使用「回复」时提供多个自然、可直接发送的选项。"; "settings.aiAgent.multipleReplies.description" = "普通聊天提供自然、正式和轻松趣味选项;邀约与任务始终提供不同立场,避免 AI 替你做决定。";
"settings.clipboard.title" = "剪贴板"; "settings.clipboard.title" = "剪贴板";
"settings.clipboard.section" = "功能"; "settings.clipboard.section" = "功能";
"settings.clipboard.subtitle.on" = "已开启"; "settings.clipboard.subtitle.on" = "已开启";
@@ -682,17 +682,21 @@
"polishStyles.learn.progress" = "%lld / %lld 字"; "polishStyles.learn.progress" = "%lld / %lld 字";
"polishStyles.learn.remaining" = "还差 %lld 字"; "polishStyles.learn.remaining" = "还差 %lld 字";
"polishStyles.learn.ready" = "可以生成"; "polishStyles.learn.ready" = "可以生成";
"polishStyles.learn.testBuildReady" = "测试版本:已暂时取消 2500 字限制";
"polishStyles.learn.action" = "生成风格"; "polishStyles.learn.action" = "生成风格";
"polishStyles.learn.generating" = "生成中…"; "polishStyles.learn.generating" = "生成中…";
"polishStyles.learn.generated.description" = "根据经过确认的证据学习得到的个人表达风格。"; "polishStyles.learn.generated.description" = "根据经过确认的证据学习得到的个人表达风格。";
"polishStyles.learn.lowConfidence" = "低置信度,Prompt 已根据有限语料生成,请审阅。";
"polishStyles.learn.generatedAt" = "生成于"; "polishStyles.learn.generatedAt" = "生成于";
"polishStyles.learn.evidenceSummary" = "%lld 个 ASR 字符 · %lld 次回复选择 · %lld 次最终改稿"; "polishStyles.learn.evidenceSummary" = "%lld 个 ASR 字符 · %lld 次回复选择 · %lld 次最终改稿";
"polishStyles.learn.confidence" = "置信度:%lld%%";
"polishStyles.learn.select" = "使用此风格"; "polishStyles.learn.select" = "使用此风格";
"polishStyles.learn.selected" = "使用中"; "polishStyles.learn.selected" = "使用中";
"polishStyles.learn.regenerate" = "重新生成"; "polishStyles.learn.regenerate" = "重新生成";
"polishStyles.learn.regenerating" = "重新生成中…"; "polishStyles.learn.regenerating" = "重新生成中…";
"polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。"; "polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
"polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。"; "polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
"polishStyles.learn.error.title" = "无法生成风格";
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。"; "polishStyles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
"polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。"; "polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
"polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。"; "polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
+2 -2
View File
@@ -609,8 +609,8 @@ public final class KeyboardViewController: UIInputViewController {
state.submitAIHint = { [weak self] card in state.submitAIHint = { [weak self] card in
self?.aiKeyboardCoordinator.submitHintCard(card) self?.aiKeyboardCoordinator.submitHintCard(card)
} }
state.submitAIClipboardSkill = { [weak self] skill in state.submitAIClipboardSkill = { [weak self] skill, replyScene in
self?.aiKeyboardCoordinator.submitClipboardSkill(skill) self?.aiKeyboardCoordinator.submitClipboardSkill(skill, replyScene: replyScene)
} }
state.runClipboardExportSkill = { [weak self] skillID, titles in state.runClipboardExportSkill = { [weak self] skillID, titles in
AppGroupStore().setPendingShortcutRun(skillID: skillID, titles: titles) AppGroupStore().setPendingShortcutRun(skillID: skillID, titles: titles)
@@ -24,6 +24,7 @@ final class AIKeyboardCoordinator {
private var hasConversationInsertionTarget = false private var hasConversationInsertionTarget = false
private var requestOOBEFeature: ManagedGatewayOOBEFeature? private var requestOOBEFeature: ManagedGatewayOOBEFeature?
private var requestExpectsReplyVariants = false private var requestExpectsReplyVariants = false
private var requestReplyVariantSet: AIReplyVariantSet = .generic
private var requestReplySourceText: String? private var requestReplySourceText: String?
private var requestReplyFeedbackSource: String? private var requestReplyFeedbackSource: String?
private var pendingReplyFeedbackRecordID: UUID? private var pendingReplyFeedbackRecordID: UUID?
@@ -58,6 +59,7 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = nil requestInsertionFingerprint = nil
requestOOBEFeature = nil requestOOBEFeature = nil
requestExpectsReplyVariants = false requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil requestReplySourceText = nil
requestReplyFeedbackSource = nil requestReplyFeedbackSource = nil
pendingStructuredReplyResult = false pendingStructuredReplyResult = false
@@ -80,6 +82,7 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = nil requestInsertionFingerprint = nil
requestOOBEFeature = nil requestOOBEFeature = nil
requestExpectsReplyVariants = false requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil requestReplySourceText = nil
requestReplyFeedbackSource = nil requestReplyFeedbackSource = nil
pendingStructuredReplyResult = false pendingStructuredReplyResult = false
@@ -114,7 +117,10 @@ final class AIKeyboardCoordinator {
} }
/// Tap a clipboard skill chip: same fail-closed material path as hint cards. /// Tap a clipboard skill chip: same fail-closed material path as hint cards.
func submitClipboardSkill(_ skill: AIClipboardSkill) { func submitClipboardSkill(
_ skill: AIClipboardSkill,
replyScene: AIClipboardReplyScene? = nil
) {
guard canAcceptIdleSubmit else { return } guard canAcceptIdleSubmit else { return }
guard !skill.requiresShortcut guard !skill.requiresShortcut
|| state.confirmedClipboardShortcutIDs.contains(skill.id) else { || state.confirmedClipboardShortcutIDs.contains(skill.id) else {
@@ -175,13 +181,19 @@ final class AIKeyboardCoordinator {
for: skill, for: skill,
locale: AIHintLocaleResolver.packLocale(), locale: AIHintLocaleResolver.packLocale(),
translationTargetLocaleId: state.translationTargetLocaleId, translationTargetLocaleId: state.translationTargetLocaleId,
replyStyle: state.clipboardReplyStyle replyStyle: state.clipboardReplyStyle,
replyScene: replyScene
) )
let expectsReplyVariants = state.multipleReplyVariantsEnabled let replyVariantSet = AIReplyVariantSet.resolve(scene: replyScene)
&& skill.id == AIClipboardSkillCatalog.replyID let expectsReplyVariants = skill.id == AIClipboardSkillCatalog.replyID
&& AIReplyVariantSet.shouldGenerate(
multipleRepliesEnabled: state.multipleReplyVariantsEnabled,
scene: replyScene
)
requestReplyVariantSet = expectsReplyVariants ? replyVariantSet : .generic
requestReplySourceText = expectsReplyVariants ? material : nil requestReplySourceText = expectsReplyVariants ? material : nil
if expectsReplyVariants { if expectsReplyVariants {
instruction += "\n\(replyVariantsOutputContract())" instruction += "\n\(replyVariantsOutputContract(for: replyVariantSet))"
} }
if skill.kind == .export { if skill.kind == .export {
instruction += "\nPreserve the source language, addresses, names, and proper nouns." instruction += "\nPreserve the source language, addresses, names, and proper nouns."
@@ -295,6 +307,7 @@ final class AIKeyboardCoordinator {
if case .rejected(let rejection) = disposition { if case .rejected(let rejection) = disposition {
clearPendingExportSkill() clearPendingExportSkill()
requestExpectsReplyVariants = false requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil requestReplySourceText = nil
requestReplyFeedbackSource = nil requestReplyFeedbackSource = nil
state.aiSession.fail(message(for: rejection), utteranceID: nil) state.aiSession.fail(message(for: rejection), utteranceID: nil)
@@ -307,6 +320,7 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = nil requestInsertionFingerprint = nil
requestOOBEFeature = nil requestOOBEFeature = nil
requestExpectsReplyVariants = false requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil requestReplySourceText = nil
flow.cancelAIRecording() flow.cancelAIRecording()
state.aiSession.cancelCurrentWork() state.aiSession.cancelCurrentWork()
@@ -415,6 +429,7 @@ final class AIKeyboardCoordinator {
let answer = result.text, let answer = result.text,
!answer.isEmpty else { !answer.isEmpty else {
requestExpectsReplyVariants = false requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil requestReplySourceText = nil
requestReplyFeedbackSource = nil requestReplyFeedbackSource = nil
state.aiSession.fail( state.aiSession.fail(
@@ -427,10 +442,13 @@ final class AIKeyboardCoordinator {
requestExpectsReplyVariants = false requestExpectsReplyVariants = false
requestInsertionFingerprint = nil requestInsertionFingerprint = nil
let sourceText = requestReplySourceText let sourceText = requestReplySourceText
let variantSet = requestReplyVariantSet
requestReplyVariantSet = .generic
requestReplySourceText = nil requestReplySourceText = nil
switch AIReplyVariantParser.parseOrFallback( switch AIReplyVariantParser.parseOrFallback(
answer, answer,
sourceText: sourceText sourceText: sourceText,
variantSet: variantSet
) { ) {
case .variants(let variants): case .variants(let variants):
state.aiSession.receiveReplyVariants( state.aiSession.receiveReplyVariants(
@@ -487,6 +505,7 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = nil requestInsertionFingerprint = nil
requestOOBEFeature = nil requestOOBEFeature = nil
requestExpectsReplyVariants = false requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil requestReplySourceText = nil
requestReplyFeedbackSource = nil requestReplyFeedbackSource = nil
state.aiSession.fail(message, utteranceID: utteranceID) state.aiSession.fail(message, utteranceID: utteranceID)
@@ -512,6 +531,7 @@ final class AIKeyboardCoordinator {
private func prepareConversationForRequest() { private func prepareConversationForRequest() {
requestExpectsReplyVariants = false requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil requestReplySourceText = nil
requestReplyFeedbackSource = nil requestReplyFeedbackSource = nil
pendingStructuredReplyResult = false pendingStructuredReplyResult = false
@@ -579,14 +599,13 @@ final class AIKeyboardCoordinator {
private func feedbackKind( private func feedbackKind(
for kind: AIReplyVariant.Kind for kind: AIReplyVariant.Kind
) -> ClipboardReplyCandidateSnapshot.Kind { ) -> ClipboardReplyCandidateSnapshot.Kind {
switch kind { guard let snapshotKind = ClipboardReplyCandidateSnapshot.Kind(
case .ordinary: rawValue: kind.rawValue
) else {
assertionFailure("Unmapped reply variant kind: \(kind.rawValue)")
return .ordinary return .ordinary
case .formal:
return .formal
case .playful:
return .playful
} }
return snapshotKind
} }
/// The host conversation contains the structured JSON result rather than /// The host conversation contains the structured JSON result rather than
@@ -602,17 +621,55 @@ final class AIKeyboardCoordinator {
state.aiSession.resetConversationPreservingAnswer() state.aiSession.resetConversationPreservingAnswer()
} }
private func replyVariantsOutputContract() -> String { private func replyVariantsOutputContract(
""" for variantSet: AIReplyVariantSet
MULTI-REPLY OUTPUT CONTRACT (highest priority): ) -> String {
Return only one valid JSON object with exactly this shape and no Markdown fence or extra keys: let items = variantSet.kinds.map {
{"variants":[{"kind":"ordinary","emotion":"neutral","text":"..."},{"kind":"formal","emotion":"neutral","text":"..."},{"kind":"playful","emotion":"playful","text":"..."}]} #"{"kind":"\#($0.rawValue)","emotion":"neutral","text":"..."}"#
Include exactly one ordinary, one formal, and one playful item in that order. Every text must be a complete reply in the source language. All three must keep the same semantic stance, facts, and level of commitment. If the source does not establish whether the user should accept, decline, promise, schedule, or otherwise decide, do not invent that decision; stay neutral or ask for the missing detail. }.joined(separator: ",")
Every item must advance the conversation with a reaction, answer, question, decision, or next step. Never restate, paraphrase, summarize, or synonymically rewrite the clipboard text. In particular, do not begin a reply by repeating the source's subject and event. For a declarative update, react to its implication or emotion instead of reporting the update back to its sender. let roleGuidance: String
Apply the existing <user_reply_style> wording, rhythm, and stable habits to every item without changing these rules. switch variantSet {
case .generic:
roleGuidance = """
All three must keep the same semantic stance, facts, and level of commitment.
ordinary: natural for the situation; add emoji only when context makes it useful. ordinary: natural for the situation; add emoji only when context makes it useful.
formal: professional and natural; add no new emoji by default. formal: professional and natural; add no new emoji by default.
playful: relaxed and fun. Emoji has no fixed numeric cap, may be varied when context supports it, must not become meaningless stacking, and must not default to using only 😂. This playful emoji rule overrides any personal no-emoji preference. playful: relaxed and fun. Emoji has no fixed numeric cap, may be varied when context supports it, must not become meaningless stacking, and must not default to using only 😂. This playful emoji rule overrides any personal no-emoji preference.
"""
case .invitation:
roleGuidance = """
invitationAccept: naturally accept without inventing availability or commitments.
invitationDecline: politely decline without inventing a reason.
invitationTentative: stay undecided and say only that confirmation is needed.
"""
case .task:
roleGuidance = """
taskAcknowledge: acknowledge only source-supported work and timing.
taskClarify: ask only the most important missing detail.
taskNegotiate: negotiate scope or timing without inventing constraints.
"""
case .blessing:
roleGuidance = """
blessingReturn: sincerely thank and return an appropriate wish.
blessingWarm: give a concise, warm response.
blessingPlayful: respond lightly and playfully when the context is safe.
"""
case .clarification:
roleGuidance = """
clarificationDirect: answer only the part supported by available context.
clarificationQuestion: ask one essential missing question.
clarificationConfirm: briefly confirm understanding, then ask the key question.
"""
}
return """
MULTI-REPLY OUTPUT CONTRACT (highest priority):
Return only one valid JSON object with exactly this shape and no Markdown fence or extra keys:
{"variants":[\(items)]}
Include exactly these three kinds in the shown order. Every text must be a complete reply in the source language.
\(roleGuidance)
Every item must advance the conversation with a reaction, answer, question, decision, or next step. Never restate, paraphrase, summarize, or synonymically rewrite the clipboard text. In particular, do not begin a reply by repeating the source's subject and event. For a declarative update, react to its implication or emotion instead of reporting the update back to its sender.
Apply any <reply_scene> constraint to every item. It overrides the kind-specific tone guidance below when they conflict.
Apply the existing <user_reply_style> wording, rhythm, and stable habits to every item without changing these rules.
emotion must be exactly one of: neutral, warm, celebratory, empathetic, encouraging, grateful, apologetic, reassuring, playful, enthusiastic, calm. The app, not the model, chooses all icons. emotion must be exactly one of: neutral, warm, celebratory, empathetic, encouraging, grateful, apologetic, reassuring, playful, enthusiastic, calm. The app, not the model, chooses all icons.
""" """
} }
+105 -6
View File
@@ -37,9 +37,23 @@ struct AIKeyboardView: View {
static let maximumSemanticSkills = 5 static let maximumSemanticSkills = 5
} }
private struct SemanticBadgeContent {
let intentKey: String?
let domainKey: String?
var text: String {
[intentKey, domainKey]
.compactMap { $0 }
.map { ExtL10n.string($0) }
.joined(separator: " · ")
}
}
#if DEBUG #if DEBUG
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating. /// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
static var debugPreviewSkills: [AIClipboardSkill]? static var debugPreviewSkills: [AIClipboardSkill]?
/// Deterministic intent/domain labels for the assistant UI harness.
static var debugPreviewSemanticBadgeKeys: (intent: String?, domain: String?)?
/// Keeps the deterministic UI harness on the tappable idle hint. /// Keeps the deterministic UI harness on the tappable idle hint.
static var debugSkipsLongPressCoach = false static var debugSkipsLongPressCoach = false
/// Prevents deterministic feedback previews from expiring mid-assertion. /// Prevents deterministic feedback previews from expiring mid-assertion.
@@ -237,9 +251,9 @@ struct AIKeyboardView: View {
} label: { } label: {
HStack(alignment: .top, spacing: Spacing.sm) { HStack(alignment: .top, spacing: Spacing.sm) {
Image( Image(
systemName: variant.emotion.systemImage( systemName: variant.kind.usesEmotionIcon
fallback: variant.kind ? variant.emotion.systemImage(fallback: variant.kind)
) : variant.kind.systemImage
) )
.resizable() .resizable()
.scaledToFit() .scaledToFit()
@@ -315,7 +329,7 @@ struct AIKeyboardView: View {
onDismiss: dismissClipboardPresentation onDismiss: dismissClipboardPresentation
) )
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset) .padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else if showsClipboardSkills { } else if showsClipboardSkills || semanticBadgeContent != nil {
cancelTopBar( cancelTopBar(
action: dismissClipboardPresentation, action: dismissClipboardPresentation,
labelKey: "keyboard.assistant.dismissClipboard", labelKey: "keyboard.assistant.dismissClipboard",
@@ -396,7 +410,14 @@ struct AIKeyboardView: View {
} }
.accessibilityIdentifier("assistant.skillTip") .accessibilityIdentifier("assistant.skillTip")
} else if showsClipboardSkills { } else if showsClipboardSkills {
VStack(spacing: Spacing.xs) {
if let content = semanticBadgeContent {
semanticBadge(content)
}
clipboardSkillPager clipboardSkillPager
}
} else if let content = semanticBadgeContent {
semanticBadge(content)
} else if let status = activeStatus { } else if let status = activeStatus {
statusText(status.text, color: status.color) statusText(status.text, color: status.color)
} else if showsLongPressCoach { } else if showsLongPressCoach {
@@ -415,6 +436,73 @@ struct AIKeyboardView: View {
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
} }
private func semanticBadge(_ content: SemanticBadgeContent) -> some View {
HStack(spacing: 5) {
Image(systemName: "tag.fill")
.font(.system(size: 10, weight: .semibold))
.accessibilityHidden(true)
Text(content.text)
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
}
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, 10)
.frame(height: 24)
.background(palette.accentMuted, in: Capsule())
.accessibilityElement(children: .combine)
.accessibilityIdentifier("assistant.semantic.badge")
.accessibilityLabel(Text(content.text))
}
private var semanticBadgeContent: SemanticBadgeContent? {
#if DEBUG
if let keys = Self.debugPreviewSemanticBadgeKeys {
return SemanticBadgeContent(
intentKey: keys.intent,
domainKey: keys.domain
)
}
#endif
guard assistantIsResting,
state.clipboardHistoryEnabled,
let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id,
AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: newest
) else {
return nil
}
let intentKey = semanticIntentKey(snapshot.analysis)
let domainKey: String? = snapshot.analysis.domain.flatMap { domain in
guard let confidence = snapshot.analysis.domainConfidence,
confidence > 0 else {
return nil
}
return domain.localizationKey
}
guard intentKey != nil || domainKey != nil else { return nil }
return SemanticBadgeContent(intentKey: intentKey, domainKey: domainKey)
}
private func semanticIntentKey(
_ analysis: ClipboardSemanticAnalysis
) -> String? {
let candidates = [
(analysis.assistantCommand, "keyboard.semantic.intent.assistantCommand"),
(analysis.informationQuery, "keyboard.semantic.intent.informationQuery"),
(analysis.systemNotification, "keyboard.semantic.intent.systemNotification")
].filter { label, _ in
label.isDetected
&& label.confidence > 0
&& label.confidence >= label.threshold
}
return candidates.max {
$0.0.confidence < $1.0.confidence
}?.1
}
private func statusText(_ text: String, color: Color) -> some View { private func statusText(_ text: String, color: Color) -> some View {
Text(text) Text(text)
.font(TypeStyle.body) .font(TypeStyle.body)
@@ -588,7 +676,7 @@ struct AIKeyboardView: View {
private func skillChip(_ skill: AIClipboardSkill) -> some View { private func skillChip(_ skill: AIClipboardSkill) -> some View {
Button { Button {
state.submitAIClipboardSkill(skill) state.submitAIClipboardSkill(skill, replyScene(for: skill))
} label: { } label: {
VStack(spacing: 6) { VStack(spacing: 6) {
Image(systemName: skill.systemImage) Image(systemName: skill.systemImage)
@@ -610,6 +698,17 @@ struct AIKeyboardView: View {
.accessibilityLabel(Text(clipboardSkillTitle(skill))) .accessibilityLabel(Text(clipboardSkillTitle(skill)))
} }
private func replyScene(for skill: AIClipboardSkill) -> AIClipboardReplyScene? {
guard skill.id == AIClipboardSkillCatalog.replyID,
state.oobePracticeSession == nil,
let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return nil
}
return AIClipboardReplyScene.resolve(from: snapshot.analysis)
}
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String { private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
if skill.id == AIClipboardSkillCatalog.translateID { if skill.id == AIClipboardSkillCatalog.translateID {
return AIClipboardSkillCatalog.translateButtonTitle( return AIClipboardSkillCatalog.translateButtonTitle(
@@ -1193,7 +1292,7 @@ struct AIKeyboardView: View {
private func resetCarousel() { private func resetCarousel() {
reloadHintPool(resetBag: true) reloadHintPool(resetBag: true)
guard !showsClipboardSkills else { return } guard !showsClipboardSkills, semanticBadgeContent == nil else { return }
showNextHint(animated: false) showNextHint(animated: false)
} }
+27
View File
@@ -314,9 +314,36 @@
"keyboard.ai.replyVariant.ordinary" = "Ordinary"; "keyboard.ai.replyVariant.ordinary" = "Ordinary";
"keyboard.ai.replyVariant.formal" = "Formal"; "keyboard.ai.replyVariant.formal" = "Formal";
"keyboard.ai.replyVariant.playful" = "Relaxed & playful"; "keyboard.ai.replyVariant.playful" = "Relaxed & playful";
"keyboard.ai.replyVariant.invitationAccept" = "Accept";
"keyboard.ai.replyVariant.invitationDecline" = "Decline";
"keyboard.ai.replyVariant.invitationTentative" = "Decide later";
"keyboard.ai.replyVariant.taskAcknowledge" = "Acknowledge";
"keyboard.ai.replyVariant.taskClarify" = "Clarify";
"keyboard.ai.replyVariant.taskNegotiate" = "Negotiate";
"keyboard.ai.replyVariant.blessingReturn" = "Thank & return wish";
"keyboard.ai.replyVariant.blessingWarm" = "Warm";
"keyboard.ai.replyVariant.blessingPlayful" = "Lighthearted";
"keyboard.ai.replyVariant.clarificationDirect" = "Direct reply";
"keyboard.ai.replyVariant.clarificationQuestion" = "Ask key detail";
"keyboard.ai.replyVariant.clarificationConfirm" = "Confirm & ask";
"keyboard.ai.replyVariant.insertHint" = "Insert this complete reply."; "keyboard.ai.replyVariant.insertHint" = "Insert this complete reply.";
"keyboard.assistant.dismissClipboard" = "Dismiss clipboard suggestions"; "keyboard.assistant.dismissClipboard" = "Dismiss clipboard suggestions";
"keyboard.assistant.dismissClipboardHint" = "Hide the current clipboard summary and skills."; "keyboard.assistant.dismissClipboardHint" = "Hide the current clipboard summary and skills.";
"keyboard.semantic.intent.assistantCommand" = "Assistant command";
"keyboard.semantic.intent.informationQuery" = "Information query";
"keyboard.semantic.intent.systemNotification" = "System notification";
"keyboard.semantic.domain.finance" = "Finance";
"keyboard.semantic.domain.travel" = "Travel";
"keyboard.semantic.domain.calendar" = "Calendar";
"keyboard.semantic.domain.communication" = "Communication";
"keyboard.semantic.domain.media" = "Media";
"keyboard.semantic.domain.smartHome" = "Smart home";
"keyboard.semantic.domain.shopping" = "Shopping";
"keyboard.semantic.domain.dining" = "Dining";
"keyboard.semantic.domain.health" = "Health";
"keyboard.semantic.domain.weather" = "Weather";
"keyboard.semantic.domain.accountService" = "Account & service";
"keyboard.semantic.domain.generalKnowledge" = "General knowledge";
"keyboard.ai.error.missingAPIKey" = "Configure an AI service in the main app first"; "keyboard.ai.error.missingAPIKey" = "Configure an AI service in the main app first";
"keyboard.ai.error.pipelineBusy" = "Voice input is busy. Try again shortly"; "keyboard.ai.error.pipelineBusy" = "Voice input is busy. Try again shortly";
"keyboard.ai.error.startTimeout" = "Microphone startup timed out. Try again"; "keyboard.ai.error.startTimeout" = "Microphone startup timed out. Try again";
@@ -314,9 +314,36 @@
"keyboard.ai.replyVariant.ordinary" = "普通"; "keyboard.ai.replyVariant.ordinary" = "普通";
"keyboard.ai.replyVariant.formal" = "正式"; "keyboard.ai.replyVariant.formal" = "正式";
"keyboard.ai.replyVariant.playful" = "轻松趣味"; "keyboard.ai.replyVariant.playful" = "轻松趣味";
"keyboard.ai.replyVariant.invitationAccept" = "接受";
"keyboard.ai.replyVariant.invitationDecline" = "婉拒";
"keyboard.ai.replyVariant.invitationTentative" = "待定";
"keyboard.ai.replyVariant.taskAcknowledge" = "确认处理";
"keyboard.ai.replyVariant.taskClarify" = "澄清";
"keyboard.ai.replyVariant.taskNegotiate" = "协商";
"keyboard.ai.replyVariant.blessingReturn" = "感谢并回祝";
"keyboard.ai.replyVariant.blessingWarm" = "简短温暖";
"keyboard.ai.replyVariant.blessingPlayful" = "轻松活泼";
"keyboard.ai.replyVariant.clarificationDirect" = "直接回应";
"keyboard.ai.replyVariant.clarificationQuestion" = "追问关键点";
"keyboard.ai.replyVariant.clarificationConfirm" = "确认并追问";
"keyboard.ai.replyVariant.insertHint" = "插入这条完整回复。"; "keyboard.ai.replyVariant.insertHint" = "插入这条完整回复。";
"keyboard.assistant.dismissClipboard" = "关闭剪贴板建议"; "keyboard.assistant.dismissClipboard" = "关闭剪贴板建议";
"keyboard.assistant.dismissClipboardHint" = "隐藏当前剪贴板摘要和技能。"; "keyboard.assistant.dismissClipboardHint" = "隐藏当前剪贴板摘要和技能。";
"keyboard.semantic.intent.assistantCommand" = "助手操作";
"keyboard.semantic.intent.informationQuery" = "信息查询";
"keyboard.semantic.intent.systemNotification" = "系统通知";
"keyboard.semantic.domain.finance" = "金融";
"keyboard.semantic.domain.travel" = "出行";
"keyboard.semantic.domain.calendar" = "日历";
"keyboard.semantic.domain.communication" = "沟通";
"keyboard.semantic.domain.media" = "媒体";
"keyboard.semantic.domain.smartHome" = "智能家居";
"keyboard.semantic.domain.shopping" = "购物";
"keyboard.semantic.domain.dining" = "餐饮";
"keyboard.semantic.domain.health" = "健康";
"keyboard.semantic.domain.weather" = "天气";
"keyboard.semantic.domain.accountService" = "账户与服务";
"keyboard.semantic.domain.generalKnowledge" = "通用知识";
"keyboard.ai.error.missingAPIKey" = "请先在主 App 配置可用的 AI 服务"; "keyboard.ai.error.missingAPIKey" = "请先在主 App 配置可用的 AI 服务";
"keyboard.ai.error.pipelineBusy" = "语音服务正忙,请稍后重试"; "keyboard.ai.error.pipelineBusy" = "语音服务正忙,请稍后重试";
"keyboard.ai.error.startTimeout" = "麦克风启动超时,请重试"; "keyboard.ai.error.startTimeout" = "麦克风启动超时,请重试";
+26 -9
View File
@@ -6,6 +6,11 @@
import SwiftUI import SwiftUI
private struct MacPolishStyleErrorAlert {
let title: String
let message: String
}
struct MacPolishStylesView: View { struct MacPolishStylesView: View {
@ObservedObject var viewModel: MacDictationViewModel @ObservedObject var viewModel: MacDictationViewModel
@ObservedObject private var history = SpeechHistoryStore.shared @ObservedObject private var history = SpeechHistoryStore.shared
@@ -13,7 +18,7 @@ struct MacPolishStylesView: View {
@State private var editingPack: PolishStylePack? @State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack? @State private var viewingPack: PolishStylePack?
@State private var errorMessage: String? @State private var errorAlert: MacPolishStyleErrorAlert?
@State private var isGeneratingLearnedStyle = false @State private var isGeneratingLearnedStyle = false
private var lang: AppUILanguage { viewModel.config.uiLanguage } private var lang: AppUILanguage { viewModel.config.uiLanguage }
@@ -95,15 +100,15 @@ struct MacPolishStylesView: View {
MacPolishStylePromptDetailSheet(pack: pack, language: lang) MacPolishStylePromptDetailSheet(pack: pack, language: lang)
} }
.alert( .alert(
MacL10n.string("mac.styles.error", language: lang), errorAlert?.title ?? "",
isPresented: Binding( isPresented: Binding(
get: { errorMessage != nil }, get: { errorAlert != nil },
set: { if !$0 { errorMessage = nil } } set: { if !$0 { errorAlert = nil } }
) )
) { ) {
Button(MacL10n.string("mac.done", language: lang)) { errorMessage = nil } Button(MacL10n.string("mac.done", language: lang)) { errorAlert = nil }
} message: { } message: {
Text(errorMessage ?? "") Text(errorAlert?.message ?? "")
} }
.task { .task {
await MacICloudSyncBootstrap.polishStyleSync.pullAndMergeIfEnabled() await MacICloudSyncBootstrap.polishStyleSync.pullAndMergeIfEnabled()
@@ -276,7 +281,13 @@ struct MacPolishStylesView: View {
// becomes the active dictation personality. // becomes the active dictation personality.
editingPack = generated editingPack = generated
} catch { } catch {
errorMessage = localizedLearningError(error) errorAlert = MacPolishStyleErrorAlert(
title: MacL10n.string(
"mac.styles.learn.error.title",
language: lang
),
message: localizedLearningError(error)
)
} }
} }
} }
@@ -292,7 +303,10 @@ struct MacPolishStylesView: View {
case .requestTooLarge: case .requestTooLarge:
return MacL10n.string("mac.styles.learn.error.requestTooLarge", language: lang) return MacL10n.string("mac.styles.learn.error.requestTooLarge", language: lang)
case nil: case nil:
return MacL10n.string("mac.styles.learn.error.request", language: lang) return PolishStyleLearningFailureMessage.localized(
for: error,
language: lang
) ?? MacL10n.string("mac.styles.learn.error.request", language: lang)
} }
} }
@@ -323,7 +337,10 @@ struct MacPolishStylesView: View {
try? await MacICloudSyncBootstrap.settingsSync.pushLocalIfEnabled() try? await MacICloudSyncBootstrap.settingsSync.pushLocalIfEnabled()
} }
} catch { } catch {
errorMessage = MacL10n.string("mac.styles.validation", language: lang) errorAlert = MacPolishStyleErrorAlert(
title: MacL10n.string("mac.styles.error", language: lang),
message: MacL10n.string("mac.styles.validation", language: lang)
)
} }
} }
@@ -6,7 +6,7 @@
import Foundation import Foundation
public struct LiveConfigurationSnapshot { public struct LiveConfigurationSnapshot: @unchecked Sendable {
public let providerId: String public let providerId: String
public let baseURL: String public let baseURL: String
public let apiKey: String public let apiKey: String
@@ -66,6 +66,32 @@ public struct LiveConfigurationSnapshot {
self.cloudASRPersistence = cloudASRPersistence self.cloudASRPersistence = cloudASRPersistence
} }
/// Capture every value exposed by an arbitrary configuration store.
/// Later source changes cannot alter provider, credential, or style
/// selection for an operation already using this snapshot.
public init(store: any ConfigurationStore) {
self.init(
providerId: store.providerId,
baseURL: store.baseURL,
apiKey: store.apiKey,
model: store.model,
asrProviderId: store.asrProviderId,
asrBaseURL: store.asrBaseURL,
asrApiKey: store.asrApiKey,
asrModel: store.asrModel,
engineMode: store.engineMode,
credentialSource: store.credentialSource,
polishIntensity: store.polishIntensity,
aiResponseLength: store.aiResponseLength,
llmThinkingEnabled: store.llmThinkingEnabled,
personalDictionary: store.personalDictionary,
polishStyleCatalog: store.polishStyleCatalog,
activePolishStyleId: store.activePolishStyleId,
detectedAppContext: store.detectedAppContext,
cloudASRPersistence: store.cloudASRPersistence
)
}
/// Build from live `ProviderConfig` plus persisted App Group extras. /// Build from live `ProviderConfig` plus persisted App Group extras.
public init(config: ProviderConfig, fallback: AppGroupStore) { public init(config: ProviderConfig, fallback: AppGroupStore) {
self.init( self.init(
@@ -101,6 +127,10 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
self.snapshot = snapshot self.snapshot = snapshot
} }
public init(store: any ConfigurationStore) {
self.init(snapshot: LiveConfigurationSnapshot(store: store))
}
public init(config: ProviderConfig, fallback: AppGroupStore) { public init(config: ProviderConfig, fallback: AppGroupStore) {
self.init(snapshot: LiveConfigurationSnapshot(config: config, fallback: fallback)) self.init(snapshot: LiveConfigurationSnapshot(config: config, fallback: fallback))
} }
@@ -131,7 +161,7 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
) -> LLMClient { ) -> LLMClient {
if credentialSource == .managed || requestPurpose == .oobe { if credentialSource == .managed || requestPurpose == .oobe {
return ManagedLLMClient( return ManagedLLMClient(
capability: .polish, capability: .resolve(taskKind: taskKind),
taskKind: taskKind, taskKind: taskKind,
requestPurpose: requestPurpose, requestPurpose: requestPurpose,
oobeFeature: oobeFeature, oobeFeature: oobeFeature,
@@ -26,6 +26,17 @@ public struct ManagedLLMClient: LLMClient {
case .agent: .agentPlanning case .agent: .agentPlanning
} }
} }
static func resolve(taskKind: ManagedGatewayTaskKind?) -> Self {
switch taskKind {
case .dictationPolish, .translation, .editLastInput, nil:
return .polish
case .aiQuestion, .currentInformationQuestion, .clipboardTransform, .customSkill:
return .assistant
case .agentPlanning:
return .agent
}
}
} }
private struct Attempt { private struct Attempt {
+150 -8
View File
@@ -11,6 +11,18 @@ public struct AIReplyVariant: Equatable, Identifiable, Sendable {
case ordinary case ordinary
case formal case formal
case playful case playful
case invitationAccept
case invitationDecline
case invitationTentative
case taskAcknowledge
case taskClarify
case taskNegotiate
case blessingReturn
case blessingWarm
case blessingPlayful
case clarificationDirect
case clarificationQuestion
case clarificationConfirm
public var systemImage: String { public var systemImage: String {
switch self { switch self {
@@ -20,6 +32,26 @@ public struct AIReplyVariant: Equatable, Identifiable, Sendable {
return "briefcase.fill" return "briefcase.fill"
case .playful: case .playful:
return "theatermasks.fill" return "theatermasks.fill"
case .invitationAccept, .taskAcknowledge:
return "checkmark.circle.fill"
case .invitationDecline:
return "hand.raised.fill"
case .invitationTentative:
return "clock.fill"
case .taskClarify, .clarificationQuestion:
return "questionmark.bubble.fill"
case .taskNegotiate:
return "arrow.left.arrow.right"
case .blessingReturn:
return "heart.fill"
case .blessingWarm:
return "hands.sparkles.fill"
case .blessingPlayful:
return "party.popper.fill"
case .clarificationDirect:
return "bubble.left.and.text.bubble.right.fill"
case .clarificationConfirm:
return "checkmark.bubble.fill"
} }
} }
@@ -31,6 +63,52 @@ public struct AIReplyVariant: Equatable, Identifiable, Sendable {
return "keyboard.ai.replyVariant.formal" return "keyboard.ai.replyVariant.formal"
case .playful: case .playful:
return "keyboard.ai.replyVariant.playful" return "keyboard.ai.replyVariant.playful"
case .invitationAccept:
return "keyboard.ai.replyVariant.invitationAccept"
case .invitationDecline:
return "keyboard.ai.replyVariant.invitationDecline"
case .invitationTentative:
return "keyboard.ai.replyVariant.invitationTentative"
case .taskAcknowledge:
return "keyboard.ai.replyVariant.taskAcknowledge"
case .taskClarify:
return "keyboard.ai.replyVariant.taskClarify"
case .taskNegotiate:
return "keyboard.ai.replyVariant.taskNegotiate"
case .blessingReturn:
return "keyboard.ai.replyVariant.blessingReturn"
case .blessingWarm:
return "keyboard.ai.replyVariant.blessingWarm"
case .blessingPlayful:
return "keyboard.ai.replyVariant.blessingPlayful"
case .clarificationDirect:
return "keyboard.ai.replyVariant.clarificationDirect"
case .clarificationQuestion:
return "keyboard.ai.replyVariant.clarificationQuestion"
case .clarificationConfirm:
return "keyboard.ai.replyVariant.clarificationConfirm"
}
}
/// Generic choices communicate tone through emotion icons. Intent
/// choices keep their fixed icon so the user's decision stays clear.
public var usesEmotionIcon: Bool {
switch self {
case .ordinary, .formal, .playful:
return true
case .invitationAccept,
.invitationDecline,
.invitationTentative,
.taskAcknowledge,
.taskClarify,
.taskNegotiate,
.blessingReturn,
.blessingWarm,
.blessingPlayful,
.clarificationDirect,
.clarificationQuestion,
.clarificationConfirm:
return false
} }
} }
} }
@@ -96,6 +174,59 @@ public struct AIReplyVariant: Equatable, Identifiable, Sendable {
} }
} }
public enum AIReplyVariantSet: String, CaseIterable, Sendable {
case generic
case invitation
case task
case blessing
case clarification
public var kinds: [AIReplyVariant.Kind] {
switch self {
case .generic:
return [.ordinary, .formal, .playful]
case .invitation:
return [.invitationAccept, .invitationDecline, .invitationTentative]
case .task:
return [.taskAcknowledge, .taskClarify, .taskNegotiate]
case .blessing:
return [.blessingReturn, .blessingWarm, .blessingPlayful]
case .clarification:
return [
.clarificationDirect,
.clarificationQuestion,
.clarificationConfirm
]
}
}
public static func resolve(scene: AIClipboardReplyScene?) -> Self {
switch scene {
case .invitation:
return .invitation
case .task:
return .task
case .blessing:
return .blessing
case .clarification:
return .clarification
case .complaint, .negativeQuestion, nil:
return .generic
}
}
public static func resolve(kinds: Set<AIReplyVariant.Kind>) -> Self? {
allCases.first { Set($0.kinds) == kinds }
}
public static func shouldGenerate(
multipleRepliesEnabled: Bool,
scene: AIClipboardReplyScene?
) -> Bool {
multipleRepliesEnabled || scene?.requiresIntentVariants == true
}
}
public enum AIReplyVariantParsingResult: Equatable, Sendable { public enum AIReplyVariantParsingResult: Equatable, Sendable {
case variants([AIReplyVariant]) case variants([AIReplyVariant])
case single(AIReplyVariant) case single(AIReplyVariant)
@@ -107,22 +238,25 @@ public enum AIReplyVariantParser {
/// with exactly one item of each kind and no additional JSON fields. /// with exactly one item of each kind and no additional JSON fields.
public static func parse( public static func parse(
_ raw: String, _ raw: String,
sourceText: String? = nil sourceText: String? = nil,
variantSet: AIReplyVariantSet = .generic
) -> [AIReplyVariant]? { ) -> [AIReplyVariant]? {
guard let data = raw.data(using: .utf8), guard let data = raw.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data), let root = try? JSONSerialization.jsonObject(with: data),
let object = root as? [String: Any], let object = root as? [String: Any],
Set(object.keys) == ["variants"], Set(object.keys) == ["variants"],
let items = object["variants"] as? [[String: Any]], let items = object["variants"] as? [[String: Any]],
items.count == AIReplyVariant.Kind.allCases.count else { items.count == variantSet.kinds.count else {
return nil return nil
} }
let expectedKinds = Set(variantSet.kinds)
var variantsByKind: [AIReplyVariant.Kind: AIReplyVariant] = [:] var variantsByKind: [AIReplyVariant.Kind: AIReplyVariant] = [:]
for item in items { for item in items {
guard Set(item.keys) == ["kind", "emotion", "text"], guard Set(item.keys) == ["kind", "emotion", "text"],
let rawKind = item["kind"] as? String, let rawKind = item["kind"] as? String,
let kind = AIReplyVariant.Kind(rawValue: rawKind), let kind = AIReplyVariant.Kind(rawValue: rawKind),
expectedKinds.contains(kind),
variantsByKind[kind] == nil, variantsByKind[kind] == nil,
let rawEmotion = item["emotion"] as? String, let rawEmotion = item["emotion"] as? String,
let rawText = item["text"] as? String else { let rawText = item["text"] as? String else {
@@ -141,19 +275,27 @@ public enum AIReplyVariantParser {
) )
} }
let ordered = AIReplyVariant.Kind.allCases.compactMap { variantsByKind[$0] } let ordered = variantSet.kinds.compactMap { variantsByKind[$0] }
return ordered.count == AIReplyVariant.Kind.allCases.count ? ordered : nil return ordered.count == variantSet.kinds.count ? ordered : nil
} }
/// Strict multi-reply parsing with a conservative single ordinary fallback. /// Strict multi-reply parsing with a conservative single ordinary fallback
/// Fenced or malformed JSON is never surfaced verbatim to the insertion UI. /// only for generic tone choices. Intent scenes fail closed instead.
public static func parseOrFallback( public static func parseOrFallback(
_ raw: String, _ raw: String,
sourceText: String? = nil sourceText: String? = nil,
variantSet: AIReplyVariantSet = .generic
) -> AIReplyVariantParsingResult? { ) -> AIReplyVariantParsingResult? {
if let variants = parse(raw, sourceText: sourceText) { if let variants = parse(
raw,
sourceText: sourceText,
variantSet: variantSet
) {
return .variants(variants) return .variants(variants)
} }
// A plain-text fallback cannot safely preserve the user's intended
// stance for invitation, task, blessing, or clarification choices.
guard variantSet == .generic else { return nil }
guard let text = fallbackText(from: raw), guard let text = fallbackText(from: raw),
!isSourceEcho(text, sourceText: sourceText) else { !isSourceEcho(text, sourceText: sourceText) else {
return nil return nil
@@ -122,7 +122,7 @@ public struct AISessionState: Equatable, Sendable {
phase == .ready phase == .ready
&& answer == nil && answer == nil
&& selectedReplyVariant == nil && selectedReplyVariant == nil
&& replyVariants.count == AIReplyVariant.Kind.allCases.count && AIReplyVariantSet.resolve(kinds: Set(replyVariants.map(\.kind))) != nil
} }
public var canSend: Bool { public var canSend: Bool {
@@ -201,12 +201,12 @@ public struct AISessionState: Equatable, Sendable {
) { ) {
guard isActive, activeUtteranceID == utteranceID else { return } guard isActive, activeUtteranceID == utteranceID else { return }
let kinds = Set(variants.map(\.kind)) let kinds = Set(variants.map(\.kind))
guard variants.count == AIReplyVariant.Kind.allCases.count, guard let variantSet = AIReplyVariantSet.resolve(kinds: kinds),
kinds == Set(AIReplyVariant.Kind.allCases) else { variants.count == variantSet.kinds.count else {
return return
} }
answer = nil answer = nil
replyVariants = AIReplyVariant.Kind.allCases.compactMap { kind in replyVariants = variantSet.kinds.compactMap { kind in
variants.first { $0.kind == kind } variants.first { $0.kind == kind }
} }
selectedReplyVariant = nil selectedReplyVariant = nil
@@ -213,7 +213,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
) -> LLMClient { ) -> LLMClient {
if credentialSource == .managed || requestPurpose == .oobe { if credentialSource == .managed || requestPurpose == .oobe {
return ManagedLLMClient( return ManagedLLMClient(
capability: .polish, capability: .resolve(taskKind: taskKind),
taskKind: taskKind, taskKind: taskKind,
requestPurpose: requestPurpose, requestPurpose: requestPurpose,
oobeFeature: oobeFeature, oobeFeature: oobeFeature,
+167 -101
View File
@@ -41,6 +41,142 @@ public struct AIClipboardReplyStyleContext: Equatable, Sendable {
} }
} }
/// A semantic modifier for the generic Reply action. Scenes change how the
/// reply is expressed without creating another user-facing keyboard action.
public enum AIClipboardReplyScene: Equatable, Sendable {
case invitation
case task
case blessing
case clarification
case complaint
case negativeQuestion
public static func resolve(from analysis: ClipboardSemanticAnalysis) -> Self? {
if analysis.complaint.isDetected,
analysis.complaint.isApprovedForAutomaticRouting {
return .complaint
}
if analysis.invitation.isDetected,
analysis.invitation.isApprovedForAutomaticRouting {
return .invitation
}
if analysis.blessing.isDetected,
analysis.blessing.isApprovedForAutomaticRouting {
return .blessing
}
if [
analysis.confirmationDecision,
analysis.followUpReminder,
analysis.task
].contains(where: {
$0.isDetected && $0.isApprovedForAutomaticRouting
}) {
return .task
}
if analysis.sentiment == .negative,
analysis.question.isDetected,
analysis.question.isApprovedForAutomaticRouting {
return .negativeQuestion
}
if [
analysis.scheduleNegotiation,
analysis.question
].contains(where: {
$0.isDetected && $0.isApprovedForAutomaticRouting
}) {
return .clarification
}
return nil
}
public var requiresIntentVariants: Bool {
switch self {
case .invitation, .task, .blessing, .clarification:
return true
case .complaint, .negativeQuestion:
return false
}
}
fileprivate func instruction(locale: String) -> String {
let zh = locale == "zh"
switch self {
case .invitation:
return zh
? """
<reply_scene type="invitation">
</reply_scene>
"""
: """
<reply_scene type="invitation">
Scene modifier: the sender is making an invitation. The three variants must respectively accept, decline, and stay tentative. Never invent the user's schedule, reason for declining, companion, or time/place commitment. The accepting variant may confirm source-supported details; the declining variant may briefly thank without over-apologizing; the tentative variant may say the user needs to check without inventing when they will decide.
</reply_scene>
"""
case .task:
return zh
? """
<reply_scene type="task">
</reply_scene>
"""
: """
<reply_scene type="task">
Scene modifier: the sender is assigning a task, requesting action, asking for confirmation, or following up. The three variants must respectively acknowledge, ask for essential clarification, and negotiate scope or timing. Never invent completion, deadlines, ownership, capability, or commitments. Reuse only task and timing details stated in the source.
</reply_scene>
"""
case .blessing:
return zh
? """
<reply_scene type="blessing">
</reply_scene>
"""
: """
<reply_scene type="blessing">
Scene modifier: the sender is sharing a holiday, birthday, or life-event wish. The three variants must respectively thank and return the wish, respond briefly and warmly, and respond lightly and playfully. First determine whether the user is the recipient; when a group is wishing someone else, join only as a group member. Invent no relationship, shared history, or commitment.
</reply_scene>
"""
case .clarification:
return zh
? """
<reply_scene type="clarification">
</reply_scene>
"""
: """
<reply_scene type="clarification">
Scene modifier: the question, schedule negotiation, or request lacks information needed to answer or act. The three variants must respectively respond to what can already be confirmed, ask one key missing detail, and briefly confirm understanding before asking. Use at most two questions per variant. Never present a guess as an answer or sound like a form, interrogation, or support questionnaire.
</reply_scene>
"""
case .complaint:
return zh
? """
<reply_scene type="complaint">
便诿使 playful
</reply_scene>
"""
: """
<reply_scene type="complaint">
Scene modifier: the sender is expressing clear frustration. First acknowledge the emotion in everyday language, then respond directly to the core issue and offer a safe next step only when supported by the source. Avoid canned support phrases, deflection, minimizing the problem, and invented responsibility, progress, or promises. In multi-reply mode every variant must keep this empathetic stance; playful may only sound more conversational and must not joke, tease the sender, or use the playful emotion.
</reply_scene>
"""
case .negativeQuestion:
return zh
? """
<reply_scene type="negative_question">
使 playful
</reply_scene>
"""
: """
<reply_scene type="negative_question">
Scene modifier: the question carries urgency, frustration, or concern. Briefly acknowledge that feeling, then answer directly or state the next step. A negative tone alone does not prove the user is at fault, so do not apologize, accept blame, or promise anything without source support. In multi-reply mode every variant must stay measured and considerate; playful must not joke, tease the sender, or use the playful emotion.
</reply_scene>
"""
}
}
}
public struct AIClipboardSkill: Identifiable, Equatable, Sendable { public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public let id: String public let id: String
public let systemImage: String public let systemImage: String
@@ -133,32 +269,20 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let declineInvitationID = "declineInvitation" public static let declineInvitationID = "declineInvitation"
public static let acceptTaskID = "acceptTask" public static let acceptTaskID = "acceptTask"
public static let clarifyRequestID = "clarifyRequest" public static let clarifyRequestID = "clarifyRequest"
/// Legacy ID consolidated into `replyID`.
public static let empathyReplyID = "empathyReply" public static let empathyReplyID = "empathyReply"
public static let blessingReplyID = "blessingReply" public static let blessingReplyID = "blessingReply"
/// Legacy ID consolidated into `clarifyRequestID`. /// Legacy ID consolidated into `clarifyRequestID`.
public static let askForDetailsID = "askForDetails" public static let askForDetailsID = "askForDetails"
public static let businessReplyID = "businessReply" public static let businessReplyID = "businessReply"
public static let organizeListID = "organizeList" public static let organizeListID = "organizeList"
public static let replyStyleSkillIDs: Set<String> = [ public static let replyStyleSkillIDs: Set<String> = [replyID]
replyID,
acceptInvitationID,
declineInvitationID,
acceptTaskID,
clarifyRequestID,
empathyReplyID,
blessingReplyID
]
/// Contextual system actions remain available to semantic ranking but are /// Contextual system actions remain available to semantic ranking but are
/// not user-managed entries in the host app's Skills catalog. /// not user-managed entries in the host app's Skills catalog.
public static let hiddenFromSkillManagementIDs: Set<String> = [ public static let hiddenFromSkillManagementIDs: Set<String> = [
replyID, replyID,
declineInvitationID,
empathyReplyID,
blessingReplyID,
acceptInvitationID,
callPhoneID, callPhoneID,
createContactID, createContactID
clarifyRequestID
] ]
public static let extractTodosID = "extractTodos" public static let extractTodosID = "extractTodos"
public static let extractTodosShortcutName = "OSGExtractTodos" public static let extractTodosShortcutName = "OSGExtractTodos"
@@ -239,60 +363,6 @@ public enum AIClipboardSkillCatalog: Sendable {
kind: .transform, kind: .transform,
isDefault: true isDefault: true
), ),
AIClipboardSkill(
id: acceptInvitationID,
systemImage: "checkmark.bubble.fill",
titleKey: "keyboard.ai.skill.acceptInvitation",
cardTitleKey: "skills.acceptInvitation.name",
descriptionKey: "skills.acceptInvitation.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: declineInvitationID,
systemImage: "hand.raised.fill",
titleKey: "keyboard.ai.skill.declineInvitation",
cardTitleKey: "skills.declineInvitation.name",
descriptionKey: "skills.declineInvitation.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: acceptTaskID,
systemImage: "checkmark.circle.fill",
titleKey: "keyboard.ai.skill.acceptTask",
cardTitleKey: "skills.acceptTask.name",
descriptionKey: "skills.acceptTask.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: clarifyRequestID,
systemImage: "questionmark.bubble.fill",
titleKey: "keyboard.ai.skill.clarifyRequest",
cardTitleKey: "skills.clarifyRequest.name",
descriptionKey: "skills.clarifyRequest.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: empathyReplyID,
systemImage: "heart.fill",
titleKey: "keyboard.ai.skill.empathyReply",
cardTitleKey: "skills.empathyReply.name",
descriptionKey: "skills.empathyReply.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: blessingReplyID,
systemImage: "party.popper.fill",
titleKey: "keyboard.ai.skill.blessingReply",
cardTitleKey: "skills.blessingReply.name",
descriptionKey: "skills.blessingReply.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill( AIClipboardSkill(
id: organizeListID, id: organizeListID,
systemImage: "list.bullet.rectangle", systemImage: "list.bullet.rectangle",
@@ -349,6 +419,15 @@ public enum AIClipboardSkillCatalog: Sendable {
/// Hidden compatibility objects for stale direct lookups. They are not /// Hidden compatibility objects for stale direct lookups. They are not
/// part of `catalog`, defaults, skill management, or keyboard visibility. /// part of `catalog`, defaults, skill management, or keyboard visibility.
private static let legacyReplySkills: [String: AIClipboardSkill] = [ private static let legacyReplySkills: [String: AIClipboardSkill] = [
empathyReplyID: AIClipboardSkill(
id: empathyReplyID,
systemImage: "heart.fill",
titleKey: "keyboard.ai.skill.empathyReply",
cardTitleKey: "skills.empathyReply.name",
descriptionKey: "skills.empathyReply.description",
kind: .transform,
isDefault: false
),
playfulReplyID: AIClipboardSkill( playfulReplyID: AIClipboardSkill(
id: playfulReplyID, id: playfulReplyID,
systemImage: "theatermasks.fill", systemImage: "theatermasks.fill",
@@ -374,12 +453,19 @@ public enum AIClipboardSkillCatalog: Sendable {
public static func canonicalID(for id: String) -> String { public static func canonicalID(for id: String) -> String {
switch id { switch id {
case replyInSourceLanguageID, playfulReplyID, businessReplyID: case replyInSourceLanguageID,
playfulReplyID,
businessReplyID,
empathyReplyID,
acceptInvitationID,
declineInvitationID,
acceptTaskID,
clarifyRequestID,
blessingReplyID,
askForDetailsID:
return replyID return replyID
case extractConclusionsID: case extractConclusionsID:
return summarizeID return summarizeID
case askForDetailsID:
return clarifyRequestID
default: default:
return id return id
} }
@@ -425,7 +511,7 @@ public enum AIClipboardSkillCatalog: Sendable {
).first { $0.id == resolvedID } ).first { $0.id == resolvedID }
} }
/// `enabledIDs` is the Skills-tab order. `nil` keeps the default three. /// `enabledIDs` is the Skills-tab order. `nil` keeps current defaults.
/// An explicit empty array shows no chips (carousel fallback). /// An explicit empty array shows no chips (carousel fallback).
public static func visible( public static func visible(
enabledIDs: [String]? = nil, enabledIDs: [String]? = nil,
@@ -457,6 +543,7 @@ public enum AIClipboardSkillCatalog: Sendable {
locale: String, locale: String,
translationTargetLocaleId: String, translationTargetLocaleId: String,
replyStyle: AIClipboardReplyStyleContext? = nil, replyStyle: AIClipboardReplyStyleContext? = nil,
replyScene: AIClipboardReplyScene? = nil,
preferredLanguages: [String] = Locale.preferredLanguages, preferredLanguages: [String] = Locale.preferredLanguages,
now: Date = Date() now: Date = Date()
) -> String { ) -> String {
@@ -479,7 +566,8 @@ public enum AIClipboardSkillCatalog: Sendable {
baseInstruction, baseInstruction,
skillID: skill.id, skillID: skill.id,
locale: locale, locale: locale,
style: replyStyle style: replyStyle,
scene: canonicalID(for: skill.id) == replyID ? replyScene : nil
) )
} }
@@ -535,30 +623,6 @@ public enum AIClipboardSkillCatalog: Sendable {
locale: locale, locale: locale,
preferredLanguages: preferredLanguages preferredLanguages: preferredLanguages
) )
case acceptInvitationID:
return zh
? "请自然、爽快地接受剪贴板中的邀约,像聊天一样确认必要的时间或地点。不要客套过头,也不要虚构用户的安排。"
: "Accept the invitation in a relaxed, natural chat tone and confirm any necessary time or place. Avoid excessive pleasantries and invented plans."
case declineInvitationID:
return zh
? "请用自然、不端着的口吻婉拒剪贴板中的邀约。可以简单表达感谢,但不要过度道歉、长篇解释或虚构理由。"
: "Decline the invitation naturally without sounding stiff. A brief thank-you is fine; avoid excessive apology, long explanations, or invented reasons."
case acceptTaskID:
return zh
? "请像聊天一样简短确认收到剪贴板中的任务或行动请求,可自然带上事项和截止时间。不要写成正式回执,也不要虚构承诺。"
: "Acknowledge the task or action request in a short chat-style reply, naturally confirming the work and deadline. Do not sound like a formal receipt or invent commitments."
case clarifyRequestID:
return zh
? "请理解剪贴板中的问题、任务或故障描述,找出回答、执行、定位或解决前最缺的关键信息,用自然聊天口吻最多追问两个最必要的问题。问题要简短、不重复,不要像表单、审问或客服问卷。"
: "Understand the question, task, or problem in the clipboard, identify the key information missing before answering, acting, diagnosing, or resolving it, and ask at most two essential questions in a natural chat tone. Keep them short and non-repetitive, not like a form, interrogation, or support questionnaire."
case empathyReplyID:
return zh
? "请先用日常口语接住对方的不满,再确认核心问题并给出稳妥下一步。避免“深表歉意”“给您带来不便”等客服模板,不推诿或过度承诺。"
: "Respond to the frustration in everyday language, acknowledge the core issue, and give a safe next step. Avoid canned support phrases, deflection, and overpromising."
case blessingReplyID:
return zh
? "请根据剪贴板中的祝福写一段简短、自然、可直接发送的回复。若祝福是发给用户的,先真诚感谢,再自然回祝;若群聊里是在祝福第三方,就以群成员身份接一句祝福,不要假装自己是收件人。保留节日、生日或人生事件,不虚构关系、经历和承诺。"
: "Write a short, natural, sendable response to the blessing in the clipboard. If it is addressed to the user, thank the sender sincerely and return an appropriate wish. If a group message blesses someone else, join the wish as a group member without pretending to be the recipient. Preserve the holiday, birthday, or life event, and invent no relationship, history, or commitment."
case businessReplyID: case businessReplyID:
return zh return zh
? "请写一段专业但不官腔的商务聊天回复,表达直接、自然,保留人名、组织名、时间和承诺边界,可直接发送。不要套用正式邮件开场和结尾。" ? "请写一段专业但不官腔的商务聊天回复,表达直接、自然,保留人名、组织名、时间和承诺边界,可直接发送。不要套用正式邮件开场和结尾。"
@@ -671,7 +735,8 @@ public enum AIClipboardSkillCatalog: Sendable {
_ baseInstruction: String, _ baseInstruction: String,
skillID: String, skillID: String,
locale: String, locale: String,
style: AIClipboardReplyStyleContext? style: AIClipboardReplyStyleContext?,
scene: AIClipboardReplyScene?
) -> String { ) -> String {
let zh = locale == "zh" let zh = locale == "zh"
let conversationalBaseline: String let conversationalBaseline: String
@@ -692,9 +757,10 @@ public enum AIClipboardSkillCatalog: Sendable {
Voice baseline: sound like an ordinary person chatting naturally with a friend, close friend, or colleague. Match the relationship without putting on a voice, and never sound like a memo, support template, or AI. Prefer short sentences, everyday wording, and natural conversational cues. When the message clearly carries warmth, comfort, frustration, apology, or another emotion, one fitting emoji may be used naturally; never force or stack emojis. Usually write 13 sentences with no title, quotation marks, or explanation. Voice baseline: sound like an ordinary person chatting naturally with a friend, close friend, or colleague. Match the relationship without putting on a voice, and never sound like a memo, support template, or AI. Prefer short sentences, everyday wording, and natural conversational cues. When the message clearly carries warmth, comfort, frustration, apology, or another emotion, one fitting emoji may be used naturally; never force or stack emojis. Usually write 13 sentences with no title, quotation marks, or explanation.
""" """
} }
let sceneInstruction = scene.map { "\n\($0.instruction(locale: locale))" } ?? ""
guard let style, guard let style,
!style.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { !style.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return "\(baseInstruction)\n\(conversationalBaseline)" return "\(baseInstruction)\n\(conversationalBaseline)\(sceneInstruction)"
} }
let boundedStyle = String( let boundedStyle = String(
style.prompt style.prompt
@@ -714,7 +780,7 @@ public enum AIClipboardSkillCatalog: Sendable {
</user_reply_style> </user_reply_style>
Apply only stable wording, rhythm, and expression habits from this style. It must not change the selected skill's intent, facts, safety boundaries, or output language; the selected skill wins on conflict. Apply only stable wording, rhythm, and expression habits from this style. It must not change the selected skill's intent, facts, safety boundaries, or output language; the selected skill wins on conflict.
""" """
return "\(baseInstruction)\n\(conversationalBaseline)\n\(personalStyle)" return "\(baseInstruction)\n\(conversationalBaseline)\(sceneInstruction)\n\(personalStyle)"
} }
/// Clipboard translation always follows the device's primary system language. /// Clipboard translation always follows the device's primary system language.
@@ -318,6 +318,8 @@ public struct AIQuestionService: Sendable {
switch error { switch error {
case .cancelled: case .cancelled:
return .cancelled return .cancelled
case .timeout:
return .timeout
case .transport, .rateLimited: case .transport, .rateLimited:
return .network return .network
case .invalidURL, .noAPIKey, .decoding: case .invalidURL, .noAPIKey, .decoding:
@@ -107,6 +107,8 @@ public struct AnthropicMessagesClient: LLMClient {
throw LLMError.cancelled throw LLMError.cancelled
} catch let urlError as URLError where urlError.code == .cancelled { } catch let urlError as URLError where urlError.code == .cancelled {
throw LLMError.cancelled throw LLMError.cancelled
} catch let urlError as URLError where urlError.code == .timedOut {
throw LLMError.timeout
} catch { } catch {
throw LLMError.transport(String(describing: error)) throw LLMError.transport(String(describing: error))
} }
@@ -458,6 +458,9 @@ public struct AppGroupStore: @unchecked Sendable {
// v9 consolidates playful and business reply into Reply. Do not // v9 consolidates playful and business reply into Reply. Do not
// add Reply here: `sanitized` preserves it only when any reply ID // add Reply here: `sanitized` preserves it only when any reply ID
// was enabled, so a user's explicit disabled state stays disabled. // was enabled, so a user's explicit disabled state stays disabled.
// v10 applies the same canonical migration to Empathetic Reply.
// v11 folds invitation, task, blessing, and clarification replies
// into Reply. `sanitized` again preserves explicit disabled state.
let additions = catalog.map(\.id).filter { let additions = catalog.map(\.id).filter {
additionIDs.contains($0) && !decoded.enabledIDs.contains($0) additionIDs.contains($0) && !decoded.enabledIDs.contains($0)
} }
@@ -483,7 +486,7 @@ public struct AppGroupStore: @unchecked Sendable {
} }
} }
private static let currentAgentSkillDefaultsMigrationVersion = 9 private static let currentAgentSkillDefaultsMigrationVersion = 11
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog { private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else { guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
@@ -12,6 +12,18 @@ public struct ClipboardReplyCandidateSnapshot: Codable, Equatable, Identifiable,
case ordinary case ordinary
case formal case formal
case playful case playful
case invitationAccept
case invitationDecline
case invitationTentative
case taskAcknowledge
case taskClarify
case taskNegotiate
case blessingReturn
case blessingWarm
case blessingPlayful
case clarificationDirect
case clarificationQuestion
case clarificationConfirm
} }
public let id: UUID public let id: UUID
@@ -121,12 +133,29 @@ public final class ClipboardReplyFeedbackStore {
now: Date = Date() now: Date = Date()
) -> [PolishStyleReplyLearningExample] { ) -> [PolishStyleReplyLearningExample] {
records(now: now).compactMap { record in records(now: now).compactMap { record in
guard record.outcome != .awaitingSelection, guard record.outcome != .awaitingSelection else {
let ordinary = record.candidates.first(where: { return nil
}
guard let ordinary = record.candidates.first(where: {
$0.kind == .ordinary $0.kind == .ordinary
}) else { }) else {
// Scene-specific choices express the user's decision, not a
// reusable tone preference. Only a later user-authored edit is
// valid personal-style evidence.
guard record.outcome == .selected,
let selected = record.selectedCandidate,
let finalText = record.finalText else {
return nil return nil
} }
return PolishStyleReplyLearningExample(
receivedMessage: record.sourceText,
ordinaryCandidate: selected.text,
selection: .contextual,
finalEdit: finalText,
createdAt: record.createdAt,
styleID: record.styleID
)
}
let selection: PolishStyleReplySelection let selection: PolishStyleReplySelection
if record.outcome == .discarded { if record.outcome == .discarded {
selection = .discarded selection = .discarded
@@ -279,6 +308,19 @@ public final class ClipboardReplyFeedbackStore {
return .formal return .formal
case .playful: case .playful:
return .playful return .playful
case .invitationAccept,
.invitationDecline,
.invitationTentative,
.taskAcknowledge,
.taskClarify,
.taskNegotiate,
.blessingReturn,
.blessingWarm,
.blessingPlayful,
.clarificationDirect,
.clarificationQuestion,
.clarificationConfirm:
return .contextual
} }
} }
@@ -38,6 +38,25 @@ public struct ClipboardIntentLabel: Equatable, Sendable {
public let isApprovedForAutomaticRouting: Bool public let isApprovedForAutomaticRouting: Bool
} }
public enum ClipboardSemanticDomain: String, CaseIterable, Equatable, Sendable {
case finance
case travel
case calendar
case communication
case media
case smartHome
case shopping
case dining
case health
case weather
case accountService
case generalKnowledge
public var localizationKey: String {
"keyboard.semantic.domain.\(rawValue)"
}
}
public struct ClipboardVerifierDecision: Equatable, Sendable { public struct ClipboardVerifierDecision: Equatable, Sendable {
public let group: String public let group: String
public let label: String public let label: String
@@ -68,6 +87,11 @@ public struct ClipboardSemanticAnalysis: Equatable, Sendable {
public let blessing: ClipboardIntentLabel public let blessing: ClipboardIntentLabel
public let actionVerifier: ClipboardVerifierDecision? public let actionVerifier: ClipboardVerifierDecision?
public let coordinationVerifier: ClipboardVerifierDecision? public let coordinationVerifier: ClipboardVerifierDecision?
public let assistantCommand: ClipboardIntentLabel
public let informationQuery: ClipboardIntentLabel
public let systemNotification: ClipboardIntentLabel
public let domain: ClipboardSemanticDomain?
public let domainConfidence: Double?
public var hasDateOrTime: Bool { !dates.isEmpty } public var hasDateOrTime: Bool { !dates.isEmpty }
public var hasAddress: Bool { !addresses.isEmpty } public var hasAddress: Bool { !addresses.isEmpty }
@@ -81,6 +105,69 @@ public struct ClipboardSemanticAnalysis: Equatable, Sendable {
} }
public var hasPersonName: Bool { !personNames.isEmpty } public var hasPersonName: Bool { !personNames.isEmpty }
public var hasOrganizationName: Bool { !organizationNames.isEmpty } public var hasOrganizationName: Bool { !organizationNames.isEmpty }
public init(
language: ClipboardLanguageLabel?,
dates: [ClipboardDateLabel],
addresses: [ClipboardTextLabel],
phoneNumbers: [ClipboardTextLabel],
urls: [URL],
personNames: [ClipboardTextLabel],
organizationNames: [ClipboardTextLabel],
sentiment: ClipboardSentimentLabel,
sentimentConfidence: Double,
task: ClipboardIntentLabel,
question: ClipboardIntentLabel,
invitation: ClipboardIntentLabel,
complaint: ClipboardIntentLabel,
replyableMessage: ClipboardIntentLabel,
scheduleNegotiation: ClipboardIntentLabel,
confirmationDecision: ClipboardIntentLabel,
followUpReminder: ClipboardIntentLabel,
blessing: ClipboardIntentLabel,
actionVerifier: ClipboardVerifierDecision?,
coordinationVerifier: ClipboardVerifierDecision?,
assistantCommand: ClipboardIntentLabel = .notDetected,
informationQuery: ClipboardIntentLabel = .notDetected,
systemNotification: ClipboardIntentLabel = .notDetected,
domain: ClipboardSemanticDomain? = nil,
domainConfidence: Double? = nil
) {
self.language = language
self.dates = dates
self.addresses = addresses
self.phoneNumbers = phoneNumbers
self.urls = urls
self.personNames = personNames
self.organizationNames = organizationNames
self.sentiment = sentiment
self.sentimentConfidence = sentimentConfidence
self.task = task
self.question = question
self.invitation = invitation
self.complaint = complaint
self.replyableMessage = replyableMessage
self.scheduleNegotiation = scheduleNegotiation
self.confirmationDecision = confirmationDecision
self.followUpReminder = followUpReminder
self.blessing = blessing
self.actionVerifier = actionVerifier
self.coordinationVerifier = coordinationVerifier
self.assistantCommand = assistantCommand
self.informationQuery = informationQuery
self.systemNotification = systemNotification
self.domain = domain
self.domainConfidence = domainConfidence
}
}
public extension ClipboardIntentLabel {
static let notDetected = ClipboardIntentLabel(
confidence: 0,
threshold: 1,
isDetected: false,
isApprovedForAutomaticRouting: false
)
} }
/// Deterministic HTTP(S) extraction shared by analysis and direct URL skills. /// Deterministic HTTP(S) extraction shared by analysis and direct URL skills.
@@ -199,6 +286,20 @@ public actor ClipboardSemanticAnalyzer {
case confirmationDecision case confirmationDecision
case followUpReminder case followUpReminder
case blessing case blessing
case assistantCommand
case informationQuery
case systemNotification
var isDisplayOnly: Bool {
switch self {
case .assistantCommand, .informationQuery, .systemNotification:
return true
case .task, .question, .invitation, .complaint,
.replyableMessage, .scheduleNegotiation,
.confirmationDecision, .followUpReminder, .blessing:
return false
}
}
} }
private static let resourceDirectory = "ClipboardSemantics" private static let resourceDirectory = "ClipboardSemantics"
@@ -286,7 +387,26 @@ public actor ClipboardSemanticAnalyzer {
segments: segments, segments: segments,
languageIdentifier: languageIdentifier languageIdentifier: languageIdentifier
) )
let blessing = adjustedBlessingLabel(blessingCandidate, text: text) let assistantCommand = intentLabel(
.assistantCommand,
segments: segments,
languageIdentifier: languageIdentifier
)
let informationQuery = intentLabel(
.informationQuery,
segments: segments,
languageIdentifier: languageIdentifier
)
let systemNotification = intentLabel(
.systemNotification,
segments: segments,
languageIdentifier: languageIdentifier
)
let domain = domainLabel(
segments: segments,
languageIdentifier: languageIdentifier
)
let blessing = Self.adjustedBlessingLabel(blessingCandidate, text: text)
let sentiment = sentimentLabel(segments: segments) let sentiment = sentimentLabel(segments: segments)
let actionVerifier = verifierDecision( let actionVerifier = verifierDecision(
id: "action", id: "action",
@@ -343,7 +463,12 @@ public actor ClipboardSemanticAnalyzer {
followUpReminder: verifiedCoordination.followUpReminder, followUpReminder: verifiedCoordination.followUpReminder,
blessing: blessing, blessing: blessing,
actionVerifier: actionVerifier, actionVerifier: actionVerifier,
coordinationVerifier: coordinationVerifier coordinationVerifier: coordinationVerifier,
assistantCommand: assistantCommand,
informationQuery: informationQuery,
systemNotification: systemNotification,
domain: domain.value,
domainConfidence: domain.confidence
) )
} }
@@ -519,21 +644,21 @@ public actor ClipboardSemanticAnalyzer {
return !explicitTaskMarkers.contains { normalized.contains($0) } return !explicitTaskMarkers.contains { normalized.contains($0) }
} }
private func adjustedBlessingLabel( static func adjustedBlessingLabel(
_ candidate: ClipboardIntentLabel, _ candidate: ClipboardIntentLabel,
text: String text: String
) -> ClipboardIntentLabel { ) -> ClipboardIntentLabel {
guard Self.hasExplicitBlessingMarker(in: text) else { if isRejectedBlessingContext(in: text) {
return ClipboardIntentLabel( return ClipboardIntentLabel(
confidence: candidate.confidence, confidence: candidate.confidence,
threshold: candidate.threshold, threshold: 1,
isDetected: false, isDetected: false,
isApprovedForAutomaticRouting: candidate.isApprovedForAutomaticRouting isApprovedForAutomaticRouting: candidate.isApprovedForAutomaticRouting
) )
} }
// Explicit blessing phrases are deterministic routing evidence. The
// statistical model remains useful for diagnostics, but cannot route if hasExplicitBlessingMarker(in: text) {
// broad positive language without one of these high-precision markers. // Explicit blessing phrases are deterministic routing evidence.
return ClipboardIntentLabel( return ClipboardIntentLabel(
confidence: 1, confidence: 1,
threshold: 1, threshold: 1,
@@ -542,41 +667,124 @@ public actor ClipboardSemanticAnalyzer {
) )
} }
let modelThreshold = max(candidate.threshold, 0.98)
let isModelApproved = candidate.isApprovedForAutomaticRouting
&& candidate.confidence >= modelThreshold
return ClipboardIntentLabel(
confidence: candidate.confidence,
threshold: modelThreshold,
isDetected: isModelApproved,
isApprovedForAutomaticRouting: candidate.isApprovedForAutomaticRouting
)
}
static func hasExplicitBlessingMarker(in text: String) -> Bool { static func hasExplicitBlessingMarker(in text: String) -> Bool {
let normalized = text.lowercased() let normalized = normalizedBlessingText(text)
let quotedOrMetaContexts = [ guard !isRejectedBlessingContext(in: normalized) else {
"祝福模板", "祝福语模板", "文章引用", "搜索词", "系统正在检查",
"文档里收录", "贺卡名单", "收集祝福", "greeting template",
"message template", "the article quotes", "search phrase",
"system is checking", "document contains", "card list",
"quotes the phrase", "如何描述生日快乐", "怎么说生日快乐",
"如何写生日祝福", "how would you describe a happy birthday",
"how do you say happy birthday", "what does happy birthday mean",
"宁愿你", "祝你倒闭", "祝你立马倒闭", "祝你去死", "祝你倒霉",
"祝你失败", "祝你完蛋"
]
guard !quotedOrMetaContexts.contains(where: { normalized.contains($0) }) else {
return false return false
} }
let markers = [ let markers = [
"生日快乐", "新年快乐", "春节快乐", "节日快乐", "圣诞快乐", "生日快乐", "新年快乐", "春节快乐", "元旦快乐", "元宵节快乐",
"中秋快乐", "恭喜", "预祝", "祝你", "祝您", "祝大家", "祝他", "祝她", "端午安康", "端午快乐", "节日快乐", "圣诞快乐", "中秋快乐",
"愿你", "愿您", "happy birthday", "happy new year", "国庆快乐", "新婚快乐", "毕业快乐", "纪念日快乐", "恭喜",
"merry christmas", "happy holidays", "congratulations", "祝贺", "预祝", "祝你", "祝您", "祝大家", "祝各位", "祝我们",
"congrats", "best wishes", "good luck", "wishing you", "祝他", "祝她", "祝他们", "祝愿", "愿你", "愿您", "愿大家",
"wish you", "wish him", "wish her", "wish them", "let us wish", "愿各位", "愿我们", "愿他", "愿她", "愿他们", "一路顺风",
"let's wish", "we wish", "may you" "一路平安", "早日康复", "前程似锦", "万事如意", "心想事成",
"平安喜乐", "节哀顺变", "开业大吉", "做个好梦",
"happy birthday", "happy new year", "happy anniversary",
"happy graduation", "happy wedding", "merry christmas",
"happy holidays", "congratulations", "congrats", "best wishes",
"good luck", "safe travels", "get well soon", "sweet dreams",
"all the best", "wishing you", "wishing him", "wishing her",
"wishing them", "wish you", "wish him", "wish her", "wish them",
"let us wish", "let's wish", "we wish", "may you", "may your",
"hope you have"
] ]
return markers.contains { normalized.contains($0) } return markers.contains { normalized.contains($0) }
} }
private func emptyAnalysis() -> ClipboardSemanticAnalysis { static func isRejectedBlessingContext(in text: String) -> Bool {
let emptyIntent = ClipboardIntentLabel( let normalized = normalizedBlessingText(text)
confidence: 0, let blockedFragments = [
threshold: 1, "祝福模板", "祝福语模板", "祝福文案", "文章引用", "搜索词",
isDetected: false, "系统正在检查", "文档里收录", "文档里引用", "海报上印着",
isApprovedForAutomaticRouting: false "示例文本", "关键词列表", "分析句式", "贺卡名单", "收集祝福",
"如何描述生日快乐", "怎么说生日快乐", "如何写生日祝福",
"怎么写生日祝福", "帮我写一段祝福", "帮我生成祝福",
"greeting template", "message template", "blessing template",
"the article quotes", "the document quotes", "search phrase",
"system is checking", "document contains", "card list",
"quotes the phrase", "sample text", "keyword list",
"how would you describe a happy birthday",
"how do you say happy birthday", "how to write a birthday wish",
"what does happy birthday mean", "write a birthday wish",
"宁愿你", "祝你倒闭", "祝你立马倒闭", "祝你去死", "祝你倒霉",
"祝你失败", "祝你完蛋", "wish you would die", "wish you bad luck"
]
if blockedFragments.contains(where: { normalized.contains($0) }) {
return true
}
let receivedPatterns = [
#"(?:谢谢|感谢|收到|收到了|多谢).{0,20}(?:祝福|祝愿|生日快乐|恭喜)"#,
#"(?:thank|thanks).{0,64}(?:wish|wishes|congratulations|birthday message)"#
]
let reportedOrMetaPatterns = [
#"(?:帮我写|帮我生成|搜索|查找).{0,16}(?:祝福|祝福语|祝愿|生日快乐)"#,
#"(?:他说|她说|他们说|会议记录|新闻|群公告).{0,20}(?:祝|愿|恭喜)"#,
#"(?:he said|she said|they said|meeting notes|the article reports).{0,32}(?:wish|congratulat)"#
]
if reportedOrMetaPatterns.contains(where: {
normalized.range(of: $0, options: .regularExpression) != nil
}) {
return true
}
let containsReciprocalWish = [
"也祝", "同样祝", ",祝你", ",祝您", "。祝你", "。祝您",
". wish you", ". wishing you", "! wish you", "! wishing you",
", and wish you", ", wishing you", "same to you"
].contains { normalized.contains($0) }
if !containsReciprocalWish,
receivedPatterns.contains(where: {
normalized.range(of: $0, options: .regularExpression) != nil
}) {
return true
}
let plainGreetings = [
"你好", "您好", "早上好", "中午好", "下午好", "晚上好",
"晚安", "好久不见", "hello", "good morning", "good afternoon",
"good evening", "long time no see"
]
let trimmed = normalized.trimmingCharacters(
in: .whitespacesAndNewlines.union(.punctuationCharacters)
) )
if plainGreetings.contains(trimmed) {
return true
}
let celebrationOnly = [
"庆祝", "庆功", "庆典", "celebrate", "celebration"
].contains { normalized.contains($0) }
return celebrationOnly && !containsDirectWishCue(in: normalized)
}
private static func normalizedBlessingText(_ text: String) -> String {
text.precomposedStringWithCompatibilityMapping.lowercased()
}
private static func containsDirectWishCue(in normalized: String) -> Bool {
[
"祝你", "祝您", "祝大家", "祝各位", "祝他", "祝她", "祝他们",
"愿你", "愿您", "愿大家", "愿他", "愿她", "恭喜", "祝贺",
"wishing you", "wish you", "wish him", "wish her", "wish them",
"congratulations", "congrats", "good luck", "best wishes"
].contains { normalized.contains($0) }
}
private func emptyAnalysis() -> ClipboardSemanticAnalysis {
let emptyIntent = ClipboardIntentLabel.notDetected
return ClipboardSemanticAnalysis( return ClipboardSemanticAnalysis(
language: nil, language: nil,
dates: [], dates: [],
@@ -597,7 +805,12 @@ public actor ClipboardSemanticAnalyzer {
followUpReminder: emptyIntent, followUpReminder: emptyIntent,
blessing: emptyIntent, blessing: emptyIntent,
actionVerifier: nil, actionVerifier: nil,
coordinationVerifier: nil coordinationVerifier: nil,
assistantCommand: emptyIntent,
informationQuery: emptyIntent,
systemNotification: emptyIntent,
domain: nil,
domainConfidence: nil
) )
} }
@@ -756,15 +969,50 @@ public actor ClipboardSemanticAnalyzer {
maximumCount: 2 maximumCount: 2
)[positiveLabel] ?? 0 )[positiveLabel] ?? 0
}.max() ?? 0 }.max() ?? 0
// Boundary classifiers launch in display/shadow mode. They may expose a
// threshold-crossing result, but can never authorize an existing route.
let approved = entry.configuration.acceptedForAutomaticRouting let approved = entry.configuration.acceptedForAutomaticRouting
&& !id.isDisplayOnly
return ClipboardIntentLabel( return ClipboardIntentLabel(
confidence: rounded(confidence), confidence: rounded(confidence),
threshold: rounded(threshold), threshold: rounded(threshold),
isDetected: approved && confidence >= threshold, isDetected: (approved || id.isDisplayOnly) && confidence >= threshold,
isApprovedForAutomaticRouting: approved isApprovedForAutomaticRouting: approved
) )
} }
private func domainLabel(
segments: [String],
languageIdentifier: String?
) -> (value: ClipboardSemanticDomain?, confidence: Double?) {
guard let entry = modelEntry(id: "domain") else {
return (nil, nil)
}
let threshold = languageIdentifier.flatMap {
entry.configuration.confidenceThresholdsByLanguage?[$0]
} ?? entry.configuration.confidenceThreshold ?? 1
let winners = segments.compactMap { segment -> (
domain: ClipboardSemanticDomain,
confidence: Double
)? in
let ranked = entry.model.predictedLabelHypotheses(
for: segment,
maximumCount: ClipboardSemanticDomain.allCases.count
).sorted { $0.value > $1.value }
guard let winner = ranked.first,
let domain = ClipboardSemanticDomain(rawValue: winner.key) else {
return nil
}
return (domain, winner.value)
}
guard let winner = winners.max(by: {
$0.confidence < $1.confidence
}), winner.confidence >= threshold else {
return (nil, nil)
}
return (winner.domain, rounded(winner.confidence))
}
private func sentimentLabel( private func sentimentLabel(
segments: [String] segments: [String]
) -> (label: ClipboardSentimentLabel, confidence: Double) { ) -> (label: ClipboardSentimentLabel, confidence: Double) {
@@ -905,7 +1153,7 @@ public actor ClipboardSemanticAnalyzer {
guard let url, guard let url,
let data = try? Data(contentsOf: url), let data = try? Data(contentsOf: url),
let decoded = try? decoder.decode(Manifest.self, from: data), let decoded = try? decoder.decode(Manifest.self, from: data),
(1...3).contains(decoded.schemaVersion) else { (1...4).contains(decoded.schemaVersion) else {
continue continue
} }
manifest = decoded manifest = decoded
@@ -11,7 +11,6 @@ import Foundation
public enum ClipboardSkillSemanticRanker { public enum ClipboardSkillSemanticRanker {
private static let longTextCharacterThreshold = 360 private static let longTextCharacterThreshold = 360
private static let languageConfidenceThreshold = 0.75 private static let languageConfidenceThreshold = 0.75
private static let maximumReplyRecommendations = 2
public static func ranked( public static func ranked(
skills: [AIClipboardSkill], skills: [AIClipboardSkill],
@@ -31,8 +30,8 @@ public enum ClipboardSkillSemanticRanker {
) )
} }
/// Returns semantically relevant skills and always keeps the generic Reply /// Returns semantically relevant skills and keeps generic Reply as a safe
/// action available as a safe fallback for accepted clipboard text. /// fallback unless a display-only boundary intent suppresses human routing.
public static func recommended( public static func recommended(
skills: [AIClipboardSkill], skills: [AIClipboardSkill],
sourceText: String, sourceText: String,
@@ -47,7 +46,9 @@ public enum ClipboardSkillSemanticRanker {
analysis: analysis, analysis: analysis,
preferredLanguages: preferredLanguages preferredLanguages: preferredLanguages
) )
let genericReply = skills.first { $0.id == AIClipboardSkillCatalog.replyID } let genericReply = suppressesInterpersonalRouting(analysis)
? nil
: skills.first { $0.id == AIClipboardSkillCatalog.replyID }
if genericReply != nil { if genericReply != nil {
scores[AIClipboardSkillCatalog.replyID, default: 0] = max( scores[AIClipboardSkillCatalog.replyID, default: 0] = max(
1, 1,
@@ -56,7 +57,6 @@ public enum ClipboardSkillSemanticRanker {
} }
let relevant = skills.filter { scores[$0.id, default: 0] > 0 } let relevant = skills.filter { scores[$0.id, default: 0] > 0 }
var selected: [AIClipboardSkill] = [] var selected: [AIClipboardSkill] = []
var specializedReplyCount = 0
for skill in sorted(relevant, scores: scores) { for skill in sorted(relevant, scores: scores) {
let mustReserveGenericReply = genericReply != nil let mustReserveGenericReply = genericReply != nil
&& !selected.contains(where: { $0.id == AIClipboardSkillCatalog.replyID }) && !selected.contains(where: { $0.id == AIClipboardSkillCatalog.replyID })
@@ -67,10 +67,6 @@ public enum ClipboardSkillSemanticRanker {
selected.append(skill) selected.append(skill)
continue continue
} }
if skill.supportsReplyStyle {
guard specializedReplyCount < maximumReplyRecommendations else { continue }
specializedReplyCount += 1
}
selected.append(skill) selected.append(skill)
} }
if let genericReply, if let genericReply,
@@ -116,61 +112,55 @@ public enum ClipboardSkillSemanticRanker {
boost(AIClipboardSkillCatalog.navigateID, 180) boost(AIClipboardSkillCatalog.navigateID, 180)
} }
let suppressesInterpersonalRouting = suppressesInterpersonalRouting(analysis)
if !suppressesInterpersonalRouting {
if isRoutingEvidence(analysis.invitation) { if isRoutingEvidence(analysis.invitation) {
if analysis.hasDateOrTime { if analysis.hasDateOrTime {
boost(AIClipboardSkillCatalog.extractEventsID, 260) boost(AIClipboardSkillCatalog.extractEventsID, 260)
} }
boost(AIClipboardSkillCatalog.acceptInvitationID, 240) boost(AIClipboardSkillCatalog.replyID, 300)
boost(AIClipboardSkillCatalog.declineInvitationID, 230) } else if analysis.hasDateOrTime {
boost(AIClipboardSkillCatalog.replyID, 60) boost(AIClipboardSkillCatalog.extractEventsID, 110)
}
} else if analysis.hasDateOrTime { } else if analysis.hasDateOrTime {
boost(AIClipboardSkillCatalog.extractEventsID, 110) boost(AIClipboardSkillCatalog.extractEventsID, 110)
} }
if !suppressesInterpersonalRouting {
// A threshold-crossing, evaluation-gated model may still rank a // A threshold-crossing, evaluation-gated model may still rank a
// reversible chip; execution always remains explicitly user-initiated. // reversible chip; execution always remains explicitly user-initiated.
if isRoutingEvidence(analysis.scheduleNegotiation) { if isRoutingEvidence(analysis.scheduleNegotiation) {
boost(AIClipboardSkillCatalog.clarifyRequestID, 300)
boost(AIClipboardSkillCatalog.extractEventsID, 200) boost(AIClipboardSkillCatalog.extractEventsID, 200)
boost(AIClipboardSkillCatalog.replyID, 250) boost(AIClipboardSkillCatalog.replyID, 300)
} }
if isRoutingEvidence(analysis.confirmationDecision) { if isRoutingEvidence(analysis.confirmationDecision) {
boost(AIClipboardSkillCatalog.acceptTaskID, 300) boost(AIClipboardSkillCatalog.replyID, 300)
boost(AIClipboardSkillCatalog.replyID, 280)
} }
if isRoutingEvidence(analysis.followUpReminder) { if isRoutingEvidence(analysis.followUpReminder) {
boost(AIClipboardSkillCatalog.extractTodosID, 285) boost(AIClipboardSkillCatalog.extractTodosID, 285)
boost(AIClipboardSkillCatalog.acceptTaskID, 250) boost(AIClipboardSkillCatalog.replyID, 250)
boost(AIClipboardSkillCatalog.clarifyRequestID, 170)
boost(AIClipboardSkillCatalog.replyID, 90)
} }
if isRoutingEvidence(analysis.task) { if isRoutingEvidence(analysis.task) {
boost(AIClipboardSkillCatalog.extractTodosID, 155) boost(AIClipboardSkillCatalog.extractTodosID, 155)
boost(AIClipboardSkillCatalog.acceptTaskID, 140) boost(AIClipboardSkillCatalog.replyID, 140)
boost(AIClipboardSkillCatalog.clarifyRequestID, 105)
} }
if isRoutingEvidence(analysis.question) { if isRoutingEvidence(analysis.question) {
boost(AIClipboardSkillCatalog.replyID, 145) boost(AIClipboardSkillCatalog.replyID, 145)
boost(AIClipboardSkillCatalog.clarifyRequestID, 110)
} }
if isRoutingEvidence(analysis.blessing) { if isRoutingEvidence(analysis.blessing) {
boost(AIClipboardSkillCatalog.blessingReplyID, 300) boost(AIClipboardSkillCatalog.replyID, 300)
boost(AIClipboardSkillCatalog.replyID, 95)
} }
if isRoutingEvidence(analysis.complaint) { if isRoutingEvidence(analysis.complaint) {
boost(AIClipboardSkillCatalog.empathyReplyID, 105) boost(AIClipboardSkillCatalog.replyID, 105)
boost(AIClipboardSkillCatalog.clarifyRequestID, 90)
boost(AIClipboardSkillCatalog.replyID, 55)
} else if analysis.sentiment == .negative, } else if analysis.sentiment == .negative,
isRoutingEvidence(analysis.question) { isRoutingEvidence(analysis.question) {
boost(AIClipboardSkillCatalog.empathyReplyID, 85) boost(AIClipboardSkillCatalog.replyID, 85)
boost(AIClipboardSkillCatalog.clarifyRequestID, 65)
} }
if analysis.hasOrganizationName, if analysis.hasOrganizationName,
@@ -181,6 +171,7 @@ public enum ClipboardSkillSemanticRanker {
} else if analysis.hasOrganizationName { } else if analysis.hasOrganizationName {
boost(AIClipboardSkillCatalog.replyID, 70) boost(AIClipboardSkillCatalog.replyID, 70)
} }
}
if isListLike(sourceText) { if isListLike(sourceText) {
boost(AIClipboardSkillCatalog.organizeListID, 145) boost(AIClipboardSkillCatalog.organizeListID, 145)
@@ -193,6 +184,7 @@ public enum ClipboardSkillSemanticRanker {
boost(AIClipboardSkillCatalog.saveToNotesID, 85) boost(AIClipboardSkillCatalog.saveToNotesID, 85)
} }
if !suppressesInterpersonalRouting {
let hasSpecializedReplyIntent = isRoutingEvidence(analysis.task) let hasSpecializedReplyIntent = isRoutingEvidence(analysis.task)
|| isRoutingEvidence(analysis.question) || isRoutingEvidence(analysis.question)
|| isRoutingEvidence(analysis.invitation) || isRoutingEvidence(analysis.invitation)
@@ -216,9 +208,56 @@ public enum ClipboardSkillSemanticRanker {
if analysis.sentiment == .positive { if analysis.sentiment == .positive {
boost(AIClipboardSkillCatalog.replyID, 45) boost(AIClipboardSkillCatalog.replyID, 45)
} }
}
applyDomainBoosts(
analysis,
sourceText: sourceText,
suppressesInterpersonalRouting: suppressesInterpersonalRouting,
boost: boost
)
return scores return scores
} }
private static func applyDomainBoosts(
_ analysis: ClipboardSemanticAnalysis,
sourceText: String,
suppressesInterpersonalRouting: Bool,
boost: (String, Int) -> Void
) {
guard let domain = analysis.domain,
let confidence = analysis.domainConfidence,
confidence > 0 else {
return
}
switch domain {
case .calendar:
if analysis.hasDateOrTime {
boost(AIClipboardSkillCatalog.extractEventsID, 20)
}
case .travel:
if analysis.hasAddress {
boost(AIClipboardSkillCatalog.navigateID, 20)
}
case .media, .generalKnowledge:
if sourceText.count >= longTextCharacterThreshold {
boost(AIClipboardSkillCatalog.summarizeID, 15)
}
case .communication:
if !suppressesInterpersonalRouting,
hasInterpersonalRoutingEvidence(analysis) {
boost(AIClipboardSkillCatalog.replyID, 15)
}
case .finance, .accountService:
if !suppressesInterpersonalRouting,
isRoutingEvidence(analysis.question)
|| isRoutingEvidence(analysis.complaint) {
boost(AIClipboardSkillCatalog.replyID, 10)
}
case .smartHome, .shopping, .dining, .health, .weather:
return
}
}
private static func sorted( private static func sorted(
_ skills: [AIClipboardSkill], _ skills: [AIClipboardSkill],
scores: [String: Int] scores: [String: Int]
@@ -255,6 +294,34 @@ public enum ClipboardSkillSemanticRanker {
label.isDetected && label.isApprovedForAutomaticRouting label.isDetected && label.isApprovedForAutomaticRouting
} }
private static func isDisplayEvidence(_ label: ClipboardIntentLabel) -> Bool {
label.isDetected
&& label.confidence > 0
&& label.confidence >= label.threshold
}
private static func suppressesInterpersonalRouting(
_ analysis: ClipboardSemanticAnalysis
) -> Bool {
isDisplayEvidence(analysis.assistantCommand)
|| isDisplayEvidence(analysis.informationQuery)
|| isDisplayEvidence(analysis.systemNotification)
}
private static func hasInterpersonalRoutingEvidence(
_ analysis: ClipboardSemanticAnalysis
) -> Bool {
isRoutingEvidence(analysis.task)
|| isRoutingEvidence(analysis.question)
|| isRoutingEvidence(analysis.invitation)
|| isRoutingEvidence(analysis.complaint)
|| isRoutingEvidence(analysis.replyableMessage)
|| isRoutingEvidence(analysis.scheduleNegotiation)
|| isRoutingEvidence(analysis.confirmationDecision)
|| isRoutingEvidence(analysis.followUpReminder)
|| isRoutingEvidence(analysis.blessing)
}
private static func isListLike(_ text: String) -> Bool { private static func isListLike(_ text: String) -> Bool {
let lines = text let lines = text
.split(whereSeparator: \.isNewline) .split(whereSeparator: \.isNewline)
@@ -362,8 +362,8 @@ public final class KeyboardState: ObservableObject {
public var performAssistantFieldAction: () -> Void = {} public var performAssistantFieldAction: () -> Void = {}
/// Sends a tapped idle hint card as the AI question (skip microphone). /// Sends a tapped idle hint card as the AI question (skip microphone).
public var submitAIHint: (AIHintCard) -> Void = { _ in } public var submitAIHint: (AIHintCard) -> Void = { _ in }
/// Sends a clipboard skill (reply / summarize / translate / export). /// Sends a clipboard skill plus any source-bound Reply scene modifier.
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in } public var submitAIClipboardSkill: (AIClipboardSkill, AIClipboardReplyScene?) -> Void = { _, _ in }
/// Writes extract-todos titles and opens the host to run the Shortcut. /// Writes extract-todos titles and opens the host to run the Shortcut.
public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in } public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
public var openSettings: () -> Void = {} public var openSettings: () -> Void = {}
@@ -12,6 +12,7 @@ public enum LLMError: Error, LocalizedError, Sendable, Equatable {
case http(status: Int) case http(status: Int)
case decoding(String) case decoding(String)
case transport(String) case transport(String)
case timeout
case cancelled case cancelled
case rateLimited case rateLimited
@@ -27,6 +28,8 @@ public enum LLMError: Error, LocalizedError, Sendable, Equatable {
return SharedL10n.string("error.llm.decoding") return SharedL10n.string("error.llm.decoding")
case .transport: case .transport:
return SharedL10n.string("error.llm.transport") return SharedL10n.string("error.llm.transport")
case .timeout:
return SharedL10n.string("error.llm.timeout")
case .rateLimited: case .rateLimited:
return SharedL10n.string("error.llm.rateLimited") return SharedL10n.string("error.llm.rateLimited")
case .cancelled: case .cancelled:
@@ -291,6 +294,8 @@ public struct OpenAICompatibleClient: LLMClient {
throw LLMError.cancelled throw LLMError.cancelled
} catch let urlError as URLError where urlError.code == .cancelled { } catch let urlError as URLError where urlError.code == .cancelled {
throw LLMError.cancelled throw LLMError.cancelled
} catch let urlError as URLError where urlError.code == .timedOut {
throw LLMError.timeout
} catch { } catch {
throw LLMError.transport(String(describing: error)) throw LLMError.transport(String(describing: error))
} }
@@ -39,6 +39,9 @@ public enum PolishStyleReplySelection: String, Codable, Equatable, Sendable {
case ordinary case ordinary
case formal case formal
case playful case playful
/// A scene-specific decision (for example accept/decline) is not a
/// reusable voice preference. Only its user-authored final edit may teach.
case contextual
case discarded case discarded
} }
@@ -84,6 +87,7 @@ public struct PolishStyleLearningEvidence: Codable, Equatable, Sendable {
public enum Source: String, Codable, Hashable, Sendable { public enum Source: String, Codable, Hashable, Sendable {
case asrUserEdit case asrUserEdit
case asrRepeatedBefore case asrRepeatedBefore
case asrObservedBefore
case replyFinalEdit case replyFinalEdit
case replyCrossContextSelection case replyCrossContextSelection
case replyAcceptance case replyAcceptance
@@ -259,6 +263,138 @@ public enum PolishStyleLearningError: Error, Equatable, Sendable {
case requestTooLarge case requestTooLarge
} }
/// Converts provider and credential failures into safe, actionable messages.
/// Raw transport details can contain endpoint data, so they are never shown.
public enum PolishStyleLearningFailureMessage {
public static func localized(
for error: Error,
language: AppUILanguage
) -> String? {
if let polishError = error as? PolishingService.PolishError {
switch polishError {
case .noTranscript:
return SharedL10n.string(
"styleLearning.error.emptyRequest",
language: language
)
case .timeout:
return SharedL10n.string(
"styleLearning.error.timeout",
language: language
)
case .missingAPIKey:
return SharedL10n.string(
"styleLearning.error.missingAPIKey",
language: language
)
case .keychainLocked:
return SharedL10n.string(
"styleLearning.error.keychainLocked",
language: language
)
}
}
if let llmError = error as? LLMError {
switch llmError {
case .invalidURL:
return SharedL10n.string("error.llm.invalidURL", language: language)
case .noAPIKey:
return SharedL10n.string(
"styleLearning.error.missingAPIKey",
language: language
)
case .http(let status):
return SharedL10n.format(
"error.llm.http",
language: language,
Int64(status)
)
case .decoding:
return SharedL10n.string("error.llm.decoding", language: language)
case .transport:
return SharedL10n.string("error.llm.transport", language: language)
case .timeout:
return SharedL10n.string("error.llm.timeout", language: language)
case .cancelled:
return SharedL10n.string("error.llm.cancelled", language: language)
case .rateLimited:
return SharedL10n.string("error.llm.rateLimited", language: language)
}
}
if let managedError = error as? ManagedGatewayError {
switch managedError {
case .missingGrant:
return SharedL10n.string(
"managed.error.grantUnavailable",
language: language
)
case .scopeNotGranted(let scope):
return SharedL10n.format(
"managed.error.scopeNotGranted",
language: language,
scope.rawValue
)
case .invalidGrant:
return SharedL10n.string(
"managed.error.grantRejected",
language: language
)
case .insufficientCredits:
return SharedL10n.string(
"managed.error.insufficientCredits",
language: language
)
case .oobeFeatureAlreadyUsed:
return SharedL10n.string(
"managed.error.oobeFeatureAlreadyUsed",
language: language
)
case .timeout:
return SharedL10n.string("managed.error.timeout", language: language)
case .providerUnavailable:
return SharedL10n.string(
"managed.error.providerUnavailable",
language: language
)
case .providerRateLimited:
return SharedL10n.string(
"managed.error.providerRateLimited",
language: language
)
case .providerTimeout:
return SharedL10n.string(
"managed.error.providerTimeout",
language: language
)
case .providerFailure:
return SharedL10n.string(
"managed.error.providerFailure",
language: language
)
case .internalFailure:
return SharedL10n.string(
"managed.error.internalFailure",
language: language
)
case .server(let code, let status, _):
return SharedL10n.format(
"managed.error.server",
language: language,
code,
Int64(status)
)
}
}
if error is CancellationError {
return SharedL10n.string("error.llm.cancelled", language: language)
}
return nil
}
}
public actor PolishStyleLearningService { public actor PolishStyleLearningService {
private struct StyleReference: Codable { private struct StyleReference: Codable {
let id: String let id: String
@@ -275,6 +411,7 @@ public actor PolishStyleLearningService {
} }
private struct ASRInput: Codable { private struct ASRInput: Codable {
let residualBaseline: StyleReference
let currentStyleContamination: StyleReference let currentStyleContamination: StyleReference
let historicalStyleContamination: [StyleReference] let historicalStyleContamination: [StyleReference]
let examples: [ASRExamplePayload] let examples: [ASRExamplePayload]
@@ -324,7 +461,12 @@ public actor PolishStyleLearningService {
private static let maximumEvidenceItemsPerDomain = 24 private static let maximumEvidenceItemsPerDomain = 24
private static let maximumContradictionsPerDomain = 12 private static let maximumContradictionsPerDomain = 12
private static let maximumEvidenceFieldCharacters = 320 private static let maximumEvidenceFieldCharacters = 320
private static let learningSchemaVersion = 2 private static let learningSchemaVersion = 3
private static let generationOptions = LLMGenerationOptions(
temperature: 0.1,
topP: 0.9,
maxTokens: 4_096
)
private let store: any ConfigurationStore private let store: any ConfigurationStore
private let client: LLMClient? private let client: LLMClient?
@@ -340,42 +482,51 @@ public actor PolishStyleLearningService {
public func generateStyle( public func generateStyle(
from corpus: PolishStyleLearningCorpus, from corpus: PolishStyleLearningCorpus,
replyExamples: [PolishStyleReplyLearningExample] = [], replyExamples: [PolishStyleReplyLearningExample] = [],
outputLanguage: AppUILanguage outputLanguage: AppUILanguage,
minimumEffectiveCharacterCount: Int =
PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
) async throws -> PolishStylePack { ) async throws -> PolishStylePack {
let requiredCharacterCount = max(0, minimumEffectiveCharacterCount)
let verifiedCharacterCount = corpus.examples.reduce(into: 0) { count, example in let verifiedCharacterCount = corpus.examples.reduce(into: 0) { count, example in
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount( count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
in: example.prePolishText in: example.prePolishText
) )
} }
guard verifiedCharacterCount guard verifiedCharacterCount >= requiredCharacterCount else {
>= PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount else {
throw PolishStyleLearningError.insufficientCorpus( throw PolishStyleLearningError.insufficientCorpus(
required: PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount, required: requiredCharacterCount,
actual: verifiedCharacterCount actual: verifiedCharacterCount
) )
} }
// Freeze provider, model, credential channel and contamination controls
// so retries and both model stages describe one coherent operation.
let configuration = LiveConfigurationStore(
snapshot: LiveConfigurationSnapshot(store: store)
)
let notifiesManagedCredits = client == nil
&& configuration.credentialSource == .managed
let selectedASRExamples = Self.selectExamples(from: corpus.examples) let selectedASRExamples = Self.selectExamples(from: corpus.examples)
let selectedReplyExamples = Self.selectReplyExamples(from: replyExamples) let selectedReplyExamples = Self.selectReplyExamples(from: replyExamples)
let evidencePayload = try Self.makeEvidenceRequestPayload( let evidencePayload = try Self.makeEvidenceRequestPayload(
corpus: corpus, corpus: corpus,
replyExamples: selectedReplyExamples, replyExamples: selectedReplyExamples,
activeStyleID: store.activePolishStyleId, activeStyleID: configuration.activePolishStyleId,
catalog: store.polishStyleCatalog, catalog: configuration.polishStyleCatalog,
outputLanguage: outputLanguage outputLanguage: outputLanguage
) )
let service = PolishingService( let service = PolishingService(
store: store, store: configuration,
client: client, client: client,
timeout: 45 timeout: 45,
maximumTimeout: 45
) )
let evidenceResponse = try await service.polish( let evidence = try await extractEvidence(
evidencePayload, payload: evidencePayload,
systemPrompt: Self.evidenceExtractorSystemPrompt(), service: service,
taskKind: .customSkill requiresBestEffortASRCandidate: !selectedASRExamples.isEmpty,
notifiesManagedCredits: notifiesManagedCredits
) )
notifyManagedCreditsMayHaveChanged()
let evidence = try Self.parseEvidence(evidenceResponse)
let metadata = PolishStylePack.LearningMetadata( let metadata = PolishStylePack.LearningMetadata(
schemaVersion: Self.learningSchemaVersion, schemaVersion: Self.learningSchemaVersion,
evidenceStatus: evidence.status.rawValue, evidenceStatus: evidence.status.rawValue,
@@ -392,28 +543,99 @@ public actor PolishStyleLearningService {
}.count, }.count,
generatedAt: Date() generatedAt: Date()
) )
// Always synthesize from this operation's evidence. Low-confidence
// profiles use their strongest candidate traits; only genuinely empty
// profiles may disclose that no personal tendency was observed.
let synthesisPayload = try Self.makeSynthesisRequestPayload( let synthesisPayload = try Self.makeSynthesisRequestPayload(
evidence: evidence, evidence: evidence,
metadata: metadata metadata: metadata
) )
let synthesisResponse = try await service.polish( return try await synthesizeStyle(
synthesisPayload, payload: synthesisPayload,
systemPrompt: Self.synthesizerSystemPrompt( service: service,
outputLanguage: outputLanguage
),
taskKind: .customSkill
)
notifyManagedCreditsMayHaveChanged()
return try Self.parseGeneratedStyle(
synthesisResponse,
evidenceStatus: evidence.status,
learningMetadata: metadata, learningMetadata: metadata,
outputLanguage: outputLanguage outputLanguage: outputLanguage,
notifiesManagedCredits: notifiesManagedCredits
) )
} }
private func notifyManagedCreditsMayHaveChanged() { private func extractEvidence(
guard client == nil, store.credentialSource == .managed else { return } payload: String,
service: PolishingService,
requiresBestEffortASRCandidate: Bool,
notifiesManagedCredits: Bool
) async throws -> PolishStyleLearningEvidence {
let response = try await service.polish(
payload,
systemPrompt: Self.evidenceExtractorSystemPrompt(),
options: Self.generationOptions,
taskKind: .customSkill
)
notifyManagedCreditsMayHaveChanged(ifNeeded: notifiesManagedCredits)
do {
return try Self.parseEvidence(
response,
requiresBestEffortASRCandidate: requiresBestEffortASRCandidate
)
} catch let error as PolishStyleLearningError
where error == .invalidResponse {
let repairedResponse = try await service.polish(
payload,
systemPrompt: Self.evidenceRepairSystemPrompt(),
options: Self.generationOptions,
taskKind: .customSkill
)
notifyManagedCreditsMayHaveChanged(ifNeeded: notifiesManagedCredits)
return try Self.parseEvidence(
repairedResponse,
requiresBestEffortASRCandidate: requiresBestEffortASRCandidate
)
}
}
private func synthesizeStyle(
payload: String,
service: PolishingService,
learningMetadata: PolishStylePack.LearningMetadata,
outputLanguage: AppUILanguage,
notifiesManagedCredits: Bool
) async throws -> PolishStylePack {
let response = try await service.polish(
payload,
systemPrompt: Self.synthesizerSystemPrompt(
outputLanguage: outputLanguage
),
options: Self.generationOptions,
taskKind: .customSkill
)
notifyManagedCreditsMayHaveChanged(ifNeeded: notifiesManagedCredits)
do {
return try Self.parseGeneratedStyle(
response,
learningMetadata: learningMetadata,
outputLanguage: outputLanguage
)
} catch let error as PolishStyleLearningError
where error == .invalidResponse {
let repairedResponse = try await service.polish(
payload,
systemPrompt: Self.synthesisRepairSystemPrompt(
outputLanguage: outputLanguage
),
options: Self.generationOptions,
taskKind: .customSkill
)
notifyManagedCreditsMayHaveChanged(ifNeeded: notifiesManagedCredits)
return try Self.parseGeneratedStyle(
repairedResponse,
learningMetadata: learningMetadata,
outputLanguage: outputLanguage
)
}
}
private func notifyManagedCreditsMayHaveChanged(ifNeeded shouldNotify: Bool) {
guard shouldNotify else { return }
NotificationCenter.default.post(name: .managedCreditsMayHaveChanged, object: nil) NotificationCenter.default.post(name: .managedCreditsMayHaveChanged, object: nil)
} }
@@ -445,6 +667,10 @@ public actor PolishStyleLearningService {
userCatalog: catalog userCatalog: catalog
) )
let selectedExamples = selectExamples(from: corpus.examples) let selectedExamples = selectExamples(from: corpus.examples)
let baselineStyle = PolishStylePackCatalog.resolve(
id: "builtin.chat",
userCatalog: catalog
)
let references = styleReferences( let references = styleReferences(
for: selectedExamples, for: selectedExamples,
activeStyle: activeStyle, activeStyle: activeStyle,
@@ -454,6 +680,10 @@ public actor PolishStyleLearningService {
let payload = EvidenceRequestPayload( let payload = EvidenceRequestPayload(
schemaVersion: learningSchemaVersion, schemaVersion: learningSchemaVersion,
asr: ASRInput( asr: ASRInput(
residualBaseline: reference(
for: baselineStyle,
outputLanguage: outputLanguage
),
currentStyleContamination: reference( currentStyleContamination: reference(
for: activeStyle, for: activeStyle,
outputLanguage: outputLanguage outputLanguage: outputLanguage
@@ -487,15 +717,15 @@ public actor PolishStyleLearningService {
return try encodeRequest(payload) return try encodeRequest(payload)
} }
static func parseEvidence(_ raw: String) throws -> PolishStyleLearningEvidence { static func parseEvidence(
guard raw.count <= maximumEvidenceResponseCharacters else { _ raw: String,
throw PolishStyleLearningError.invalidResponse requiresBestEffortASRCandidate: Bool = false
} ) throws -> PolishStyleLearningEvidence {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let data = try extractUniqueJSONObject(
guard trimmed.first == "{", from: raw,
trimmed.last == "}", maximumCharacters: maximumEvidenceResponseCharacters
let data = trimmed.data(using: .utf8), )
hasExactEvidenceProtocol(data), guard hasExactEvidenceProtocol(data),
let evidence = try? JSONDecoder().decode( let evidence = try? JSONDecoder().decode(
PolishStyleLearningEvidence.self, PolishStyleLearningEvidence.self,
from: data from: data
@@ -503,34 +733,28 @@ public actor PolishStyleLearningService {
isValid(evidence) else { isValid(evidence) else {
throw PolishStyleLearningError.invalidResponse throw PolishStyleLearningError.invalidResponse
} }
if requiresBestEffortASRCandidate,
evidence.status == .insufficient,
evidence.asr.traits.isEmpty {
throw PolishStyleLearningError.invalidResponse
}
return evidence return evidence
} }
static func parseGeneratedStyle( static func parseGeneratedStyle(
_ raw: String, _ raw: String,
evidenceStatus: PolishStyleLearningEvidence.Status = .sufficient,
learningMetadata: PolishStylePack.LearningMetadata? = nil, learningMetadata: PolishStylePack.LearningMetadata? = nil,
outputLanguage: AppUILanguage outputLanguage: AppUILanguage
) throws -> PolishStylePack { ) throws -> PolishStylePack {
guard raw.count <= maximumSynthesisResponseCharacters else { let data = try extractUniqueJSONObject(
throw PolishStyleLearningError.invalidResponse from: raw,
} maximumCharacters: maximumSynthesisResponseCharacters
let trimmedResponse = raw.trimmingCharacters(in: .whitespacesAndNewlines) )
guard trimmedResponse.first == "{", guard hasExactGeneratedStyleProtocol(data),
trimmedResponse.last == "}",
let data = trimmedResponse.data(using: .utf8),
hasExactGeneratedStyleProtocol(data),
let generated = try? JSONDecoder().decode(GeneratedStyle.self, from: data) else { let generated = try? JSONDecoder().decode(GeneratedStyle.self, from: data) else {
throw PolishStyleLearningError.invalidResponse throw PolishStyleLearningError.invalidResponse
} }
if evidenceStatus == .insufficient {
return insufficientEvidencePack(
outputLanguage: outputLanguage,
learningMetadata: learningMetadata
)
}
let prompt = PolishStylePackCatalog.runtimePersonality( let prompt = PolishStylePackCatalog.runtimePersonality(
for: PolishStylePack( for: PolishStylePack(
name: "Generated", name: "Generated",
@@ -579,9 +803,33 @@ public actor PolishStyleLearningService {
Markdown, prose, code fences, extra keys, or trailing content. Markdown, prose, code fences, extra keys, or trailing content.
- Keep every string at most 320 characters and every array small. - Keep every string at most 320 characters and every array small.
PERSONAL RESIDUAL METHOD:
- Use asr.residualBaseline (always builtin.chat) only to subtract generic
AI cleanup operations. It is not a population norm and must not erase
concrete habits observed in the user's raw before text merely because
builtin.chat also preserves or permits those habits.
- Learn the user's strongest supported residual or, when evidence is
sparse, the strongest bounded candidate tendency in raw before text.
- Deduplicate exact and near-duplicate examples before counting support.
Template variants and repeated copies count as one observation.
- Subtract scene, audience/relationship, topic, transient emotion, and
ASR recognition artifacts. Also subtract both currentStyleContamination
and historicalStyleContamination; those prompts are negative controls,
never evidence of identity or preference.
- Evaluate residuals separately for information order, epistemic stance,
directness, speech acts, rhythm, connective words, register, humor,
and Emoji. Do not collapse these dimensions into a vague persona.
- Label each described trait as retention or migration. Retention means
a native habit to preserve when already present. Migration means a
supported relative preference that may be actively transferred.
EVIDENCE DOMAINS MUST STAY SEPARATE: EVIDENCE DOMAINS MUST STAY SEPARATE:
- asr contains dictation before/after pairs and prior style prompts used - asr contains dictation before/after pairs and style prompts used only
only as negative contamination controls. as negative contamination controls. before is the user's native voice.
A userEdited=true after is the user's highest-priority final revision.
A userEdited=false after is untouched AI output: it can reveal what
was retained from before, but cannot support a user preference or
migration trait.
- reply contains received messages, one or three AI candidates, the - reply contains received messages, one or three AI candidates, the
selection or explicit discard, and an optional user finalEdit. selection or explicit discard, and an optional user finalEdit.
- receivedMessage and every selected/candidate AI text are NOT the - receivedMessage and every selected/candidate AI text are NOT the
@@ -589,22 +837,40 @@ public actor PolishStyleLearningService {
- Reply preferences must never become ASR traits. - Reply preferences must never become ASR traits.
EVIDENCE PRIORITY: EVIDENCE PRIORITY:
- ASR: userEdited=true after > traits repeated across before. - Across both domains, the user's final revision is strongest.
- ASR: userEdited=true after > traits repeated across native before.
- Reply: finalEdit > the same selection preference repeated across - Reply: finalEdit > the same selection preference repeated across
different received-message contexts > one accepted selection. different received-message contexts > one accepted selection.
Cross-context selection is relative preference evidence between the
offered candidates, not a sample of the user's original voice.
A discarded set is negative evidence, never a positive voice sample. A discarded set is negative evidence, never a positive voice sample.
A contextual selection records a scene decision, not a tone
preference. Always ignore its selected candidate for voice learning;
when finalEdit exists, use only that user-authored finalEdit.
- asrRepeatedBefore and replyCrossContextSelection require supportCount - asrRepeatedBefore and replyCrossContextSelection require supportCount
of at least 2. Order evidence strongest first. of at least 2. Order evidence strongest first.
- A single accepted AI candidate is weak preference evidence only. - A single accepted AI candidate is weak preference evidence only.
INSUFFICIENT EVIDENCE: INSUFFICIENT EVIDENCE:
- Include only repeatedly supported traits. - Insufficient means confidence is low, not that personalization must
become neutral. When asr.examples is non-empty, always include 13
concrete candidate retention traits grounded directly in raw before
text. Use asrObservedBefore for a single observation and
asrRepeatedBefore for a pattern supported by at least two deduplicated
observations.
- Candidate traits should describe observable form: information order,
directness, sentence length and rhythm, connective words, register,
speech acts, humor, or Emoji usage. Do not reduce them to generic
"preserve meaning", "be clear", or ASR-correction rules.
- If support is insufficient or contradictory, set status to - If support is insufficient or contradictory, set status to
"insufficient", confidence no higher than 0.25, and return empty "insufficient" and confidence no higher than 0.35. Traits may be
traits, evidence, and contradictions in both domains. Never guess. present only when their own confidence is no higher than 0.35 and
they have matching evidence. Empty domains are valid. Never guess.
The ASR domain may be empty only when asr.examples itself is empty.
- Set status to "sufficient" only when total confidence is at least 0.5.
Allowed source values: Allowed source values:
asrUserEdit, asrRepeatedBefore, replyFinalEdit, asrUserEdit, asrRepeatedBefore, asrObservedBefore, replyFinalEdit,
replyCrossContextSelection, replyAcceptance. replyCrossContextSelection, replyAcceptance.
Return this exact Codable shape: Return this exact Codable shape:
@@ -625,6 +891,18 @@ public actor PolishStyleLearningService {
""" """
} }
static func evidenceRepairSystemPrompt() -> String {
evidenceExtractorSystemPrompt() + """
REPAIR ATTEMPT:
- The previous response failed local protocol validation.
- Reanalyze the original payload above. Emit only one syntactically
valid JSON object matching the exact schema and validation limits.
- Do not mention the failed response and do not add a second object.
"""
}
static func synthesizerSystemPrompt(outputLanguage: AppUILanguage) -> String { static func synthesizerSystemPrompt(outputLanguage: AppUILanguage) -> String {
let language = outputLanguage.resolvedLanguageCode().hasPrefix("zh") let language = outputLanguage.resolvedLanguageCode().hasPrefix("zh")
? "Simplified Chinese" ? "Simplified Chinese"
@@ -654,15 +932,30 @@ public actor PolishStyleLearningService {
PolishPromptComposer owns those stable contracts. Do not invent a trait PolishPromptComposer owns those stable contracts. Do not invent a trait
absent from the evidence. Represent contradictions as boundaries. absent from the evidence. Represent contradictions as boundaries.
STATUS AND CONFIDENCE:
- Always return a generated prompt, including when evidence.status is
"insufficient" or both evidence domains are empty.
- Drive the prompt from evidence.status. For insufficient or low-
confidence evidence, actively turn every supported candidate trait
into a concrete, scoped retention rule and representative example.
Low confidence changes the scope and disclosure, not whether the
observed personal tendency is applied.
- Never replace non-empty candidate traits with a generic neutral prompt,
"preserve meaning", "be clear", or ASR-correction boilerplate. The
generated prompt must visibly differ according to the supplied traits.
- Never invent migration, identity, persona, humor, Emoji habits, or
other characteristics to make an insufficient result feel complete.
Only when both evidence domains are genuinely empty may the output
state that no personal tendency could be observed.
- Migration belongs only in AI reply active-transfer mode and requires
supported reply evidence. ASR retention never authorizes migration.
Emoji boundary: never create a generic no-emoji rule for AI reply Emoji boundary: never create a generic no-emoji rule for AI reply
active-transfer mode. Legal Emoji produced by a playful/fun skill must active-transfer mode. Legal Emoji produced by a playful/fun skill must
survive. Set allowsAddedEmoji=true only when reply evidence supports survive. Set allowsAddedEmoji=true only when reply evidence supports
user-added or repeatedly selected Emoji; ASR preserve mode still may not user-added or repeatedly selected Emoji; ASR preserve mode still may not
add unsupported Emoji. add unsupported Emoji.
If evidence.status is "insufficient", return a conservative JSON object;
its content will be replaced by the app's deterministic no-trait fallback.
SECURITY AND PROTOCOL: SECURITY AND PROTOCOL:
- Return exactly one JSON object with exactly these three keys. - Return exactly one JSON object with exactly these three keys.
- No Markdown fences, surrounding prose, extra keys, or trailing text. - No Markdown fences, surrounding prose, extra keys, or trailing text.
@@ -673,6 +966,19 @@ public actor PolishStyleLearningService {
""" """
} }
static func synthesisRepairSystemPrompt(outputLanguage: AppUILanguage) -> String {
synthesizerSystemPrompt(outputLanguage: outputLanguage) + """
REPAIR ATTEMPT:
- The previous response failed local protocol validation.
- Re-synthesize from the original validated evidence payload above.
Emit only one valid JSON object with exactly name, prompt, and
allowsAddedEmoji. Preserve all required sections and mode labels.
- Do not mention the failed response and do not add a second object.
"""
}
private static func selectExamples( private static func selectExamples(
from examples: [PolishStyleLearningExample] from examples: [PolishStyleLearningExample]
) -> [PolishStyleLearningExample] { ) -> [PolishStyleLearningExample] {
@@ -859,6 +1165,70 @@ public actor PolishStyleLearningService {
) )
} }
private static func extractUniqueJSONObject(
from raw: String,
maximumCharacters: Int
) throws -> Data {
guard raw.count <= maximumCharacters else {
throw PolishStyleLearningError.invalidResponse
}
var objectRanges: [Range<String.Index>] = []
var objectStart: String.Index?
var depth = 0
var isInsideString = false
var isEscaped = false
var index = raw.startIndex
while index < raw.endIndex {
let character = raw[index]
let nextIndex = raw.index(after: index)
if objectStart == nil {
if character == "{" {
objectStart = index
depth = 1
isInsideString = false
isEscaped = false
}
} else if isInsideString {
if isEscaped {
isEscaped = false
} else if character == "\\" {
isEscaped = true
} else if character == "\"" {
isInsideString = false
}
} else {
switch character {
case "\"":
isInsideString = true
case "{":
depth += 1
case "}":
depth -= 1
if depth == 0, let start = objectStart {
objectRanges.append(start..<nextIndex)
objectStart = nil
}
default:
break
}
}
index = nextIndex
}
guard objectStart == nil,
objectRanges.count == 1 else {
throw PolishStyleLearningError.invalidResponse
}
let object = String(raw[objectRanges[0]])
guard object.count <= maximumCharacters,
let data = object.data(using: .utf8) else {
throw PolishStyleLearningError.invalidResponse
}
return data
}
private static func hasExactEvidenceProtocol(_ data: Data) -> Bool { private static func hasExactEvidenceProtocol(_ data: Data) -> Bool {
guard let object = try? JSONSerialization.jsonObject(with: data), guard let object = try? JSONSerialization.jsonObject(with: data),
let root = object as? [String: Any], let root = object as? [String: Any],
@@ -903,7 +1273,11 @@ public actor PolishStyleLearningService {
(0...1).contains(evidence.confidence), (0...1).contains(evidence.confidence),
isValid( isValid(
evidence.asr, evidence.asr,
allowedSources: [.asrUserEdit, .asrRepeatedBefore] allowedSources: [
.asrUserEdit,
.asrRepeatedBefore,
.asrObservedBefore
]
), ),
isValid( isValid(
evidence.reply, evidence.reply,
@@ -917,11 +1291,12 @@ public actor PolishStyleLearningService {
} }
if evidence.status == .insufficient { if evidence.status == .insufficient {
return evidence.confidence <= 0.25 return evidence.confidence <= 0.35
&& isEmpty(evidence.asr) && evidence.asr.traits.allSatisfy { $0.confidence <= 0.35 }
&& isEmpty(evidence.reply) && evidence.reply.traits.allSatisfy { $0.confidence <= 0.35 }
} }
return !evidence.asr.traits.isEmpty || !evidence.reply.traits.isEmpty return evidence.confidence >= 0.5
&& (!evidence.asr.traits.isEmpty || !evidence.reply.traits.isEmpty)
} }
private static func isValid( private static func isValid(
@@ -967,7 +1342,7 @@ public actor PolishStyleLearningService {
switch item.source { switch item.source {
case .asrRepeatedBefore, .replyCrossContextSelection: case .asrRepeatedBefore, .replyCrossContextSelection:
return item.supportCount >= 2 return item.supportCount >= 2
case .asrUserEdit, .replyFinalEdit, .replyAcceptance: case .asrUserEdit, .asrObservedBefore, .replyFinalEdit, .replyAcceptance:
return true return true
} }
} }
@@ -988,19 +1363,11 @@ public actor PolishStyleLearningService {
return 0 return 0
case .asrRepeatedBefore, .replyCrossContextSelection: case .asrRepeatedBefore, .replyCrossContextSelection:
return 1 return 1
case .replyAcceptance: case .asrObservedBefore, .replyAcceptance:
return 2 return 2
} }
} }
private static func isEmpty(
_ domain: PolishStyleLearningEvidence.Domain
) -> Bool {
domain.traits.isEmpty
&& domain.evidence.isEmpty
&& domain.contradictions.isEmpty
}
private static func hasRequiredPromptSections(_ prompt: String) -> Bool { private static func hasRequiredPromptSections(_ prompt: String) -> Bool {
let hasRole = prompt.contains("# 角色") let hasRole = prompt.contains("# 角色")
|| prompt.contains("#角色") || prompt.contains("#角色")
@@ -1017,46 +1384,6 @@ public actor PolishStyleLearningService {
&& lowercased.contains("ai reply active-transfer mode") && lowercased.contains("ai reply active-transfer mode")
} }
private static func insufficientEvidencePack(
outputLanguage: AppUILanguage,
learningMetadata: PolishStylePack.LearningMetadata?
) -> PolishStylePack {
let isChinese = outputLanguage.resolvedLanguageCode().hasPrefix("zh")
let name = isChinese ? "保守保真风格" : "Conservative Preserve Style"
let prompt = isChinese
? """
#
#
ASR preserve mode
AI reply active-transfer mode
#
"""
: """
# Role
#
With insufficient evidence, infer no personal voice and preserve expression conservatively.
# Style Boundaries
#
ASR preserve mode: preserve meaning, speech act, wording, and directness without reply preferences.
AI reply active-transfer mode: no reply preference has enough evidence, so transfer no inferred trait.
# Examples
#
Input Preserve intent and voice without adding unsupported habits.
"""
return PolishStylePack(
name: name,
prompt: prompt,
allowsAddedEmoji: false,
learningMetadata: learningMetadata
)
}
private static func containsInstructionOverride(_ prompt: String) -> Bool { private static func containsInstructionOverride(_ prompt: String) -> Bool {
let lowercased = prompt.lowercased() let lowercased = prompt.lowercased()
let unsafeMarkers = [ let unsafeMarkers = [
@@ -63,6 +63,7 @@ public actor PolishingService {
let raw: String let raw: String
let mode: PolishMode let mode: PolishMode
let systemPrompt: String? let systemPrompt: String?
let options: LLMGenerationOptions?
let providerIdOverride: String? let providerIdOverride: String?
let taskKind: ManagedGatewayTaskKind? let taskKind: ManagedGatewayTaskKind?
let requestPurpose: ManagedGatewayRequestPurpose? let requestPurpose: ManagedGatewayRequestPurpose?
@@ -92,6 +93,7 @@ public actor PolishingService {
private let store: any ConfigurationStore private let store: any ConfigurationStore
private let timeout: TimeInterval private let timeout: TimeInterval
private let maximumTimeout: TimeInterval
private let analyticsClient: any AnalyticsClient private let analyticsClient: any AnalyticsClient
/// Optional injected client (mostly for testing). When nil we build /// Optional injected client (mostly for testing). When nil we build
/// one from `store.makeClient()` per call. /// one from `store.makeClient()` per call.
@@ -102,15 +104,19 @@ public actor PolishingService {
/// shared `LLMClient.requestTimeout`. The safety-net timer adds its /// shared `LLMClient.requestTimeout`. The safety-net timer adds its
/// own slack on top of the length-scaled budget in `polishRemote`, so /// own slack on top of the length-scaled budget in `polishRemote`, so
/// no `+1` is baked in here. /// no `+1` is baked in here.
/// `maximumTimeout` defaults to the keyboard watchdog-compatible 35 s;
/// explicit host workflows may raise it together with their baseline.
public init( public init(
store: any ConfigurationStore = AppGroupStore(), store: any ConfigurationStore = AppGroupStore(),
client: LLMClient? = nil, client: LLMClient? = nil,
timeout: TimeInterval? = nil, timeout: TimeInterval? = nil,
maximumTimeout: TimeInterval = FlowSessionKeys.maxPolishTimeout,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient() analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
) { ) {
self.store = store self.store = store
self.injectedClient = client self.injectedClient = client
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
self.maximumTimeout = maximumTimeout
self.analyticsClient = analyticsClient self.analyticsClient = analyticsClient
} }
@@ -124,6 +130,7 @@ public actor PolishingService {
_ raw: String, _ raw: String,
mode: PolishMode = .polish, mode: PolishMode = .polish,
systemPrompt: String? = nil, systemPrompt: String? = nil,
options: LLMGenerationOptions? = nil,
providerIdOverride: String? = nil, providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil, taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil, requestPurpose: ManagedGatewayRequestPurpose? = nil,
@@ -135,6 +142,7 @@ public actor PolishingService {
raw: raw, raw: raw,
mode: mode, mode: mode,
systemPrompt: systemPrompt, systemPrompt: systemPrompt,
options: options,
providerIdOverride: providerIdOverride, providerIdOverride: providerIdOverride,
taskKind: taskKind, taskKind: taskKind,
requestPurpose: requestPurpose, requestPurpose: requestPurpose,
@@ -150,6 +158,7 @@ public actor PolishingService {
_ raw: String, _ raw: String,
mode: PolishMode = .polish, mode: PolishMode = .polish,
systemPrompt: String? = nil, systemPrompt: String? = nil,
options: LLMGenerationOptions? = nil,
providerIdOverride: String? = nil, providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil, taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil, requestPurpose: ManagedGatewayRequestPurpose? = nil,
@@ -161,6 +170,7 @@ public actor PolishingService {
raw: raw, raw: raw,
mode: mode, mode: mode,
systemPrompt: systemPrompt, systemPrompt: systemPrompt,
options: options,
providerIdOverride: providerIdOverride, providerIdOverride: providerIdOverride,
taskKind: taskKind, taskKind: taskKind,
requestPurpose: requestPurpose, requestPurpose: requestPurpose,
@@ -230,6 +240,7 @@ public actor PolishingService {
trimmed, trimmed,
mode: mode, mode: mode,
systemPrompt: systemPrompt, systemPrompt: systemPrompt,
options: request.options,
providerIdOverride: providerIdOverride, providerIdOverride: providerIdOverride,
taskKind: taskKind, taskKind: taskKind,
requestPurpose: requestPurpose, requestPurpose: requestPurpose,
@@ -303,6 +314,8 @@ public actor PolishingService {
switch error { switch error {
case .cancelled: case .cancelled:
return .cancelled return .cancelled
case .timeout:
return .timeout
case .transport, .rateLimited: case .transport, .rateLimited:
return .network return .network
case .invalidURL, .noAPIKey, .decoding: case .invalidURL, .noAPIKey, .decoding:
@@ -327,6 +340,7 @@ public actor PolishingService {
_ trimmed: String, _ trimmed: String,
mode: PolishMode, mode: PolishMode,
systemPrompt: String? = nil, systemPrompt: String? = nil,
options: LLMGenerationOptions? = nil,
providerIdOverride: String? = nil, providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil, taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil, requestPurpose: ManagedGatewayRequestPurpose? = nil,
@@ -397,9 +411,8 @@ public actor PolishingService {
id: activeStyle.id, id: activeStyle.id,
intensity: store.polishIntensity intensity: store.polishIntensity
) )
let firstOptions: LLMGenerationOptions = usesHeavyFunPersonality let firstOptions = options
? .funCreative ?? (usesHeavyFunPersonality ? .funCreative : .polishDefault)
: .polishDefault
logPolishConfiguration( logPolishConfiguration(
prompt: prompt, prompt: prompt,
mode: mode, mode: mode,
@@ -471,7 +484,7 @@ public actor PolishingService {
options: options options: options
) )
} }
} catch is CancellationError { } catch HardTimeoutError.timedOut {
throw PolishError.timeout throw PolishError.timeout
} }
} }
@@ -581,7 +594,7 @@ public actor PolishingService {
/// the *actual* value handed to `LLMClient.polish(timeout:)`, so long /// the *actual* value handed to `LLMClient.polish(timeout:)`, so long
/// dictations (which generate long, listified, multi-paragraph output) /// dictations (which generate long, listified, multi-paragraph output)
/// are not cut off mid-generation by a fixed 15 s ceiling. Grows by /// are not cut off mid-generation by a fixed 15 s ceiling. Grows by
/// ~10 s per 100 characters, capped at 120 s. /// ~10 s per 100 characters, capped by `maximumTimeout`.
/// ///
/// Previously this value was computed but only used for the safety-net /// Previously this value was computed but only used for the safety-net
/// timer while the URLRequest stayed pinned at 15 s the scaling was /// timer while the URLRequest stayed pinned at 15 s the scaling was
@@ -589,14 +602,17 @@ public actor PolishingService {
/// (unpolished, unsegmented) ASR text. /// (unpolished, unsegmented) ASR text.
internal func effectiveTimeout(for text: String) -> TimeInterval { internal func effectiveTimeout(for text: String) -> TimeInterval {
if timeout == LLMClientFactory.defaultRequestTimeout { if timeout == LLMClientFactory.defaultRequestTimeout {
return FlowSessionKeys.polishTimeout(forCharacterCount: text.count) return min(
FlowSessionKeys.polishTimeout(forCharacterCount: text.count),
maximumTimeout
)
} }
let scaled = timeout + (Double(text.count) / 100.0) * 10.0 let scaled = timeout + (Double(text.count) / 100.0) * 10.0
// The cap participates in the keyboard-watchdog budget see // The cap participates in the keyboard-watchdog budget see
// `FlowSessionKeys.keyboardResultTimeout`. Raising it here without // `FlowSessionKeys.keyboardResultTimeout`. Raising it here without
// going through that constant would silently break the invariant // going through that constant would silently break the invariant
// "keyboard timeout > host worst case". // "keyboard timeout > host worst case".
return min(max(scaled, timeout), FlowSessionKeys.maxPolishTimeout) return min(max(scaled, timeout), maximumTimeout)
} }
internal static func resolvedProviderId( internal static func resolvedProviderId(
@@ -229,6 +229,10 @@ private final class HardTimeoutRace<T: Sendable>: @unchecked Sendable {
} }
} }
public enum HardTimeoutError: Error, Equatable, Sendable {
case timedOut
}
public enum HardTimeout { public enum HardTimeout {
/// Returns at the deadline even when the losing operation ignores /// Returns at the deadline even when the losing operation ignores
/// cooperative cancellation. The detached loser is still cancelled, but /// cooperative cancellation. The detached loser is still cancelled, but
@@ -252,7 +256,7 @@ public enum HardTimeout {
try await Task.sleep( try await Task.sleep(
nanoseconds: UInt64(max(0, seconds) * 1_000_000_000) nanoseconds: UInt64(max(0, seconds) * 1_000_000_000)
) )
race.resolve(.failure(CancellationError())) race.resolve(.failure(HardTimeoutError.timedOut))
} catch { } catch {
// The operation won and cancelled this timer. // The operation won and cancelled this timer.
} }
@@ -45,9 +45,16 @@
"error.llm.http" = "API returned HTTP %lld. Try again later or contact the provider."; "error.llm.http" = "API returned HTTP %lld. Try again later or contact the provider.";
"error.llm.decoding" = "Failed to parse the API response."; "error.llm.decoding" = "Failed to parse the API response.";
"error.llm.transport" = "Network error. Check your connection and try again."; "error.llm.transport" = "Network error. Check your connection and try again.";
"error.llm.timeout" = "The AI request timed out. Please try again.";
"error.llm.rateLimited" = "Too many API requests. Please wait and try again."; "error.llm.rateLimited" = "Too many API requests. Please wait and try again.";
"error.llm.cancelled" = "Request cancelled."; "error.llm.cancelled" = "Request cancelled.";
/* Personal style learning errors */
"styleLearning.error.emptyRequest" = "There is no learning content to send to the AI. Reopen this page and try again.";
"styleLearning.error.timeout" = "Style generation timed out while analyzing your history. Please try again.";
"styleLearning.error.missingAPIKey" = "The current AI service has no API key. Configure it in Settings and try again.";
"styleLearning.error.keychainLocked" = "The API key is temporarily unavailable. Unlock your device and try again.";
/* ASR errors */ /* ASR errors */
"error.asr.localeUnsupported" = "Speech language assets are unavailable. Try again later or switch the recognition language."; "error.asr.localeUnsupported" = "Speech language assets are unavailable. Try again later or switch the recognition language.";
"error.asr.assetsNotReady" = "Speech language assets are not ready. Try again later."; "error.asr.assetsNotReady" = "Speech language assets are not ready. Try again later.";
@@ -202,6 +209,7 @@
"mac.styles.learn.generating" = "Generating…"; "mac.styles.learn.generating" = "Generating…";
"mac.styles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving."; "mac.styles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
"mac.styles.learn.limit" = "Delete a custom style before generating another one."; "mac.styles.learn.limit" = "Delete a custom style before generating another one.";
"mac.styles.learn.error.title" = "Couldnt Generate Style";
"mac.styles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available."; "mac.styles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available.";
"mac.styles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again."; "mac.styles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again.";
"mac.styles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again."; "mac.styles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again.";
@@ -45,9 +45,16 @@
"error.llm.http" = "API 返回 HTTP %lld。请稍后重试或联系服务方。"; "error.llm.http" = "API 返回 HTTP %lld。请稍后重试或联系服务方。";
"error.llm.decoding" = "解析 API 响应失败。"; "error.llm.decoding" = "解析 API 响应失败。";
"error.llm.transport" = "网络错误,请检查连接后重试。"; "error.llm.transport" = "网络错误,请检查连接后重试。";
"error.llm.timeout" = "AI 请求超时,请稍后重试。";
"error.llm.rateLimited" = "API 调用过于频繁,请稍候再试。"; "error.llm.rateLimited" = "API 调用过于频繁,请稍候再试。";
"error.llm.cancelled" = "请求已取消。"; "error.llm.cancelled" = "请求已取消。";
/* Personal style learning errors */
"styleLearning.error.emptyRequest" = "没有可发送给 AI 的学习内容,请重新打开页面后重试。";
"styleLearning.error.timeout" = "生成风格超时。AI 需要分析较多历史记录,请稍后重试。";
"styleLearning.error.missingAPIKey" = "当前 AI 服务未配置 API Key,请前往「设置」完成配置后重试。";
"styleLearning.error.keychainLocked" = "暂时无法读取 API Key,请解锁设备后重试。";
/* ASR errors */ /* ASR errors */
"error.asr.localeUnsupported" = "当前系统未分配可用语音语言模型,请稍后重试或切换语言。"; "error.asr.localeUnsupported" = "当前系统未分配可用语音语言模型,请稍后重试或切换语言。";
"error.asr.assetsNotReady" = "语音语言资源未就绪,请稍后重试。"; "error.asr.assetsNotReady" = "语音语言资源未就绪,请稍后重试。";
@@ -201,6 +208,7 @@
"mac.styles.learn.generating" = "生成中…"; "mac.styles.learn.generating" = "生成中…";
"mac.styles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。"; "mac.styles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
"mac.styles.learn.limit" = "请先删除一个自定义风格,再生成新风格。"; "mac.styles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
"mac.styles.learn.error.title" = "无法生成风格";
"mac.styles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。"; "mac.styles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
"mac.styles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。"; "mac.styles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
"mac.styles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。"; "mac.styles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";