feat(keyboard): add custom skills plus events and navigate Shortcuts

Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69.
This commit is contained in:
Rocky
2026-08-13 23:01:43 +08:00
parent 148ff807f2
commit 0a60006d2d
50 changed files with 11612 additions and 251 deletions
@@ -90,7 +90,7 @@ final class AIKeyboardCoordinator {
if case .materialUnavailable = resolution {
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
}
submitResolvedPrompt(resolution)
submitResolvedPrompt(resolution, thinkingEnabled: skill.thinkingEnabled)
}
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
@@ -114,7 +114,10 @@ final class AIKeyboardCoordinator {
}
}
private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) {
private func submitResolvedPrompt(
_ resolution: AIClipboardPrompt.Resolution,
thinkingEnabled: Bool? = nil
) {
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
state.pendingClipboardSkillID = nil
@@ -130,7 +133,8 @@ final class AIKeyboardCoordinator {
}
let disposition = flow.submitAIQuestion(
text: prompt,
conversationID: conversationID
conversationID: conversationID,
thinkingEnabled: thinkingEnabled
)
if case .rejected(let rejection) = disposition {
state.pendingClipboardSkillID = nil
@@ -236,14 +240,37 @@ final class AIKeyboardCoordinator {
private var isPendingExportSkill: Bool {
guard let id = state.pendingClipboardSkillID else { return false }
return AIClipboardSkillCatalog.skill(id: id)?.kind == .export
return resolvedSkill(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 resolvedSkill(id: String) -> AIClipboardSkill? {
AIClipboardSkillCatalog.skill(
id: id,
userCatalog: AppGroupStore().agentUserSkillCatalog
)
}
/// Parse an export skill. Empty in-keyboard tip, stay in the host app.
/// Lines 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)
let skillID = state.pendingClipboardSkillID
let items: [String]
let emptyTipKey: String
switch skillID {
case AIClipboardSkillCatalog.extractEventsID:
items = AIEventExtraction.lines(from: answer, sourceClipboard: source)
emptyTipKey = "keyboard.ai.skill.noEvents"
case AIClipboardSkillCatalog.extractTodosID:
items = AITodoExtraction.items(from: answer, sourceClipboard: source)
emptyTipKey = "keyboard.ai.skill.noTodos"
case AIClipboardSkillCatalog.navigateID:
items = AIAddressExtraction.lines(from: answer, sourceClipboard: source)
emptyTipKey = "keyboard.ai.skill.noAddress"
default:
items = AIGenericSkillExport.items(from: answer)
emptyTipKey = "keyboard.ai.skill.noExportItems"
}
AIAgentShortcutRun.traceBody("keyboard.llmRaw", answer)
AIAgentShortcutRun.trace(
"keyboard.parse items=\(items.count) clipboardChars=\(source?.count ?? 0)"
@@ -253,7 +280,6 @@ final class AIKeyboardCoordinator {
AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
}
#endif
let skillID = state.pendingClipboardSkillID
state.pendingClipboardSkillID = nil
if state.aiSession.isBusy {
state.aiSession.cancelCurrentWork()
@@ -263,11 +289,11 @@ final class AIKeyboardCoordinator {
}
guard !items.isEmpty else {
AIAgentShortcutRun.trace("keyboard.parse empty — skip Shortcuts")
state.skillTipText = ExtL10n.string("keyboard.ai.skill.noTodos")
state.skillTipText = ExtL10n.string(emptyTipKey)
return
}
guard let skillID,
AIClipboardSkillCatalog.skill(id: skillID)?.shortcutName != nil else {
resolvedSkill(id: skillID)?.shortcutName != nil else {
AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")")
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
return
@@ -670,12 +670,17 @@ final class KeyboardFlowCoordinator {
func submitAIQuestion(
text: String,
conversationID: UUID
conversationID: UUID,
thinkingEnabled: Bool? = nil
) -> FlowUtteranceStartDisposition {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
return startUtterance(
.aiQuestion(conversationID: conversationID, prefilledQuestion: trimmed)
.aiQuestion(
conversationID: conversationID,
prefilledQuestion: trimmed,
thinkingEnabled: thinkingEnabled
)
)
}
@@ -1868,6 +1873,7 @@ final class KeyboardFlowCoordinator {
utteranceMode: .aiQuestion,
aiConversationID: currentUtteranceRequest?.aiConversationID,
aiQuestionText: text,
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
startDeadlineAt: currentStartDeadlineAt
)
FlowSessionBridge.writeCommand(command)
+73 -29
View File
@@ -15,9 +15,18 @@ struct AIKeyboardView: View {
static let actionButtonMaxWidth: CGFloat = 150
static let statusHeight: CGFloat = 20
static let carouselInterval: TimeInterval = 4
static let skillButtonSize: CGFloat = 52
static let skillButtonSize: CGFloat = 48
static let skillsPerRow = 4
static let skillRowSpacing: CGFloat = 8
static let skillCellSpacing: CGFloat = 16
static let skillCellWidth: CGFloat = 64
}
#if DEBUG
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
static var debugPreviewSkills: [AIClipboardSkill]?
#endif
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@@ -236,40 +245,52 @@ struct AIKeyboardView: View {
}
private var clipboardSkillRow: some View {
let skills = visibleClipboardSkills
let row = HStack(spacing: Spacing.lg) {
ForEach(skills) { skill in
Button {
state.submitAIClipboardSkill(skill)
} label: {
VStack(spacing: 6) {
Image(systemName: skill.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.textPrimary.opacity(0.85))
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
.glassEffect(.regular.interactive(), in: Circle())
Text(clipboardSkillTitle(skill))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
let rows = skillRows(from: visibleClipboardSkills)
return VStack(spacing: 0) {
Spacer(minLength: 0)
VStack(spacing: Layout.skillRowSpacing) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, rowSkills in
HStack(spacing: Layout.skillCellSpacing) {
ForEach(rowSkills) { skill in
skillChip(skill)
}
}
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
}
return Group {
if skills.count > 4 {
ScrollView(.horizontal, showsIndicators: false) {
row.padding(.horizontal, Spacing.md)
}
} else {
row
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func skillRows(from skills: [AIClipboardSkill]) -> [[AIClipboardSkill]] {
stride(from: 0, to: skills.count, by: Layout.skillsPerRow).map { start in
Array(skills[start ..< min(start + Layout.skillsPerRow, skills.count)])
}
}
private func skillChip(_ skill: AIClipboardSkill) -> some View {
Button {
state.submitAIClipboardSkill(skill)
} label: {
VStack(spacing: 6) {
Image(systemName: skill.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.textPrimary.opacity(0.85))
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
.glassEffect(.regular.interactive(), in: Circle())
Text(clipboardSkillTitle(skill))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(width: Layout.skillCellWidth)
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
/// Translate follows the keyboard target; Reply / Summarize stay static.
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
if skill.id == AIClipboardSkillCatalog.translateID {
@@ -278,11 +299,20 @@ struct AIKeyboardView: View {
uiLanguage: AppGroupStore().uiLanguage
)
}
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
!custom.isEmpty {
return custom
}
return ExtL10n.string(skill.titleKey)
}
/// Copy-then-30s window: skill chips replace the rotating hint.
private var showsClipboardSkills: Bool {
#if DEBUG
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
return showsPlaceholder
}
#endif
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
guard !visibleClipboardSkills.isEmpty else { return false }
return AIHintPool.isClipboardSkillWindowActive(
@@ -292,7 +322,15 @@ struct AIKeyboardView: View {
}
private var visibleClipboardSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.visible(enabledIDs: state.enabledClipboardSkillIDs)
#if DEBUG
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
return preview
}
#endif
return AIClipboardSkillCatalog.visible(
enabledIDs: state.enabledClipboardSkillIDs,
userCatalog: AppGroupStore().agentUserSkillCatalog
)
}
/// No draft/answer yet show the centered hint carousel instead of a scroll body.
@@ -474,6 +512,12 @@ struct AIKeyboardView: View {
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
return ExtL10n.string("keyboard.ai.skill.extracting")
}
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractEventsID {
return ExtL10n.string("keyboard.ai.skill.extractingEvents")
}
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.navigateID {
return ExtL10n.string("keyboard.ai.skill.extractingAddress")
}
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
return ExtL10n.string("keyboard.ai.generating")
}
+10
View File
@@ -298,8 +298,18 @@
"keyboard.ai.skill.summarize" = "Summarize";
"keyboard.ai.skill.translate" = "Translate";
"keyboard.ai.skill.extractTodos" = "Tasks";
"keyboard.ai.skill.extractEvents" = "Events";
"keyboard.ai.skill.navigate" = "Navigate";
"keyboard.ai.skill.previewPolish" = "Polish";
"keyboard.ai.skill.previewIdeas" = "Ideas";
"keyboard.ai.skill.previewTone" = "Tone";
"keyboard.ai.skill.noTodos" = "No tasks in the clipboard";
"keyboard.ai.skill.noEvents" = "No date or time in the clipboard";
"keyboard.ai.skill.noAddress" = "No address in the clipboard";
"keyboard.ai.skill.noExportItems" = "Nothing to send to the Shortcut";
"keyboard.ai.skill.extracting" = "Finding tasks…";
"keyboard.ai.skill.extractingEvents" = "Finding events…";
"keyboard.ai.skill.extractingAddress" = "Finding an address…";
"keyboard.ai.skill.runningShortcut" = "Running Shortcut…";
"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
"keyboard.ai.skill.handoffFailed" = "Couldnt open the app to run the Shortcut";
@@ -298,8 +298,18 @@
"keyboard.ai.skill.summarize" = "总结";
"keyboard.ai.skill.translate" = "翻译";
"keyboard.ai.skill.extractTodos" = "待办";
"keyboard.ai.skill.extractEvents" = "日程";
"keyboard.ai.skill.navigate" = "导航";
"keyboard.ai.skill.previewPolish" = "润色";
"keyboard.ai.skill.previewIdeas" = "点子";
"keyboard.ai.skill.previewTone" = "语气";
"keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项";
"keyboard.ai.skill.noEvents" = "剪贴板内容没有日期或时间";
"keyboard.ai.skill.noAddress" = "剪贴板内容没有可导航的地址";
"keyboard.ai.skill.noExportItems" = "没有可发给捷径的内容";
"keyboard.ai.skill.extracting" = "正在提取待办…";
"keyboard.ai.skill.extractingEvents" = "正在提取日程…";
"keyboard.ai.skill.extractingAddress" = "正在识别地址…";
"keyboard.ai.skill.runningShortcut" = "正在运行捷径…";
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";