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:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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" = "Couldn’t 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 运行捷径";
|
||||
|
||||
Reference in New Issue
Block a user