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:
@@ -13,6 +13,7 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
case pending
|
||||
case skillFailure
|
||||
case skills
|
||||
case semanticBadge
|
||||
case search
|
||||
}
|
||||
|
||||
@@ -55,8 +56,9 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
.background(backgroundColor.ignoresSafeArea())
|
||||
.onDisappear {
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
AIKeyboardView.debugSkipsLongPressCoach = false
|
||||
AIKeyboardView.debugKeepsSkillTip = false
|
||||
AIKeyboardView.debugPreviewSemanticBadgeKeys = nil
|
||||
AIKeyboardView.debugSkipsLongPressCoach = false
|
||||
AIKeyboardView.debugKeepsSkillTip = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +76,7 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
state.aiServiceAvailable = true
|
||||
state.micDisabled = false
|
||||
state.returnKeyRole = .send
|
||||
AIKeyboardView.debugPreviewSemanticBadgeKeys = nil
|
||||
|
||||
let keyboardState = state
|
||||
state.tapMic = { [weak keyboardState] in
|
||||
@@ -190,6 +193,12 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
AIKeyboardView.debugPreviewSkills = previewSkills
|
||||
state.undoAvailable = true
|
||||
state.editAvailable = true
|
||||
case .semanticBadge:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
AIKeyboardView.debugPreviewSemanticBadgeKeys = (
|
||||
intent: "keyboard.semantic.intent.informationQuery",
|
||||
domain: "keyboard.semantic.domain.weather"
|
||||
)
|
||||
case .search:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.returnKeyRole = .search
|
||||
|
||||
@@ -13,6 +13,11 @@ typealias LearnedStyleGenerator = @MainActor @Sendable (
|
||||
AppUILanguage
|
||||
) async throws -> PolishStylePack
|
||||
|
||||
private struct PolishStyleErrorAlert {
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PolishStylesView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@@ -26,11 +31,14 @@ struct PolishStylesView: View {
|
||||
/// receives a concrete pack (avoids `isPresented` + nil race showing defaults).
|
||||
@State private var editingPack: PolishStylePack?
|
||||
@State private var viewingPack: PolishStylePack?
|
||||
@State private var errorMessage: String?
|
||||
@State private var errorAlert: PolishStyleErrorAlert?
|
||||
@State private var isGeneratingLearnedStyle = false
|
||||
@State private var learnedStyleGenerationTask: Task<Void, Never>?
|
||||
@State private var learnedStyleGenerationID: UUID?
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let learnedStyleGenerator: LearnedStyleGenerator
|
||||
private let pullsCloudStylesOnAppear: Bool
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing),
|
||||
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing)
|
||||
@@ -38,16 +46,23 @@ struct PolishStylesView: View {
|
||||
|
||||
init(
|
||||
initialEditingPack: PolishStylePack? = nil,
|
||||
pullsCloudStylesOnAppear: Bool = true,
|
||||
learnedStyleGenerator: @escaping LearnedStyleGenerator = { corpus, replyExamples, language in
|
||||
try await PolishStyleLearningService(store: AppGroupStore())
|
||||
.generateStyle(
|
||||
from: corpus,
|
||||
replyExamples: replyExamples,
|
||||
outputLanguage: language
|
||||
outputLanguage: language,
|
||||
minimumEffectiveCharacterCount:
|
||||
AppDistributionChannel.allowsInternalTools
|
||||
? 0
|
||||
: PolishStyleLearningCorpusBuilder
|
||||
.requiredEffectiveCharacterCount
|
||||
)
|
||||
}
|
||||
) {
|
||||
_editingPack = State(initialValue: initialEditingPack)
|
||||
self.pullsCloudStylesOnAppear = pullsCloudStylesOnAppear
|
||||
self.learnedStyleGenerator = learnedStyleGenerator
|
||||
}
|
||||
|
||||
@@ -92,7 +107,10 @@ struct PolishStylesView: View {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
.disabled(catalog.entries.count >= PolishStyleLimits.maximumUserPacks)
|
||||
.disabled(
|
||||
catalog.entries.count >= PolishStyleLimits.maximumUserPacks
|
||||
|| isGeneratingLearnedStyle
|
||||
)
|
||||
.accessibilityLabel(Text("polishStyles.add"))
|
||||
}
|
||||
}
|
||||
@@ -109,18 +127,19 @@ struct PolishStylesView: View {
|
||||
PolishStylePromptDetailSheet(pack: pack, language: config.uiLanguage)
|
||||
}
|
||||
.alert(
|
||||
Text("polishStyles.error.title"),
|
||||
Text(errorAlert?.title ?? ""),
|
||||
isPresented: Binding(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
get: { errorAlert != nil },
|
||||
set: { if !$0 { errorAlert = nil } }
|
||||
)
|
||||
) {
|
||||
Button("common.done") { errorMessage = nil }
|
||||
Button("common.done") { errorAlert = nil }
|
||||
} message: {
|
||||
Text(errorMessage ?? "")
|
||||
Text(errorAlert?.message ?? "")
|
||||
}
|
||||
.task {
|
||||
reload()
|
||||
guard pullsCloudStylesOnAppear else { return }
|
||||
await PolishStyleCloudSync.shared.pullAndMergeIfEnabled()
|
||||
reload()
|
||||
}
|
||||
@@ -130,12 +149,28 @@ struct PolishStylesView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
reload()
|
||||
}
|
||||
.onDisappear {
|
||||
cancelLearnedStyleGeneration()
|
||||
}
|
||||
}
|
||||
|
||||
private var styleLearningCorpus: PolishStyleLearningCorpus {
|
||||
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? {
|
||||
catalog.entries
|
||||
.filter { $0.learningMetadata != nil }
|
||||
@@ -152,7 +187,7 @@ struct PolishStylesView: View {
|
||||
let corpus = styleLearningCorpus
|
||||
let required = PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
|
||||
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
|
||||
let isActionAvailable = corpus.isReady && !reachedLimit
|
||||
let isActionAvailable = isEligibleForStyleGeneration(corpus) && !reachedLimit
|
||||
let canGenerate = isActionAvailable && !isGeneratingLearnedStyle
|
||||
let completedCharacterCount = min(corpus.effectiveCharacterCount, required)
|
||||
let learnedFraction = required > 0
|
||||
@@ -205,7 +240,9 @@ struct PolishStylesView: View {
|
||||
Spacer()
|
||||
|
||||
Text(
|
||||
corpus.isReady
|
||||
bypassesStyleLearningCharacterGate && !corpus.isReady
|
||||
? AppL10n.string("polishStyles.learn.testBuildReady")
|
||||
: corpus.isReady
|
||||
? AppL10n.string("polishStyles.learn.ready")
|
||||
: AppL10n.format(
|
||||
"polishStyles.learn.remaining",
|
||||
@@ -213,7 +250,11 @@ struct PolishStylesView: View {
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(corpus.isReady ? palette.accent : palette.textTertiary)
|
||||
.foregroundStyle(
|
||||
isEligibleForStyleGeneration(corpus)
|
||||
? palette.accent
|
||||
: palette.textTertiary
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -266,7 +307,9 @@ struct PolishStylesView: View {
|
||||
corpus: PolishStyleLearningCorpus
|
||||
) -> some View {
|
||||
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 actionShape = RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
@@ -303,9 +346,18 @@ struct PolishStylesView: View {
|
||||
Text(pack.displayName(language: config.uiLanguage))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("polishStyles.learn.generated.description")
|
||||
Text(
|
||||
hasInsufficientEvidence
|
||||
? AppL10n.string("polishStyles.learn.lowConfidence")
|
||||
: AppL10n.string("polishStyles.learn.generated.description")
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.foregroundStyle(
|
||||
hasInsufficientEvidence
|
||||
? palette.danger
|
||||
: palette.textSecondary
|
||||
)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -319,6 +371,7 @@ struct PolishStylesView: View {
|
||||
.background(palette.surfaceElevated, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isGeneratingLearnedStyle)
|
||||
.accessibilityLabel(Text("polishStyles.edit"))
|
||||
}
|
||||
|
||||
@@ -340,6 +393,12 @@ struct PolishStylesView: View {
|
||||
Int64(metadata.replyFinalEditCount)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"polishStyles.learn.confidence",
|
||||
Self.confidencePercentage(metadata.confidence)
|
||||
)
|
||||
)
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
@@ -398,6 +457,8 @@ struct PolishStylesView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canRegenerate)
|
||||
.accessibilityIdentifier("polishStyles.learn.regenerate")
|
||||
.accessibilityValue(Text(pack.id))
|
||||
}
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
@@ -486,6 +547,7 @@ struct PolishStylesView: View {
|
||||
}
|
||||
.padding(Spacing.sm)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isGeneratingLearnedStyle)
|
||||
.accessibilityLabel(Text("polishStyles.edit"))
|
||||
|
||||
if isSelected {
|
||||
@@ -515,10 +577,12 @@ struct PolishStylesView: View {
|
||||
Button("polishStyles.duplicate") {
|
||||
duplicate(pack)
|
||||
}
|
||||
.disabled(isGeneratingLearnedStyle)
|
||||
if pack.kind == .user {
|
||||
Button("common.delete", role: .destructive) {
|
||||
delete(pack)
|
||||
}
|
||||
.disabled(isGeneratingLearnedStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,16 +591,24 @@ struct PolishStylesView: View {
|
||||
from corpus: PolishStyleLearningCorpus,
|
||||
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
|
||||
Task {
|
||||
defer { isGeneratingLearnedStyle = false }
|
||||
learnedStyleGenerationTask = Task { @MainActor in
|
||||
defer { finishLearnedStyleGeneration(id: generationID) }
|
||||
do {
|
||||
let generated = try await learnedStyleGenerator(
|
||||
corpus,
|
||||
ClipboardReplyFeedbackStore.shared.learningExamples(),
|
||||
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
|
||||
// it is saved, synced, or made active.
|
||||
if let existingPack {
|
||||
@@ -554,11 +626,31 @@ struct PolishStylesView: View {
|
||||
editingPack = generated
|
||||
}
|
||||
} 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 {
|
||||
guard pack.kind == .builtin else { return "polishStyles.custom.description" }
|
||||
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 {
|
||||
try catalog.upsert(pack)
|
||||
try updatedCatalog.upsert(pack)
|
||||
catalog = updatedCatalog
|
||||
store.setPolishStyleCatalog(catalog)
|
||||
store.setActivePolishStyleId(pack.id)
|
||||
activeID = pack.id
|
||||
@@ -597,14 +691,23 @@ struct PolishStylesView: View {
|
||||
try? await PolishStyleCloudSync.shared.pushLocalIfEnabled(catalog)
|
||||
try? await AppCloudSync.shared.settingsSyncService.pushLocalIfEnabled()
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = localized(error)
|
||||
errorAlert = PolishStyleErrorAlert(
|
||||
title: AppL10n.string("polishStyles.error.title"),
|
||||
message: localized(error)
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func duplicate(_ pack: PolishStylePack) {
|
||||
guard !isGeneratingLearnedStyle else { return }
|
||||
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
|
||||
}
|
||||
editingPack = PolishStylePack(
|
||||
@@ -625,7 +728,7 @@ struct PolishStylesView: View {
|
||||
}
|
||||
|
||||
private func delete(_ pack: PolishStylePack) {
|
||||
guard pack.kind == .user else { return }
|
||||
guard pack.kind == .user, !isGeneratingLearnedStyle else { return }
|
||||
catalog.recordDeletion(of: pack.id)
|
||||
store.setPolishStyleCatalog(catalog)
|
||||
if activeID == pack.id {
|
||||
@@ -649,7 +752,10 @@ struct PolishStylesView: View {
|
||||
case .requestTooLarge:
|
||||
return AppL10n.string("polishStyles.learn.error.requestTooLarge")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -701,7 +821,7 @@ private struct PolishStylePromptDetailSheet: View {
|
||||
private struct PolishStyleEditorSheet: View {
|
||||
let pack: PolishStylePack
|
||||
let isNew: Bool
|
||||
let onSave: (PolishStylePack) -> Void
|
||||
let onSave: (PolishStylePack) -> Bool
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.themePalette) private var palette
|
||||
@@ -712,7 +832,7 @@ private struct PolishStyleEditorSheet: View {
|
||||
init(
|
||||
pack: PolishStylePack,
|
||||
isNew: Bool,
|
||||
onSave: @escaping (PolishStylePack) -> Void
|
||||
onSave: @escaping (PolishStylePack) -> Bool
|
||||
) {
|
||||
self.pack = pack
|
||||
self.isNew = isNew
|
||||
@@ -725,6 +845,35 @@ private struct PolishStyleEditorSheet: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
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") {
|
||||
TextField("polishStyles.editor.namePlaceholder", text: $name)
|
||||
.settingsListRow()
|
||||
@@ -743,6 +892,7 @@ private struct PolishStyleEditorSheet: View {
|
||||
.frame(minHeight: 320)
|
||||
.padding(Spacing.md)
|
||||
.cardListRow(elevated: false)
|
||||
.accessibilityIdentifier("polishStyles.editor.prompt")
|
||||
.onChange(of: prompt) { _, newValue in
|
||||
// Paste-only custom prompts that declare emoji opt-in
|
||||
// should flip the toggle so post-processing keeps them.
|
||||
@@ -787,8 +937,9 @@ private struct PolishStyleEditorSheet: View {
|
||||
createdAt: pack.createdAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
onSave(result)
|
||||
dismiss()
|
||||
if onSave(result) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.disabled(
|
||||
name.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 showsSavedGeneratedStyle: Bool
|
||||
private let simulatesGeneration: Bool
|
||||
private let usesServiceBackedGeneration: Bool
|
||||
private let usesInsufficientEvidence: Bool
|
||||
private let failsServiceBackedSynthesis: Bool
|
||||
private let delaysServiceBackedGeneration: Bool
|
||||
|
||||
init() {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
language = ReleaseNotesScreenshotFixture.language
|
||||
ProviderConfig.shared.uiLanguage = 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"
|
||||
)
|
||||
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()
|
||||
}
|
||||
showsSavedGeneratedStyle = ProcessInfo.processInfo.arguments.contains(
|
||||
if testsRegeneration || failsServiceBackedSynthesis {
|
||||
ReleaseNotesScreenshotFixture.seedGeneratedStyle(language: language)
|
||||
}
|
||||
showsSavedGeneratedStyle = arguments.contains(
|
||||
"--polish-styles-generated-saved"
|
||||
)
|
||||
generatedPack = ProcessInfo.processInfo.arguments.contains(
|
||||
generatedPack = arguments.contains(
|
||||
"--polish-styles-generated-review"
|
||||
)
|
||||
? ReleaseNotesScreenshotFixture.generatedStyle(language: language)
|
||||
@@ -40,7 +73,13 @@ struct PolishStylesScreenshotHarness: View {
|
||||
|
||||
var body: some View {
|
||||
ThemedRoot {
|
||||
if simulatesGeneration {
|
||||
if usesServiceBackedGeneration {
|
||||
PolishStylesServiceUITestHarness(
|
||||
usesInsufficientEvidence: usesInsufficientEvidence,
|
||||
failsSynthesis: failsServiceBackedSynthesis,
|
||||
delaysGeneration: delaysServiceBackedGeneration
|
||||
)
|
||||
} else if simulatesGeneration {
|
||||
PolishStylesView(
|
||||
learnedStyleGenerator: { _, _, language in
|
||||
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
|
||||
struct HomeDictionaryScreenshotHarness: View {
|
||||
@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) {
|
||||
let pack = generatedStyle(language: language)
|
||||
var catalog = PolishStyleCatalog()
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
"settings.aiAgent.responseLength.section" = "Answers";
|
||||
"settings.aiAgent.responseLength.title" = "Response length";
|
||||
"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.section" = "Features";
|
||||
"settings.clipboard.subtitle.on" = "On";
|
||||
@@ -683,17 +683,21 @@
|
||||
"polishStyles.learn.progress" = "%lld / %lld characters";
|
||||
"polishStyles.learn.remaining" = "%lld to go";
|
||||
"polishStyles.learn.ready" = "Ready";
|
||||
"polishStyles.learn.testBuildReady" = "Test build: 2,500-character limit disabled";
|
||||
"polishStyles.learn.action" = "Generate Style";
|
||||
"polishStyles.learn.generating" = "Generating…";
|
||||
"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.evidenceSummary" = "%lld ASR characters · %lld reply choices · %lld final edits";
|
||||
"polishStyles.learn.confidence" = "Confidence: %lld%%";
|
||||
"polishStyles.learn.select" = "Use this style";
|
||||
"polishStyles.learn.selected" = "In use";
|
||||
"polishStyles.learn.regenerate" = "Regenerate";
|
||||
"polishStyles.learn.regenerating" = "Regenerating…";
|
||||
"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.error.title" = "Couldn’t Generate Style";
|
||||
"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.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again.";
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
"settings.aiAgent.responseLength.section" = "回答";
|
||||
"settings.aiAgent.responseLength.title" = "回复篇幅";
|
||||
"settings.aiAgent.multipleReplies.title" = "提供多种回复";
|
||||
"settings.aiAgent.multipleReplies.description" = "使用「回复」时提供多个自然、可直接发送的选项。";
|
||||
"settings.aiAgent.multipleReplies.description" = "普通聊天提供自然、正式和轻松趣味选项;邀约与任务始终提供不同立场,避免 AI 替你做决定。";
|
||||
"settings.clipboard.title" = "剪贴板";
|
||||
"settings.clipboard.section" = "功能";
|
||||
"settings.clipboard.subtitle.on" = "已开启";
|
||||
@@ -682,17 +682,21 @@
|
||||
"polishStyles.learn.progress" = "%lld / %lld 字";
|
||||
"polishStyles.learn.remaining" = "还差 %lld 字";
|
||||
"polishStyles.learn.ready" = "可以生成";
|
||||
"polishStyles.learn.testBuildReady" = "测试版本:已暂时取消 2500 字限制";
|
||||
"polishStyles.learn.action" = "生成风格";
|
||||
"polishStyles.learn.generating" = "生成中…";
|
||||
"polishStyles.learn.generated.description" = "根据经过确认的证据学习得到的个人表达风格。";
|
||||
"polishStyles.learn.lowConfidence" = "低置信度,Prompt 已根据有限语料生成,请审阅。";
|
||||
"polishStyles.learn.generatedAt" = "生成于";
|
||||
"polishStyles.learn.evidenceSummary" = "%lld 个 ASR 字符 · %lld 次回复选择 · %lld 次最终改稿";
|
||||
"polishStyles.learn.confidence" = "置信度:%lld%%";
|
||||
"polishStyles.learn.select" = "使用此风格";
|
||||
"polishStyles.learn.selected" = "使用中";
|
||||
"polishStyles.learn.regenerate" = "重新生成";
|
||||
"polishStyles.learn.regenerating" = "重新生成中…";
|
||||
"polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
|
||||
"polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
|
||||
"polishStyles.learn.error.title" = "无法生成风格";
|
||||
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
|
||||
"polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
|
||||
"polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
|
||||
|
||||
Reference in New Issue
Block a user