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:
+8
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -14,6 +14,8 @@ struct MainTabContent: View {
|
||||
switch tab {
|
||||
case .keyboard:
|
||||
HomeView()
|
||||
case .skills:
|
||||
AIAgentSkillsView()
|
||||
case .styles:
|
||||
PolishStylesView()
|
||||
case .settings:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("不要把整段原文当成一条待办"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -132,6 +132,8 @@
|
||||
"OSGKeyboardTests/CursorNavigationTests",
|
||||
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
|
||||
"OSGKeyboardTests/AIHintPoolTests",
|
||||
"OSGKeyboardTests/AIHintKeywordExtractorTests",
|
||||
"OSGKeyboardTests/AIAgentSkillLayoutTests",
|
||||
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
|
||||
"OSGKeyboardTests/ClipboardHistoryStoreTests"
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user