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 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
+189 -28
View File
@@ -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()
+5 -1
View File
@@ -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 个字符,请重试。";