Expand onboarding and adaptive keyboard intelligence

Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
Rocky
2026-08-22 16:33:18 +08:00
parent ac374631ae
commit e5a83843db
162 changed files with 23574 additions and 1013 deletions
+28 -7
View File
@@ -65,6 +65,9 @@ public final class KeyboardViewController: UIInputViewController {
private var cancellables = Set<AnyCancellable>()
/// Coalesces host-document refreshes after mutations issued by this keyboard.
private var assistantFieldActionRefreshTask: Task<Void, Never>?
/// One random ID per keyboard presentation. The repository splits this ID
/// into independent UTC-day fragments when a presentation crosses midnight.
private var keyboardUsageSessionID = UUID()
private var memoryTelemetryContext: String {
let language = typingSessionStorage?.language.rawValue ?? "-"
@@ -306,6 +309,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
keyboardUsageSessionID = UUID()
AnalyticsExtensionService.shared.recordPresentation(
hasFullAccess: hasFullAccess
)
@@ -329,7 +333,7 @@ public final class KeyboardViewController: UIInputViewController {
state: state,
host: WhatsNewDemoDriver.HostHooks(
insertText: { [weak self] text in
self?.insertTextIntoDocument(text)
self?.insertTextIntoDocument(text, source: .debugDemo)
},
deleteBackward: { [weak self] in
self?.deleteBackwardFromDocument()
@@ -415,7 +419,9 @@ public final class KeyboardViewController: UIInputViewController {
editHintScheduler = EditHintScheduler(state: state)
textInserter = KeyboardTextInserter(
state: state,
insertText: { [weak self] text in self?.insertTextIntoDocument(text) },
insertText: { [weak self] text, source in
self?.insertTextIntoDocument(text, source: source)
},
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() },
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
@@ -622,8 +628,12 @@ public final class KeyboardViewController: UIInputViewController {
self?.configSync.persistTranslationTargetLocaleId(id)
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
}
state.insertNewline = { [weak self] in self?.insertTextIntoDocument("\n") }
state.insertSpace = { [weak self] in self?.insertTextIntoDocument(" ") }
state.insertNewline = { [weak self] in
self?.insertTextIntoDocument("\n", source: .manualKeyboard)
}
state.insertSpace = { [weak self] in
self?.insertTextIntoDocument(" ", source: .manualKeyboard)
}
state.deleteBackward = { [weak self] in self?.deleteBackwardFromDocument() }
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
@@ -771,9 +781,20 @@ public final class KeyboardViewController: UIInputViewController {
/// Keeps field-action UI current even when a host app does not immediately
/// echo this keyboard's own document mutation through `textDidChange`.
private func insertTextIntoDocument(_ text: String) {
private func insertTextIntoDocument(
_ text: String,
source: KeyboardTextInsertionSource
) {
guard !text.isEmpty else { return }
textDocumentProxy.insertText(text)
if source.contributesToKeyboardUsage {
let counts = KeyboardUsageCharacterClassifier.classify(text)
AnalyticsExtensionService.shared.keyboardUsageRecorder
.recordManualKeyboardCounts(
counts,
sessionID: keyboardUsageSessionID
)
}
// A non-empty insertion makes content actions available immediately.
// The next host callback remains the authoritative correction.
refreshAssistantFieldAction(hasTextOverride: true)
@@ -786,7 +807,7 @@ public final class KeyboardViewController: UIInputViewController {
}
private func performDocumentReturn() {
textDocumentProxy.insertText("\n")
insertTextIntoDocument("\n", source: .assistantAction)
scheduleAssistantFieldActionRefresh()
}
@@ -980,7 +1001,7 @@ public final class KeyboardViewController: UIInputViewController {
state: state,
typing: typingSession,
onInsert: { [weak self] text in
self?.insertTextIntoDocument(text)
self?.insertTextIntoDocument(text, source: .manualKeyboard)
},
onDeleteBackward: { [weak self] in
self?.deleteBackwardFromDocument()
@@ -18,6 +18,7 @@ final class AIKeyboardCoordinator {
private var requestInsertionFingerprint: String?
private var conversationInsertionFingerprint: String?
private var hasConversationInsertionTarget = false
private var requestOOBEFeature: ManagedGatewayOOBEFeature?
init(
state: KeyboardState,
@@ -37,6 +38,7 @@ final class AIKeyboardCoordinator {
endConversationIfNeeded()
state.aiSession.enter()
requestInsertionFingerprint = nil
requestOOBEFeature = nil
conversationInsertionFingerprint = nil
hasConversationInsertionTarget = false
}
@@ -53,6 +55,7 @@ final class AIKeyboardCoordinator {
endConversationIfNeeded()
state.aiSession.leave()
requestInsertionFingerprint = nil
requestOOBEFeature = nil
conversationInsertionFingerprint = nil
hasConversationInsertionTarget = false
}
@@ -66,7 +69,12 @@ final class AIKeyboardCoordinator {
case .idle, .awaitingSend, .inserted, .sent, .failed:
prepareConversationForRequest()
guard let conversationID = state.aiSession.conversationID else { return }
let disposition = flow.beginAIRecording(conversationID: conversationID)
let oobeFeature = expectedOOBEFeature(.askAI)
requestOOBEFeature = oobeFeature
let disposition = flow.beginAIRecording(
conversationID: conversationID,
oobeFeature: oobeFeature
)
if case .rejected(let rejection) = disposition {
state.aiSession.fail(message(for: rejection), utteranceID: nil)
}
@@ -81,8 +89,23 @@ final class AIKeyboardCoordinator {
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
func submitClipboardSkill(_ skill: AIClipboardSkill) {
guard canAcceptIdleSubmit else { return }
guard !skill.requiresShortcut
|| state.confirmedClipboardShortcutIDs.contains(skill.id) else {
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
return
}
prepareConversationForRequest()
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
let oobeFeature = oobeFeature(for: skill)
let material: String?
if let oobeFeature {
// OOBE clipboard lessons are intentionally isolated from the
// user's real clipboard history.
material = oobeMaterial(for: oobeFeature)
} else if state.oobePracticeSession != nil {
material = nil
} else {
material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
}
if skill.kind == .export {
state.pendingClipboardSkillID = skill.id
state.pendingClipboardSkillSource = material
@@ -113,6 +136,7 @@ final class AIKeyboardCoordinator {
submitResolvedPrompt(
resolution,
taskKind: skill.managedGatewayTaskKind,
oobeFeature: oobeFeature,
thinkingEnabled: skill.thinkingEnabled
)
}
@@ -120,12 +144,19 @@ final class AIKeyboardCoordinator {
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
func submitHintCard(_ card: AIHintCard) {
guard canAcceptIdleSubmit else { return }
// The OOBE ask-AI lesson must use the explicit hold-to-talk path. Idle
// cards can otherwise pull unrelated clipboard material into a request.
guard state.oobePracticeSession == nil else { return }
prepareConversationForRequest()
let resolution = AIHintPool.resolvePrompt(
for: card,
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
)
submitResolvedPrompt(resolution, taskKind: .aiQuestion)
submitResolvedPrompt(
resolution,
taskKind: .aiQuestion,
oobeFeature: nil
)
}
private var canAcceptIdleSubmit: Bool {
@@ -141,6 +172,7 @@ final class AIKeyboardCoordinator {
private func submitResolvedPrompt(
_ resolution: AIClipboardPrompt.Resolution,
taskKind: ManagedGatewayTaskKind,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
thinkingEnabled: Bool? = nil
) {
guard case .ready(let prompt) = resolution else {
@@ -156,10 +188,12 @@ final class AIKeyboardCoordinator {
clearPendingExportSkill()
return
}
requestOOBEFeature = oobeFeature
let disposition = flow.submitAIQuestion(
text: prompt,
conversationID: conversationID,
taskKind: taskKind,
oobeFeature: oobeFeature,
thinkingEnabled: thinkingEnabled
)
if case .rejected(let rejection) = disposition {
@@ -172,6 +206,7 @@ final class AIKeyboardCoordinator {
guard state.aiSession.isBusy else { return }
clearPendingExportSkill()
requestInsertionFingerprint = nil
requestOOBEFeature = nil
flow.cancelAIRecording()
state.aiSession.cancelCurrentWork()
}
@@ -179,6 +214,7 @@ final class AIKeyboardCoordinator {
func confirmPendingAnswer() {
if state.aiSession.canInsert, let answer = state.aiSession.answer {
guard insertAnswer(answer) else { return }
markOOBECompletedAfterInsertion()
state.aiSession.markAnswerInserted(
offersSend: state.returnKeyRole.usesActionFill
)
@@ -190,6 +226,7 @@ final class AIKeyboardCoordinator {
func discardPendingAnswer() {
state.aiSession.discardReadyAnswer()
requestInsertionFingerprint = nil
requestOOBEFeature = nil
}
func performCurrentFieldAction() {
@@ -274,6 +311,7 @@ final class AIKeyboardCoordinator {
// fallback when the field or caret changed during generation.
return
}
markOOBECompletedAfterInsertion()
state.aiSession.markAnswerInserted(
offersSend: state.returnKeyRole.usesActionFill
)
@@ -284,6 +322,7 @@ final class AIKeyboardCoordinator {
func fail(_ message: String, utteranceID: UUID?) {
clearPendingExportSkill()
requestInsertionFingerprint = nil
requestOOBEFeature = nil
state.aiSession.fail(message, utteranceID: utteranceID)
}
@@ -319,16 +358,51 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = currentFingerprint
}
private func oobeFeature(for skill: AIClipboardSkill) -> ManagedGatewayOOBEFeature? {
switch skill.id {
case AIClipboardSkillCatalog.replyID:
return expectedOOBEFeature(.clipboardReply)
case AIClipboardSkillCatalog.translateID:
return expectedOOBEFeature(.clipboardTranslate)
default:
return nil
}
}
private func expectedOOBEFeature(
_ feature: ManagedGatewayOOBEFeature
) -> ManagedGatewayOOBEFeature? {
guard state.oobePracticeSession?.expectedFeature == feature else { return nil }
return feature
}
private func oobeMaterial(for feature: ManagedGatewayOOBEFeature?) -> String? {
guard let feature,
feature == .clipboardReply || feature == .clipboardTranslate,
let session = state.oobePracticeSession else {
return nil
}
return KeyboardSetupBridge.oobeClipboardMaterial(sessionID: session.sessionID)
}
private func markOOBECompletedAfterInsertion() {
guard let feature = requestOOBEFeature,
let session = state.oobePracticeSession else {
return
}
_ = KeyboardSetupBridge.markOOBEPracticeCompleted(
sessionID: session.sessionID,
feature: feature
)
}
private var isPendingExportSkill: Bool {
guard let id = state.pendingClipboardSkillID else { return false }
return resolvedSkill(id: id)?.kind == .export
}
private func resolvedSkill(id: String) -> AIClipboardSkill? {
AIClipboardSkillCatalog.skill(
id: id,
userCatalog: AppGroupStore().agentUserSkillCatalog
)
state.enabledClipboardSkills.first { $0.id == id }
}
/// Parse an export skill. Empty in-keyboard tip, stay in the host app.
@@ -11,6 +11,7 @@ final class AnalyticsExtensionService: Sendable {
static let shared = AnalyticsExtensionService()
let client: any AnalyticsClient
let keyboardUsageRecorder: any KeyboardUsageRecording
private init() {
let environment = Self.environment
@@ -18,7 +19,15 @@ final class AnalyticsExtensionService: Sendable {
environment: environment,
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint)
)
let keyboardUsageRuntime = KeyboardUsageRuntime(
environment: environment,
analyticsRepository: runtime.repository,
uploadConfiguration: KeyboardUsageUploadConfiguration(
endpoint: Self.keyboardUsageEndpoint
)
)
client = runtime.client
keyboardUsageRecorder = keyboardUsageRuntime.recorder
}
func recordPresentation(hasFullAccess _: Bool) {
@@ -32,6 +41,10 @@ final class AnalyticsExtensionService: Sendable {
string: "https://account.osglab.com/v1/analytics/events"
)!
private static let keyboardUsageEndpoint = URL(
string: "https://account.osglab.com/v1/analytics/keyboard-usage"
)!
private static var environment: AnalyticsEnvironment {
let appVersion = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
@@ -42,7 +42,7 @@ public struct AppGroupPersistor {
state.handednessPreference = store.handednessPreference
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
applySkillSnapshot(store: store, into: state)
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
@@ -97,11 +97,30 @@ public struct AppGroupPersistor {
state.handednessPreference = store.handednessPreference
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
applySkillSnapshot(store: store, into: state)
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
}
/// Resolve once outside SwiftUI body evaluation. Assigning the complete
/// value snapshot publishes remote copy changes even when slot IDs are stable.
private func applySkillSnapshot(
store: AppGroupStore,
into state: KeyboardViewController.State
) {
let layout = store.agentSkillLayout
let language = store.uiLanguage
state.uiLanguage = language
state.enabledClipboardSkillIDs = layout.enabledIDs
state.confirmedClipboardShortcutIDs = layout.confirmedShortcutIDs
state.enabledClipboardSkills = AIClipboardSkillCatalog.visible(
enabledIDs: layout.enabledIDs,
officialCatalog: store.officialSkillCatalog,
userCatalog: store.agentUserSkillCatalog,
uiLanguage: language
)
}
/// Cloud without ASR/LLM keys blocks the mic. Local ASR still works when
/// the polish key is missing show a soft tip above the mic instead.
private func applyAPIKeyAvailability(
@@ -34,6 +34,7 @@ final class ClipboardCaptureCoordinator {
private let state: KeyboardState
private let history: ClipboardHistoryStore
private let semanticRanking: ClipboardSemanticRankingStore
private let pasteboard: ClipboardPasteboardProviding
private var pollTimer: Timer?
private var isSecureProvider: () -> Bool = { false }
@@ -48,10 +49,12 @@ final class ClipboardCaptureCoordinator {
init(
state: KeyboardState,
history: ClipboardHistoryStore = .shared,
semanticRanking: ClipboardSemanticRankingStore = .shared,
pasteboard: ClipboardPasteboardProviding = SystemClipboardPasteboard()
) {
self.state = state
self.history = history
self.semanticRanking = semanticRanking
self.pasteboard = pasteboard
}
@@ -79,6 +82,9 @@ final class ClipboardCaptureCoordinator {
// A suggestion belongs to one keyboard presentation. Clear any
// presentation state left behind by a reused extension controller.
endCurrentSuggestion()
if let newest = history.newestAIHintEligibleEntry() {
semanticRanking.analyze(newest)
}
forcesNextSample = true
// Delay the system pasteboard read until the first poll tick. A
// Universal Clipboard fetch or paste alert during the appear sequence
@@ -94,6 +100,7 @@ final class ClipboardCaptureCoordinator {
stopPolling()
// A1 policy: closing the keyboard ends this generation's suggestion.
endCurrentSuggestion()
semanticRanking.clear()
}
func refreshFlagsFromStore() {
@@ -102,6 +109,9 @@ final class ClipboardCaptureCoordinator {
if !state.clipboardHistoryEnabled || !state.clipboardCandidateBarEnabled {
endCurrentSuggestion()
}
if !state.clipboardHistoryEnabled {
semanticRanking.clear()
}
}
func secureEntryDidChange(isSecure: Bool) {
@@ -117,6 +127,7 @@ final class ClipboardCaptureCoordinator {
return
}
endCurrentSuggestion()
semanticRanking.clear()
state.clipboardOverlay = .none
}
@@ -153,12 +164,16 @@ final class ClipboardCaptureCoordinator {
func clearHistory() {
endCurrentSuggestion()
semanticRanking.clear()
history.clearAll()
}
func deleteEntry(id: UUID) {
let deletedChangeCount = history.entries.first(where: { $0.id == id })?.changeCount
history.remove(id: id)
if semanticRanking.snapshot?.entryID == id {
semanticRanking.clear()
}
if deletedChangeCount == state.clipboardSuggestionChangeCount {
endCurrentSuggestion()
}
@@ -233,6 +248,7 @@ final class ClipboardCaptureCoordinator {
// Prefer hasStrings peek before reading body (reduces empty reads).
guard pasteboard.hasStrings else {
semanticRanking.clear()
history.lastObservedChangeCount = changeCount
return
}
@@ -243,7 +259,9 @@ final class ClipboardCaptureCoordinator {
if isCurrentGeneration {
return
}
semanticRanking.clear()
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
semanticRanking.analyze(entry)
updateSuggestion(with: entry, changeCount: changeCount)
} else {
history.lastObservedChangeCount = changeCount
@@ -306,6 +324,7 @@ final class ClipboardCaptureCoordinator {
// including generations that contain no acceptable text.
clearSuggestion()
guard sample.hasStrings else {
semanticRanking.clear()
history.lastObservedChangeCount = changeCount
return
}
@@ -313,7 +332,9 @@ final class ClipboardCaptureCoordinator {
// republish content from an already observed generation.
guard !isCurrentGeneration else { return }
semanticRanking.clear()
if let entry = history.ingest(rawText: sample.text, changeCount: changeCount) {
semanticRanking.analyze(entry)
updateSuggestion(with: entry, changeCount: changeCount)
} else {
history.lastObservedChangeCount = changeCount
@@ -92,7 +92,21 @@ final class KeyboardConfigSync {
// Keychain fallback: a reboot must not resurrect the mic gate when
// App Group transiently reads empty.
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
state.isOnboardingPracticeActive = KeyboardSetupBridge.isOnboardingPracticeActive
let practice = KeyboardSetupBridge.activeOOBEPracticeSession
state.oobePracticeSession = practice
state.isOnboardingPracticeActive = practice != nil
|| KeyboardSetupBridge.isOnboardingPracticeActive
switch practice?.expectedFeature {
case .voiceInput, .askAI:
state.surface = .voice
case .clipboardTranslate, .clipboardReply:
state.surface = .ai
if !state.aiSession.isActive {
state.aiSession.enter()
}
case nil:
break
}
}
func persistLocale(_ id: String) {
@@ -677,15 +677,22 @@ final class KeyboardFlowCoordinator {
}
func beginAIRecording(
conversationID: UUID
conversationID: UUID,
oobeFeature: ManagedGatewayOOBEFeature? = nil
) -> FlowUtteranceStartDisposition {
startUtterance(.aiQuestion(conversationID: conversationID))
startUtterance(
.aiQuestion(
conversationID: conversationID,
oobeFeature: oobeFeature
)
)
}
func submitAIQuestion(
text: String,
conversationID: UUID,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
thinkingEnabled: Bool? = nil
) -> FlowUtteranceStartDisposition {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -695,6 +702,7 @@ final class KeyboardFlowCoordinator {
conversationID: conversationID,
prefilledQuestion: trimmed,
taskKind: taskKind,
oobeFeature: oobeFeature,
thinkingEnabled: thinkingEnabled
)
)
@@ -1181,8 +1189,14 @@ final class KeyboardFlowCoordinator {
let mode: FlowUtteranceMode? = request.mode == .dictation
? nil
: request.mode
let implicitOOBEFeature: ManagedGatewayOOBEFeature? =
state.oobePracticeSession?.expectedFeature == .voiceInput
&& request.mode == .dictation
? .voiceInput
: nil
let managedOOBEFeature = request.managedOOBEFeature ?? implicitOOBEFeature
let managedRequestPurpose = request.managedRequestPurpose
?? (state.isOnboardingPracticeActive && request.mode == .dictation ? .oobe : nil)
?? (managedOOBEFeature == nil ? nil : .oobe)
let command = FlowCommand(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
@@ -1201,6 +1215,7 @@ final class KeyboardFlowCoordinator {
aiConversationID: request.aiConversationID,
aiTaskKind: request.aiTaskKind,
managedRequestPurpose: managedRequestPurpose,
managedOOBEFeature: managedOOBEFeature,
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
processingDeadlineAt: action == .stopRecording && request.isEdit
? Date().timeIntervalSince1970
@@ -1909,6 +1924,8 @@ final class KeyboardFlowCoordinator {
aiConversationID: currentUtteranceRequest?.aiConversationID,
aiQuestionText: text,
aiTaskKind: currentUtteranceRequest?.aiTaskKind,
managedRequestPurpose: currentUtteranceRequest?.managedRequestPurpose,
managedOOBEFeature: currentUtteranceRequest?.managedOOBEFeature,
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
startDeadlineAt: currentStartDeadlineAt
)
@@ -15,7 +15,7 @@ final class KeyboardTextInserter {
private static let caretVerificationLimit = 80
private let state: KeyboardState
private let insertText: (String) -> Void
private let insertText: (String, KeyboardTextInsertionSource) -> Void
private let deleteBackward: () -> Void
private let contextBeforeInput: () -> String?
private let fieldContextProvider: () -> FlowFieldContext?
@@ -41,7 +41,7 @@ final class KeyboardTextInserter {
init(
state: KeyboardState,
insertText: @escaping (String) -> Void,
insertText: @escaping (String, KeyboardTextInsertionSource) -> Void,
deleteBackward: @escaping () -> Void,
contextBeforeInput: @escaping () -> String?,
fieldContextProvider: @escaping () -> FlowFieldContext?,
@@ -87,8 +87,16 @@ final class KeyboardTextInserter {
insertion: trimmed
)
let inserted = separator + trimmed
insertText(inserted)
insertText(inserted, .voiceTranscription)
KeyboardSetupBridge.markVoiceInsertion()
if delivery.polishWarning == nil,
let practice = state.oobePracticeSession,
practice.expectedFeature == .voiceInput {
_ = KeyboardSetupBridge.markOOBEPracticeCompleted(
sessionID: practice.sessionID,
feature: .voiceInput
)
}
state.noteUserDidInputText()
recordLastInsertion(
inserted,
@@ -120,7 +128,7 @@ final class KeyboardTextInserter {
insertion: trimmed
)
let inserted = separator + trimmed
insertText(inserted)
insertText(inserted, .aiGenerated)
state.noteUserDidInputText()
let mutation = HistoryMutation(
@@ -152,7 +160,7 @@ final class KeyboardTextInserter {
guard !text.isEmpty else { return }
// Verbatim on purpose paste must reproduce exactly what was copied,
// unlike dictation which needs word-boundary hygiene.
insertText(text)
insertText(text, .pasteboard)
recordUndoableInsertion(text)
// The paste pushed any previous input away from the caret, so the
// "editable last input" hint no longer applies.
@@ -207,7 +215,7 @@ final class KeyboardTextInserter {
state.redoAvailable = false
return
}
insertText(text)
insertText(text, .redo)
recordUndoableInsertion(text)
OSGLog.keyboardExt.info("redo length=\(text.count, privacy: .public)")
}
@@ -375,7 +383,7 @@ final class KeyboardTextInserter {
let inserted = separator + result
transaction.appliedInsertedText = inserted
PendingTextEditTransactionStore.save(transaction)
insertText(inserted)
insertText(inserted, .editGenerated)
let verificationSuffix = String(inserted.suffix(80))
guard contextBeforeInput()?.hasSuffix(verificationSuffix) == true else {
// Never blindly delete after a partial/opaque host insertion. The
@@ -448,7 +456,7 @@ final class KeyboardTextInserter {
switch transaction.deliveryMode {
case .replace:
insertText(transaction.beforeText)
insertText(transaction.beforeText, .editGenerated)
let restore = HistoryMutation(
action: transaction.historyMutation.action == .append ? .delete : .restore,
entryID: transaction.historyMutation.entryID,
+58 -7
View File
@@ -50,6 +50,7 @@ struct AIKeyboardView: View {
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
@ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared
let onInsert: (String) -> Void
@AppStorage("keyboard.assistant.longPressCoachCount")
@@ -108,10 +109,13 @@ struct AIKeyboardView: View {
selectedSkillPage = 0
resetCarousel()
}
.onChange(of: state.enabledClipboardSkillIDs) { _, _ in
.onChange(of: state.enabledClipboardSkills) { _, _ in
selectedSkillPage = 0
resetCarousel()
}
.onChange(of: semanticRanking.snapshot) { _, _ in
selectedSkillPage = 0
}
.onChange(of: state.skillTipText) { _, tip in
guard let tip, !tip.isEmpty else { return }
#if DEBUG
@@ -292,8 +296,10 @@ struct AIKeyboardView: View {
@ViewBuilder
private var contextArea: some View {
ZStack {
if state.isOnboardingPracticeActive, activeStatus == nil {
Text(ExtL10n.string("keyboard.onboarding.practice.mic"))
if showsOOBEClipboardSkill, activeStatus == nil {
clipboardSkillPager
} else if let onboardingPracticeHint, activeStatus == nil {
Text(onboardingPracticeHint)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
@@ -532,7 +538,7 @@ struct AIKeyboardView: View {
if skill.id == AIClipboardSkillCatalog.translateID {
return AIClipboardSkillCatalog.translateButtonTitle(
translationTargetLocaleId: state.translationTargetLocaleId,
uiLanguage: AppGroupStore().uiLanguage
uiLanguage: state.uiLanguage
)
}
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -550,6 +556,9 @@ struct AIKeyboardView: View {
return assistantIsResting
}
#endif
if showsOOBEClipboardSkill {
return assistantIsResting
}
guard assistantIsResting, state.clipboardHistoryEnabled else { return false }
guard !visibleClipboardSkills.isEmpty,
let newest = clipboardHistory.newestEntry,
@@ -568,12 +577,54 @@ struct AIKeyboardView: View {
return preview
}
#endif
return AIClipboardSkillCatalog.visible(
enabledIDs: state.enabledClipboardSkillIDs,
userCatalog: AppGroupStore().agentUserSkillCatalog
if let expectedSkillID = oobeExpectedSkillID {
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
}
guard let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return state.enabledClipboardSkills
}
return ClipboardSkillSemanticRanker.ranked(
skills: state.enabledClipboardSkills,
sourceText: newest.text,
analysis: snapshot.analysis,
uiLanguage: state.uiLanguage
)
}
private var showsOOBEClipboardSkill: Bool {
oobeExpectedSkillID != nil
}
private var oobeExpectedSkillID: String? {
switch state.oobePracticeSession?.expectedFeature {
case .clipboardTranslate:
return AIClipboardSkillCatalog.translateID
case .clipboardReply:
return AIClipboardSkillCatalog.replyID
case .voiceInput, .askAI, nil:
return nil
}
}
private var onboardingPracticeHint: String? {
switch state.oobePracticeSession?.expectedFeature {
case .voiceInput:
return ExtL10n.string("keyboard.onboarding.practice.mic")
case .clipboardTranslate:
return ExtL10n.string("keyboard.onboarding.practice.translate")
case .clipboardReply:
return ExtL10n.string("keyboard.onboarding.practice.reply")
case .askAI:
return ExtL10n.string("keyboard.onboarding.practice.askAI")
case nil:
return state.isOnboardingPracticeActive
? ExtL10n.string("keyboard.onboarding.practice.mic")
: nil
}
}
// MARK: - Primary actions
private var primaryActionRow: some View {
+13
View File
@@ -15,6 +15,9 @@
"onboarding.api.localReady.title" = "Local ASR is ready";
"onboarding.api.localReady.body" = "Recognition works on-device. Add an API key in Settings for AI polish.";
"keyboard.onboarding.practice.mic" = "Tap the mic, then tap again when done";
"keyboard.onboarding.practice.translate" = "Tap Translate, then insert the generated text";
"keyboard.onboarding.practice.reply" = "Tap Reply and let AI draft a response";
"keyboard.onboarding.practice.askAI" = "Hold the mic and ask your question";
/* Common navigation */
"common.back" = "Back";
@@ -317,8 +320,18 @@
"keyboard.ai.error.requestFailed" = "AI response failed. Try again";
"keyboard.ai.error.clipboardUnavailable" = "This clipboard suggestion expired. Copy the text again";
"keyboard.ai.skill.reply" = "Reply";
"keyboard.ai.skill.replyInSourceLanguage" = "Same language";
"keyboard.ai.skill.summarize" = "Summarize";
"keyboard.ai.skill.extractConclusions" = "Conclusions";
"keyboard.ai.skill.translate" = "Translate";
"keyboard.ai.skill.acceptInvitation" = "Accept";
"keyboard.ai.skill.declineInvitation" = "Decline";
"keyboard.ai.skill.acceptTask" = "Acknowledge";
"keyboard.ai.skill.clarifyRequest" = "Clarify";
"keyboard.ai.skill.empathyReply" = "Empathize";
"keyboard.ai.skill.askForDetails" = "Ask details";
"keyboard.ai.skill.businessReply" = "Business";
"keyboard.ai.skill.organizeList" = "Organize";
"keyboard.ai.skill.extractTodos" = "Tasks";
"keyboard.ai.skill.extractEvents" = "Events";
"keyboard.ai.skill.saveToNotes" = "Notes";
@@ -15,6 +15,9 @@
"onboarding.api.localReady.title" = "本地识别已就绪";
"onboarding.api.localReady.body" = "识别在端侧完成。请在设置中填写 API Key 以开启 AI 润色。";
"keyboard.onboarding.practice.mic" = "点按麦克风,说完后再点一次";
"keyboard.onboarding.practice.translate" = "点击「翻译」,再插入生成的文字";
"keyboard.onboarding.practice.reply" = "点击「回复」,让 AI 起草可发送的回复";
"keyboard.onboarding.practice.askAI" = "长按麦克风,说出你想问的问题";
/* Common navigation */
"common.back" = "返回";
@@ -317,8 +320,18 @@
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
"keyboard.ai.skill.reply" = "回复";
"keyboard.ai.skill.replyInSourceLanguage" = "原语言回复";
"keyboard.ai.skill.summarize" = "总结";
"keyboard.ai.skill.extractConclusions" = "提取结论";
"keyboard.ai.skill.translate" = "翻译";
"keyboard.ai.skill.acceptInvitation" = "接受邀约";
"keyboard.ai.skill.declineInvitation" = "委婉拒绝";
"keyboard.ai.skill.acceptTask" = "确认任务";
"keyboard.ai.skill.clarifyRequest" = "澄清要求";
"keyboard.ai.skill.empathyReply" = "共情回复";
"keyboard.ai.skill.askForDetails" = "询问细节";
"keyboard.ai.skill.businessReply" = "商务回复";
"keyboard.ai.skill.organizeList" = "整理清单";
"keyboard.ai.skill.extractTodos" = "待办";
"keyboard.ai.skill.extractEvents" = "日程";
"keyboard.ai.skill.saveToNotes" = "备忘录";