feat(keyboard): add Skills tab and extract-todos Shortcut export

Ship a Skills catalog with drag-to-reorder chips and a companion Shortcut that receives extracted titles and writes them to Reminders.
This commit is contained in:
Rocky
2026-08-13 18:20:42 +08:00
parent ff99b7bff1
commit 148ff807f2
33 changed files with 1628 additions and 23 deletions
+8 -1
View File
@@ -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
+1
View File
@@ -69,6 +69,7 @@
<string>zalo</string>
<string>skype</string>
<string>zoomus</string>
<string>shortcuts</string>
<string>things</string>
<string>todoist</string>
<string>evernote</string>
@@ -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)
}
}
}
@@ -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
}
}
+341
View File
@@ -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)
}
}
@@ -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)
}
+6
View File
@@ -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.
+2
View File
@@ -14,6 +14,8 @@ struct MainTabContent: View {
switch tab {
case .keyboard:
HomeView()
case .skills:
AIAgentSkillsView()
case .styles:
PolishStylesView()
case .settings:
+27
View File
@@ -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. Dont 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";
@@ -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" = "添加风格";
@@ -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")
@@ -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:
@@ -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)
}
+44 -2
View File
@@ -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")
}
+6
View File
@@ -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" = "Couldnt open the app to run the Shortcut";
@@ -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 运行捷径";
@@ -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<String>()
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<String>()
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
}
@@ -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
@@ -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
}
}
@@ -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()
}
}
@@ -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
? "请根据剪贴板内容完成用户选择的操作。"
@@ -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<String> = [
"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<String>()
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()
}
}
@@ -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) }
@@ -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.
@@ -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)")
}
@@ -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..<AIAgentSkillLayout.maximumEnabled).map(String.init),
confirmedShortcutIDs: []
)
XCTAssertTrue(full.isFull)
XCTAssertEqual(AIAgentSkillLayout.maximumEnabled, 8)
}
func testDisableKeepsShortcutConfirmation() {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
_ = store.confirmShortcutAndEnable(AIClipboardSkillCatalog.extractTodosID)
store.disable(AIClipboardSkillCatalog.extractTodosID)
XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.extractTodosID))
XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.extractTodosID))
XCTAssertEqual(store.enable(AIClipboardSkillCatalog.extractTodosID), .enabled)
}
func testReorderMovesEnabledSkill() {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
store.moveEnabled(
id: AIClipboardSkillCatalog.translateID,
onto: AIClipboardSkillCatalog.replyID
)
XCTAssertEqual(
store.layout.enabledIDs,
[
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID,
]
)
}
func testVisibleEmptyEnabledIDsShowsNoChips() {
XCTAssertEqual(AIClipboardSkillCatalog.visible(enabledIDs: []).map(\.id), [])
}
func testNONEAndEmptyProduceNoItems() {
XCTAssertEqual(AITodoExtraction.items(from: "NONE"), [])
XCTAssertEqual(AITodoExtraction.items(from: "没有待办事项"), [])
XCTAssertEqual(AITodoExtraction.items(from: " \n "), [])
XCTAssertEqual(AITodoExtraction.items(from: "no tasks"), [])
}
func testStripsBulletsAndCapsAtTwenty() {
let lines = (1...25).map { "- 任务\($0)" }.joined(separator: "\n")
let items = AITodoExtraction.items(from: lines)
XCTAssertEqual(items.count, 20)
XCTAssertEqual(items.first, "任务1")
}
func testSingleShortTaskIsKept() {
XCTAssertEqual(AITodoExtraction.items(from: "买牛奶"), ["买牛奶"])
}
func testWholeClipboardEchoIsRejected() {
let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4)
XCTAssertEqual(
AITodoExtraction.items(from: source, sourceClipboard: source),
[]
)
}
func testPendingShortcutPayloadExpires() {
let old = AIAgentShortcutRunPayload(
skillID: "extractTodos",
titles: ["买牛奶"],
createdAt: Date(timeIntervalSinceNow: -120)
)
let data = AIAgentShortcutRun.encode(old)!
XCTAssertNil(AIAgentShortcutRun.decode(data))
}
func testShortcutsRunURLEncodesNameAndText() {
let url = AIAgentShortcutRun.shortcutsRunURL(name: "OSG · 提取待办", text: "买牛奶\n回邮件")
XCTAssertEqual(url?.scheme, "shortcuts")
XCTAssertEqual(url?.host, "run-shortcut")
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSG · 提取待办")
XCTAssertEqual(items.first { $0.name == "input" }?.value, "text")
XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶\n回邮件")
XCTAssertNil(items.first { $0.name == "x-success" })
}
func testXCallbackRunURLUsesCallbackHost() {
let url = AIAgentShortcutRun.shortcutsRunURL(
name: "OSG · 提取待办",
text: "买牛奶",
xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
xError: "osgkeyboard://skill/shortcut-result?status=error",
xCancel: "osgkeyboard://skill/shortcut-result?status=cancel"
)
XCTAssertEqual(url?.scheme, "shortcuts")
XCTAssertEqual(url?.host, "x-callback-url")
XCTAssertEqual(url?.path, "/run-shortcut")
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶")
XCTAssertEqual(
items.first { $0.name == "x-success" }?.value,
"osgkeyboard://skill/shortcut-result?status=success"
)
}
func testPreviewEscapesNewlines() {
XCTAssertEqual(AIAgentShortcutRun.preview("买牛奶\n回邮件"), "买牛奶\\n回邮件")
}
func testExtractTodosUsesICloudShareLink() {
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractTodosID)
XCTAssertEqual(
skill?.shortcutICloudURL,
AIClipboardSkillCatalog.extractTodosShortcutICloudURL
)
XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com")
XCTAssertEqual(skill?.shortcutName, "OSG · 提取待办")
}
func testICloudShareLinkMapsToShortcutsInstallURL() {
let share = URL(string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81")!
let url = AIAgentShortcutRun.shortcutsInstallURL(from: share)
XCTAssertEqual(url?.scheme, "shortcuts")
XCTAssertEqual(url?.host, "shortcuts")
XCTAssertEqual(url?.path, "/520317da7ae74759b64d5fb069c71f81")
XCTAssertEqual(
AIAgentShortcutRun.iCloudShareToken(from: share),
"520317da7ae74759b64d5fb069c71f81"
)
}
}
@@ -235,4 +235,14 @@ final class AIClipboardSkillTests: XCTestCase {
XCTAssertTrue(prompt.contains("概括"))
XCTAssertTrue(prompt.contains("不要改写成可发送的短消息"))
}
func testExtractTodosAsksForNONEWhenEmpty() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.extractTodosID,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
)
XCTAssertTrue(prompt.contains("NONE"))
XCTAssertTrue(prompt.contains("不要把整段原文当成一条待办"))
}
}
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Build the companion Shortcut for the Extract Tasks skill.
Writes an unsigned binary plist that `shortcuts sign` can notarize.
Workflow (must materialize Shortcut Input as Text first Split Text's
WFInput/ExtensionInput binding finishes the run without iterating):
Text (Shortcut Input)
Split Text (new lines)
Repeat Each
Add New Reminder (title = Repeat Item, default list, no composer)
"""
from __future__ import annotations
import plistlib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts"
UNSIGNED = OUT_DIR / "OSGExtractTodos.unsigned.shortcut"
SIGNED = OUT_DIR / "OSGExtractTodos.shortcut"
OBJECT_REPLACEMENT = "\ufffc"
TEXT_UUID = "6A2D0A80-3B1C-4E00-8A5A-FD7E1B0C396F"
SPLIT_UUID = "7A3E1B90-4C2D-4F11-9A6B-0E8F2C1D4A70"
REPEAT_GROUP = "2B9C8D11-55AA-4E02-B3C4-91F0E6A7B812"
REPEAT_START = "3C0D9E22-66BB-4F13-A4D5-02A1F7B8C923"
REMINDER_UUID = "4D1E0F33-77CC-4014-B5E6-13B2A8C9D034"
REPEAT_END = "5E2F1044-88DD-4125-C6F7-24C3B9DAE145"
def attachment(value: dict) -> 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()
+2
View File
@@ -132,6 +132,8 @@
"OSGKeyboardTests/CursorNavigationTests",
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
"OSGKeyboardTests/AIHintPoolTests",
"OSGKeyboardTests/AIHintKeywordExtractorTests",
"OSGKeyboardTests/AIAgentSkillLayoutTests",
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
"OSGKeyboardTests/ClipboardHistoryStoreTests"
]
+3
View File
@@ -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