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]
|
## [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
|
### 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 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(日历、天气、新闻、股票、热搜、搜索)。
|
- **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 内边距。
|
- **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 按钮。
|
- **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 圆。
|
- **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 文字。
|
- **Home library card titles**: History and Personal dictionary headers use a 16 pt icon and 13 pt label. / **首页资料卡标题**:历史与个性词库标题改为 16 pt 图标、13 pt 文字。
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
<string>zalo</string>
|
<string>zalo</string>
|
||||||
<string>skype</string>
|
<string>skype</string>
|
||||||
<string>zoomus</string>
|
<string>zoomus</string>
|
||||||
|
<string>shortcuts</string>
|
||||||
<string>things</string>
|
<string>things</string>
|
||||||
<string>todoist</string>
|
<string>todoist</string>
|
||||||
<string>evernote</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
|
// MinimalTabBar.swift
|
||||||
// OSGKeyboard · Main App
|
// 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
|
// 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.
|
// fill inside that capsule (Photos-style), not a second glass layer.
|
||||||
// History + dictionary live as Home cards (not dock tabs).
|
// History + dictionary live as Home cards (not dock tabs).
|
||||||
@@ -11,13 +11,14 @@ import OSGKeyboardShared
|
|||||||
|
|
||||||
enum AppTab: Int, CaseIterable {
|
enum AppTab: Int, CaseIterable {
|
||||||
case keyboard
|
case keyboard
|
||||||
|
case skills
|
||||||
case styles
|
case styles
|
||||||
case settings
|
case settings
|
||||||
|
|
||||||
var icon: MaterialIconName {
|
var icon: MaterialIconName {
|
||||||
switch self {
|
switch self {
|
||||||
case .keyboard: return .keyboard
|
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
|
case .settings: return .settings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ enum AppTab: Int, CaseIterable {
|
|||||||
/// SF Symbol overrides shared with the Mac and iPad sidebars.
|
/// SF Symbol overrides shared with the Mac and iPad sidebars.
|
||||||
var sfSymbol: String? {
|
var sfSymbol: String? {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .skills: return "sparkles"
|
||||||
case .styles: return "text.badge.star"
|
case .styles: return "text.badge.star"
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
@@ -33,6 +35,7 @@ enum AppTab: Int, CaseIterable {
|
|||||||
var accessibilityKey: LocalizedStringKey {
|
var accessibilityKey: LocalizedStringKey {
|
||||||
switch self {
|
switch self {
|
||||||
case .keyboard: return "tab.keyboard"
|
case .keyboard: return "tab.keyboard"
|
||||||
|
case .skills: return "tab.skills"
|
||||||
case .styles: return "tab.styles"
|
case .styles: return "tab.styles"
|
||||||
case .settings: return "tab.settings"
|
case .settings: return "tab.settings"
|
||||||
}
|
}
|
||||||
@@ -44,6 +47,7 @@ enum AppTab: Int, CaseIterable {
|
|||||||
var sidebarSystemImage: String {
|
var sidebarSystemImage: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .keyboard: return "house"
|
case .keyboard: return "house"
|
||||||
|
case .skills: return "sparkles"
|
||||||
case .styles: return "text.badge.star"
|
case .styles: return "text.badge.star"
|
||||||
case .settings: return "gearshape"
|
case .settings: return "gearshape"
|
||||||
}
|
}
|
||||||
@@ -110,7 +114,7 @@ struct MinimalTabBar: View {
|
|||||||
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
|
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
|
||||||
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
|
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
|
||||||
.glassEffect(.regular.interactive(), in: .capsule)
|
.glassEffect(.regular.interactive(), in: .capsule)
|
||||||
.frame(maxWidth: 280)
|
.frame(maxWidth: 360)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.bottom, TabBarDockMetrics.bottomPadding)
|
.padding(.bottom, TabBarDockMetrics.bottomPadding)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,12 @@ struct MainAppRoot: View {
|
|||||||
switch url.host {
|
switch url.host {
|
||||||
case "startflow":
|
case "startflow":
|
||||||
flowManager.startSession(coldStart: true, reason: "url.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":
|
case "deployrime":
|
||||||
// The keyboard sends the user here precisely because typing
|
// The keyboard sends the user here precisely because typing
|
||||||
// resources are missing — deploy without waiting for warmup.
|
// resources are missing — deploy without waiting for warmup.
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ struct MainTabContent: View {
|
|||||||
switch tab {
|
switch tab {
|
||||||
case .keyboard:
|
case .keyboard:
|
||||||
HomeView()
|
HomeView()
|
||||||
|
case .skills:
|
||||||
|
AIAgentSkillsView()
|
||||||
case .styles:
|
case .styles:
|
||||||
PolishStylesView()
|
PolishStylesView()
|
||||||
case .settings:
|
case .settings:
|
||||||
|
|||||||
@@ -427,8 +427,35 @@
|
|||||||
"tab.history" = "History";
|
"tab.history" = "History";
|
||||||
"tab.dictionary" = "Dictionary";
|
"tab.dictionary" = "Dictionary";
|
||||||
"tab.styles" = "Styles";
|
"tab.styles" = "Styles";
|
||||||
|
"tab.skills" = "Skills";
|
||||||
"tab.settings" = "Settings";
|
"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 */
|
/* Polish style packs */
|
||||||
"polishStyles.title" = "Polish styles";
|
"polishStyles.title" = "Polish styles";
|
||||||
"polishStyles.add" = "Add style";
|
"polishStyles.add" = "Add style";
|
||||||
|
|||||||
@@ -426,8 +426,35 @@
|
|||||||
"tab.history" = "历史";
|
"tab.history" = "历史";
|
||||||
"tab.dictionary" = "词库";
|
"tab.dictionary" = "词库";
|
||||||
"tab.styles" = "风格";
|
"tab.styles" = "风格";
|
||||||
|
"tab.skills" = "技能";
|
||||||
"tab.settings" = "设置";
|
"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.title" = "润色风格";
|
||||||
"polishStyles.add" = "添加风格";
|
"polishStyles.add" = "添加风格";
|
||||||
|
|||||||
@@ -520,6 +520,11 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.submitAIClipboardSkill = { [weak self] skill in
|
state.submitAIClipboardSkill = { [weak self] skill in
|
||||||
self?.aiKeyboardCoordinator.submitClipboardSkill(skill)
|
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.openSettings = { [weak self] in self?.openHostApp() }
|
||||||
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
|
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
|
||||||
state.openClipboardSettings = { [weak self] in
|
state.openClipboardSettings = { [weak self] in
|
||||||
@@ -942,6 +947,19 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
// MARK: - Open host app
|
// 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") {
|
private func openHostApp(path: String = "settings") {
|
||||||
guard hasFullAccess else {
|
guard hasFullAccess else {
|
||||||
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
|
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||||||
|
|||||||
@@ -70,15 +70,26 @@ final class AIKeyboardCoordinator {
|
|||||||
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
||||||
guard canAcceptIdleSubmit else { return }
|
guard canAcceptIdleSubmit else { return }
|
||||||
enterIfNeeded()
|
enterIfNeeded()
|
||||||
|
state.pendingClipboardSkillID = skill.kind == .export ? skill.id : nil
|
||||||
let instruction = AIClipboardSkillCatalog.instruction(
|
let instruction = AIClipboardSkillCatalog.instruction(
|
||||||
for: skill,
|
for: skill,
|
||||||
locale: AIHintLocaleResolver.packLocale(),
|
locale: AIHintLocaleResolver.packLocale(),
|
||||||
translationTargetLocaleId: state.translationTargetLocaleId
|
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(
|
let resolution = AIClipboardPrompt.resolve(
|
||||||
instruction: instruction,
|
instruction: instruction,
|
||||||
material: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
material: material
|
||||||
)
|
)
|
||||||
|
if case .materialUnavailable = resolution {
|
||||||
|
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
|
||||||
|
}
|
||||||
submitResolvedPrompt(resolution)
|
submitResolvedPrompt(resolution)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,24 +117,30 @@ final class AIKeyboardCoordinator {
|
|||||||
private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) {
|
private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) {
|
||||||
guard case .ready(let prompt) = resolution else {
|
guard case .ready(let prompt) = resolution else {
|
||||||
// The clipboard window closed between rendering and this tap.
|
// The clipboard window closed between rendering and this tap.
|
||||||
|
state.pendingClipboardSkillID = nil
|
||||||
state.aiSession.fail(
|
state.aiSession.fail(
|
||||||
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
|
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
|
||||||
utteranceID: nil
|
utteranceID: nil
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let conversationID = state.aiSession.conversationID else { return }
|
guard let conversationID = state.aiSession.conversationID else {
|
||||||
|
state.pendingClipboardSkillID = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
let disposition = flow.submitAIQuestion(
|
let disposition = flow.submitAIQuestion(
|
||||||
text: prompt,
|
text: prompt,
|
||||||
conversationID: conversationID
|
conversationID: conversationID
|
||||||
)
|
)
|
||||||
if case .rejected(let rejection) = disposition {
|
if case .rejected(let rejection) = disposition {
|
||||||
|
state.pendingClipboardSkillID = nil
|
||||||
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
guard state.aiSession.isBusy else { return }
|
guard state.aiSession.isBusy else { return }
|
||||||
|
state.pendingClipboardSkillID = nil
|
||||||
flow.cancelAIRecording()
|
flow.cancelAIRecording()
|
||||||
state.aiSession.cancelCurrentWork()
|
state.aiSession.cancelCurrentWork()
|
||||||
}
|
}
|
||||||
@@ -181,6 +198,7 @@ final class AIKeyboardCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func receivePartialAnswer(_ draft: String, utteranceID: UUID) {
|
func receivePartialAnswer(_ draft: String, utteranceID: UUID) {
|
||||||
|
if isPendingExportSkill { return }
|
||||||
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
|
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +206,11 @@ final class AIKeyboardCoordinator {
|
|||||||
guard result.resolvedUtteranceMode == .aiQuestion else {
|
guard result.resolvedUtteranceMode == .aiQuestion else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isPendingExportSkill {
|
||||||
|
guard result.aiConversationID == state.aiSession.conversationID else { return }
|
||||||
|
finishExportSkill(answer: result.text ?? "")
|
||||||
|
return
|
||||||
|
}
|
||||||
guard result.aiConversationID == state.aiSession.conversationID,
|
guard result.aiConversationID == state.aiSession.conversationID,
|
||||||
let answer = result.text,
|
let answer = result.text,
|
||||||
!answer.isEmpty else {
|
!answer.isEmpty else {
|
||||||
@@ -201,14 +224,59 @@ final class AIKeyboardCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fail(_ message: String, utteranceID: UUID?) {
|
func fail(_ message: String, utteranceID: UUID?) {
|
||||||
|
state.pendingClipboardSkillID = nil
|
||||||
state.aiSession.fail(message, utteranceID: utteranceID)
|
state.aiSession.fail(message, utteranceID: utteranceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func endConversationIfNeeded() {
|
private func endConversationIfNeeded() {
|
||||||
|
state.pendingClipboardSkillID = nil
|
||||||
guard let conversationID = state.aiSession.conversationID else { return }
|
guard let conversationID = state.aiSession.conversationID else { return }
|
||||||
flow.endAIConversation(conversationID)
|
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 {
|
private func message(for rejection: FlowUtteranceStartRejection) -> String {
|
||||||
switch rejection {
|
switch rejection {
|
||||||
case .onboardingIncomplete:
|
case .onboardingIncomplete:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ public struct AppGroupPersistor {
|
|||||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||||
|
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||||
applyAPIKeyAvailability(store: store, into: state)
|
applyAPIKeyAvailability(store: store, into: state)
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ public struct AppGroupPersistor {
|
|||||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||||
|
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||||
applyAPIKeyAvailability(store: store, into: state)
|
applyAPIKeyAvailability(store: store, into: state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ struct AIKeyboardView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
|
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
|
||||||
.onChange(of: clipboardHistory.entries.first?.id) { _, _ 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(
|
.onReceive(
|
||||||
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
|
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
|
||||||
) { _ in
|
) { _ in
|
||||||
@@ -172,6 +183,19 @@ struct AIKeyboardView: View {
|
|||||||
statusLine
|
statusLine
|
||||||
.frame(height: Layout.statusHeight)
|
.frame(height: Layout.statusHeight)
|
||||||
.padding(.horizontal, Spacing.md)
|
.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 {
|
private var clipboardSkillRow: some View {
|
||||||
HStack(spacing: Spacing.lg) {
|
let skills = visibleClipboardSkills
|
||||||
ForEach(AIClipboardSkillCatalog.visible()) { skill in
|
let row = HStack(spacing: Spacing.lg) {
|
||||||
|
ForEach(skills) { skill in
|
||||||
Button {
|
Button {
|
||||||
state.submitAIClipboardSkill(skill)
|
state.submitAIClipboardSkill(skill)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -233,6 +258,15 @@ struct AIKeyboardView: View {
|
|||||||
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
.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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,12 +284,17 @@ struct AIKeyboardView: View {
|
|||||||
/// Copy-then-30s window: skill chips replace the rotating hint.
|
/// Copy-then-30s window: skill chips replace the rotating hint.
|
||||||
private var showsClipboardSkills: Bool {
|
private var showsClipboardSkills: Bool {
|
||||||
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
|
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
|
||||||
|
guard !visibleClipboardSkills.isEmpty else { return false }
|
||||||
return AIHintPool.isClipboardSkillWindowActive(
|
return AIHintPool.isClipboardSkillWindowActive(
|
||||||
clipboardHistoryEnabled: true,
|
clipboardHistoryEnabled: true,
|
||||||
newestClipboard: clipboardHistory.newestEntry
|
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.
|
/// No draft/answer yet — show the centered hint carousel instead of a scroll body.
|
||||||
private var showsPlaceholder: Bool {
|
private var showsPlaceholder: Bool {
|
||||||
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
|
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
|
||||||
@@ -432,6 +471,9 @@ struct AIKeyboardView: View {
|
|||||||
}
|
}
|
||||||
return state.aiSession.transcript
|
return state.aiSession.transcript
|
||||||
case .generating:
|
case .generating:
|
||||||
|
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
|
||||||
|
return ExtL10n.string("keyboard.ai.skill.extracting")
|
||||||
|
}
|
||||||
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
|
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
|
||||||
return ExtL10n.string("keyboard.ai.generating")
|
return ExtL10n.string("keyboard.ai.generating")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,3 +297,9 @@
|
|||||||
"keyboard.ai.skill.reply" = "Reply";
|
"keyboard.ai.skill.reply" = "Reply";
|
||||||
"keyboard.ai.skill.summarize" = "Summarize";
|
"keyboard.ai.skill.summarize" = "Summarize";
|
||||||
"keyboard.ai.skill.translate" = "Translate";
|
"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.reply" = "回复";
|
||||||
"keyboard.ai.skill.summarize" = "总结";
|
"keyboard.ai.skill.summarize" = "总结";
|
||||||
"keyboard.ai.skill.translate" = "翻译";
|
"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"
|
"config.flowInactivityDuration.migratedToFiveMinuteDefault"
|
||||||
/// Diagnostic switch: when false, local ASR skips the custom language model.
|
/// Diagnostic switch: when false, local ASR skips the custom language model.
|
||||||
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
|
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
|
// MARK: - Stored fields
|
||||||
@@ -113,8 +115,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
public var flowSkipAppSwitch: Bool
|
public var flowSkipAppSwitch: Bool
|
||||||
/// Idle timeout before the Flow session ends; resets on each utterance.
|
/// Idle timeout before the Flow session ends; resets on each utterance.
|
||||||
public var flowInactivityDuration: FlowInactivityDuration
|
public var flowInactivityDuration: FlowInactivityDuration
|
||||||
/// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
|
/// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
|
||||||
public var localASRCustomLanguageModelEnabled: Bool
|
public var localASRCustomLanguageModelEnabled: Bool
|
||||||
|
|
||||||
// MARK: - Derived
|
// 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
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
|
// 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
|
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 struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let systemImage: 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
|
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.id = id
|
||||||
self.systemImage = systemImage
|
self.systemImage = systemImage
|
||||||
self.titleKey = titleKey
|
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 replyID = "reply"
|
||||||
public static let summarizeID = "summarize"
|
public static let summarizeID = "summarize"
|
||||||
public static let translateID = "translate"
|
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.
|
/// Full built-in catalog, in a stable display order for the Skills tab.
|
||||||
public static let builtIn: [AIClipboardSkill] = [
|
public static let catalog: [AIClipboardSkill] = [
|
||||||
AIClipboardSkill(
|
AIClipboardSkill(
|
||||||
id: replyID,
|
id: replyID,
|
||||||
systemImage: "arrowshape.turn.up.left.fill",
|
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(
|
AIClipboardSkill(
|
||||||
id: summarizeID,
|
id: summarizeID,
|
||||||
systemImage: "doc.text.magnifyingglass",
|
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(
|
AIClipboardSkill(
|
||||||
id: translateID,
|
id: translateID,
|
||||||
systemImage: "character.bubble.fill",
|
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] {
|
public static func visible(enabledIDs: [String]? = nil) -> [AIClipboardSkill] {
|
||||||
guard let enabledIDs, !enabledIDs.isEmpty else { return builtIn }
|
let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
|
||||||
let byID = Dictionary(uniqueKeysWithValues: builtIn.map { ($0.id, $0) })
|
guard !ids.isEmpty else { return [] }
|
||||||
return enabledIDs.compactMap { byID[$0] }
|
let byID = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
|
||||||
|
return ids.compactMap { byID[$0] }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func instruction(
|
public static func instruction(
|
||||||
@@ -102,6 +176,18 @@ public enum AIClipboardSkillCatalog: Sendable {
|
|||||||
locale: locale,
|
locale: locale,
|
||||||
translationTargetLocaleId: translationTargetLocaleId
|
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:
|
default:
|
||||||
return zh
|
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 polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
|
||||||
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
|
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
|
||||||
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
|
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
|
// MARK: - Writes
|
||||||
|
|
||||||
@@ -199,6 +203,59 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
|
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 {
|
public var hasCompletedOnboarding: Bool {
|
||||||
get { configuration.hasCompletedOnboarding }
|
get { configuration.hasCompletedOnboarding }
|
||||||
set { setHasCompletedOnboarding(newValue) }
|
set { setHasCompletedOnboarding(newValue) }
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ public final class KeyboardState: ObservableObject {
|
|||||||
@Published public var clipboardHistoryEnabled: Bool = false
|
@Published public var clipboardHistoryEnabled: Bool = false
|
||||||
/// Opt-in clipboard suggestion strip (requires history enabled).
|
/// Opt-in clipboard suggestion strip (requires history enabled).
|
||||||
@Published public var clipboardCandidateBarEnabled: Bool = false
|
@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.
|
/// Host field is a password / secure entry — never read pasteboard.
|
||||||
@Published public var isSecureTextEntry: Bool = false
|
@Published public var isSecureTextEntry: Bool = false
|
||||||
/// Secure fields hide every clipboard-history entry point.
|
/// Secure fields hide every clipboard-history entry point.
|
||||||
@@ -239,8 +245,10 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var sendAIAnswer: () -> Void = {}
|
public var sendAIAnswer: () -> Void = {}
|
||||||
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
||||||
public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
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 }
|
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 = {}
|
public var openSettings: () -> Void = {}
|
||||||
/// Opens the host app straight to input-resource deployment. Used by the
|
/// Opens the host app straight to input-resource deployment. Used by the
|
||||||
/// typing surface when Rime resources have not been deployed yet.
|
/// typing surface when Rime resources have not been deployed yet.
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public enum OSGDiag {
|
|||||||
OSGLog.keyboardExt.info("\(line, privacy: .public)")
|
OSGLog.keyboardExt.info("\(line, privacy: .public)")
|
||||||
case "flow", "asr":
|
case "flow", "asr":
|
||||||
OSGLog.flow.info("\(line, privacy: .public)")
|
OSGLog.flow.info("\(line, privacy: .public)")
|
||||||
|
case "skills":
|
||||||
|
OSGLog.config.info("\(line, privacy: .public)")
|
||||||
default:
|
default:
|
||||||
OSGLog.config.info("\(line, privacy: .public)")
|
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("概括"))
|
||||||
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/CursorNavigationTests",
|
||||||
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
|
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
|
||||||
"OSGKeyboardTests/AIHintPoolTests",
|
"OSGKeyboardTests/AIHintPoolTests",
|
||||||
|
"OSGKeyboardTests/AIHintKeywordExtractorTests",
|
||||||
|
"OSGKeyboardTests/AIAgentSkillLayoutTests",
|
||||||
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
|
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
|
||||||
"OSGKeyboardTests/ClipboardHistoryStoreTests"
|
"OSGKeyboardTests/ClipboardHistoryStoreTests"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ targets:
|
|||||||
# crashes when both are passed. iOS 26 uses Icon Composer only.
|
# crashes when both are passed. iOS 26 uses Icon Composer only.
|
||||||
- "Assets.xcassets/AppIcon.appiconset"
|
- "Assets.xcassets/AppIcon.appiconset"
|
||||||
- "Resources/CustomLanguageModel/**"
|
- "Resources/CustomLanguageModel/**"
|
||||||
|
- "Resources/Shortcuts/**"
|
||||||
# Real keyboard chrome for DEBUG what's-new recording (same UI as Ext).
|
# Real keyboard chrome for DEBUG what's-new recording (same UI as Ext).
|
||||||
- path: OSGKeyboardExt/Utilities/ExtL10n.swift
|
- path: OSGKeyboardExt/Utilities/ExtL10n.swift
|
||||||
- path: OSGKeyboardExt/Views/AIKeyboardView.swift
|
- path: OSGKeyboardExt/Views/AIKeyboardView.swift
|
||||||
@@ -120,6 +121,7 @@ targets:
|
|||||||
- path: OSGKeyboard/Resources/Typing/Rime/manifest.json
|
- path: OSGKeyboard/Resources/Typing/Rime/manifest.json
|
||||||
- path: OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin
|
- path: OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin
|
||||||
- path: OSGKeyboard/Resources/HostCLM/v1/compiled-manifest.json
|
- 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/LocalASR/local-asr-catalog.json
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
|
||||||
@@ -194,6 +196,7 @@ targets:
|
|||||||
- zalo
|
- zalo
|
||||||
- skype
|
- skype
|
||||||
- zoomus
|
- zoomus
|
||||||
|
- shortcuts
|
||||||
- things
|
- things
|
||||||
- todoist
|
- todoist
|
||||||
- evernote
|
- evernote
|
||||||
|
|||||||
Reference in New Issue
Block a user