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
+4
View File
@@ -70,6 +70,10 @@
<string>skype</string>
<string>zoomus</string>
<string>shortcuts</string>
<string>iosamap</string>
<string>amapuri</string>
<string>baidumap</string>
<string>maps</string>
<string>things</string>
<string>todoist</string>
<string>evernote</string>
+2
View File
@@ -36,6 +36,8 @@ struct OSGKeyboardApp: App {
EditDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--ai-demo") {
AIKeyboardDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
AIClipboardSkillLayoutDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
ClipboardHistoryDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
Binary file not shown.
@@ -10,8 +10,6 @@ import UIKit
import OSGKeyboardShared
enum AIAgentShortcutInstaller {
static let bundledResourceName = "OSGExtractTodos"
@MainActor
static func openInstallPage(for skill: AIClipboardSkill) {
if let shareURL = skill.shortcutICloudURL,
@@ -19,17 +17,19 @@ enum AIAgentShortcutInstaller {
UIApplication.shared.open(installURL)
return
}
openBundledShortcut()
openBundledShortcut(for: skill)
}
@MainActor
private static func openBundledShortcut() {
guard let bundled = Bundle.main.url(
forResource: bundledResourceName,
withExtension: "shortcut"
) else { return }
private static func openBundledShortcut(for skill: AIClipboardSkill) {
guard let resource = skill.shortcutResourceName,
let bundled = Bundle.main.url(
forResource: resource,
withExtension: "shortcut"
) else { return }
let fileName = skill.shortcutName ?? resource
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("\(AIClipboardSkillCatalog.extractTodosShortcutName).shortcut")
.appendingPathComponent("\(fileName).shortcut")
try? FileManager.default.removeItem(at: tmp)
do {
try FileManager.default.copyItem(at: bundled, to: tmp)
@@ -1,7 +1,7 @@
// AIAgentShortcutRunner.swift
// OSGKeyboard · Main App
//
// Consumes the keyboard's pending extract-todos payload and opens the
// Consumes the keyboard's pending export-skill payload and opens the
// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add
// x-callback URLs so Console can record success / error / cancel.
@@ -13,15 +13,22 @@ enum AIAgentShortcutRunner {
static func runPendingIfNeeded() {
AIAgentShortcutRun.trace("host.runPending begin")
guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID),
let catalog = AppGroupStore().agentUserSkillCatalog
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog),
let name = skill.shortcutName else {
AIAgentShortcutRun.trace(
"host.runPending skip unknownSkill=\(payload.skillID)"
)
return
}
AIAgentShortcutRun.traceBody("host.titlesToShortcut", payload.joinedTitles)
guard let url = shortcutsURL(name: name, text: payload.joinedTitles) else {
guard let text = shortcutText(for: skill, payload: payload) else {
AIAgentShortcutRun.trace(
"host.runPending skip textBuildFailed skill=\(payload.skillID)"
)
return
}
AIAgentShortcutRun.traceBody("host.titlesToShortcut", text)
guard let url = shortcutsURL(name: name, text: text) else {
AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)")
return
}
@@ -55,6 +62,21 @@ enum AIAgentShortcutRunner {
)
}
/// Navigate: pick Apple Maps, pass that URL to the Shortcut.
/// Other export skills send the parsed lines unchanged.
private static func shortcutText(
for skill: AIClipboardSkill,
payload: AIAgentShortcutRunPayload
) -> String? {
if skill.id == AIClipboardSkillCatalog.navigateID {
return AIMapNavigation.shortcutInput(
from: payload.joinedTitles,
canOpen: { UIApplication.shared.canOpenURL($0) }
)
}
return payload.joinedTitles
}
private static func shortcutsURL(name: String, text: String) -> URL? {
#if DEBUG
return AIAgentShortcutRun.shortcutsRunURL(
@@ -1358,13 +1358,15 @@ final class FlowSessionManager: ObservableObject {
let utteranceId = command.utteranceId
let commandSeq = command.commandSeq
let sessionId = command.sessionId
let thinkingEnabled = command.aiThinkingEnabled ?? true
Task { @MainActor [weak self] in
await self?.answerPrefilledAIQuestion(
question: question,
conversationID: conversationID,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
commandSeq: commandSeq,
thinkingEnabled: thinkingEnabled
)
}
}
@@ -1385,7 +1387,8 @@ final class FlowSessionManager: ObservableObject {
conversationID: UUID?,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64
commandSeq: Int64,
thinkingEnabled: Bool
) async {
// Hint-card / prefilled questions never go through `finalizeUtterance`,
// so this path must drop the processing gate itself. Leaving it set
@@ -1415,7 +1418,8 @@ final class FlowSessionManager: ObservableObject {
do {
let service = try AIQuestionService.configured(
store: pipelineStore,
conversations: aiConversations
conversations: aiConversations,
thinkingEnabled: thinkingEnabled
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let answer = try await service.answer(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
// AIClipboardSkillLayoutDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// Interactive layout preview of AI Agent clipboard skills on the real
// `AIKeyboardView`. Launch with `--ai-skills-demo` and optional
// `--skills-count=N` (1...8).
#if DEBUG
import SwiftUI
import OSGKeyboardShared
struct AIClipboardSkillLayoutDemoView: View {
@StateObject private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@State private var count: Int = Self.initialCount
var body: some View {
VStack(spacing: 0) {
controls
Spacer(minLength: 0)
AIKeyboardView(
state: state,
typing: typing,
onInsert: { _ in }
)
.background(Palette.light.background)
}
.background(Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea())
.environment(\.locale, Locale(identifier: "zh-Hans"))
.preferredColorScheme(.light)
.onAppear { apply(count) }
.onChange(of: count) { _, newValue in apply(newValue) }
}
private var controls: some View {
VStack(spacing: 10) {
Text("技能布局预览")
.font(.headline)
.foregroundStyle(.white)
HStack(spacing: 16) {
Button {
count = max(1, count - 1)
} label: {
Image(systemName: "minus.circle.fill")
.font(.system(size: 28))
}
Text("\(count)")
.font(.title2.monospacedDigit().weight(.semibold))
.foregroundStyle(.white)
.frame(minWidth: 72)
Button {
count = min(8, count + 1)
} label: {
Image(systemName: "plus.circle.fill")
.font(.system(size: 28))
}
}
.foregroundStyle(.green)
HStack(spacing: 8) {
ForEach([3, 5, 8], id: \.self) { n in
Button("\(n)") {
count = n
}
.buttonStyle(.borderedProminent)
.tint(count == n ? .green : .gray)
}
}
}
.padding(.top, 56)
.padding(.bottom, 16)
}
private func apply(_ count: Int) {
let clamped = min(max(count, 1), 8)
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
state.surface = .ai
state.aiServiceAvailable = true
state.micDisabled = false
state.layoutWidth = 390
state.usesIPadLayoutMetrics = false
state.clipboardHistoryEnabled = true
state.enabledClipboardSkillIDs = Array(
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
)
state.aiSession.enter()
}
private static var initialCount: Int {
let prefix = "--skills-count="
if let arg = ProcessInfo.processInfo.arguments.first(where: { $0.hasPrefix(prefix) }),
let value = Int(arg.dropFirst(prefix.count)),
(1...8).contains(value) {
return value
}
return 5
}
private static func previewSkills(count: Int) -> [AIClipboardSkill] {
let catalog = AIClipboardSkillCatalog.catalog
if count <= catalog.count {
return Array(catalog.prefix(count))
}
var extras: [AIClipboardSkill] = [
AIClipboardSkill(
id: "preview-polish",
systemImage: "wand.and.stars",
titleKey: "keyboard.ai.skill.previewPolish",
cardTitleKey: "skills.reply.name",
descriptionKey: "skills.reply.description",
kind: .transform,
isDefault: false
),
AIClipboardSkill(
id: "preview-ideas",
systemImage: "lightbulb.fill",
titleKey: "keyboard.ai.skill.previewIdeas",
cardTitleKey: "skills.summarize.name",
descriptionKey: "skills.summarize.description",
kind: .transform,
isDefault: false
),
AIClipboardSkill(
id: "preview-tone",
systemImage: "theatermasks.fill",
titleKey: "keyboard.ai.skill.previewTone",
cardTitleKey: "skills.translate.name",
descriptionKey: "skills.translate.description",
kind: .transform,
isDefault: false
),
]
extras = Array(extras.prefix(count - catalog.count))
return catalog + extras
}
}
#endif
+31
View File
@@ -0,0 +1,31 @@
// CatalogCardChrome.swift
// OSGKeyboard · Main App
//
// Shared edit / selected badges for style and skill cards.
import SwiftUI
import OSGKeyboardShared
enum CatalogCardChrome {
static let badgeSize: CGFloat = 30
static let editIconSize: CGFloat = 15
static let checkIconSize: CGFloat = 21
static let editHitSize: CGFloat = 44
static func editIcon(palette: ThemePalette) -> some View {
Image(systemName: "pencil")
.font(.system(size: editIconSize, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(width: badgeSize, height: badgeSize)
.background(palette.background.opacity(0.75), in: Circle())
}
static func checkIcon(palette: ThemePalette) -> some View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: checkIconSize, weight: .semibold))
.foregroundStyle(palette.accent)
.padding(Spacing.sm)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.allowsHitTesting(false)
}
}
+3 -15
View File
@@ -142,26 +142,14 @@ struct PolishStylesView: View {
editingPack = pack
}
} label: {
Image(systemName: pack.kind == .builtin ? "eye" : "pencil")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(width: 30, height: 30)
.background(palette.background.opacity(0.75), in: Circle())
CatalogCardChrome.editIcon(palette: palette)
}
.padding(Spacing.sm)
.buttonStyle(.plain)
.accessibilityLabel(
Text(pack.kind == .builtin ? "polishStyles.viewPrompt" : "polishStyles.edit")
)
.accessibilityLabel(Text("polishStyles.edit"))
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(palette.accent)
.background(Color.white, in: Circle())
.padding(Spacing.sm)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.allowsHitTesting(false)
CatalogCardChrome.checkIcon(palette: palette)
}
}
.background(
+38 -1
View File
@@ -435,7 +435,10 @@
"skills.enabled.section" = "In use (%d/%d)";
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
"skills.available.section" = "Available";
"skills.clipboardHistory.banner" = "Turn on Clipboard History in Settings to use these skills after you copy text.";
"skills.clipboard.guide.title" = "Clipboard access needed";
"skills.clipboard.guide.body" = "Turn on Clipboard History in the app, then allow paste access in iOS Settings, so these skills can run after you copy text.";
"skills.clipboard.guide.openAppSettings" = "Open Clipboard settings";
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
"skills.detail.title" = "Skill";
"skills.badge.default" = "Default skill";
"skills.action.turnOff" = "Turn off";
@@ -452,9 +455,43 @@
"skills.translate.description" = "Translate the copied text using your keyboard language setting.";
"skills.extractTodos.name" = "Extract tasks";
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
"skills.extractEvents.name" = "Extract events";
"skills.extractEvents.description" = "Extract events from copied text into Calendar.";
"skills.navigate.name" = "Navigate";
"skills.navigate.description" = "Find an address in the copied text and start driving directions. Uses Amap if installed, then Baidu Maps, then Apple Maps.";
"skills.install.lead" = "Add it, then confirm. Dont rename it.";
"skills.install.openShortcuts" = "Add Shortcut";
"skills.install.confirmAdded" = "I've added it";
"skills.add" = "Add skill";
"skills.edit" = "Edit skill";
"skills.custom.description" = "Custom clipboard skill";
"skills.editor.name" = "Name";
"skills.editor.namePlaceholder" = "Skill name";
"skills.editor.summary" = "About";
"skills.editor.summaryPlaceholder" = "Shown on the skill card";
"skills.editor.icon" = "Icon";
"skills.editor.iconChoose" = "Choose SF Symbol";
"skills.editor.iconSearch" = "Search symbols";
"skills.editor.prompt" = "Processing prompt";
"skills.editor.thinking" = "Thinking";
"skills.editor.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning for this skill.";
"skills.editor.shortcut" = "Shortcut";
"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
"skills.editor.shortcutNamePlaceholder" = "Shortcut name (can differ from the skill name)";
"skills.editor.shortcutHint" = "Paste an iCloud share link. The published Shortcut name is filled in automatically and you can change it. Dont rename it in the Shortcuts app after adding.";
"skills.editor.lookingUp" = "Looking up Shortcut name…";
"skills.editor.resolvedName" = "Will run: %@";
"skills.editor.lookupFailed" = "Couldnt read the Shortcut name. Check the link, or type the name yourself.";
"skills.delete.title" = "Delete this skill?";
"skills.delete.message" = "It will be removed from this device. The Shortcut in the Shortcuts app is not deleted.";
"skills.error.title" = "Couldnt save skill";
"skills.error.emptyName" = "Enter a skill name.";
"skills.error.emptyPrompt" = "The prompt cannot be empty.";
"skills.error.emptyShortcutName" = "Enter the Shortcut name used to run it.";
"skills.error.invalidLink" = "Enter a valid iCloud Shortcuts link.";
"skills.error.emptyIcon" = "Choose an icon.";
"skills.error.promptTooLong" = "The prompt can contain up to 6,000 characters.";
"skills.error.generic" = "Try again.";
/* Polish style packs */
"polishStyles.title" = "Polish styles";
+38 -1
View File
@@ -434,7 +434,10 @@
"skills.enabled.section" = "使用中(%d/%d";
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
"skills.available.section" = "可添加";
"skills.clipboardHistory.banner" = "请先在设置中打开剪贴板历史,复制文字后才能在键盘上使用这些技能。";
"skills.clipboard.guide.title" = "需要剪贴板权限";
"skills.clipboard.guide.body" = "请先在 App 里打开剪贴板历史,再到系统设置中允许粘贴授权,复制后才能使用这些技能。";
"skills.clipboard.guide.openAppSettings" = "打开剪贴板设置";
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
"skills.detail.title" = "技能";
"skills.badge.default" = "默认技能";
"skills.action.turnOff" = "关闭";
@@ -451,9 +454,43 @@
"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
"skills.extractTodos.name" = "提取待办";
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
"skills.extractEvents.name" = "提取日程";
"skills.extractEvents.description" = "从复制内容提取日程,写入日历。";
"skills.navigate.name" = "导航";
"skills.navigate.description" = "从复制内容识别地址并开始驾车导航。优先高德,其次百度,最后 Apple 地图。";
"skills.install.lead" = "添加后点「我已添加」,请勿改名。";
"skills.install.openShortcuts" = "添加捷径";
"skills.install.confirmAdded" = "我已添加";
"skills.add" = "添加技能";
"skills.edit" = "编辑技能";
"skills.custom.description" = "自定义剪贴板技能";
"skills.editor.name" = "名称";
"skills.editor.namePlaceholder" = "技能名称";
"skills.editor.summary" = "介绍";
"skills.editor.summaryPlaceholder" = "显示在技能卡片上";
"skills.editor.icon" = "图标";
"skills.editor.iconChoose" = "选择 SF Symbol";
"skills.editor.iconSearch" = "搜索符号";
"skills.editor.prompt" = "文本处理提示词";
"skills.editor.thinking" = "思考";
"skills.editor.thinkingHint" = "默认关闭。仅在需要该技能更慢、更深的推理时开启。";
"skills.editor.shortcut" = "捷径";
"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
"skills.editor.shortcutNamePlaceholder" = "捷径名称(可与技能名称不同)";
"skills.editor.shortcutHint" = "粘贴 iCloud 分享链接。发布名称会自动填入,也可以自行修改。添加到「快捷指令」后请勿改名。";
"skills.editor.lookingUp" = "正在读取捷径名称…";
"skills.editor.resolvedName" = "将运行:%@";
"skills.editor.lookupFailed" = "无法读取捷径名称。请检查链接,或手动填写名称。";
"skills.delete.title" = "删除此技能?";
"skills.delete.message" = "会从本机移除。快捷指令 App 里的捷径不会被删除。";
"skills.error.title" = "无法保存技能";
"skills.error.emptyName" = "请输入技能名称。";
"skills.error.emptyPrompt" = "提示词不能为空。";
"skills.error.emptyShortcutName" = "请填写用于运行的捷径名称。";
"skills.error.invalidLink" = "请输入有效的 iCloud 捷径链接。";
"skills.error.emptyIcon" = "请选择图标。";
"skills.error.promptTooLong" = "提示词最多 6,000 字。";
"skills.error.generic" = "请再试一次。";
/* 润色风格包 */
"polishStyles.title" = "润色风格";