feat(keyboard): expand contextual skills and managed flows

Add local clipboard intent recommendations, webpage and phone actions, and safer host handoffs. Refine managed gateway, catalog refresh, onboarding, and adaptive polish behavior.
This commit is contained in:
Rocky
2026-08-23 14:02:33 +08:00
parent 7df5bbdaa0
commit 2cc81c4628
68 changed files with 10368 additions and 7765 deletions
@@ -488,6 +488,15 @@ public final class KeyboardViewController: UIInputViewController {
},
captureInsertionFingerprint: { [weak self] in
self?.captureFieldContext().deliveryFingerprint
},
openWebURL: { [weak self] url in
self?.openWebURL(url)
},
callPhone: { [weak self] phoneNumber in
self?.callPhone(phoneNumber)
},
createContact: { [weak self] phoneNumber in
self?.createContact(phoneNumber)
}
)
clipboardCapture = ClipboardCaptureCoordinator(state: state)
@@ -1094,6 +1103,47 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - Open host app
private func openWebURL(_ url: URL) {
guard hasFullAccess else {
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
return
}
HostAppLauncher.open(url: url, from: self) { [weak self] success in
guard !success else { return }
self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.openLinkFailed")
}
}
private func callPhone(_ phoneNumber: String) {
guard hasFullAccess else {
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
return
}
guard let url = AIPhoneNumberResolver.telephoneURL(for: phoneNumber) else {
state.skillTipText = ExtL10n.string("keyboard.ai.skill.callPhoneFailed")
return
}
HostAppLauncher.open(url: url, from: self) { [weak self] success in
guard !success else { return }
self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.callPhoneFailed")
}
}
private func createContact(_ phoneNumber: String) {
guard hasFullAccess else {
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
return
}
AppGroupStore().setPendingContactCreation(phoneNumber: phoneNumber)
guard let url = URL(string: "osgkeyboard://contact/new") else { return }
HostAppLauncher.open(url: url, from: self) { [weak self] success in
guard !success else { return }
self?.state.skillTipText = ExtL10n.string(
"keyboard.ai.skill.createContactFailed"
)
}
}
private func openSkillShortcutRun() {
guard hasFullAccess else {
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
@@ -15,6 +15,9 @@ final class AIKeyboardCoordinator {
private let insertAnswer: (AIAnswer) -> Bool
private let performReturn: () -> Void
private let captureInsertionFingerprint: () -> String?
private let openWebURL: (URL) -> Void
private let callPhone: (String) -> Void
private let createContact: (String) -> Void
private var requestInsertionFingerprint: String?
private var conversationInsertionFingerprint: String?
private var hasConversationInsertionTarget = false
@@ -25,13 +28,19 @@ final class AIKeyboardCoordinator {
flow: KeyboardFlowCoordinator,
insertAnswer: @escaping (AIAnswer) -> Bool,
performReturn: @escaping () -> Void,
captureInsertionFingerprint: @escaping () -> String?
captureInsertionFingerprint: @escaping () -> String?,
openWebURL: @escaping (URL) -> Void,
callPhone: @escaping (String) -> Void,
createContact: @escaping (String) -> Void
) {
self.state = state
self.flow = flow
self.insertAnswer = insertAnswer
self.performReturn = performReturn
self.captureInsertionFingerprint = captureInsertionFingerprint
self.openWebURL = openWebURL
self.callPhone = callPhone
self.createContact = createContact
}
func beginNewPresentation() {
@@ -94,7 +103,6 @@ final class AIKeyboardCoordinator {
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
return
}
prepareConversationForRequest()
let oobeFeature = oobeFeature(for: skill)
let material: String?
if let oobeFeature {
@@ -106,6 +114,35 @@ final class AIKeyboardCoordinator {
} else {
material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
}
if skill.id == AIClipboardSkillCatalog.openLinkID {
guard let material,
let url = ClipboardWebLinkResolver.singleWebURL(in: material) else {
state.skillTipText = ExtL10n.string("keyboard.ai.error.clipboardUnavailable")
return
}
openWebURL(url)
return
}
if skill.id == AIClipboardSkillCatalog.callPhoneID {
guard let material,
let phoneNumber = AIPhoneNumberResolver.singlePhoneNumber(in: material) else {
state.skillTipText = ExtL10n.string("keyboard.ai.error.clipboardUnavailable")
return
}
callPhone(phoneNumber)
return
}
if skill.id == AIClipboardSkillCatalog.createContactID {
guard let material,
let phoneNumber = AIPhoneNumberResolver.singlePhoneNumber(in: material) else {
state.skillTipText = ExtL10n.string("keyboard.ai.error.clipboardUnavailable")
return
}
createContact(phoneNumber)
return
}
prepareConversationForRequest()
if skill.kind == .export {
state.pendingClipboardSkillID = skill.id
state.pendingClipboardSkillSource = material
@@ -134,6 +171,27 @@ final class AIKeyboardCoordinator {
if case .materialUnavailable = resolution {
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
}
if skill.id == AIClipboardSkillCatalog.summarizeWebPageID,
let material,
let url = ClipboardWebLinkResolver.singleWebURL(in: material) {
submitResolvedPrompt(
.ready(instruction),
taskKind: skill.managedGatewayTaskKind,
oobeFeature: oobeFeature,
thinkingEnabled: skill.thinkingEnabled,
webPageURL: url
)
return
}
if skill.id == AIClipboardSkillCatalog.summarizeWebPageID {
submitResolvedPrompt(
.materialUnavailable,
taskKind: skill.managedGatewayTaskKind,
oobeFeature: oobeFeature,
thinkingEnabled: skill.thinkingEnabled
)
return
}
submitResolvedPrompt(
resolution,
taskKind: skill.managedGatewayTaskKind,
@@ -155,8 +213,10 @@ final class AIKeyboardCoordinator {
)
submitResolvedPrompt(
resolution,
taskKind: .aiQuestion,
oobeFeature: nil
taskKind: card.taskKind,
requestSource: .hotword,
oobeFeature: nil,
thinkingEnabled: true
)
}
@@ -173,8 +233,10 @@ final class AIKeyboardCoordinator {
private func submitResolvedPrompt(
_ resolution: AIClipboardPrompt.Resolution,
taskKind: ManagedGatewayTaskKind,
requestSource: ManagedGatewayRequestSource? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
thinkingEnabled: Bool? = nil
thinkingEnabled: Bool? = nil,
webPageURL: URL? = nil
) {
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
@@ -194,8 +256,10 @@ final class AIKeyboardCoordinator {
text: prompt,
conversationID: conversationID,
taskKind: taskKind,
requestSource: requestSource,
oobeFeature: oobeFeature,
thinkingEnabled: thinkingEnabled
thinkingEnabled: thinkingEnabled,
webPageURL: webPageURL
)
if case .rejected(let rejection) = disposition {
clearPendingExportSkill()
@@ -692,8 +692,10 @@ final class KeyboardFlowCoordinator {
text: String,
conversationID: UUID,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
requestSource: ManagedGatewayRequestSource? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
thinkingEnabled: Bool? = nil
thinkingEnabled: Bool? = nil,
webPageURL: URL? = nil
) -> FlowUtteranceStartDisposition {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
@@ -702,8 +704,10 @@ final class KeyboardFlowCoordinator {
conversationID: conversationID,
prefilledQuestion: trimmed,
taskKind: taskKind,
requestSource: requestSource,
oobeFeature: oobeFeature,
thinkingEnabled: thinkingEnabled
thinkingEnabled: thinkingEnabled,
webPageURL: webPageURL
)
)
}
@@ -1214,6 +1218,7 @@ final class KeyboardFlowCoordinator {
sourceHistoryEntryRevision: request.sourceHistoryEntryRevision,
aiConversationID: request.aiConversationID,
aiTaskKind: request.aiTaskKind,
managedRequestSource: request.managedRequestSource,
managedRequestPurpose: managedRequestPurpose,
managedOOBEFeature: managedOOBEFeature,
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
@@ -1923,7 +1928,9 @@ final class KeyboardFlowCoordinator {
utteranceMode: .aiQuestion,
aiConversationID: currentUtteranceRequest?.aiConversationID,
aiQuestionText: text,
aiWebPageURL: currentUtteranceRequest?.aiWebPageURL,
aiTaskKind: currentUtteranceRequest?.aiTaskKind,
managedRequestSource: currentUtteranceRequest?.managedRequestSource,
managedRequestPurpose: currentUtteranceRequest?.managedRequestPurpose,
managedOOBEFeature: currentUtteranceRequest?.managedOOBEFeature,
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
+10 -2
View File
@@ -319,9 +319,14 @@
"keyboard.ai.error.requestTimeout" = "AI response timed out. Try again";
"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.reply" = "Same-language reply";
"keyboard.ai.skill.playfulReply" = "Playful";
"keyboard.ai.skill.replyInSourceLanguage" = "Same language";
"keyboard.ai.skill.summarize" = "Summarize";
"keyboard.ai.skill.summarize" = "Key points";
"keyboard.ai.skill.openLink" = "Open";
"keyboard.ai.skill.summarizeWebPage" = "Web summary";
"keyboard.ai.skill.callPhone" = "Call";
"keyboard.ai.skill.createContact" = "New contact";
"keyboard.ai.skill.extractConclusions" = "Conclusions";
"keyboard.ai.skill.translate" = "Translate";
"keyboard.ai.skill.acceptInvitation" = "Accept";
@@ -352,3 +357,6 @@
"keyboard.ai.skill.openingMaps" = "Opening Maps…";
"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
"keyboard.ai.skill.handoffFailed" = "Couldnt open the app to run the Shortcut";
"keyboard.ai.skill.openLinkFailed" = "Couldnt open this link";
"keyboard.ai.skill.callPhoneFailed" = "Couldnt start this call";
"keyboard.ai.skill.createContactFailed" = "Couldnt open the new contact form";
+11 -3
View File
@@ -319,15 +319,20 @@
"keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试";
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
"keyboard.ai.skill.reply" = "回复";
"keyboard.ai.skill.reply" = "原语言回复";
"keyboard.ai.skill.playfulReply" = "俏皮回复";
"keyboard.ai.skill.replyInSourceLanguage" = "原语言回复";
"keyboard.ai.skill.summarize" = "总结";
"keyboard.ai.skill.summarize" = "总结要点";
"keyboard.ai.skill.openLink" = "打开";
"keyboard.ai.skill.summarizeWebPage" = "总结网页";
"keyboard.ai.skill.callPhone" = "拨打";
"keyboard.ai.skill.createContact" = "新建联系人";
"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.clarifyRequest" = "澄清追问";
"keyboard.ai.skill.empathyReply" = "共情回复";
"keyboard.ai.skill.askForDetails" = "询问细节";
"keyboard.ai.skill.businessReply" = "商务回复";
@@ -352,3 +357,6 @@
"keyboard.ai.skill.openingMaps" = "正在打开地图…";
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
"keyboard.ai.skill.openLinkFailed" = "无法打开该链接";
"keyboard.ai.skill.callPhoneFailed" = "无法拨打该号码";
"keyboard.ai.skill.createContactFailed" = "无法打开新建联系人页面";