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:
@@ -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,
|
||||
|
||||
@@ -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" = "Couldn’t open the app to run the Shortcut";
|
||||
"keyboard.ai.skill.openLinkFailed" = "Couldn’t open this link";
|
||||
"keyboard.ai.skill.callPhoneFailed" = "Couldn’t start this call";
|
||||
"keyboard.ai.skill.createContactFailed" = "Couldn’t open the new contact form";
|
||||
|
||||
@@ -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" = "无法打开新建联系人页面";
|
||||
|
||||
Reference in New Issue
Block a user