diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36062f7..1edcc90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **Skills tab**: Home dock and iPad sidebar add Skills between Home and Styles; cards list default Reply / Summarize / Translate plus installable export skills (max 8 enabled, long-press drag to reorder). / **技能 Tab**:首页 Dock 与 iPad 侧栏在「首页」和「风格」之间新增「技能」;卡片列出默认的回复 / 总结 / 翻译以及可安装的出口技能(最多启用 8 个,长按拖动排序)。
+- **Extract tasks skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取待办` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开已做好的配套捷径 `OSG · 提取待办` 的系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。
+
+### Fixed
+- **Extract tasks Shortcut**: receive Shortcut Input as Text, then split lines and add each title to Reminders — the previous recipe could finish successfully without creating items. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。
+
### Changed
- **AI idle hint keywords**: chips show the entity (from feed `metadata.title` / city / holiday name) instead of a long sentence; category prefixes like「全网热点:」are dropped, and LLM compression is only a last resort. / **AI 空闲建议关键词**:芯片展示实体(来自 feed 的 `metadata.title` / 城市 / 节日名)而不再是长句;去掉「全网热点:」一类前缀,LLM 压缩仅作兜底。
- **AI idle hint chrome**: each rotating suggestion sits in a Liquid Glass capsule with a category SF Symbol (calendar, weather, news, stocks, trending, search). / **AI 空闲建议样式**:轮播建议放入 Liquid Glass 胶囊,左侧为类型 SF Symbol(日历、天气、新闻、股票、热搜、搜索)。
@@ -16,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **AI idle capsule size**: rotating suggestion chips keep a 44 pt tap height with 16 pt side padding. / **AI 空闲胶囊尺寸**:轮播建议芯片保持 44 pt 点击高度,左右各 16 pt 内边距。
- **Undo / translation chrome**: voice mic-row undo and translation controls are 52 pt circular Liquid Glass buttons. / **撤销与翻译按钮**:语音麦克风行的撤销、翻译改为 52 pt 圆形 Liquid Glass 按钮。
- **Clipboard skill circles**: Reply / Summarize / Translate idle buttons match the same 52 pt circle. / **剪贴板技能圆钮**:回复 / 总结 / 翻译空闲按钮与语音侧键同为 52 pt 圆。
-- **Home tab selection**: the dock stays Liquid Glass at its original height; the selected tab is a wider green fill capsule with a 5 pt inset, not a second glass chip. Dock items are 24 pt icons with Home / Styles / Settings labels. / **首页 Tab 选中态**:dock 仍是原高度 Liquid Glass;选中项改为更宽的绿色填充胶囊,距栏边 5 pt,不再套第二层玻璃。dock 为 24 pt 图标加「首页 / 风格 / 设置」文字。
+- **Home tab selection**: the dock stays Liquid Glass at its original height; the selected tab is a wider green fill capsule with a 5 pt inset, not a second glass chip. Dock items are 24 pt icons with Home / Skills / Styles / Settings labels. / **首页 Tab 选中态**:dock 仍是原高度 Liquid Glass;选中项改为更宽的绿色填充胶囊,距栏边 5 pt,不再套第二层玻璃。dock 为 24 pt 图标加「首页 / 技能 / 风格 / 设置」文字。
- **Home library card titles**: History and Personal dictionary headers use a 16 pt icon and 13 pt label. / **首页资料卡标题**:历史与个性词库标题改为 16 pt 图标、13 pt 文字。
### Fixed
diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist
index 1add840..d06719a 100644
--- a/OSGKeyboard/Info.plist
+++ b/OSGKeyboard/Info.plist
@@ -69,6 +69,7 @@
zalo
skype
zoomus
+ shortcuts
things
todoist
evernote
diff --git a/OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut b/OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut
new file mode 100644
index 0000000..0286763
Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut differ
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
new file mode 100644
index 0000000..0286763
Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut differ
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut
new file mode 100644
index 0000000..25f2bb2
Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut differ
diff --git a/OSGKeyboard/Services/AIAgentShortcutInstaller.swift b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift
new file mode 100644
index 0000000..8d973a5
--- /dev/null
+++ b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift
@@ -0,0 +1,41 @@
+// AIAgentShortcutInstaller.swift
+// OSGKeyboard · Main App
+//
+// Opening the HTTPS iCloud share page from the app is claimed as a
+// Universal Link and often lands on Gallery. `shortcuts://shortcuts/TOKEN`
+// opens the Add sheet in Shortcuts directly.
+
+import Foundation
+import UIKit
+import OSGKeyboardShared
+
+enum AIAgentShortcutInstaller {
+ static let bundledResourceName = "OSGExtractTodos"
+
+ @MainActor
+ static func openInstallPage(for skill: AIClipboardSkill) {
+ if let shareURL = skill.shortcutICloudURL,
+ let installURL = AIAgentShortcutRun.shortcutsInstallURL(from: shareURL) {
+ UIApplication.shared.open(installURL)
+ return
+ }
+ openBundledShortcut()
+ }
+
+ @MainActor
+ private static func openBundledShortcut() {
+ guard let bundled = Bundle.main.url(
+ forResource: bundledResourceName,
+ withExtension: "shortcut"
+ ) else { return }
+ let tmp = FileManager.default.temporaryDirectory
+ .appendingPathComponent("\(AIClipboardSkillCatalog.extractTodosShortcutName).shortcut")
+ try? FileManager.default.removeItem(at: tmp)
+ do {
+ try FileManager.default.copyItem(at: bundled, to: tmp)
+ UIApplication.shared.open(tmp)
+ } catch {
+ UIApplication.shared.open(bundled)
+ }
+ }
+}
diff --git a/OSGKeyboard/Services/AIAgentShortcutRunner.swift b/OSGKeyboard/Services/AIAgentShortcutRunner.swift
new file mode 100644
index 0000000..95d61f2
--- /dev/null
+++ b/OSGKeyboard/Services/AIAgentShortcutRunner.swift
@@ -0,0 +1,71 @@
+// AIAgentShortcutRunner.swift
+// OSGKeyboard · Main App
+//
+// Consumes the keyboard's pending extract-todos payload and opens the
+// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add
+// x-callback URLs so Console can record success / error / cancel.
+
+import UIKit
+import OSGKeyboardShared
+
+enum AIAgentShortcutRunner {
+ @MainActor
+ static func runPendingIfNeeded() {
+ AIAgentShortcutRun.trace("host.runPending begin")
+ guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
+ guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID),
+ 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 {
+ AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)")
+ return
+ }
+ let textItem = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first { $0.name == "text" }?
+ .value
+ AIAgentShortcutRun.trace(
+ "host.openShortcuts scheme=\(url.scheme ?? "") host=\(url.host ?? "") "
+ + "path=\(url.path) urlChars=\(url.absoluteString.count) "
+ + "textQueryChars=\(textItem?.count ?? -1) name=\(name)"
+ )
+ AIAgentShortcutRun.traceBody("host.textQuery", textItem ?? "")
+ UIApplication.shared.open(url) { success in
+ AIAgentShortcutRun.trace(
+ "host.openShortcuts result success=\(success) "
+ + "(iOS accepted the URL; not proof Reminders were created)"
+ )
+ }
+ }
+
+ static func logShortcutCallback(_ url: URL) {
+ let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let status = items.first { $0.name == "status" }?.value ?? "unknown"
+ let errorMessage = items.first { $0.name == "errorMessage" }?.value
+ let errorCode = items.first { $0.name == "errorCode" }?.value
+ AIAgentShortcutRun.trace(
+ "host.shortcutCallback status=\(status) errorCode=\(errorCode ?? "-") "
+ + "errorMessage=\(errorMessage ?? "-") "
+ + "(Shortcuts finished; does not prove reminder rows exist)"
+ )
+ }
+
+ private static func shortcutsURL(name: String, text: String) -> URL? {
+ #if DEBUG
+ return AIAgentShortcutRun.shortcutsRunURL(
+ name: name,
+ text: text,
+ xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
+ xError: "osgkeyboard://skill/shortcut-result?status=error",
+ xCancel: "osgkeyboard://skill/shortcut-result?status=cancel"
+ )
+ #else
+ return AIAgentShortcutRun.shortcutsRunURL(name: name, text: text)
+ #endif
+ }
+}
diff --git a/OSGKeyboard/Views/AIAgentSkillsView.swift b/OSGKeyboard/Views/AIAgentSkillsView.swift
new file mode 100644
index 0000000..72884fb
--- /dev/null
+++ b/OSGKeyboard/Views/AIAgentSkillsView.swift
@@ -0,0 +1,341 @@
+// AIAgentSkillsView.swift
+// OSGKeyboard · Main App
+//
+// Catalog of AI Agent clipboard skills. Enabled cards (max 8) appear on
+// the keyboard after a copy; long-press drag reorders that row. Export
+// skills confirm a companion Shortcut before they can occupy a slot.
+
+import SwiftUI
+import UIKit
+import OSGKeyboardShared
+
+struct AIAgentSkillsView: View {
+ @Environment(\.themePalette) private var palette
+ @ObservedObject private var config = ProviderConfig.shared
+ @ObservedObject private var store = AIAgentSkillLayoutStore.shared
+
+ @State private var viewingSkill: AIClipboardSkill?
+ @State private var showFullAlert = false
+
+ private let columns = [
+ GridItem(.flexible(), spacing: Spacing.sm),
+ GridItem(.flexible(), spacing: Spacing.sm),
+ ]
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ CardPageContent(spacing: Spacing.xl) {
+ if !config.clipboardHistoryEnabled {
+ clipboardHistoryBanner
+ }
+ enabledSection
+ if !store.availableSkills.isEmpty {
+ availableSection
+ }
+ }
+ .tabBarScrollBottomPadding()
+ }
+ .background(palette.background)
+ .navigationTitle("skills.title")
+ .navigationBarTitleDisplayMode(.large)
+ }
+ .sheet(item: $viewingSkill) { skill in
+ SkillDetailSheet(
+ store: store,
+ skill: skill,
+ onDismiss: { viewingSkill = nil },
+ onAddOrWarn: addOrWarn,
+ onConfirmInstall: confirmShortcutInstall
+ )
+ }
+ .alert(
+ AppL10n.string("skills.full.title", language: config.uiLanguage),
+ isPresented: $showFullAlert
+ ) {
+ Button("common.done") { showFullAlert = false }
+ } message: {
+ Text(AppL10n.string("skills.full.message", language: config.uiLanguage))
+ }
+ .onAppear { store.reload() }
+ }
+
+ private var clipboardHistoryBanner: some View {
+ Text("skills.clipboardHistory.banner")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(Spacing.md)
+ .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(palette.divider, lineWidth: 0.5)
+ )
+ }
+
+ private var enabledSection: some View {
+ CardSection(
+ title: AppL10n.format(
+ "skills.enabled.section",
+ language: config.uiLanguage,
+ store.enabledSkills.count,
+ AIAgentSkillLayout.maximumEnabled
+ )
+ ) {
+ if store.enabledSkills.isEmpty {
+ Text("skills.enabled.empty")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textTertiary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ LazyVGrid(columns: columns, spacing: Spacing.sm) {
+ ForEach(store.enabledSkills) { skill in
+ skillCard(skill, showsEnabledBadge: true)
+ .onTapGesture { viewingSkill = skill }
+ .draggable(skill.id) {
+ skillCard(skill, showsEnabledBadge: true)
+ .frame(width: 160)
+ }
+ .dropDestination(for: String.self) { items, _ in
+ guard let dragged = items.first else { return false }
+ store.moveEnabled(id: dragged, onto: skill.id)
+ return true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private var availableSection: some View {
+ CardSection("skills.available.section") {
+ LazyVGrid(columns: columns, spacing: Spacing.sm) {
+ ForEach(store.availableSkills) { skill in
+ Button {
+ viewingSkill = skill
+ } label: {
+ skillCard(skill, showsEnabledBadge: false)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ private func skillCard(_ skill: AIClipboardSkill, showsEnabledBadge: Bool) -> some View {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
+ HStack(spacing: Spacing.sm) {
+ Image(systemName: skill.systemImage)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ Spacer(minLength: 0)
+ if showsEnabledBadge {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ }
+ }
+ Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage))
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+ .lineLimit(1)
+ Text(AppL10n.string(skill.descriptionKey, language: config.uiLanguage))
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textTertiary)
+ .lineLimit(2)
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
+ .padding(Spacing.md)
+ .background(
+ showsEnabledBadge ? palette.accentMuted : palette.surface,
+ in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(
+ showsEnabledBadge ? palette.accent : palette.divider,
+ lineWidth: showsEnabledBadge ? 1.5 : 0.5
+ )
+ )
+ .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ .contentShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ }
+
+ private func addOrWarn(_ skill: AIClipboardSkill) {
+ switch store.enable(skill.id) {
+ case .enabled, .alreadyEnabled:
+ viewingSkill = nil
+ case .full:
+ showFullAlert = true
+ case .needsShortcut, .unknown:
+ break
+ }
+ }
+
+ private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
+ switch store.confirmShortcutAndEnable(skill.id) {
+ case .enabled, .alreadyEnabled:
+ viewingSkill = nil
+ case .full:
+ showFullAlert = true
+ case .needsShortcut, .unknown:
+ break
+ }
+ }
+}
+
+private struct SkillDetailSheet: View {
+ @Environment(\.themePalette) private var palette
+ @ObservedObject var store: AIAgentSkillLayoutStore
+ @ObservedObject private var config = ProviderConfig.shared
+
+ let skill: AIClipboardSkill
+ let onDismiss: () -> Void
+ let onAddOrWarn: (AIClipboardSkill) -> Void
+ let onConfirmInstall: (AIClipboardSkill) -> Void
+
+ /// Content stack height; nav chrome is added for the detent.
+ @State private var contentHeight: CGFloat = 240
+ private let navigationChrome: CGFloat = 56
+
+ var body: some View {
+ NavigationStack {
+ CardPageContent(
+ spacing: Spacing.md,
+ topPadding: Spacing.md,
+ bottomPadding: Spacing.lg
+ ) {
+ header
+ explanation
+ skillActions
+ }
+ .background(palette.background)
+ .fixedSize(horizontal: false, vertical: true)
+ .onGeometryChange(for: CGFloat.self) { proxy in
+ proxy.size.height
+ } action: { newHeight in
+ let next = newHeight + navigationChrome
+ if abs(contentHeight - next) > 1 {
+ contentHeight = next
+ }
+ }
+ .navigationTitle("skills.detail.title")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("common.done", action: onDismiss)
+ }
+ }
+ }
+ .presentationDetents([.height(contentHeight)])
+ .presentationDragIndicator(.visible)
+ .presentationContentInteraction(.resizes)
+ .animation(.easeInOut(duration: 0.2), value: contentHeight)
+ }
+
+ private var explanation: some View {
+ Text(AppL10n.string(explanationKey, language: config.uiLanguage))
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textSecondary)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, minHeight: 80, maxHeight: 80, alignment: .topLeading)
+ }
+
+ private var explanationKey: String {
+ if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
+ return "skills.install.lead"
+ }
+ if skill.requiresShortcut, store.layout.isEnabled(skill.id) {
+ return "skills.action.turnOffHint"
+ }
+ return skill.descriptionKey
+ }
+
+ private var header: some View {
+ HStack(alignment: .center, spacing: Spacing.md) {
+ Image(systemName: skill.systemImage)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ .frame(width: 36, height: 36)
+ .background(palette.accentMuted, in: Circle())
+ VStack(alignment: .leading, spacing: 2) {
+ Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage))
+ .font(TypeStyle.title3)
+ .foregroundStyle(palette.textPrimary)
+ if skill.isDefault {
+ Text("skills.badge.default")
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textSecondary)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ @ViewBuilder
+ private var skillActions: some View {
+ let enabled = store.layout.isEnabled(skill.id)
+ if skill.requiresShortcut {
+ if !store.layout.hasConfirmedShortcut(skill.id) {
+ shortcutInstallBlock
+ } else {
+ if enabled {
+ fullWidthButton("skills.action.turnOff", prominent: false) {
+ store.disable(skill.id)
+ onDismiss()
+ }
+ } else {
+ fullWidthButton("skills.action.addToKeyboard", prominent: true) {
+ onAddOrWarn(skill)
+ }
+ }
+ fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
+ openShortcutInstall()
+ }
+ }
+ } else if enabled {
+ fullWidthButton("skills.action.turnOff", prominent: false) {
+ store.disable(skill.id)
+ onDismiss()
+ }
+ } else {
+ fullWidthButton("skills.action.addToKeyboard", prominent: true) {
+ onAddOrWarn(skill)
+ }
+ }
+ }
+
+ private var shortcutInstallBlock: some View {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
+ fullWidthButton("skills.install.openShortcuts", prominent: true) {
+ openShortcutInstall()
+ }
+ fullWidthButton("skills.install.confirmAdded", prominent: false) {
+ onConfirmInstall(skill)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func fullWidthButton(
+ _ titleKey: LocalizedStringKey,
+ prominent: Bool,
+ action: @escaping () -> Void
+ ) -> some View {
+ let label = Text(titleKey)
+ .frame(maxWidth: .infinity, minHeight: 38)
+ if prominent {
+ Button(action: action) { label }
+ .buttonStyle(.borderedProminent)
+ .tint(palette.accent)
+ } else {
+ Button(action: action) { label }
+ .buttonStyle(.bordered)
+ }
+ }
+
+ private func openShortcutInstall() {
+ AIAgentShortcutInstaller.openInstallPage(for: skill)
+ }
+}
diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift
index 91ce86e..5b72805 100644
--- a/OSGKeyboard/Views/Components/MinimalTabBar.swift
+++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift
@@ -1,7 +1,7 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
-// Bottom tab bar — icon + label for Home / Styles / Settings.
+// Bottom tab bar — icon + label for Home / Skills / Styles / Settings.
// The dock capsule is iOS 26 Liquid Glass; the selected tab is a green
// fill inside that capsule (Photos-style), not a second glass layer.
// History + dictionary live as Home cards (not dock tabs).
@@ -11,13 +11,14 @@ import OSGKeyboardShared
enum AppTab: Int, CaseIterable {
case keyboard
+ case skills
case styles
case settings
var icon: MaterialIconName {
switch self {
case .keyboard: return .keyboard
- case .styles: return .menuBook // unused — styles uses SF Symbol
+ case .skills, .styles: return .menuBook // unused — these tabs use SF Symbols
case .settings: return .settings
}
}
@@ -25,6 +26,7 @@ enum AppTab: Int, CaseIterable {
/// SF Symbol overrides shared with the Mac and iPad sidebars.
var sfSymbol: String? {
switch self {
+ case .skills: return "sparkles"
case .styles: return "text.badge.star"
default: return nil
}
@@ -33,6 +35,7 @@ enum AppTab: Int, CaseIterable {
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
+ case .skills: return "tab.skills"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
@@ -44,6 +47,7 @@ enum AppTab: Int, CaseIterable {
var sidebarSystemImage: String {
switch self {
case .keyboard: return "house"
+ case .skills: return "sparkles"
case .styles: return "text.badge.star"
case .settings: return "gearshape"
}
@@ -110,7 +114,7 @@ struct MinimalTabBar: View {
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
.glassEffect(.regular.interactive(), in: .capsule)
- .frame(maxWidth: 280)
+ .frame(maxWidth: 360)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, TabBarDockMetrics.bottomPadding)
}
diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift
index 8f97a6b..45f46cf 100644
--- a/OSGKeyboard/Views/MainAppRoot.swift
+++ b/OSGKeyboard/Views/MainAppRoot.swift
@@ -214,6 +214,12 @@ struct MainAppRoot: View {
switch url.host {
case "startflow":
flowManager.startSession(coldStart: true, reason: "url.startflow")
+ case "skill":
+ if url.path.contains("shortcut-result") {
+ AIAgentShortcutRunner.logShortcutCallback(url)
+ } else if url.path.contains("run") {
+ AIAgentShortcutRunner.runPendingIfNeeded()
+ }
case "deployrime":
// The keyboard sends the user here precisely because typing
// resources are missing — deploy without waiting for warmup.
diff --git a/OSGKeyboard/Views/MainTabContent.swift b/OSGKeyboard/Views/MainTabContent.swift
index 9a3b3de..df1014e 100644
--- a/OSGKeyboard/Views/MainTabContent.swift
+++ b/OSGKeyboard/Views/MainTabContent.swift
@@ -14,6 +14,8 @@ struct MainTabContent: View {
switch tab {
case .keyboard:
HomeView()
+ case .skills:
+ AIAgentSkillsView()
case .styles:
PolishStylesView()
case .settings:
diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings
index df445f2..efaeca5 100644
--- a/OSGKeyboard/en.lproj/Localizable.strings
+++ b/OSGKeyboard/en.lproj/Localizable.strings
@@ -427,8 +427,35 @@
"tab.history" = "History";
"tab.dictionary" = "Dictionary";
"tab.styles" = "Styles";
+"tab.skills" = "Skills";
"tab.settings" = "Settings";
+/* AI Agent skills */
+"skills.title" = "Skills";
+"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.detail.title" = "Skill";
+"skills.badge.default" = "Default skill";
+"skills.action.turnOff" = "Turn off";
+"skills.action.turnOffHint" = "Removes it from the keyboard only.";
+"skills.action.addToKeyboard" = "Add to keyboard";
+"skills.action.reinstallShortcut" = "Reinstall Shortcut";
+"skills.full.title" = "Skill slots full";
+"skills.full.message" = "You can enable up to 8 skills. Turn one off, then add this skill from its card.";
+"skills.reply.name" = "Reply";
+"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
+"skills.summarize.name" = "Summarize";
+"skills.summarize.description" = "Keep the key facts and conclusions, without rewriting it as a chat message.";
+"skills.translate.name" = "Translate";
+"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.install.lead" = "Add it, then confirm. Don’t rename it.";
+"skills.install.openShortcuts" = "Add Shortcut";
+"skills.install.confirmAdded" = "I've added it";
+
/* Polish style packs */
"polishStyles.title" = "Polish styles";
"polishStyles.add" = "Add style";
diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
index f41e7da..891f7b8 100644
--- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings
+++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
@@ -426,8 +426,35 @@
"tab.history" = "历史";
"tab.dictionary" = "词库";
"tab.styles" = "风格";
+"tab.skills" = "技能";
"tab.settings" = "设置";
+/* AI Agent 技能 */
+"skills.title" = "技能";
+"skills.enabled.section" = "使用中(%d/%d)";
+"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
+"skills.available.section" = "可添加";
+"skills.clipboardHistory.banner" = "请先在设置中打开剪贴板历史,复制文字后才能在键盘上使用这些技能。";
+"skills.detail.title" = "技能";
+"skills.badge.default" = "默认技能";
+"skills.action.turnOff" = "关闭";
+"skills.action.turnOffHint" = "只从键盘拿掉,不会删除捷径。";
+"skills.action.addToKeyboard" = "添加到键盘";
+"skills.action.reinstallShortcut" = "重新安装捷径";
+"skills.full.title" = "技能已满";
+"skills.full.message" = "最多同时启用 8 个技能。请先关闭一个,再从卡片手动添加。";
+"skills.reply.name" = "回复";
+"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
+"skills.summarize.name" = "总结";
+"skills.summarize.description" = "保留关键事实与结论,不会改写成可发送的短消息。";
+"skills.translate.name" = "翻译";
+"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
+"skills.extractTodos.name" = "提取待办";
+"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
+"skills.install.lead" = "添加后点「我已添加」,请勿改名。";
+"skills.install.openShortcuts" = "添加捷径";
+"skills.install.confirmAdded" = "我已添加";
+
/* 润色风格包 */
"polishStyles.title" = "润色风格";
"polishStyles.add" = "添加风格";
diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift
index 42f608b..9d13838 100644
--- a/OSGKeyboardExt/KeyboardViewController.swift
+++ b/OSGKeyboardExt/KeyboardViewController.swift
@@ -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")
diff --git a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
index ddaa662..6cbe42f 100644
--- a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
+++ b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
@@ -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:
diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift
index f66561e..a3df9d4 100644
--- a/OSGKeyboardExt/Services/AppGroupPersistor.swift
+++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift
@@ -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)
}
diff --git a/OSGKeyboardExt/Views/AIKeyboardView.swift b/OSGKeyboardExt/Views/AIKeyboardView.swift
index fb43f32..16bb6e9 100644
--- a/OSGKeyboardExt/Views/AIKeyboardView.swift
+++ b/OSGKeyboardExt/Views/AIKeyboardView.swift
@@ -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")
}
diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings
index 39367d3..46f10dc 100644
--- a/OSGKeyboardExt/en.lproj/Keyboard.strings
+++ b/OSGKeyboardExt/en.lproj/Keyboard.strings
@@ -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";
diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
index b16546a..3280535 100644
--- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
+++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
@@ -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 运行捷径";
diff --git a/OSGKeyboardShared/Models/AIAgentSkillLayout.swift b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift
new file mode 100644
index 0000000..2194185
--- /dev/null
+++ b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift
@@ -0,0 +1,78 @@
+// AIAgentSkillLayout.swift
+// OSGKeyboard · Shared
+//
+// Enabled clipboard-skill slots (max 8, ordered) plus which export skills
+// the user confirmed a companion Shortcut for. Missing storage hydrates
+// to the three default transform skills; an explicit empty list is kept
+// so turning every skill off is distinct from a fresh install.
+
+import Foundation
+
+public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
+ public static let maximumEnabled = 8
+ public static let defaultEnabledIDs = [
+ AIClipboardSkillCatalog.replyID,
+ AIClipboardSkillCatalog.summarizeID,
+ AIClipboardSkillCatalog.translateID,
+ ]
+
+ /// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
+ public var enabledIDs: [String]
+ /// Export skills whose companion Shortcut the user marked as added.
+ public var confirmedShortcutIDs: [String]
+
+ public static let `default` = AIAgentSkillLayout(
+ enabledIDs: defaultEnabledIDs,
+ confirmedShortcutIDs: []
+ )
+
+ public init(enabledIDs: [String], confirmedShortcutIDs: [String]) {
+ self.enabledIDs = enabledIDs
+ self.confirmedShortcutIDs = confirmedShortcutIDs
+ }
+
+ public var isFull: Bool {
+ enabledIDs.count >= Self.maximumEnabled
+ }
+
+ public func isEnabled(_ id: String) -> Bool {
+ enabledIDs.contains(id)
+ }
+
+ public func hasConfirmedShortcut(_ id: String) -> Bool {
+ confirmedShortcutIDs.contains(id)
+ }
+
+ /// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
+ public func sanitized() -> AIAgentSkillLayout {
+ let known = Dictionary(uniqueKeysWithValues: AIClipboardSkillCatalog.catalog.map { ($0.id, $0) })
+ var seenEnabled = Set()
+ let enabled = enabledIDs.filter { id in
+ guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
+ if skill.requiresShortcut {
+ return confirmedShortcutIDs.contains(id)
+ }
+ return true
+ }
+ .prefix(Self.maximumEnabled)
+
+ var seenConfirmed = Set()
+ let confirmed = confirmedShortcutIDs.filter { id in
+ guard known[id]?.requiresShortcut == true else { return false }
+ return seenConfirmed.insert(id).inserted
+ }
+
+ return AIAgentSkillLayout(
+ enabledIDs: Array(enabled),
+ confirmedShortcutIDs: confirmed
+ )
+ }
+}
+
+public enum AIAgentSkillEnableResult: Equatable, Sendable {
+ case enabled
+ case alreadyEnabled
+ case needsShortcut
+ case full
+ case unknown
+}
diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
index 5e63e66..044dd28 100644
--- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift
+++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
@@ -69,6 +69,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
"config.flowInactivityDuration.migratedToFiveMinuteDefault"
/// Diagnostic switch: when false, local ASR skips the custom language model.
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
+ /// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
+ public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
}
// MARK: - Stored fields
@@ -113,8 +115,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var flowSkipAppSwitch: Bool
/// Idle timeout before the Flow session ends; resets on each utterance.
public var flowInactivityDuration: FlowInactivityDuration
- /// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
- public var localASRCustomLanguageModelEnabled: Bool
+ /// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
+ public var localASRCustomLanguageModelEnabled: Bool
// MARK: - Derived
diff --git a/OSGKeyboardShared/Services/AIAgentShortcutRun.swift b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift
new file mode 100644
index 0000000..206e4c8
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift
@@ -0,0 +1,128 @@
+// AIAgentShortcutRun.swift
+// OSGKeyboard · Shared
+//
+// Ephemeral App Group payload for one keyboard → host → Shortcuts hop.
+// The keyboard writes titles here, then opens `osgkeyboard://skill/run`.
+// The host consumes the payload (once) and opens the Shortcuts URL.
+
+import Foundation
+
+public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable {
+ public let skillID: String
+ public let titles: [String]
+ public let createdAt: Date
+
+ public init(skillID: String, titles: [String], createdAt: Date = Date()) {
+ self.skillID = skillID
+ self.titles = titles
+ self.createdAt = createdAt
+ }
+
+ public var joinedTitles: String {
+ titles.joined(separator: "\n")
+ }
+}
+
+public enum AIAgentShortcutRun {
+ public static let pendingKey = "config.aiAgentSkills.pendingRun.v1"
+ /// Drop payloads older than this; a leftover write must not fire later.
+ public static let payloadTTL: TimeInterval = 60
+
+ public static func shortcutsRunURL(
+ name: String,
+ text: String,
+ xSuccess: String? = nil,
+ xError: String? = nil,
+ xCancel: String? = nil
+ ) -> URL? {
+ var components = URLComponents()
+ components.scheme = "shortcuts"
+ let usesCallback = xSuccess != nil || xError != nil || xCancel != nil
+ if usesCallback {
+ components.host = "x-callback-url"
+ components.path = "/run-shortcut"
+ } else {
+ components.host = "run-shortcut"
+ }
+ var items = [
+ URLQueryItem(name: "name", value: name),
+ URLQueryItem(name: "input", value: "text"),
+ URLQueryItem(name: "text", value: text),
+ ]
+ if let xSuccess {
+ items.append(URLQueryItem(name: "x-success", value: xSuccess))
+ }
+ if let xError {
+ items.append(URLQueryItem(name: "x-error", value: xError))
+ }
+ if let xCancel {
+ items.append(URLQueryItem(name: "x-cancel", value: xCancel))
+ }
+ components.queryItems = items
+ return components.url
+ }
+
+ /// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies.
+ public static func trace(_ message: String) {
+ OSGDiag.log(message, category: "skills")
+ }
+
+ /// Single-line preview so Console keeps the format (`\\n` for newlines).
+ public static func preview(_ text: String, limit: Int = 1200) -> String {
+ let escaped = text
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\r\n", with: "\\n")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ .replacingOccurrences(of: "\r", with: "\\n")
+ .replacingOccurrences(of: "\t", with: "\\t")
+ if escaped.count <= limit { return escaped }
+ return String(escaped.prefix(limit)) + "…(chars=\(text.count))"
+ }
+
+ public static func traceBody(_ label: String, _ text: String) {
+ #if DEBUG
+ trace("\(label) chars=\(text.count) body=\(preview(text))")
+ #else
+ trace("\(label) chars=\(text.count)")
+ #endif
+ }
+
+ /// Opens the Shortcuts Add sheet for an iCloud share token.
+ /// Do not `open` the HTTPS share page from the app — Universal Links
+ /// often land on Gallery and drop the token.
+ public static func shortcutsInstallURL(from shareURL: URL) -> URL? {
+ guard let token = iCloudShareToken(from: shareURL) else { return nil }
+ return URL(string: "shortcuts://shortcuts/\(token)")
+ }
+
+ public static func iCloudShareToken(from shareURL: URL) -> String? {
+ guard let host = shareURL.host, host.contains("icloud.com") else { return nil }
+ let parts = shareURL.path.split(separator: "/").map(String.init)
+ guard let index = parts.firstIndex(of: "shortcuts"),
+ parts.count > index + 1 else { return nil }
+ let token = parts[index + 1]
+ guard token != "api", !token.isEmpty else { return nil }
+ return token
+ }
+
+ public static func openShortcutURL(name: String) -> URL? {
+ var components = URLComponents()
+ components.scheme = "shortcuts"
+ components.host = "open-shortcut"
+ components.queryItems = [URLQueryItem(name: "name", value: name)]
+ return components.url
+ }
+
+ public static func encode(_ payload: AIAgentShortcutRunPayload) -> Data? {
+ try? JSONEncoder().encode(payload)
+ }
+
+ public static func decode(_ data: Data, now: Date = Date()) -> AIAgentShortcutRunPayload? {
+ guard let payload = try? JSONDecoder().decode(AIAgentShortcutRunPayload.self, from: data) else {
+ return nil
+ }
+ guard now.timeIntervalSince(payload.createdAt) <= payloadTTL else { return nil }
+ guard !payload.titles.isEmpty else { return nil }
+ return payload
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift
new file mode 100644
index 0000000..be6d884
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift
@@ -0,0 +1,110 @@
+// AIAgentSkillLayoutStore.swift
+// OSGKeyboard · Shared
+//
+// Observable facade over the persisted skill layout. The Skills tab mutates
+// this; the keyboard reads the same App Group snapshot on each config poll.
+
+import Foundation
+import Combine
+
+@MainActor
+public final class AIAgentSkillLayoutStore: ObservableObject {
+ public static let shared = AIAgentSkillLayoutStore()
+
+ @Published public private(set) var layout: AIAgentSkillLayout
+
+ private let defaults: UserDefaults?
+ private let persist: (AIAgentSkillLayout) -> Void
+ private let load: () -> AIAgentSkillLayout
+
+ public init(defaults: UserDefaults? = nil) {
+ if let defaults {
+ self.defaults = defaults
+ self.load = { AppGroupStore(defaults: defaults).agentSkillLayout }
+ self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
+ } else {
+ self.defaults = nil
+ self.load = { AppGroupStore().agentSkillLayout }
+ self.persist = { AppGroupStore().setAgentSkillLayout($0) }
+ }
+ self.layout = self.load()
+ }
+
+ public func reload() {
+ layout = load()
+ }
+
+ public var enabledSkills: [AIClipboardSkill] {
+ AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs)
+ }
+
+ public var availableSkills: [AIClipboardSkill] {
+ AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) }
+ }
+
+ @discardableResult
+ public func enable(_ id: String) -> AIAgentSkillEnableResult {
+ let current = layout.sanitized()
+ guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown }
+ if current.isEnabled(id) { return .alreadyEnabled }
+ if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
+ return .needsShortcut
+ }
+ if current.isFull { return .full }
+ commit(
+ AIAgentSkillLayout(
+ enabledIDs: current.enabledIDs + [id],
+ confirmedShortcutIDs: current.confirmedShortcutIDs
+ )
+ )
+ return .enabled
+ }
+
+ /// Drops the keyboard slot only. Companion Shortcuts stay installed;
+ /// the user deletes them in the Shortcuts app if they want them gone.
+ public func disable(_ id: String) {
+ let current = layout.sanitized()
+ commit(
+ AIAgentSkillLayout(
+ enabledIDs: current.enabledIDs.filter { $0 != id },
+ confirmedShortcutIDs: current.confirmedShortcutIDs
+ )
+ )
+ }
+
+ /// Marks the companion Shortcut as added, then tries to occupy a slot.
+ @discardableResult
+ public func confirmShortcutAndEnable(_ id: String) -> AIAgentSkillEnableResult {
+ guard let skill = AIClipboardSkillCatalog.skill(id: id), skill.requiresShortcut else {
+ return .unknown
+ }
+ var current = layout.sanitized()
+ if !current.confirmedShortcutIDs.contains(id) {
+ current.confirmedShortcutIDs.append(id)
+ }
+ commit(current)
+ return enable(id)
+ }
+
+ public func moveEnabled(id draggedID: String, onto targetID: String) {
+ var ids = layout.sanitized().enabledIDs
+ guard let from = ids.firstIndex(of: draggedID),
+ let to = ids.firstIndex(of: targetID),
+ from != to else { return }
+ ids.move(
+ fromOffsets: IndexSet(integer: from),
+ toOffset: to > from ? to + 1 : to
+ )
+ commit(
+ AIAgentSkillLayout(
+ enabledIDs: ids,
+ confirmedShortcutIDs: layout.confirmedShortcutIDs
+ )
+ )
+ }
+
+ private func commit(_ layout: AIAgentSkillLayout) {
+ persist(layout)
+ self.layout = load()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIClipboardSkill.swift b/OSGKeyboardShared/Services/AIClipboardSkill.swift
index f7d0b56..3001289 100644
--- a/OSGKeyboardShared/Services/AIClipboardSkill.swift
+++ b/OSGKeyboardShared/Services/AIClipboardSkill.swift
@@ -2,20 +2,57 @@
// OSGKeyboard · Shared
//
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
-// Settings can later persist a subset or permutation without changing the view.
+// Settings / the Skills tab can persist a subset or permutation without
+// changing the view. Transform skills insert into the current field;
+// export skills hand off to a companion Shortcut after the model runs.
import Foundation
+public enum AIClipboardSkillKind: String, Sendable {
+ /// LLM output is reviewed and inserted into the current text field.
+ case transform
+ /// LLM output is parsed and sent to a companion Shortcut. Never inserted.
+ case export
+}
+
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public let id: String
public let systemImage: String
- /// Keyboard.strings key for the short button title.
+ /// Keyboard.strings key for the short chip title.
public let titleKey: String
+ /// App Localizable key for the Skills-tab card title. Falls back to `titleKey`.
+ public let cardTitleKey: String
+ public let descriptionKey: String
+ public let kind: AIClipboardSkillKind
+ /// Default skills can be turned off but not removed from the catalog.
+ public let isDefault: Bool
+ /// Frozen companion Shortcut name. Nil for transform skills.
+ public let shortcutName: String?
+ /// Optional `icloud.com/shortcuts/` share URL. Nil → open the bundled file.
+ public let shortcutICloudURL: URL?
- public init(id: String, systemImage: String, titleKey: String) {
+ public var requiresShortcut: Bool { kind == .export }
+
+ public init(
+ id: String,
+ systemImage: String,
+ titleKey: String,
+ cardTitleKey: String,
+ descriptionKey: String,
+ kind: AIClipboardSkillKind,
+ isDefault: Bool,
+ shortcutName: String? = nil,
+ shortcutICloudURL: URL? = nil
+ ) {
self.id = id
self.systemImage = systemImage
self.titleKey = titleKey
+ self.cardTitleKey = cardTitleKey
+ self.descriptionKey = descriptionKey
+ self.kind = kind
+ self.isDefault = isDefault
+ self.shortcutName = shortcutName
+ self.shortcutICloudURL = shortcutICloudURL
}
}
@@ -23,31 +60,68 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let replyID = "reply"
public static let summarizeID = "summarize"
public static let translateID = "translate"
+ public static let extractTodosID = "extractTodos"
+ public static let extractTodosShortcutName = "OSG · 提取待办"
+ public static let extractTodosShortcutICloudURL = URL(
+ string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81"
+ )!
- /// Default set, in display order. Future skills append here.
- public static let builtIn: [AIClipboardSkill] = [
+ /// Full built-in catalog, in a stable display order for the Skills tab.
+ public static let catalog: [AIClipboardSkill] = [
AIClipboardSkill(
id: replyID,
systemImage: "arrowshape.turn.up.left.fill",
- titleKey: "keyboard.ai.skill.reply"
+ titleKey: "keyboard.ai.skill.reply",
+ cardTitleKey: "skills.reply.name",
+ descriptionKey: "skills.reply.description",
+ kind: .transform,
+ isDefault: true
),
AIClipboardSkill(
id: summarizeID,
systemImage: "doc.text.magnifyingglass",
- titleKey: "keyboard.ai.skill.summarize"
+ titleKey: "keyboard.ai.skill.summarize",
+ cardTitleKey: "skills.summarize.name",
+ descriptionKey: "skills.summarize.description",
+ kind: .transform,
+ isDefault: true
),
AIClipboardSkill(
id: translateID,
systemImage: "character.bubble.fill",
- titleKey: "keyboard.ai.skill.translate"
+ titleKey: "keyboard.ai.skill.translate",
+ cardTitleKey: "skills.translate.name",
+ descriptionKey: "skills.translate.description",
+ kind: .transform,
+ isDefault: true
+ ),
+ AIClipboardSkill(
+ id: extractTodosID,
+ systemImage: "checklist",
+ titleKey: "keyboard.ai.skill.extractTodos",
+ cardTitleKey: "skills.extractTodos.name",
+ descriptionKey: "skills.extractTodos.description",
+ kind: .export,
+ isDefault: false,
+ shortcutName: extractTodosShortcutName,
+ shortcutICloudURL: extractTodosShortcutICloudURL
),
]
- /// `enabledIDs` is the future Settings hook: `nil` keeps the built-in list.
+ /// Legacy alias: the three default transform skills used to be the whole list.
+ public static let builtIn: [AIClipboardSkill] = catalog
+
+ public static func skill(id: String) -> AIClipboardSkill? {
+ catalog.first { $0.id == id }
+ }
+
+ /// `enabledIDs` is the Skills-tab order. `nil` keeps the default three.
+ /// An explicit empty array shows no chips (carousel fallback).
public static func visible(enabledIDs: [String]? = nil) -> [AIClipboardSkill] {
- guard let enabledIDs, !enabledIDs.isEmpty else { return builtIn }
- let byID = Dictionary(uniqueKeysWithValues: builtIn.map { ($0.id, $0) })
- return enabledIDs.compactMap { byID[$0] }
+ let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
+ guard !ids.isEmpty else { return [] }
+ let byID = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
+ return ids.compactMap { byID[$0] }
}
public static func instruction(
@@ -102,6 +176,18 @@ public enum AIClipboardSkillCatalog: Sendable {
locale: locale,
translationTargetLocaleId: translationTargetLocaleId
)
+ case extractTodosID:
+ return zh
+ ? """
+ 请从剪贴板中只提取明确的待办事项。每条一行,只要标题,不要编号、不要项目符号、不要解释。最多 20 条。
+ 若没有任何可执行的待办,只输出 NONE,不要把整段原文当成一条待办。
+ 若原文本身就是一句短待办(例如「买牛奶」),输出那一句即可。
+ """
+ : """
+ Extract only explicit to-do items from the clipboard. One title per line; no numbering, bullets, or commentary. Maximum 20 lines.
+ If there are no actionable tasks, output NONE and nothing else. Do not treat the whole clipboard as one task.
+ If the clipboard itself is already one short task (for example "buy milk"), output that single line.
+ """
default:
return zh
? "请根据剪贴板内容完成用户选择的操作。"
diff --git a/OSGKeyboardShared/Services/AITodoExtraction.swift b/OSGKeyboardShared/Services/AITodoExtraction.swift
new file mode 100644
index 0000000..262dfc3
--- /dev/null
+++ b/OSGKeyboardShared/Services/AITodoExtraction.swift
@@ -0,0 +1,82 @@
+// AITodoExtraction.swift
+// OSGKeyboard · Shared
+//
+// Parses the LLM's extract-todos reply into reminder titles. Fail closed:
+// empty / NONE / "no tasks" never become a Shortcut run. A single long
+// echo of the clipboard is also rejected so the model cannot dump the
+// whole paste as one reminder.
+
+import Foundation
+
+public enum AITodoExtraction: Sendable {
+ public static let maximumItems = 20
+
+ private static let emptyTokens: Set = [
+ "none", "no", "n/a", "na", "nil", "null",
+ "无", "没有", "没有待办", "没有待办事项", "无待办", "无待办事项",
+ "no tasks", "no task", "no todos", "no to-dos", "no to-do",
+ "no actionable items", "no action items",
+ ]
+
+ /// Titles to send to the companion Shortcut. Empty → do not run it.
+ public static func items(from raw: String, sourceClipboard: String? = nil) -> [String] {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return [] }
+ if isEmptyToken(trimmed) { return [] }
+
+ var seen = Set()
+ var items: [String] = []
+ for line in trimmed.components(separatedBy: .newlines) {
+ let title = stripBullet(line)
+ guard !title.isEmpty, !isEmptyToken(title) else { continue }
+ let key = title.lowercased()
+ guard seen.insert(key).inserted else { continue }
+ items.append(title)
+ if items.count == maximumItems { break }
+ }
+
+ if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
+ return []
+ }
+ return items
+ }
+
+ private static func isEmptyToken(_ text: String) -> Bool {
+ let folded = text
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "。.!!"))
+ .lowercased()
+ return emptyTokens.contains(folded)
+ }
+
+ private static func stripBullet(_ line: String) -> String {
+ var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ let prefixes = ["- ", "* ", "• ", "、"]
+ for prefix in prefixes where text.hasPrefix(prefix) {
+ text = String(text.dropFirst(prefix.count))
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
+ text = String(text[dotted.upperBound...])
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ return text
+ }
+
+ /// One long line that is essentially the clipboard body is not a todo.
+ private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
+ guard let source, source.count > 80, item.count > 80 else { return false }
+ let a = collapse(item)
+ let b = collapse(source)
+ guard !a.isEmpty, !b.isEmpty else { return false }
+ if a == b { return true }
+ return a.contains(b) || b.contains(a)
+ }
+
+ private static func collapse(_ text: String) -> String {
+ text.components(separatedBy: .whitespacesAndNewlines)
+ .filter { !$0.isEmpty }
+ .joined(separator: " ")
+ .lowercased()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift
index 0357a30..80b2ab4 100644
--- a/OSGKeyboardShared/Services/AppGroupStore.swift
+++ b/OSGKeyboardShared/Services/AppGroupStore.swift
@@ -97,6 +97,10 @@ public struct AppGroupStore: @unchecked Sendable {
public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
+ /// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it.
+ public var agentSkillLayout: AIAgentSkillLayout {
+ Self.decodeAgentSkillLayout(from: defaults)
+ }
// MARK: - Writes
@@ -199,6 +203,59 @@ public struct AppGroupStore: @unchecked Sendable {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
}
+ public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
+ do {
+ let data = try JSONEncoder().encode(layout.sanitized())
+ defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
+ } catch {
+ OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)")
+ }
+ AppGroupConfigDarwin.postConfigChanged()
+ }
+
+ public func setPendingShortcutRun(skillID: String, titles: [String]) {
+ let payload = AIAgentShortcutRunPayload(skillID: skillID, titles: titles)
+ if let data = AIAgentShortcutRun.encode(payload) {
+ defaults.set(data, forKey: AIAgentShortcutRun.pendingKey)
+ AIAgentShortcutRun.trace(
+ "appGroup.writePending skill=\(skillID) items=\(titles.count) bytes=\(data.count)"
+ )
+ } else {
+ AIAgentShortcutRun.trace("appGroup.writePending FAILED encode skill=\(skillID)")
+ }
+ }
+
+ public func consumePendingShortcutRun(now: Date = Date()) -> AIAgentShortcutRunPayload? {
+ let data = defaults.data(forKey: AIAgentShortcutRun.pendingKey)
+ defaults.removeObject(forKey: AIAgentShortcutRun.pendingKey)
+ guard let data else {
+ AIAgentShortcutRun.trace("appGroup.consumePending missing")
+ return nil
+ }
+ guard let payload = AIAgentShortcutRun.decode(data, now: now) else {
+ AIAgentShortcutRun.trace(
+ "appGroup.consumePending dropped bytes=\(data.count) (expired or empty titles)"
+ )
+ return nil
+ }
+ AIAgentShortcutRun.trace(
+ "appGroup.consumePending ok skill=\(payload.skillID) items=\(payload.titles.count)"
+ )
+ return payload
+ }
+
+ private static func decodeAgentSkillLayout(from defaults: UserDefaults) -> AIAgentSkillLayout {
+ guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
+ return .default
+ }
+ do {
+ return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data).sanitized()
+ } catch {
+ OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
+ return .default
+ }
+ }
+
public var hasCompletedOnboarding: Bool {
get { configuration.hasCompletedOnboarding }
set { setHasCompletedOnboarding(newValue) }
diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift
index ee49223..6e923ee 100644
--- a/OSGKeyboardShared/Services/KeyboardState.swift
+++ b/OSGKeyboardShared/Services/KeyboardState.swift
@@ -140,6 +140,12 @@ public final class KeyboardState: ObservableObject {
@Published public var clipboardHistoryEnabled: Bool = false
/// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false
+ /// Skills-tab order for clipboard chips (max 8). Empty → hint carousel.
+ @Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
+ /// Export skill currently waiting on the LLM. Nil for transform skills.
+ @Published public var pendingClipboardSkillID: String?
+ /// In-keyboard toast (e.g. no todos). Does not leave the host app.
+ @Published public var skillTipText: String?
/// Host field is a password / secure entry — never read pasteboard.
@Published public var isSecureTextEntry: Bool = false
/// Secure fields hide every clipboard-history entry point.
@@ -239,8 +245,10 @@ public final class KeyboardState: ObservableObject {
public var sendAIAnswer: () -> Void = {}
/// Sends a tapped idle hint card as the AI question (skip microphone).
public var submitAIHint: (AIHintCard) -> Void = { _ in }
- /// Sends a clipboard skill (reply / summarize / translate / future).
+ /// Sends a clipboard skill (reply / summarize / translate / export).
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
+ /// Writes extract-todos titles and opens the host to run the Shortcut.
+ public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
public var openSettings: () -> Void = {}
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.
diff --git a/OSGKeyboardShared/Utilities/OSGDiag.swift b/OSGKeyboardShared/Utilities/OSGDiag.swift
index 83d5d05..b0eef3c 100644
--- a/OSGKeyboardShared/Utilities/OSGDiag.swift
+++ b/OSGKeyboardShared/Utilities/OSGDiag.swift
@@ -18,6 +18,8 @@ public enum OSGDiag {
OSGLog.keyboardExt.info("\(line, privacy: .public)")
case "flow", "asr":
OSGLog.flow.info("\(line, privacy: .public)")
+ case "skills":
+ OSGLog.config.info("\(line, privacy: .public)")
default:
OSGLog.config.info("\(line, privacy: .public)")
}
diff --git a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift
new file mode 100644
index 0000000..1b46f05
--- /dev/null
+++ b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift
@@ -0,0 +1,199 @@
+// AIAgentSkillLayoutTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+@MainActor
+final class AIAgentSkillLayoutTests: XCTestCase {
+ private func makeDefaults() -> UserDefaults {
+ let suite = "group.com.osgkeyboard.shared.tests.skills.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ return defaults
+ }
+
+ func testFreshInstallEnablesDefaultTransformSkills() {
+ let defaults = makeDefaults()
+ let layout = AppGroupStore(defaults: defaults).agentSkillLayout
+ XCTAssertEqual(layout.enabledIDs, AIAgentSkillLayout.defaultEnabledIDs)
+ XCTAssertTrue(layout.confirmedShortcutIDs.isEmpty)
+ }
+
+ func testEmptyEnabledListIsPreserved() {
+ let defaults = makeDefaults()
+ let store = AppGroupStore(defaults: defaults)
+ store.setAgentSkillLayout(
+ AIAgentSkillLayout(enabledIDs: [], confirmedShortcutIDs: [])
+ )
+ XCTAssertEqual(store.agentSkillLayout.enabledIDs, [])
+ }
+
+ func testCannotEnableExportSkillBeforeShortcutConfirmation() {
+ let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
+ XCTAssertEqual(
+ store.enable(AIClipboardSkillCatalog.extractTodosID),
+ .needsShortcut
+ )
+ XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.extractTodosID))
+ }
+
+ func testConfirmShortcutAutoEnablesWhenSlotAvailable() {
+ let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
+ XCTAssertEqual(
+ store.confirmShortcutAndEnable(AIClipboardSkillCatalog.extractTodosID),
+ .enabled
+ )
+ XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.extractTodosID))
+ XCTAssertEqual(
+ store.layout.enabledIDs.last,
+ AIClipboardSkillCatalog.extractTodosID
+ )
+ }
+
+ func testSanitizedDropsUnconfirmedExportAndUnknownIDs() {
+ let layout = AIAgentSkillLayout(
+ enabledIDs: ["reply", "extractTodos", "unknown"],
+ confirmedShortcutIDs: []
+ ).sanitized()
+ XCTAssertEqual(layout.enabledIDs, ["reply"])
+ }
+
+ func testSanitizedKeepsConfirmedExport() {
+ let layout = AIAgentSkillLayout(
+ enabledIDs: ["reply", "extractTodos"],
+ confirmedShortcutIDs: ["extractTodos"]
+ ).sanitized()
+ XCTAssertEqual(layout.enabledIDs, ["reply", "extractTodos"])
+ }
+
+ func testIsFullUsesEnabledCount() {
+ let full = AIAgentSkillLayout(
+ enabledIDs: (0.. dict:
+ return {"Value": value, "WFSerializationType": "WFTextTokenAttachment"}
+
+
+def token_string(attachment_value: dict) -> dict:
+ return {
+ "Value": {
+ "attachmentsByRange": {"{0, 1}": attachment_value},
+ "string": OBJECT_REPLACEMENT,
+ },
+ "WFSerializationType": "WFTextTokenString",
+ }
+
+
+def shortcut_input_text() -> dict:
+ return token_string({"Type": "ExtensionInput"})
+
+
+def action_output_text(output_name: str, output_uuid: str) -> dict:
+ return token_string(
+ {
+ "Aggrandizements": [],
+ "OutputName": output_name,
+ "OutputUUID": output_uuid,
+ "Type": "ActionOutput",
+ }
+ )
+
+
+def named_variable_text(variable_name: str) -> dict:
+ return token_string(
+ {
+ "Aggrandizements": [],
+ "Type": "Variable",
+ "VariableName": variable_name,
+ }
+ )
+
+
+def workflow() -> dict:
+ return {
+ "WFWorkflowClientVersion": "3600.0.4",
+ "WFWorkflowClientRelease": "26.0",
+ "WFWorkflowMinimumClientVersion": 900,
+ "WFWorkflowMinimumClientVersionString": "900",
+ "WFWorkflowName": "OSG · 提取待办",
+ "WFWorkflowHasShortcutInputVariables": True,
+ "WFWorkflowHasOutputFallback": False,
+ "WFWorkflowImportQuestions": [],
+ # Empty types: a normal library shortcut. QuickActions/ActionExtension
+ # made `run-shortcut?name=` open Shortcuts without running it.
+ "WFWorkflowTypes": [],
+ "WFWorkflowInputContentItemClasses": [
+ "WFStringContentItem",
+ "WFRichTextContentItem",
+ ],
+ "WFWorkflowOutputContentItemClasses": [],
+ "WFWorkflowNoInputBehavior": {
+ "Name": "WFWorkflowNoInputBehaviorAskForInput",
+ "Parameters": {
+ "ItemClass": "WFStringContentItem",
+ },
+ },
+ "WFWorkflowIcon": {
+ "WFWorkflowIconGlyphNumber": 59511,
+ "WFWorkflowIconStartColor": 2071128575,
+ },
+ "WFWorkflowActions": [
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
+ "WFWorkflowActionParameters": {
+ "UUID": TEXT_UUID,
+ "CustomOutputName": "快捷指令文本",
+ "WFTextActionText": shortcut_input_text(),
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
+ "WFWorkflowActionParameters": {
+ "UUID": SPLIT_UUID,
+ # Split Text reads `text`, not `WFInput`.
+ "text": action_output_text("快捷指令文本", TEXT_UUID),
+ "WFTextSeparator": "New Lines",
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
+ "WFWorkflowActionParameters": {
+ "UUID": REPEAT_START,
+ "GroupingIdentifier": REPEAT_GROUP,
+ "WFControlFlowMode": 0,
+ "WFInput": attachment(
+ {
+ "Aggrandizements": [],
+ "OutputName": "Split Text",
+ "OutputUUID": SPLIT_UUID,
+ "Type": "ActionOutput",
+ }
+ ),
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.addnewreminder",
+ "WFWorkflowActionParameters": {
+ "UUID": REMINDER_UUID,
+ "WFCalendarItemTitle": named_variable_text("Repeat Item"),
+ "WFCalendarItemShowComposer": False,
+ "ShowComposeSheet": False,
+ "WFShowWhenRun": False,
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
+ "WFWorkflowActionParameters": {
+ "UUID": REPEAT_END,
+ "GroupingIdentifier": REPEAT_GROUP,
+ "WFControlFlowMode": 2,
+ },
+ },
+ ],
+ }
+
+
+def main() -> None:
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
+ UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY))
+ print(f"wrote {UNSIGNED}")
+ print(
+ "sign with: shortcuts sign --mode anyone "
+ f"--input {UNSIGNED} --output {SIGNED}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json
index dd21bd0..5486e70 100644
--- a/Tests/suite-manifest.json
+++ b/Tests/suite-manifest.json
@@ -132,6 +132,8 @@
"OSGKeyboardTests/CursorNavigationTests",
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
"OSGKeyboardTests/AIHintPoolTests",
+ "OSGKeyboardTests/AIHintKeywordExtractorTests",
+ "OSGKeyboardTests/AIAgentSkillLayoutTests",
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
"OSGKeyboardTests/ClipboardHistoryStoreTests"
]
diff --git a/project.yml b/project.yml
index 00a0157..48c18a4 100644
--- a/project.yml
+++ b/project.yml
@@ -76,6 +76,7 @@ targets:
# crashes when both are passed. iOS 26 uses Icon Composer only.
- "Assets.xcassets/AppIcon.appiconset"
- "Resources/CustomLanguageModel/**"
+ - "Resources/Shortcuts/**"
# Real keyboard chrome for DEBUG what's-new recording (same UI as Ext).
- path: OSGKeyboardExt/Utilities/ExtL10n.swift
- path: OSGKeyboardExt/Views/AIKeyboardView.swift
@@ -120,6 +121,7 @@ targets:
- path: OSGKeyboard/Resources/Typing/Rime/manifest.json
- path: OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin
- path: OSGKeyboard/Resources/HostCLM/v1/compiled-manifest.json
+ - path: OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
- path: OSGKeyboard/Resources/LocalASR/local-asr-catalog.json
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
@@ -194,6 +196,7 @@ targets:
- zalo
- skype
- zoomus
+ - shortcuts
- things
- todoist
- evernote