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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" = "备忘录";
|
||||
|
||||
Reference in New Issue
Block a user