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