feat(keyboard): add Skills tab and extract-todos Shortcut export
Ship a Skills catalog with drag-to-reorder chips and a companion Shortcut that receives extracted titles and writes them to Reminders.
This commit is contained in:
@@ -520,6 +520,11 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.submitAIClipboardSkill = { [weak self] skill in
|
||||
self?.aiKeyboardCoordinator.submitClipboardSkill(skill)
|
||||
}
|
||||
state.runClipboardExportSkill = { [weak self] skillID, titles in
|
||||
AppGroupStore().setPendingShortcutRun(skillID: skillID, titles: titles)
|
||||
AIAgentShortcutRun.trace("keyboard.openHost osgkeyboard://skill/run")
|
||||
self?.openSkillShortcutRun()
|
||||
}
|
||||
state.openSettings = { [weak self] in self?.openHostApp() }
|
||||
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
|
||||
state.openClipboardSettings = { [weak self] in
|
||||
@@ -942,6 +947,19 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
// MARK: - Open host app
|
||||
|
||||
private func openSkillShortcutRun() {
|
||||
guard hasFullAccess else {
|
||||
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||||
return
|
||||
}
|
||||
guard let url = URL(string: "osgkeyboard://skill/run") else { return }
|
||||
HostAppLauncher.open(url: url, from: self) { [weak self] success in
|
||||
AIAgentShortcutRun.trace("keyboard.openHost result success=\(success)")
|
||||
if success { return }
|
||||
self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.handoffFailed")
|
||||
}
|
||||
}
|
||||
|
||||
private func openHostApp(path: String = "settings") {
|
||||
guard hasFullAccess else {
|
||||
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||||
|
||||
@@ -70,15 +70,26 @@ final class AIKeyboardCoordinator {
|
||||
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
||||
guard canAcceptIdleSubmit else { return }
|
||||
enterIfNeeded()
|
||||
state.pendingClipboardSkillID = skill.kind == .export ? skill.id : nil
|
||||
let instruction = AIClipboardSkillCatalog.instruction(
|
||||
for: skill,
|
||||
locale: AIHintLocaleResolver.packLocale(),
|
||||
translationTargetLocaleId: state.translationTargetLocaleId
|
||||
)
|
||||
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
|
||||
if let material {
|
||||
AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
|
||||
} else {
|
||||
AIAgentShortcutRun.trace("keyboard.clipboard missing")
|
||||
}
|
||||
let resolution = AIClipboardPrompt.resolve(
|
||||
instruction: instruction,
|
||||
material: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
material: material
|
||||
)
|
||||
if case .materialUnavailable = resolution {
|
||||
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
|
||||
}
|
||||
submitResolvedPrompt(resolution)
|
||||
}
|
||||
|
||||
@@ -106,24 +117,30 @@ final class AIKeyboardCoordinator {
|
||||
private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) {
|
||||
guard case .ready(let prompt) = resolution else {
|
||||
// The clipboard window closed between rendering and this tap.
|
||||
state.pendingClipboardSkillID = nil
|
||||
state.aiSession.fail(
|
||||
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
|
||||
utteranceID: nil
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let conversationID = state.aiSession.conversationID else { return }
|
||||
guard let conversationID = state.aiSession.conversationID else {
|
||||
state.pendingClipboardSkillID = nil
|
||||
return
|
||||
}
|
||||
let disposition = flow.submitAIQuestion(
|
||||
text: prompt,
|
||||
conversationID: conversationID
|
||||
)
|
||||
if case .rejected(let rejection) = disposition {
|
||||
state.pendingClipboardSkillID = nil
|
||||
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
guard state.aiSession.isBusy else { return }
|
||||
state.pendingClipboardSkillID = nil
|
||||
flow.cancelAIRecording()
|
||||
state.aiSession.cancelCurrentWork()
|
||||
}
|
||||
@@ -181,6 +198,7 @@ final class AIKeyboardCoordinator {
|
||||
}
|
||||
|
||||
func receivePartialAnswer(_ draft: String, utteranceID: UUID) {
|
||||
if isPendingExportSkill { return }
|
||||
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
|
||||
}
|
||||
|
||||
@@ -188,6 +206,11 @@ final class AIKeyboardCoordinator {
|
||||
guard result.resolvedUtteranceMode == .aiQuestion else {
|
||||
return
|
||||
}
|
||||
if isPendingExportSkill {
|
||||
guard result.aiConversationID == state.aiSession.conversationID else { return }
|
||||
finishExportSkill(answer: result.text ?? "")
|
||||
return
|
||||
}
|
||||
guard result.aiConversationID == state.aiSession.conversationID,
|
||||
let answer = result.text,
|
||||
!answer.isEmpty else {
|
||||
@@ -201,14 +224,59 @@ final class AIKeyboardCoordinator {
|
||||
}
|
||||
|
||||
func fail(_ message: String, utteranceID: UUID?) {
|
||||
state.pendingClipboardSkillID = nil
|
||||
state.aiSession.fail(message, utteranceID: utteranceID)
|
||||
}
|
||||
|
||||
private func endConversationIfNeeded() {
|
||||
state.pendingClipboardSkillID = nil
|
||||
guard let conversationID = state.aiSession.conversationID else { return }
|
||||
flow.endAIConversation(conversationID)
|
||||
}
|
||||
|
||||
private var isPendingExportSkill: Bool {
|
||||
guard let id = state.pendingClipboardSkillID else { return false }
|
||||
return AIClipboardSkillCatalog.skill(id: id)?.kind == .export
|
||||
}
|
||||
|
||||
/// Parse extract-todos. Empty → in-keyboard tip, stay in the host app.
|
||||
/// Titles → hand off to the host to run the companion Shortcut.
|
||||
private func finishExportSkill(answer: String) {
|
||||
let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
let items = AITodoExtraction.items(from: answer, sourceClipboard: source)
|
||||
AIAgentShortcutRun.traceBody("keyboard.llmRaw", answer)
|
||||
AIAgentShortcutRun.trace(
|
||||
"keyboard.parse items=\(items.count) clipboardChars=\(source?.count ?? 0)"
|
||||
)
|
||||
#if DEBUG
|
||||
if !items.isEmpty {
|
||||
AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
|
||||
}
|
||||
#endif
|
||||
let skillID = state.pendingClipboardSkillID
|
||||
state.pendingClipboardSkillID = nil
|
||||
if state.aiSession.isBusy {
|
||||
state.aiSession.cancelCurrentWork()
|
||||
}
|
||||
if state.aiSession.isActive {
|
||||
state.aiSession.resetConversationPreservingAnswer()
|
||||
}
|
||||
guard !items.isEmpty else {
|
||||
AIAgentShortcutRun.trace("keyboard.parse empty — skip Shortcuts")
|
||||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.noTodos")
|
||||
return
|
||||
}
|
||||
guard let skillID,
|
||||
AIClipboardSkillCatalog.skill(id: skillID)?.shortcutName != nil else {
|
||||
AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")")
|
||||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
|
||||
return
|
||||
}
|
||||
AIAgentShortcutRun.trace("keyboard.handoffToHost skill=\(skillID) items=\(items.count)")
|
||||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.runningShortcut")
|
||||
state.runClipboardExportSkill(skillID, items)
|
||||
}
|
||||
|
||||
private func message(for rejection: FlowUtteranceStartRejection) -> String {
|
||||
switch rejection {
|
||||
case .onboardingIncomplete:
|
||||
|
||||
@@ -43,6 +43,7 @@ public struct AppGroupPersistor {
|
||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||
applyAPIKeyAvailability(store: store, into: state)
|
||||
|
||||
@@ -98,6 +99,7 @@ public struct AppGroupPersistor {
|
||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||
applyAPIKeyAvailability(store: store, into: state)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,17 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
|
||||
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
|
||||
.onChange(of: state.enabledClipboardSkillIDs) { _, _ in resetCarousel() }
|
||||
.onChange(of: state.skillTipText) { _, tip in
|
||||
guard let tip, !tip.isEmpty else { return }
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 2_800_000_000)
|
||||
if state.skillTipText == tip {
|
||||
state.skillTipText = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(Motion.soft, value: state.skillTipText)
|
||||
.onReceive(
|
||||
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
|
||||
) { _ in
|
||||
@@ -172,6 +183,19 @@ struct AIKeyboardView: View {
|
||||
statusLine
|
||||
.frame(height: Layout.statusHeight)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
|
||||
if let tip = state.skillTipText, !tip.isEmpty {
|
||||
Text(tip)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassEffect(.regular, in: Capsule())
|
||||
.padding(.bottom, Layout.statusHeight + Spacing.sm)
|
||||
.transition(.opacity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +236,9 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
|
||||
private var clipboardSkillRow: some View {
|
||||
HStack(spacing: Spacing.lg) {
|
||||
ForEach(AIClipboardSkillCatalog.visible()) { skill in
|
||||
let skills = visibleClipboardSkills
|
||||
let row = HStack(spacing: Spacing.lg) {
|
||||
ForEach(skills) { skill in
|
||||
Button {
|
||||
state.submitAIClipboardSkill(skill)
|
||||
} label: {
|
||||
@@ -233,6 +258,15 @@ struct AIKeyboardView: View {
|
||||
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
||||
}
|
||||
}
|
||||
return Group {
|
||||
if skills.count > 4 {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
row.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@@ -250,12 +284,17 @@ struct AIKeyboardView: View {
|
||||
/// Copy-then-30s window: skill chips replace the rotating hint.
|
||||
private var showsClipboardSkills: Bool {
|
||||
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
|
||||
guard !visibleClipboardSkills.isEmpty else { return false }
|
||||
return AIHintPool.isClipboardSkillWindowActive(
|
||||
clipboardHistoryEnabled: true,
|
||||
newestClipboard: clipboardHistory.newestEntry
|
||||
)
|
||||
}
|
||||
|
||||
private var visibleClipboardSkills: [AIClipboardSkill] {
|
||||
AIClipboardSkillCatalog.visible(enabledIDs: state.enabledClipboardSkillIDs)
|
||||
}
|
||||
|
||||
/// No draft/answer yet — show the centered hint carousel instead of a scroll body.
|
||||
private var showsPlaceholder: Bool {
|
||||
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
|
||||
@@ -432,6 +471,9 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
return state.aiSession.transcript
|
||||
case .generating:
|
||||
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
|
||||
return ExtL10n.string("keyboard.ai.skill.extracting")
|
||||
}
|
||||
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
|
||||
return ExtL10n.string("keyboard.ai.generating")
|
||||
}
|
||||
|
||||
@@ -297,3 +297,9 @@
|
||||
"keyboard.ai.skill.reply" = "Reply";
|
||||
"keyboard.ai.skill.summarize" = "Summarize";
|
||||
"keyboard.ai.skill.translate" = "Translate";
|
||||
"keyboard.ai.skill.extractTodos" = "Tasks";
|
||||
"keyboard.ai.skill.noTodos" = "No tasks in the clipboard";
|
||||
"keyboard.ai.skill.extracting" = "Finding tasks…";
|
||||
"keyboard.ai.skill.runningShortcut" = "Running Shortcut…";
|
||||
"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";
|
||||
|
||||
@@ -297,3 +297,9 @@
|
||||
"keyboard.ai.skill.reply" = "回复";
|
||||
"keyboard.ai.skill.summarize" = "总结";
|
||||
"keyboard.ai.skill.translate" = "翻译";
|
||||
"keyboard.ai.skill.extractTodos" = "待办";
|
||||
"keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项";
|
||||
"keyboard.ai.skill.extracting" = "正在提取待办…";
|
||||
"keyboard.ai.skill.runningShortcut" = "正在运行捷径…";
|
||||
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
|
||||
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
|
||||
|
||||
Reference in New Issue
Block a user