feat(keyboard): add custom skills plus events and navigate Shortcuts

Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69.
This commit is contained in:
Rocky
2026-08-13 23:01:43 +08:00
parent 148ff807f2
commit 0a60006d2d
50 changed files with 11612 additions and 251 deletions
+1
View File
@@ -87,5 +87,6 @@ CHANGES.md
# Local scratch: DerivedData clones, stress logs, QA frames, AI notes, audits
.tmp/
.ai-skills-layout-shots/
.workbuddy-ai/
reports/
+1 -1
View File
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
### Version format
The current source-of-truth version is **1.7.5 (build 67)**. Releases use stable SemVer:
The current source-of-truth version is **1.7.5 (build 69)**. Releases use stable SemVer:
| Field | File | Rule |
|-------|------|------|
+9
View File
@@ -10,11 +10,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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,键盘上给出提示。
- **Extract events skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取日程` on the system Add page (multiple events into Calendar; date-only → all-day, time-only → today, optional end time and location). After you tap Add, copying text and tapping Events asks the LLM for events and silently adds them; no date or time stays in the current app with a keyboard tip. / **提取日程技能**:技能页会打开已做好的配套捷径 `OSG · 提取日程` 的系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。
- **Navigate skill**: the Skills tab opens a companion Shortcut named `OSG · 导航`. After you add it, copying text and tapping Navigate asks the LLM for one address (or origin → destination) and opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No address stays in the current app with a keyboard tip. / **导航技能**:技能页打开配套捷径 `OSG · 导航`。添加后,复制文字并点「导航」会抽取一条地址(或起点→终点)并开始驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。没有地址则留在当前 App,键盘上给出提示。
- **Skills clipboard guide**: when Clipboard History is off, the Skills tab shows a card that jumps to in-app Clipboard settings and to iOS Settings for paste authorization. / **技能页剪贴板指引**:未开启剪贴板历史时,技能页展示可点击卡片,分别跳转 App 内剪贴板设置和系统设置以完成粘贴授权。
- **Custom skills**: Skills tab `+` adds a user skill (name, about, SF Symbol, prompt, required iCloud Shortcut link with name lookup, independent Shortcut name, thinking off by default). Built-in thinking stays off and disabled. No cap on how many custom skills you can save; the keyboard still holds at most 8. / **自定义技能**:技能页右上角 `+` 可添加用户技能(名称、介绍、SF Symbol、提示词、必填 iCloud 捷径链接并自动读取名称、可与技能名分开的捷径名、思考默认关)。内置技能思考固定关闭且不可开。自定义数量不设上限,键盘仍最多启用 8 个。
### 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. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。
- **Skill reorder feedback**: long-press lifts a skill card and the grid slides live under the finger, matching Home Screen rearrange. / **技能拖动排序**:长按拎起技能卡片,网格随手指实时让位,接近主屏幕图标重排。
- **Skill drag preview corners**: the lift preview clips to the cards continuous rounded rect so square white corners no longer show. / **技能拖动圆角**:拖起预览按卡片连续圆角裁剪,去掉圆角外的直角白底。
### Changed
- **Skill and style cards**: every card uses a top-right edit pencil and a bottom-right selected check; skill Done sits on the right like style details; tapping a skill card toggles the keyboard slot (unconfirmed Shortcut skills still open the install sheet). / **技能与风格卡片**:右上为编辑铅笔、右下为选中勾选;技能详情「完成」改到右侧;点技能卡片切换是否上键盘(未确认捷径的仍打开安装页)。
- **Clipboard skill grid**: idle skill chips stay as one centered block, wrapping to two rows of up to four instead of scrolling sideways after four. / **剪贴板技能网格**:空闲技能按钮作为一整块居中,超过四个改为两行(每行最多四个),不再横向滚动。
- **Skill sheet buttons**: Add, Turn off, Add Shortcut, Ive added it, and Reinstall are solid capsules — primary actions use the brand green (`#3AA05A`), secondary actions use an elevated fill. / **技能弹窗按钮**:添加、关闭、添加捷径、我已添加、重新安装改为实心胶囊;主操作为品牌绿(`#3AA05A`),次要为抬升底。
- **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(日历、天气、新闻、股票、热搜、搜索)。
- **Clipboard AI skills**: within 30 seconds of a copy, AI idle shows compact circular Reply / Summarize / Translate buttons; translate follows the keyboard language setting, or Chinese ↔ English when unset. The skill list is catalog-based so more actions can be added later. / **剪贴板 AI 技能**:复制后约 30 秒内,空闲态改为小圆形「回复 / 总结 / 翻译」按钮;翻译跟随键盘目标语言,未设置时中英互译。技能来自目录,便于日后扩展。
+4
View File
@@ -70,6 +70,10 @@
<string>skype</string>
<string>zoomus</string>
<string>shortcuts</string>
<string>iosamap</string>
<string>amapuri</string>
<string>baidumap</string>
<string>maps</string>
<string>things</string>
<string>todoist</string>
<string>evernote</string>
+2
View File
@@ -36,6 +36,8 @@ struct OSGKeyboardApp: App {
EditDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--ai-demo") {
AIKeyboardDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
AIClipboardSkillLayoutDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
ClipboardHistoryDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
Binary file not shown.
@@ -10,8 +10,6 @@ import UIKit
import OSGKeyboardShared
enum AIAgentShortcutInstaller {
static let bundledResourceName = "OSGExtractTodos"
@MainActor
static func openInstallPage(for skill: AIClipboardSkill) {
if let shareURL = skill.shortcutICloudURL,
@@ -19,17 +17,19 @@ enum AIAgentShortcutInstaller {
UIApplication.shared.open(installURL)
return
}
openBundledShortcut()
openBundledShortcut(for: skill)
}
@MainActor
private static func openBundledShortcut() {
guard let bundled = Bundle.main.url(
forResource: bundledResourceName,
withExtension: "shortcut"
) else { return }
private static func openBundledShortcut(for skill: AIClipboardSkill) {
guard let resource = skill.shortcutResourceName,
let bundled = Bundle.main.url(
forResource: resource,
withExtension: "shortcut"
) else { return }
let fileName = skill.shortcutName ?? resource
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("\(AIClipboardSkillCatalog.extractTodosShortcutName).shortcut")
.appendingPathComponent("\(fileName).shortcut")
try? FileManager.default.removeItem(at: tmp)
do {
try FileManager.default.copyItem(at: bundled, to: tmp)
@@ -1,7 +1,7 @@
// AIAgentShortcutRunner.swift
// OSGKeyboard · Main App
//
// Consumes the keyboard's pending extract-todos payload and opens the
// Consumes the keyboard's pending export-skill payload and opens the
// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add
// x-callback URLs so Console can record success / error / cancel.
@@ -13,15 +13,22 @@ enum AIAgentShortcutRunner {
static func runPendingIfNeeded() {
AIAgentShortcutRun.trace("host.runPending begin")
guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID),
let catalog = AppGroupStore().agentUserSkillCatalog
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog),
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 {
guard let text = shortcutText(for: skill, payload: payload) else {
AIAgentShortcutRun.trace(
"host.runPending skip textBuildFailed skill=\(payload.skillID)"
)
return
}
AIAgentShortcutRun.traceBody("host.titlesToShortcut", text)
guard let url = shortcutsURL(name: name, text: text) else {
AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)")
return
}
@@ -55,6 +62,21 @@ enum AIAgentShortcutRunner {
)
}
/// Navigate: pick Apple Maps, pass that URL to the Shortcut.
/// Other export skills send the parsed lines unchanged.
private static func shortcutText(
for skill: AIClipboardSkill,
payload: AIAgentShortcutRunPayload
) -> String? {
if skill.id == AIClipboardSkillCatalog.navigateID {
return AIMapNavigation.shortcutInput(
from: payload.joinedTitles,
canOpen: { UIApplication.shared.canOpenURL($0) }
)
}
return payload.joinedTitles
}
private static func shortcutsURL(name: String, text: String) -> URL? {
#if DEBUG
return AIAgentShortcutRun.shortcutsRunURL(
@@ -1358,13 +1358,15 @@ final class FlowSessionManager: ObservableObject {
let utteranceId = command.utteranceId
let commandSeq = command.commandSeq
let sessionId = command.sessionId
let thinkingEnabled = command.aiThinkingEnabled ?? true
Task { @MainActor [weak self] in
await self?.answerPrefilledAIQuestion(
question: question,
conversationID: conversationID,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
commandSeq: commandSeq,
thinkingEnabled: thinkingEnabled
)
}
}
@@ -1385,7 +1387,8 @@ final class FlowSessionManager: ObservableObject {
conversationID: UUID?,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64
commandSeq: Int64,
thinkingEnabled: Bool
) async {
// Hint-card / prefilled questions never go through `finalizeUtterance`,
// so this path must drop the processing gate itself. Leaving it set
@@ -1415,7 +1418,8 @@ final class FlowSessionManager: ObservableObject {
do {
let service = try AIQuestionService.configured(
store: pipelineStore,
conversations: aiConversations
conversations: aiConversations,
thinkingEnabled: thinkingEnabled
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let answer = try await service.answer(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
// AIClipboardSkillLayoutDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// Interactive layout preview of AI Agent clipboard skills on the real
// `AIKeyboardView`. Launch with `--ai-skills-demo` and optional
// `--skills-count=N` (1...8).
#if DEBUG
import SwiftUI
import OSGKeyboardShared
struct AIClipboardSkillLayoutDemoView: View {
@StateObject private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@State private var count: Int = Self.initialCount
var body: some View {
VStack(spacing: 0) {
controls
Spacer(minLength: 0)
AIKeyboardView(
state: state,
typing: typing,
onInsert: { _ in }
)
.background(Palette.light.background)
}
.background(Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea())
.environment(\.locale, Locale(identifier: "zh-Hans"))
.preferredColorScheme(.light)
.onAppear { apply(count) }
.onChange(of: count) { _, newValue in apply(newValue) }
}
private var controls: some View {
VStack(spacing: 10) {
Text("技能布局预览")
.font(.headline)
.foregroundStyle(.white)
HStack(spacing: 16) {
Button {
count = max(1, count - 1)
} label: {
Image(systemName: "minus.circle.fill")
.font(.system(size: 28))
}
Text("\(count)")
.font(.title2.monospacedDigit().weight(.semibold))
.foregroundStyle(.white)
.frame(minWidth: 72)
Button {
count = min(8, count + 1)
} label: {
Image(systemName: "plus.circle.fill")
.font(.system(size: 28))
}
}
.foregroundStyle(.green)
HStack(spacing: 8) {
ForEach([3, 5, 8], id: \.self) { n in
Button("\(n)") {
count = n
}
.buttonStyle(.borderedProminent)
.tint(count == n ? .green : .gray)
}
}
}
.padding(.top, 56)
.padding(.bottom, 16)
}
private func apply(_ count: Int) {
let clamped = min(max(count, 1), 8)
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
state.surface = .ai
state.aiServiceAvailable = true
state.micDisabled = false
state.layoutWidth = 390
state.usesIPadLayoutMetrics = false
state.clipboardHistoryEnabled = true
state.enabledClipboardSkillIDs = Array(
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
)
state.aiSession.enter()
}
private static var initialCount: Int {
let prefix = "--skills-count="
if let arg = ProcessInfo.processInfo.arguments.first(where: { $0.hasPrefix(prefix) }),
let value = Int(arg.dropFirst(prefix.count)),
(1...8).contains(value) {
return value
}
return 5
}
private static func previewSkills(count: Int) -> [AIClipboardSkill] {
let catalog = AIClipboardSkillCatalog.catalog
if count <= catalog.count {
return Array(catalog.prefix(count))
}
var extras: [AIClipboardSkill] = [
AIClipboardSkill(
id: "preview-polish",
systemImage: "wand.and.stars",
titleKey: "keyboard.ai.skill.previewPolish",
cardTitleKey: "skills.reply.name",
descriptionKey: "skills.reply.description",
kind: .transform,
isDefault: false
),
AIClipboardSkill(
id: "preview-ideas",
systemImage: "lightbulb.fill",
titleKey: "keyboard.ai.skill.previewIdeas",
cardTitleKey: "skills.summarize.name",
descriptionKey: "skills.summarize.description",
kind: .transform,
isDefault: false
),
AIClipboardSkill(
id: "preview-tone",
systemImage: "theatermasks.fill",
titleKey: "keyboard.ai.skill.previewTone",
cardTitleKey: "skills.translate.name",
descriptionKey: "skills.translate.description",
kind: .transform,
isDefault: false
),
]
extras = Array(extras.prefix(count - catalog.count))
return catalog + extras
}
}
#endif
+31
View File
@@ -0,0 +1,31 @@
// CatalogCardChrome.swift
// OSGKeyboard · Main App
//
// Shared edit / selected badges for style and skill cards.
import SwiftUI
import OSGKeyboardShared
enum CatalogCardChrome {
static let badgeSize: CGFloat = 30
static let editIconSize: CGFloat = 15
static let checkIconSize: CGFloat = 21
static let editHitSize: CGFloat = 44
static func editIcon(palette: ThemePalette) -> some View {
Image(systemName: "pencil")
.font(.system(size: editIconSize, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(width: badgeSize, height: badgeSize)
.background(palette.background.opacity(0.75), in: Circle())
}
static func checkIcon(palette: ThemePalette) -> some View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: checkIconSize, weight: .semibold))
.foregroundStyle(palette.accent)
.padding(Spacing.sm)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.allowsHitTesting(false)
}
}
+3 -15
View File
@@ -142,26 +142,14 @@ struct PolishStylesView: View {
editingPack = pack
}
} label: {
Image(systemName: pack.kind == .builtin ? "eye" : "pencil")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(width: 30, height: 30)
.background(palette.background.opacity(0.75), in: Circle())
CatalogCardChrome.editIcon(palette: palette)
}
.padding(Spacing.sm)
.buttonStyle(.plain)
.accessibilityLabel(
Text(pack.kind == .builtin ? "polishStyles.viewPrompt" : "polishStyles.edit")
)
.accessibilityLabel(Text("polishStyles.edit"))
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(palette.accent)
.background(Color.white, in: Circle())
.padding(Spacing.sm)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.allowsHitTesting(false)
CatalogCardChrome.checkIcon(palette: palette)
}
}
.background(
+38 -1
View File
@@ -435,7 +435,10 @@
"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.clipboard.guide.title" = "Clipboard access needed";
"skills.clipboard.guide.body" = "Turn on Clipboard History in the app, then allow paste access in iOS Settings, so these skills can run after you copy text.";
"skills.clipboard.guide.openAppSettings" = "Open Clipboard settings";
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
"skills.detail.title" = "Skill";
"skills.badge.default" = "Default skill";
"skills.action.turnOff" = "Turn off";
@@ -452,9 +455,43 @@
"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.extractEvents.name" = "Extract events";
"skills.extractEvents.description" = "Extract events from copied text into Calendar.";
"skills.navigate.name" = "Navigate";
"skills.navigate.description" = "Find an address in the copied text and start driving directions. Uses Amap if installed, then Baidu Maps, then Apple Maps.";
"skills.install.lead" = "Add it, then confirm. Dont rename it.";
"skills.install.openShortcuts" = "Add Shortcut";
"skills.install.confirmAdded" = "I've added it";
"skills.add" = "Add skill";
"skills.edit" = "Edit skill";
"skills.custom.description" = "Custom clipboard skill";
"skills.editor.name" = "Name";
"skills.editor.namePlaceholder" = "Skill name";
"skills.editor.summary" = "About";
"skills.editor.summaryPlaceholder" = "Shown on the skill card";
"skills.editor.icon" = "Icon";
"skills.editor.iconChoose" = "Choose SF Symbol";
"skills.editor.iconSearch" = "Search symbols";
"skills.editor.prompt" = "Processing prompt";
"skills.editor.thinking" = "Thinking";
"skills.editor.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning for this skill.";
"skills.editor.shortcut" = "Shortcut";
"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
"skills.editor.shortcutNamePlaceholder" = "Shortcut name (can differ from the skill name)";
"skills.editor.shortcutHint" = "Paste an iCloud share link. The published Shortcut name is filled in automatically and you can change it. Dont rename it in the Shortcuts app after adding.";
"skills.editor.lookingUp" = "Looking up Shortcut name…";
"skills.editor.resolvedName" = "Will run: %@";
"skills.editor.lookupFailed" = "Couldnt read the Shortcut name. Check the link, or type the name yourself.";
"skills.delete.title" = "Delete this skill?";
"skills.delete.message" = "It will be removed from this device. The Shortcut in the Shortcuts app is not deleted.";
"skills.error.title" = "Couldnt save skill";
"skills.error.emptyName" = "Enter a skill name.";
"skills.error.emptyPrompt" = "The prompt cannot be empty.";
"skills.error.emptyShortcutName" = "Enter the Shortcut name used to run it.";
"skills.error.invalidLink" = "Enter a valid iCloud Shortcuts link.";
"skills.error.emptyIcon" = "Choose an icon.";
"skills.error.promptTooLong" = "The prompt can contain up to 6,000 characters.";
"skills.error.generic" = "Try again.";
/* Polish style packs */
"polishStyles.title" = "Polish styles";
+38 -1
View File
@@ -434,7 +434,10 @@
"skills.enabled.section" = "使用中(%d/%d";
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
"skills.available.section" = "可添加";
"skills.clipboardHistory.banner" = "请先在设置中打开剪贴板历史,复制文字后才能在键盘上使用这些技能。";
"skills.clipboard.guide.title" = "需要剪贴板权限";
"skills.clipboard.guide.body" = "请先在 App 里打开剪贴板历史,再到系统设置中允许粘贴授权,复制后才能使用这些技能。";
"skills.clipboard.guide.openAppSettings" = "打开剪贴板设置";
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
"skills.detail.title" = "技能";
"skills.badge.default" = "默认技能";
"skills.action.turnOff" = "关闭";
@@ -451,9 +454,43 @@
"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
"skills.extractTodos.name" = "提取待办";
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
"skills.extractEvents.name" = "提取日程";
"skills.extractEvents.description" = "从复制内容提取日程,写入日历。";
"skills.navigate.name" = "导航";
"skills.navigate.description" = "从复制内容识别地址并开始驾车导航。优先高德,其次百度,最后 Apple 地图。";
"skills.install.lead" = "添加后点「我已添加」,请勿改名。";
"skills.install.openShortcuts" = "添加捷径";
"skills.install.confirmAdded" = "我已添加";
"skills.add" = "添加技能";
"skills.edit" = "编辑技能";
"skills.custom.description" = "自定义剪贴板技能";
"skills.editor.name" = "名称";
"skills.editor.namePlaceholder" = "技能名称";
"skills.editor.summary" = "介绍";
"skills.editor.summaryPlaceholder" = "显示在技能卡片上";
"skills.editor.icon" = "图标";
"skills.editor.iconChoose" = "选择 SF Symbol";
"skills.editor.iconSearch" = "搜索符号";
"skills.editor.prompt" = "文本处理提示词";
"skills.editor.thinking" = "思考";
"skills.editor.thinkingHint" = "默认关闭。仅在需要该技能更慢、更深的推理时开启。";
"skills.editor.shortcut" = "捷径";
"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
"skills.editor.shortcutNamePlaceholder" = "捷径名称(可与技能名称不同)";
"skills.editor.shortcutHint" = "粘贴 iCloud 分享链接。发布名称会自动填入,也可以自行修改。添加到「快捷指令」后请勿改名。";
"skills.editor.lookingUp" = "正在读取捷径名称…";
"skills.editor.resolvedName" = "将运行:%@";
"skills.editor.lookupFailed" = "无法读取捷径名称。请检查链接,或手动填写名称。";
"skills.delete.title" = "删除此技能?";
"skills.delete.message" = "会从本机移除。快捷指令 App 里的捷径不会被删除。";
"skills.error.title" = "无法保存技能";
"skills.error.emptyName" = "请输入技能名称。";
"skills.error.emptyPrompt" = "提示词不能为空。";
"skills.error.emptyShortcutName" = "请填写用于运行的捷径名称。";
"skills.error.invalidLink" = "请输入有效的 iCloud 捷径链接。";
"skills.error.emptyIcon" = "请选择图标。";
"skills.error.promptTooLong" = "提示词最多 6,000 字。";
"skills.error.generic" = "请再试一次。";
/* 润色风格包 */
"polishStyles.title" = "润色风格";
@@ -90,7 +90,7 @@ final class AIKeyboardCoordinator {
if case .materialUnavailable = resolution {
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
}
submitResolvedPrompt(resolution)
submitResolvedPrompt(resolution, thinkingEnabled: skill.thinkingEnabled)
}
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
@@ -114,7 +114,10 @@ final class AIKeyboardCoordinator {
}
}
private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) {
private func submitResolvedPrompt(
_ resolution: AIClipboardPrompt.Resolution,
thinkingEnabled: Bool? = nil
) {
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
state.pendingClipboardSkillID = nil
@@ -130,7 +133,8 @@ final class AIKeyboardCoordinator {
}
let disposition = flow.submitAIQuestion(
text: prompt,
conversationID: conversationID
conversationID: conversationID,
thinkingEnabled: thinkingEnabled
)
if case .rejected(let rejection) = disposition {
state.pendingClipboardSkillID = nil
@@ -236,14 +240,37 @@ final class AIKeyboardCoordinator {
private var isPendingExportSkill: Bool {
guard let id = state.pendingClipboardSkillID else { return false }
return AIClipboardSkillCatalog.skill(id: id)?.kind == .export
return resolvedSkill(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 resolvedSkill(id: String) -> AIClipboardSkill? {
AIClipboardSkillCatalog.skill(
id: id,
userCatalog: AppGroupStore().agentUserSkillCatalog
)
}
/// Parse an export skill. Empty in-keyboard tip, stay in the host app.
/// Lines 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)
let skillID = state.pendingClipboardSkillID
let items: [String]
let emptyTipKey: String
switch skillID {
case AIClipboardSkillCatalog.extractEventsID:
items = AIEventExtraction.lines(from: answer, sourceClipboard: source)
emptyTipKey = "keyboard.ai.skill.noEvents"
case AIClipboardSkillCatalog.extractTodosID:
items = AITodoExtraction.items(from: answer, sourceClipboard: source)
emptyTipKey = "keyboard.ai.skill.noTodos"
case AIClipboardSkillCatalog.navigateID:
items = AIAddressExtraction.lines(from: answer, sourceClipboard: source)
emptyTipKey = "keyboard.ai.skill.noAddress"
default:
items = AIGenericSkillExport.items(from: answer)
emptyTipKey = "keyboard.ai.skill.noExportItems"
}
AIAgentShortcutRun.traceBody("keyboard.llmRaw", answer)
AIAgentShortcutRun.trace(
"keyboard.parse items=\(items.count) clipboardChars=\(source?.count ?? 0)"
@@ -253,7 +280,6 @@ final class AIKeyboardCoordinator {
AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
}
#endif
let skillID = state.pendingClipboardSkillID
state.pendingClipboardSkillID = nil
if state.aiSession.isBusy {
state.aiSession.cancelCurrentWork()
@@ -263,11 +289,11 @@ final class AIKeyboardCoordinator {
}
guard !items.isEmpty else {
AIAgentShortcutRun.trace("keyboard.parse empty — skip Shortcuts")
state.skillTipText = ExtL10n.string("keyboard.ai.skill.noTodos")
state.skillTipText = ExtL10n.string(emptyTipKey)
return
}
guard let skillID,
AIClipboardSkillCatalog.skill(id: skillID)?.shortcutName != nil else {
resolvedSkill(id: skillID)?.shortcutName != nil else {
AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")")
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
return
@@ -670,12 +670,17 @@ final class KeyboardFlowCoordinator {
func submitAIQuestion(
text: String,
conversationID: UUID
conversationID: UUID,
thinkingEnabled: Bool? = nil
) -> FlowUtteranceStartDisposition {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
return startUtterance(
.aiQuestion(conversationID: conversationID, prefilledQuestion: trimmed)
.aiQuestion(
conversationID: conversationID,
prefilledQuestion: trimmed,
thinkingEnabled: thinkingEnabled
)
)
}
@@ -1868,6 +1873,7 @@ final class KeyboardFlowCoordinator {
utteranceMode: .aiQuestion,
aiConversationID: currentUtteranceRequest?.aiConversationID,
aiQuestionText: text,
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
startDeadlineAt: currentStartDeadlineAt
)
FlowSessionBridge.writeCommand(command)
+73 -29
View File
@@ -15,9 +15,18 @@ struct AIKeyboardView: View {
static let actionButtonMaxWidth: CGFloat = 150
static let statusHeight: CGFloat = 20
static let carouselInterval: TimeInterval = 4
static let skillButtonSize: CGFloat = 52
static let skillButtonSize: CGFloat = 48
static let skillsPerRow = 4
static let skillRowSpacing: CGFloat = 8
static let skillCellSpacing: CGFloat = 16
static let skillCellWidth: CGFloat = 64
}
#if DEBUG
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
static var debugPreviewSkills: [AIClipboardSkill]?
#endif
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@@ -236,40 +245,52 @@ struct AIKeyboardView: View {
}
private var clipboardSkillRow: some View {
let skills = visibleClipboardSkills
let row = HStack(spacing: Spacing.lg) {
ForEach(skills) { skill in
Button {
state.submitAIClipboardSkill(skill)
} label: {
VStack(spacing: 6) {
Image(systemName: skill.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.textPrimary.opacity(0.85))
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
.glassEffect(.regular.interactive(), in: Circle())
Text(clipboardSkillTitle(skill))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
let rows = skillRows(from: visibleClipboardSkills)
return VStack(spacing: 0) {
Spacer(minLength: 0)
VStack(spacing: Layout.skillRowSpacing) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, rowSkills in
HStack(spacing: Layout.skillCellSpacing) {
ForEach(rowSkills) { skill in
skillChip(skill)
}
}
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
}
return Group {
if skills.count > 4 {
ScrollView(.horizontal, showsIndicators: false) {
row.padding(.horizontal, Spacing.md)
}
} else {
row
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func skillRows(from skills: [AIClipboardSkill]) -> [[AIClipboardSkill]] {
stride(from: 0, to: skills.count, by: Layout.skillsPerRow).map { start in
Array(skills[start ..< min(start + Layout.skillsPerRow, skills.count)])
}
}
private func skillChip(_ skill: AIClipboardSkill) -> some View {
Button {
state.submitAIClipboardSkill(skill)
} label: {
VStack(spacing: 6) {
Image(systemName: skill.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.textPrimary.opacity(0.85))
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
.glassEffect(.regular.interactive(), in: Circle())
Text(clipboardSkillTitle(skill))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(width: Layout.skillCellWidth)
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
/// Translate follows the keyboard target; Reply / Summarize stay static.
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
if skill.id == AIClipboardSkillCatalog.translateID {
@@ -278,11 +299,20 @@ struct AIKeyboardView: View {
uiLanguage: AppGroupStore().uiLanguage
)
}
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
!custom.isEmpty {
return custom
}
return ExtL10n.string(skill.titleKey)
}
/// Copy-then-30s window: skill chips replace the rotating hint.
private var showsClipboardSkills: Bool {
#if DEBUG
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
return showsPlaceholder
}
#endif
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
guard !visibleClipboardSkills.isEmpty else { return false }
return AIHintPool.isClipboardSkillWindowActive(
@@ -292,7 +322,15 @@ struct AIKeyboardView: View {
}
private var visibleClipboardSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.visible(enabledIDs: state.enabledClipboardSkillIDs)
#if DEBUG
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
return preview
}
#endif
return AIClipboardSkillCatalog.visible(
enabledIDs: state.enabledClipboardSkillIDs,
userCatalog: AppGroupStore().agentUserSkillCatalog
)
}
/// No draft/answer yet show the centered hint carousel instead of a scroll body.
@@ -474,6 +512,12 @@ struct AIKeyboardView: View {
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
return ExtL10n.string("keyboard.ai.skill.extracting")
}
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractEventsID {
return ExtL10n.string("keyboard.ai.skill.extractingEvents")
}
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.navigateID {
return ExtL10n.string("keyboard.ai.skill.extractingAddress")
}
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
return ExtL10n.string("keyboard.ai.generating")
}
+10
View File
@@ -298,8 +298,18 @@
"keyboard.ai.skill.summarize" = "Summarize";
"keyboard.ai.skill.translate" = "Translate";
"keyboard.ai.skill.extractTodos" = "Tasks";
"keyboard.ai.skill.extractEvents" = "Events";
"keyboard.ai.skill.navigate" = "Navigate";
"keyboard.ai.skill.previewPolish" = "Polish";
"keyboard.ai.skill.previewIdeas" = "Ideas";
"keyboard.ai.skill.previewTone" = "Tone";
"keyboard.ai.skill.noTodos" = "No tasks in the clipboard";
"keyboard.ai.skill.noEvents" = "No date or time in the clipboard";
"keyboard.ai.skill.noAddress" = "No address in the clipboard";
"keyboard.ai.skill.noExportItems" = "Nothing to send to the Shortcut";
"keyboard.ai.skill.extracting" = "Finding tasks…";
"keyboard.ai.skill.extractingEvents" = "Finding events…";
"keyboard.ai.skill.extractingAddress" = "Finding an address…";
"keyboard.ai.skill.runningShortcut" = "Running Shortcut…";
"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
"keyboard.ai.skill.handoffFailed" = "Couldnt open the app to run the Shortcut";
@@ -298,8 +298,18 @@
"keyboard.ai.skill.summarize" = "总结";
"keyboard.ai.skill.translate" = "翻译";
"keyboard.ai.skill.extractTodos" = "待办";
"keyboard.ai.skill.extractEvents" = "日程";
"keyboard.ai.skill.navigate" = "导航";
"keyboard.ai.skill.previewPolish" = "润色";
"keyboard.ai.skill.previewIdeas" = "点子";
"keyboard.ai.skill.previewTone" = "语气";
"keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项";
"keyboard.ai.skill.noEvents" = "剪贴板内容没有日期或时间";
"keyboard.ai.skill.noAddress" = "剪贴板内容没有可导航的地址";
"keyboard.ai.skill.noExportItems" = "没有可发给捷径的内容";
"keyboard.ai.skill.extracting" = "正在提取待办…";
"keyboard.ai.skill.extractingEvents" = "正在提取日程…";
"keyboard.ai.skill.extractingAddress" = "正在识别地址…";
"keyboard.ai.skill.runningShortcut" = "正在运行捷径…";
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
+5 -11
View File
@@ -246,28 +246,22 @@ private struct MacPolishStyleCard: View {
}
.buttonStyle(.plain)
// Builtin: eye view prompt; custom: pencil edit.
// Pencil opens the prompt (built-in, read-only) or the editor (custom).
Button(action: primaryAction) {
Image(systemName: isUserStyle ? "pencil" : "eye")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "pencil")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(width: 28, height: 28)
.frame(width: 30, height: 30)
.background(palette.background.opacity(isHovering ? 0.9 : 0.72), in: Circle())
}
.padding(Spacing.sm)
.buttonStyle(.plain)
.accessibilityLabel(
MacL10n.string(
isUserStyle ? "mac.styles.edit" : "mac.styles.viewPrompt",
language: language
)
)
.accessibilityLabel(MacL10n.string("mac.styles.edit", language: language))
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(palette.accent)
.background(palette.surface, in: Circle())
.padding(Spacing.sm)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.allowsHitTesting(false)
@@ -44,8 +44,10 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
}
/// 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) })
public func sanitized(
catalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog
) -> AIAgentSkillLayout {
let known = Dictionary(uniqueKeysWithValues: 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 }
+177
View File
@@ -0,0 +1,177 @@
// AIUserSkill.swift
// OSGKeyboard · Shared
//
// User-created clipboard skills. Built-in skills stay in
// `AIClipboardSkillCatalog`; this catalog is persisted in App Group so the
// keyboard can resolve custom names, prompts, and Shortcut run names.
import Foundation
public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var name: String
/// Card subtitle. Empty is allowed; the UI shows a generic fallback.
public var summary: String
public var systemImage: String
public var prompt: String
public var shortcutICloudURL: URL
/// Name used by `shortcuts://run-shortcut?name=`. Independent of `name`.
public var shortcutName: String
/// Per-skill reasoning. Built-in skills are always off; custom defaults off.
public var thinkingEnabled: Bool
public let createdAt: Date
public var updatedAt: Date
public init(
id: String = "user.\(UUID().uuidString.lowercased())",
name: String,
summary: String = "",
systemImage: String = AIUserSkillLimits.defaultSystemImage,
prompt: String,
shortcutICloudURL: URL,
shortcutName: String,
thinkingEnabled: Bool = false,
createdAt: Date = Date(),
updatedAt: Date? = nil
) {
self.id = id
self.name = name
self.summary = summary
self.systemImage = systemImage
self.prompt = prompt
self.shortcutICloudURL = shortcutICloudURL
self.shortcutName = shortcutName
self.thinkingEnabled = thinkingEnabled
self.createdAt = createdAt
self.updatedAt = updatedAt ?? createdAt
}
public var isUserCreated: Bool { id.hasPrefix("user.") }
public func asClipboardSkill() -> AIClipboardSkill {
AIClipboardSkill(
id: id,
systemImage: systemImage,
titleKey: "",
cardTitleKey: "",
descriptionKey: "",
kind: .export,
isDefault: false,
shortcutName: shortcutName,
shortcutICloudURL: shortcutICloudURL,
customName: name,
customSummary: summary,
customPrompt: prompt,
thinkingEnabled: thinkingEnabled
)
}
}
public enum AIUserSkillLimits {
public static let defaultSystemImage = "sparkles"
public static let maximumPromptCharacters = 6_000
public static let maximumNameCharacters = 40
public static let maximumSummaryCharacters = 200
public static let newPromptTemplate = """
"""
/// Curated SF Symbols for the skill-icon picker.
public static let symbolChoices: [String] = [
"sparkles",
"wand.and.stars",
"text.badge.checkmark",
"checklist",
"calendar",
"envelope.fill",
"bubble.left.and.bubble.right.fill",
"character.bubble.fill",
"doc.text.magnifyingglass",
"arrowshape.turn.up.left.fill",
"lightbulb.fill",
"star.fill",
"heart.fill",
"flag.fill",
"tag.fill",
"folder.fill",
"list.bullet",
"square.and.pencil",
"scissors",
"globe",
"paperplane.fill",
"clock.fill",
"bell.fill",
"bookmark.fill",
"person.fill",
"link",
"number",
"at",
"tray.fill",
"quote.bubble.fill",
]
}
public enum AIUserSkillValidationError: Error, Equatable, Sendable {
case emptyName
case emptyPrompt
case emptyShortcutName
case invalidShortcutLink
case emptyIcon
case promptTooLong(maximum: Int)
}
public struct AIUserSkillCatalog: Codable, Equatable, Sendable {
public var entries: [AIUserSkill]
public init(entries: [AIUserSkill] = []) {
self.entries = entries.filter(\.isUserCreated)
}
public static let empty = AIUserSkillCatalog()
public func skill(id: String) -> AIUserSkill? {
entries.first { $0.id == id }
}
public mutating func upsert(_ skill: AIUserSkill, at date: Date = Date()) throws {
let name = skill.name.trimmingCharacters(in: .whitespacesAndNewlines)
let summary = skill.summary.trimmingCharacters(in: .whitespacesAndNewlines)
let prompt = skill.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
let shortcutName = skill.shortcutName.trimmingCharacters(in: .whitespacesAndNewlines)
let icon = skill.systemImage.trimmingCharacters(in: .whitespacesAndNewlines)
guard skill.isUserCreated else { throw AIUserSkillValidationError.emptyName }
guard !name.isEmpty else { throw AIUserSkillValidationError.emptyName }
guard !prompt.isEmpty else { throw AIUserSkillValidationError.emptyPrompt }
guard prompt.count <= AIUserSkillLimits.maximumPromptCharacters else {
throw AIUserSkillValidationError.promptTooLong(
maximum: AIUserSkillLimits.maximumPromptCharacters
)
}
guard !shortcutName.isEmpty else { throw AIUserSkillValidationError.emptyShortcutName }
guard AIShortcutShareLink.isValid(skill.shortcutICloudURL) else {
throw AIUserSkillValidationError.invalidShortcutLink
}
guard !icon.isEmpty else { throw AIUserSkillValidationError.emptyIcon }
var saved = skill
saved.name = String(name.prefix(AIUserSkillLimits.maximumNameCharacters))
saved.summary = String(summary.prefix(AIUserSkillLimits.maximumSummaryCharacters))
saved.prompt = prompt
saved.shortcutName = shortcutName
saved.systemImage = icon
saved.thinkingEnabled = skill.thinkingEnabled
saved.updatedAt = date
if let index = entries.firstIndex(where: { $0.id == skill.id }) {
entries[index] = saved
} else {
entries.append(saved)
}
}
public mutating func remove(id: String) {
entries.removeAll { $0.id == id }
}
}
@@ -71,6 +71,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
/// User-created clipboard skills (no cloud sync; App Group only).
public static let agentUserSkillCatalog = "config.aiAgentSkills.userCatalog.v1"
}
// MARK: - Stored fields
@@ -41,6 +41,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
public let aiConversationID: UUID?
/// Prefilled question used only by `.submitAIQuestion`.
public let aiQuestionText: String?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
/// Absolute wall-clock deadlines survive extension reconstruction.
public let startDeadlineAt: TimeInterval?
public let processingDeadlineAt: TimeInterval?
@@ -60,6 +62,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
aiThinkingEnabled: Bool? = nil,
startDeadlineAt: TimeInterval? = nil,
processingDeadlineAt: TimeInterval? = nil
) {
@@ -77,6 +80,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiThinkingEnabled = aiThinkingEnabled
self.startDeadlineAt = startDeadlineAt
self.processingDeadlineAt = processingDeadlineAt
}
@@ -13,6 +13,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public let aiConversationID: UUID?
/// When set with `.aiQuestion`, host skips ASR and answers this text.
public let aiQuestionText: String?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
public static let dictation = FlowUtteranceRequest(mode: .dictation)
@@ -22,7 +24,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
sourceHistoryEntryID: UUID? = nil,
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil
aiQuestionText: String? = nil,
aiThinkingEnabled: Bool? = nil
) {
self.mode = mode
self.editSourceText = editSourceText
@@ -30,6 +33,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiThinkingEnabled = aiThinkingEnabled
}
public static func editLastInput(
@@ -48,12 +52,14 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public static func aiQuestion(
conversationID: UUID,
prefilledQuestion: String? = nil
prefilledQuestion: String? = nil,
thinkingEnabled: Bool? = nil
) -> FlowUtteranceRequest {
FlowUtteranceRequest(
mode: .aiQuestion,
aiConversationID: conversationID,
aiQuestionText: prefilledQuestion
aiQuestionText: prefilledQuestion,
aiThinkingEnabled: thinkingEnabled
)
}
}
@@ -0,0 +1,148 @@
// AIAddressExtraction.swift
// OSGKeyboard · Shared
//
// Parses the LLM's navigate-skill reply into one origin|destination line.
// Fail closed: empty / NONE / no destination never become a run. Only the
// first valid line is kept opening several map apps is not useful.
import Foundation
public struct AIMapRoute: Equatable, Sendable {
/// Nil means "current location" in the map app.
public let origin: String?
public let destination: String
public init(origin: String?, destination: String) {
self.origin = origin
self.destination = destination
}
}
public enum AIAddressExtraction: Sendable {
/// Canonical line: origin|destination. Empty origin keeps the pipe.
public static let fieldSeparator: Character = "|"
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有地址", "没有地点", "无地址", "无地点",
"没有可导航的地点", "没有可导航的地址",
"no address", "no addresses", "no location", "no locations",
"no place", "no places", "no destination",
]
/// Lines to send to the host. Empty do not run the companion Shortcut.
public static func lines(
from raw: String,
sourceClipboard: String? = nil
) -> [String] {
guard let route = route(from: raw, sourceClipboard: sourceClipboard) else {
return []
}
return [encode(route)]
}
public static func route(
from raw: String,
sourceClipboard: String? = nil
) -> AIMapRoute? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if isEmptyToken(trimmed) { return nil }
for line in trimmed.components(separatedBy: .newlines) {
guard let route = parseLine(line) else { continue }
if isWholeClipboardEcho(route, source: sourceClipboard) {
return nil
}
return route
}
return nil
}
public static func encode(_ route: AIMapRoute) -> String {
"\(route.origin ?? "")|\(route.destination)"
}
// MARK: - Line
private static func parseLine(_ line: String) -> AIMapRoute? {
let stripped = stripBullet(line)
guard !stripped.isEmpty, !isEmptyToken(stripped) else { return nil }
let parts = stripped
.split(separator: fieldSeparator, omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let originRaw: String
let destinationRaw: String
if parts.count == 1 {
originRaw = ""
destinationRaw = parts[0]
} else {
originRaw = parts[0]
var destParts = Array(parts[1...])
while destParts.last?.isEmpty == true {
destParts.removeLast()
}
destinationRaw = destParts
.joined(separator: String(fieldSeparator))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard let destination = sanitizedPlace(destinationRaw) else { return nil }
let origin = sanitizedPlace(originRaw)
if let origin, origin.caseInsensitiveCompare(destination) == .orderedSame {
return AIMapRoute(origin: nil, destination: destination)
}
return AIMapRoute(origin: origin, destination: destination)
}
/// Drop URLs and empty / NONE tokens. Place names stay as-is.
private static func sanitizedPlace(_ raw: String) -> String? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isEmptyToken(text) else { return nil }
if text.contains("://") { return nil }
return text
}
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 destination that is essentially the clipboard body is not a place.
private static func isWholeClipboardEcho(_ route: AIMapRoute, source: String?) -> Bool {
guard let source, source.count > 80, route.destination.count > 80 else {
return false
}
let a = collapse(route.destination)
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()
}
}
@@ -12,46 +12,65 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
public static let shared = AIAgentSkillLayoutStore()
@Published public private(set) var layout: AIAgentSkillLayout
@Published public private(set) var userCatalog: AIUserSkillCatalog
private let defaults: UserDefaults?
private let persist: (AIAgentSkillLayout) -> Void
private let load: () -> AIAgentSkillLayout
private let persistLayout: (AIAgentSkillLayout) -> Void
private let persistUserCatalog: (AIUserSkillCatalog) -> Void
private let loadLayout: () -> AIAgentSkillLayout
private let loadUserCatalog: () -> AIUserSkillCatalog
public init(defaults: UserDefaults? = nil) {
if let defaults {
self.defaults = defaults
self.load = { AppGroupStore(defaults: defaults).agentSkillLayout }
self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
self.loadUserCatalog = { AppGroupStore(defaults: defaults).agentUserSkillCatalog }
self.persistUserCatalog = { AppGroupStore(defaults: defaults).setAgentUserSkillCatalog($0) }
self.loadLayout = { AppGroupStore(defaults: defaults).agentSkillLayout }
self.persistLayout = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
} else {
self.defaults = nil
self.load = { AppGroupStore().agentSkillLayout }
self.persist = { AppGroupStore().setAgentSkillLayout($0) }
self.loadUserCatalog = { AppGroupStore().agentUserSkillCatalog }
self.persistUserCatalog = { AppGroupStore().setAgentUserSkillCatalog($0) }
self.loadLayout = { AppGroupStore().agentSkillLayout }
self.persistLayout = { AppGroupStore().setAgentSkillLayout($0) }
}
self.layout = self.load()
self.userCatalog = self.loadUserCatalog()
self.layout = self.loadLayout()
}
public func reload() {
layout = load()
userCatalog = loadUserCatalog()
layout = loadLayout()
}
public var mergedCatalog: [AIClipboardSkill] {
AIClipboardSkillCatalog.all(userCatalog: userCatalog)
}
public var enabledSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs)
AIClipboardSkillCatalog.visible(
enabledIDs: layout.enabledIDs,
userCatalog: userCatalog
)
}
public var availableSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) }
mergedCatalog.filter { !layout.isEnabled($0.id) }
}
public func userSkill(id: String) -> AIUserSkill? {
userCatalog.skill(id: id)
}
@discardableResult
public func enable(_ id: String) -> AIAgentSkillEnableResult {
let current = layout.sanitized()
guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown }
let current = layout.sanitized(catalog: mergedCatalog)
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog) else {
return .unknown
}
if current.isEnabled(id) { return .alreadyEnabled }
if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
return .needsShortcut
}
if current.isFull { return .full }
commit(
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs + [id],
confirmedShortcutIDs: current.confirmedShortcutIDs
@@ -63,8 +82,8 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
/// 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(
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs
@@ -75,27 +94,34 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
/// 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 {
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog),
skill.requiresShortcut else {
return .unknown
}
var current = layout.sanitized()
var current = layout.sanitized(catalog: mergedCatalog)
if !current.confirmedShortcutIDs.contains(id) {
current.confirmedShortcutIDs.append(id)
}
commit(current)
commitLayout(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 }
let ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
guard let to = ids.firstIndex(of: targetID) else { return }
moveEnabled(id: draggedID, toIndex: to)
}
public func moveEnabled(id draggedID: String, toIndex: Int) {
var ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
guard let from = ids.firstIndex(of: draggedID), !ids.isEmpty else { return }
let to = min(max(toIndex, 0), ids.count - 1)
guard from != to else { return }
ids.move(
fromOffsets: IndexSet(integer: from),
toOffset: to > from ? to + 1 : to
)
commit(
commitLayout(
AIAgentSkillLayout(
enabledIDs: ids,
confirmedShortcutIDs: layout.confirmedShortcutIDs
@@ -103,8 +129,47 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
)
}
private func commit(_ layout: AIAgentSkillLayout) {
persist(layout)
self.layout = load()
public func saveUserSkill(_ skill: AIUserSkill) throws {
let previousURL = userCatalog.skill(id: skill.id)?.shortcutICloudURL
var catalog = userCatalog
try catalog.upsert(skill)
commitUserCatalog(catalog)
if previousURL != nil, previousURL != skill.shortcutICloudURL {
dropShortcutConfirmation(for: skill.id)
}
}
public func deleteUserSkill(id: String) {
var catalog = userCatalog
catalog.remove(id: id)
commitUserCatalog(catalog)
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
)
)
}
private func dropShortcutConfirmation(for id: String) {
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
)
)
}
private func commitLayout(_ layout: AIAgentSkillLayout) {
persistLayout(layout)
self.layout = loadLayout()
}
private func commitUserCatalog(_ catalog: AIUserSkillCatalog) {
persistUserCatalog(catalog)
userCatalog = loadUserCatalog()
layout = loadLayout()
}
}
+143 -11
View File
@@ -30,8 +30,17 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public let shortcutName: String?
/// Optional `icloud.com/shortcuts/` share URL. Nil open the bundled file.
public let shortcutICloudURL: URL?
/// Bundled `.shortcut` resource name without extension. Nil no file fallback.
public let shortcutResourceName: String?
/// User-created skills store display copy here instead of localization keys.
public let customName: String?
public let customSummary: String?
public let customPrompt: String?
/// Built-in skills are always false. Custom skills default off.
public let thinkingEnabled: Bool
public var requiresShortcut: Bool { kind == .export }
public var isUserCreated: Bool { id.hasPrefix("user.") }
public init(
id: String,
@@ -42,7 +51,12 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
kind: AIClipboardSkillKind,
isDefault: Bool,
shortcutName: String? = nil,
shortcutICloudURL: URL? = nil
shortcutICloudURL: URL? = nil,
shortcutResourceName: String? = nil,
customName: String? = nil,
customSummary: String? = nil,
customPrompt: String? = nil,
thinkingEnabled: Bool = false
) {
self.id = id
self.systemImage = systemImage
@@ -53,6 +67,11 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
self.isDefault = isDefault
self.shortcutName = shortcutName
self.shortcutICloudURL = shortcutICloudURL
self.shortcutResourceName = shortcutResourceName
self.customName = customName
self.customSummary = customSummary
self.customPrompt = customPrompt
self.thinkingEnabled = id.hasPrefix("user.") ? thinkingEnabled : false
}
}
@@ -63,8 +82,20 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let extractTodosID = "extractTodos"
public static let extractTodosShortcutName = "OSG · 提取待办"
public static let extractTodosShortcutICloudURL = URL(
string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81"
string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
)!
public static let extractTodosResourceName = "OSGExtractTodos"
public static let extractEventsID = "extractEvents"
public static let extractEventsShortcutName = "OSG · 提取日程"
public static let extractEventsShortcutICloudURL = URL(
string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf"
)!
public static let extractEventsResourceName = "OSGExtractEvents"
public static let navigateID = "navigate"
public static let navigateShortcutName = "OSG · 导航"
public static let navigateResourceName = "OSGNavigate"
/// Full built-in catalog, in a stable display order for the Skills tab.
public static let catalog: [AIClipboardSkill] = [
@@ -104,35 +135,76 @@ public enum AIClipboardSkillCatalog: Sendable {
kind: .export,
isDefault: false,
shortcutName: extractTodosShortcutName,
shortcutICloudURL: extractTodosShortcutICloudURL
shortcutICloudURL: extractTodosShortcutICloudURL,
shortcutResourceName: extractTodosResourceName
),
AIClipboardSkill(
id: extractEventsID,
systemImage: "calendar",
titleKey: "keyboard.ai.skill.extractEvents",
cardTitleKey: "skills.extractEvents.name",
descriptionKey: "skills.extractEvents.description",
kind: .export,
isDefault: false,
shortcutName: extractEventsShortcutName,
shortcutICloudURL: extractEventsShortcutICloudURL,
shortcutResourceName: extractEventsResourceName
),
AIClipboardSkill(
id: navigateID,
systemImage: "arrow.triangle.turn.up.right.diamond.fill",
titleKey: "keyboard.ai.skill.navigate",
cardTitleKey: "skills.navigate.name",
descriptionKey: "skills.navigate.description",
kind: .export,
isDefault: false,
shortcutName: navigateShortcutName,
shortcutResourceName: navigateResourceName
),
]
/// 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 }
public static func all(userCatalog: AIUserSkillCatalog = .empty) -> [AIClipboardSkill] {
catalog + userCatalog.entries.map { $0.asClipboardSkill() }
}
public static func skill(
id: String,
userCatalog: AIUserSkillCatalog = .empty
) -> AIClipboardSkill? {
catalog.first { $0.id == id } ?? userCatalog.skill(id: id)?.asClipboardSkill()
}
/// `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,
userCatalog: AIUserSkillCatalog = .empty
) -> [AIClipboardSkill] {
let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
guard !ids.isEmpty else { return [] }
let byID = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
let byID = Dictionary(uniqueKeysWithValues: all(userCatalog: userCatalog).map { ($0.id, $0) })
return ids.compactMap { byID[$0] }
}
public static func instruction(
for skill: AIClipboardSkill,
locale: String,
translationTargetLocaleId: String
translationTargetLocaleId: String,
now: Date = Date()
) -> String {
instruction(
if let custom = skill.customPrompt?
.trimmingCharacters(in: .whitespacesAndNewlines),
!custom.isEmpty {
return custom
}
return instruction(
skillID: skill.id,
locale: locale,
translationTargetLocaleId: translationTargetLocaleId
translationTargetLocaleId: translationTargetLocaleId,
now: now
)
}
@@ -159,7 +231,8 @@ public enum AIClipboardSkillCatalog: Sendable {
public static func instruction(
skillID: String,
locale: String,
translationTargetLocaleId: String
translationTargetLocaleId: String,
now: Date = Date()
) -> String {
let zh = locale == "zh"
switch skillID {
@@ -188,6 +261,10 @@ public enum AIClipboardSkillCatalog: Sendable {
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.
"""
case extractEventsID:
return eventInstruction(zh: zh, now: now)
case navigateID:
return navigateInstruction(zh: zh)
default:
return zh
? "请根据剪贴板内容完成用户选择的操作。"
@@ -195,6 +272,61 @@ public enum AIClipboardSkillCatalog: Sendable {
}
}
private static func navigateInstruction(zh: Bool) -> String {
if zh {
return """
| |
线 |10
|
NONE
"""
}
return """
Extract one place for turn-by-turn navigation from the clipboard. One line, two fields separated by | : origin|destination
Leave origin empty when starting from the current location, but keep the pipe, for example |10 Jiuxianqiao Road
Fill both sides when the source names two places, for example Beijing South|Sanlitun Taikoo Li
A full address or a well-known place name is fine. No numbering, commentary, extra lines, or URLs.
If there are several addresses, output only the clearest one.
If there is no navigable place, output NONE and nothing else. Do not treat the whole clipboard as one place.
"""
}
/// Clock context so relative phrases (tomorrow, 3pm) resolve to local time.
private static func eventInstruction(zh: Bool, now: Date) -> String {
let clock = clockContext(now: now, zh: zh)
if zh {
return """
\(clock)
| |||
YYYY-MM-DD HH:mm YYYY-MM-DD线 | 20
使
1
NONE
"""
}
return """
\(clock)
Extract explicit calendar events from the clipboard. One event per line, four fields separated by | : start|end|title|location
Timed start uses YYYY-MM-DD HH:mm; date-only (all-day) uses YYYY-MM-DD. Leave end or location empty when unknown, but keep the pipes. Do not put | in the title. Maximum 20 lines. No numbering or commentary.
Time without a date uses today. Skip items that have neither a date nor a time.
Fill the end field when the source gives an end time; otherwise leave it empty (treated as 1 hour). Fill location when the source names a place.
If there are no events with a date or time, output NONE and nothing else. Do not treat the whole clipboard as one event.
"""
}
private static func clockContext(now: Date, zh: Bool) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: zh ? "zh_CN" : "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = zh ? "yyyy年M月d日EEEE HH:mm" : "EEEE, d MMMM yyyy, HH:mm"
let stamp = formatter.string(from: now)
return zh
? "现在是\(stamp)(设备本地时区)。"
: "It is now \(stamp) (device local timezone)."
}
/// Uses the keyboard translation target when set; otherwise Chinese English.
private static func translateInstruction(
locale: String,
@@ -0,0 +1,279 @@
// AIEventExtraction.swift
// OSGKeyboard · Shared
//
// Parses the LLM's extract-events reply into Shortcut lines. Fail closed:
// empty / NONE / lines without a parseable start never become a run.
// Canonical line: start|end|title|location
// All-day uses end token ALLDAY so the companion Shortcut can branch.
import Foundation
public enum AIEventExtraction: Sendable {
public static let maximumItems = 20
public static let defaultDuration: TimeInterval = 3600
/// End-field sentinel for all-day events (Shortcut If equals this).
public static let allDaySentinel = "ALLDAY"
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有日程", "没有事件", "无日程",
"没有日期", "没有时间", "没有日期或时间", "没有日期和时间",
"no events", "no event", "no calendar events",
"no date", "no time", "no date or time", "no date and time",
]
/// Lines to send to the companion Shortcut. Empty do not run it.
public static func lines(
from raw: String,
sourceClipboard: String? = nil,
now: Date = Date(),
calendar: Calendar = .current
) -> [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) {
guard let encoded = encodeLine(
line,
now: now,
calendar: calendar
) else { continue }
let key = encoded.lowercased()
guard seen.insert(key).inserted else { continue }
items.append(encoded)
if items.count == maximumItems { break }
}
if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
return []
}
return items
}
// MARK: - Line
private static func encodeLine(
_ line: String,
now: Date,
calendar: Calendar
) -> String? {
let fields = splitFields(line)
guard fields.count >= 2 else { return nil }
let startRaw = fields[0]
let endRaw: String
let titleRaw: String
let locationRaw: String
switch fields.count {
case 2:
endRaw = ""
titleRaw = fields[1]
locationRaw = ""
case 3:
// 3 fields: start|end|title if segment 2 is a time/empty;
// otherwise start|title|location.
if fields[1].isEmpty || parseInstant(fields[1], on: now, calendar: calendar) != nil {
endRaw = fields[1]
titleRaw = fields[2]
locationRaw = ""
} else {
endRaw = ""
titleRaw = fields[1]
locationRaw = fields[2]
}
default:
endRaw = fields[1]
titleRaw = fields[2]
locationRaw = fields[3]
}
let title = stripBullet(titleRaw)
guard !title.isEmpty, !isEmptyToken(title) else { return nil }
let location = locationRaw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let start = parseInstant(startRaw, on: now, calendar: calendar) else {
return nil
}
switch start {
case .allDay(let day):
return encode(
start: formatDay(day, calendar: calendar),
end: allDaySentinel,
title: title,
location: location
)
case .timed(let startDate, _):
let endDate = resolveEnd(
endRaw,
start: startDate,
calendar: calendar
)
return encode(
start: formatMinute(startDate, calendar: calendar),
end: formatMinute(endDate, calendar: calendar),
title: title,
location: location
)
}
}
/// Split on `|` and keep at most 4 fields; extra segments join into location.
private static func splitFields(_ line: String) -> [String] {
let parts = line
.split(separator: "|", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard parts.count > 4 else { return parts }
let location = parts[3...].joined(separator: "|")
return [parts[0], parts[1], parts[2], location]
}
private static func encode(start: String, end: String, title: String, location: String) -> String {
"\(start)|\(end)|\(title)|\(location)"
}
// MARK: - Time
private enum Instant {
case allDay(Date)
case timed(Date, timeOnly: Bool)
}
private static func parseInstant(
_ raw: String,
on day: Date,
calendar: Calendar
) -> Instant? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
if text.caseInsensitiveCompare(allDaySentinel) == .orderedSame {
return .allDay(calendar.startOfDay(for: day))
}
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm"] {
if let date = date(from: text, format: format, calendar: calendar) {
return .timed(date, timeOnly: false)
}
}
if let date = date(from: text, format: "yyyy-MM-dd", calendar: calendar) {
return .allDay(calendar.startOfDay(for: date))
}
for format in ["HH:mm:ss", "HH:mm", "H:mm"] {
if let parsed = date(from: text, format: format, calendar: calendar) {
let parts = calendar.dateComponents([.hour, .minute, .second], from: parsed)
guard let combined = calendar.date(
bySettingHour: parts.hour ?? 0,
minute: parts.minute ?? 0,
second: 0,
of: day
) else { return nil }
return .timed(combined, timeOnly: true)
}
}
return nil
}
private static func resolveEnd(
_ raw: String,
start: Date,
calendar: Calendar
) -> Date {
let fallback = start.addingTimeInterval(defaultDuration)
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
trimmed.caseInsensitiveCompare(allDaySentinel) != .orderedSame else {
return fallback
}
guard let parsed = parseInstant(trimmed, on: start, calendar: calendar) else {
return fallback
}
let end: Date
switch parsed {
case .allDay(let day):
// Date-only end with a timed start: use that calendar day at 23:59.
end = calendar.date(bySettingHour: 23, minute: 59, second: 0, of: day) ?? fallback
case .timed(let date, let timeOnly):
if timeOnly, date <= start {
end = calendar.date(byAdding: .day, value: 1, to: date) ?? fallback
} else {
end = date
}
}
return end <= start ? fallback : end
}
private static func date(from text: String, format: String, calendar: Calendar) -> Date? {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.isLenient = false
formatter.dateFormat = format
return formatter.date(from: text)
}
private static func formatDay(_ date: Date, calendar: Calendar) -> String {
format(date, "yyyy-MM-dd", calendar: calendar)
}
private static func formatMinute(_ date: Date, calendar: Calendar) -> String {
format(date, "yyyy-MM-dd HH:mm", calendar: calendar)
}
private static func format(_ date: Date, _ format: String, calendar: Calendar) -> String {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = format
return formatter.string(from: date)
}
// MARK: - Tokens
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 title that is essentially the clipboard body is not an event.
private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
guard let source, source.count > 80 else { return false }
let fields = item.split(separator: "|", omittingEmptySubsequences: false)
guard fields.count >= 3 else { return false }
let title = String(fields[2])
guard title.count > 80 else { return false }
let a = collapse(title)
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()
}
}
@@ -0,0 +1,25 @@
// AIGenericSkillExport.swift
// OSGKeyboard · Shared
//
// Line-oriented Shortcut input for user-created export skills. Built-in
// extract-todos / extract-events keep their dedicated parsers.
import Foundation
public enum AIGenericSkillExport {
public static let maximumItems = 20
public static func items(from answer: String) -> [String] {
let trimmed = answer.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
if trimmed.compare("NONE", options: .caseInsensitive) == .orderedSame {
return []
}
let lines = trimmed
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if lines.isEmpty { return [trimmed] }
return Array(lines.prefix(maximumItems))
}
}
@@ -0,0 +1,126 @@
// AIMapNavigation.swift
// OSGKeyboard · Shared
//
// Builds one turn-by-turn URL. Provider order is Amap () Baidu
// Apple Maps. Shortcuts cannot call `canOpenURL`, so the host injects that
// check and passes the resulting URL to the companion Shortcut.
import Foundation
public enum AIMapProvider: String, Equatable, Sendable {
case amap
case baidu
case apple
}
public enum AIMapNavigation: Sendable {
public static let sourceApplication = "OSGKeyboard"
public static let baiduSource = "ios.osgkeyboard"
/// Amap's usual current-location label when we have no coordinates.
public static let amapCurrentLocationName = "我的位置"
public static func provider(canOpen: (URL) -> Bool) -> AIMapProvider {
if canOpen(probe("iosamap")) || canOpen(probe("amapuri")) {
return .amap
}
if canOpen(probe("baidumap")) {
return .baidu
}
return .apple
}
public static func url(for route: AIMapRoute, canOpen: (URL) -> Bool) -> URL {
switch provider(canOpen: canOpen) {
case .amap:
if canOpen(probe("iosamap")) {
return amapURL(route, useLegacyScheme: true)
}
return amapURL(route, useLegacyScheme: false)
case .baidu:
return baiduURL(route)
case .apple:
return appleURL(route)
}
}
/// Keyboard payload is `origin|destination`. Host turns it into one URL.
public static func shortcutInput(
from encoded: String,
canOpen: (URL) -> Bool
) -> String? {
guard let route = AIAddressExtraction.route(from: encoded) else { return nil }
return url(for: route, canOpen: canOpen).absoluteString
}
// MARK: - Providers
private static func amapURL(_ route: AIMapRoute, useLegacyScheme: Bool) -> URL {
let originName = route.origin ?? amapCurrentLocationName
let items = [
URLQueryItem(name: "sourceApplication", value: sourceApplication),
URLQueryItem(name: "sname", value: originName),
URLQueryItem(name: "dname", value: route.destination),
URLQueryItem(name: "dev", value: "0"),
URLQueryItem(name: "t", value: "0"),
]
if useLegacyScheme {
return makeURL(scheme: "iosamap", host: "path", path: nil, items: items)
}
let url = makeURL(scheme: "amapuri", host: "route", path: "/plan/", items: items)
// URLComponents drops the trailing slash; Amap's documented path is `/plan/`.
let withSlash = url.absoluteString.replacingOccurrences(
of: "://route/plan?",
with: "://route/plan/?"
)
return URL(string: withSlash) ?? url
}
private static func baiduURL(_ route: AIMapRoute) -> URL {
var items = [
URLQueryItem(name: "destination", value: "name:\(route.destination)"),
URLQueryItem(name: "mode", value: "driving"),
URLQueryItem(name: "src", value: baiduSource),
]
if let origin = route.origin {
items.insert(
URLQueryItem(name: "origin", value: "name:\(origin)"),
at: 0
)
}
return makeURL(scheme: "baidumap", host: "map", path: "/direction", items: items)
}
private static func appleURL(_ route: AIMapRoute) -> URL {
var items = [
URLQueryItem(name: "daddr", value: route.destination),
URLQueryItem(name: "dirflg", value: "d"),
]
if let origin = route.origin {
items.insert(URLQueryItem(name: "saddr", value: origin), at: 0)
}
// `URLComponents(string: "maps://")` keeps the `://` that opening needs.
var components = URLComponents(string: "maps://")!
components.queryItems = items
return components.url ?? URL(string: "maps://")!
}
private static func probe(_ scheme: String) -> URL {
URL(string: "\(scheme)://")!
}
private static func makeURL(
scheme: String,
host: String?,
path: String?,
items: [URLQueryItem]
) -> URL {
var components = URLComponents()
components.scheme = scheme
components.host = host
if let path {
components.path = path
}
components.queryItems = items
return components.url ?? URL(string: "\(scheme)://")!
}
}
@@ -8,14 +8,15 @@
import Foundation
public enum AIModeLLMClientFactory {
/// Build an AI-mode client. Thinking is always forced on for this path.
/// When `allowWebSearch` is false, returns the plain polish-compatible client.
/// Build an AI-mode client. Thinking defaults on for spoken questions.
/// Clipboard skills pass `thinkingEnabled` from the skill (built-in: off).
public static func make(
providerId: String,
baseURL: String,
apiKey: String,
model: String,
allowWebSearch: Bool = true,
thinkingEnabled: Bool = true,
session: URLSession = .shared
) -> any LLMClient {
let plain = LLMClientFactory.make(
@@ -23,10 +24,10 @@ public enum AIModeLLMClientFactory {
baseURL: baseURL,
apiKey: apiKey,
model: model,
thinkingEnabled: true,
thinkingEnabled: thinkingEnabled,
session: session
)
guard allowWebSearch else { return plain }
guard thinkingEnabled, allowWebSearch else { return plain }
guard let searching = makeSearchingClient(
providerId: providerId,
@@ -123,7 +123,8 @@ public struct AIQuestionService: Sendable {
public static func configured(
store: any ConfigurationStore,
conversations: AIConversationStore
conversations: AIConversationStore,
thinkingEnabled: Bool = true
) throws -> AIQuestionService {
// Same provider + baseURL + model resolution as dictation polish so the
// Settings LLM card is the single source of truth for both modes.
@@ -151,7 +152,8 @@ public struct AIQuestionService: Sendable {
baseURL: endpoint.baseURL,
apiKey: apiKey,
model: endpoint.model,
allowWebSearch: true
allowWebSearch: true,
thinkingEnabled: thinkingEnabled
),
conversations: conversations,
responseLength: store.aiResponseLength
@@ -0,0 +1,94 @@
// AIShortcutShareLink.swift
// OSGKeyboard · Shared
//
// Validates iCloud Shortcut share URLs and reads the published name from
// Apple's undocumented records endpoint. Running a Shortcut still requires
// that name (`shortcuts://run-shortcut?name=`); the share link is install-only.
import Foundation
public enum AIShortcutShareLink {
/// `https://www.icloud.com/shortcuts/{token}` or `https://icloud.com/shortcuts/{token}`.
public static func isValid(_ url: URL) -> Bool {
guard let token = AIAgentShortcutRun.iCloudShareToken(from: url) else { return false }
let allowed = CharacterSet.alphanumerics
return (16...64).contains(token.count)
&& token.unicodeScalars.allSatisfy { allowed.contains($0) }
}
public static func parse(_ raw: String) -> URL? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let withScheme: String
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
withScheme = trimmed
} else {
withScheme = "https://\(trimmed)"
}
guard let url = URL(string: withScheme), isValid(url) else { return nil }
return url
}
}
public enum AIShortcutShareMetadataError: Error, Equatable, Sendable {
case invalidLink
case network
case missingName
}
public enum AIShortcutShareMetadata {
public static func recordsURL(for shareURL: URL) -> URL? {
guard let token = AIAgentShortcutRun.iCloudShareToken(from: shareURL) else { return nil }
return URL(string: "https://www.icloud.com/shortcuts/api/records/\(token)")
}
/// Parses `fields.name.value`, or the older `records[0].fields.name.value`.
public static func name(fromRecordsJSON data: Data) throws -> String {
let object = try JSONSerialization.jsonObject(with: data)
guard let root = object as? [String: Any] else {
throw AIShortcutShareMetadataError.missingName
}
if let name = stringName(in: root) {
return name
}
if let records = root["records"] as? [[String: Any]],
let first = records.first,
let name = stringName(in: first) {
return name
}
throw AIShortcutShareMetadataError.missingName
}
public static func fetchName(
from shareURL: URL,
session: URLSession = .shared
) async throws -> String {
guard let url = recordsURL(for: shareURL) else {
throw AIShortcutShareMetadataError.invalidLink
}
let data: Data
do {
let (bytes, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw AIShortcutShareMetadataError.network
}
data = bytes
} catch let error as AIShortcutShareMetadataError {
throw error
} catch {
throw AIShortcutShareMetadataError.network
}
return try name(fromRecordsJSON: data)
}
private static func stringName(in record: [String: Any]) -> String? {
guard let fields = record["fields"] as? [String: Any],
let nameField = fields["name"] as? [String: Any],
let value = nameField["value"] as? String else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
+43 -4
View File
@@ -99,7 +99,11 @@ public struct AppGroupStore: @unchecked Sendable {
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)
Self.decodeAgentSkillLayout(from: defaults, userCatalog: agentUserSkillCatalog)
}
public var agentUserSkillCatalog: AIUserSkillCatalog {
Self.decodeUserSkillCatalog(from: defaults)
}
// MARK: - Writes
@@ -205,7 +209,9 @@ public struct AppGroupStore: @unchecked Sendable {
public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
do {
let data = try JSONEncoder().encode(layout.sanitized())
let data = try JSONEncoder().encode(
layout.sanitized(catalog: AIClipboardSkillCatalog.all(userCatalog: agentUserSkillCatalog))
)
defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
} catch {
OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)")
@@ -213,6 +219,20 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
public func setAgentUserSkillCatalog(_ catalog: AIUserSkillCatalog) {
do {
defaults.set(
try JSONEncoder().encode(catalog),
forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog
)
} catch {
OSGLog.config.warning(
"agentUserSkillCatalog 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) {
@@ -244,18 +264,37 @@ public struct AppGroupStore: @unchecked Sendable {
return payload
}
private static func decodeAgentSkillLayout(from defaults: UserDefaults) -> AIAgentSkillLayout {
private static func decodeAgentSkillLayout(
from defaults: UserDefaults,
userCatalog: AIUserSkillCatalog
) -> AIAgentSkillLayout {
let catalog = AIClipboardSkillCatalog.all(userCatalog: userCatalog)
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
return .default
}
do {
return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data).sanitized()
return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data)
.sanitized(catalog: catalog)
} catch {
OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
return .default
}
}
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
return .empty
}
do {
return try JSONDecoder().decode(AIUserSkillCatalog.self, from: data)
} catch {
OSGLog.config.warning(
"agentUserSkillCatalog decode failed: \(error.localizedDescription, privacy: .public)"
)
return .empty
}
}
public var hasCompletedOnboarding: Bool {
get { configuration.hasCompletedOnboarding }
set { setHasCompletedOnboarding(newValue) }
@@ -0,0 +1,180 @@
// AIAddressExtractionTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class AIAddressExtractionTests: XCTestCase {
func testNONEAndEmptyProduceNoItems() {
XCTAssertEqual(AIAddressExtraction.lines(from: "NONE"), [])
XCTAssertEqual(AIAddressExtraction.lines(from: "没有地址"), [])
XCTAssertEqual(AIAddressExtraction.lines(from: "no address"), [])
XCTAssertEqual(AIAddressExtraction.lines(from: " \n "), [])
}
func testDestinationOnlyKeepsLeadingPipe() {
XCTAssertEqual(
AIAddressExtraction.lines(from: "|三里屯太古里"),
["|三里屯太古里"]
)
XCTAssertEqual(
AIAddressExtraction.lines(from: "朝阳区酒仙桥路10号"),
["|朝阳区酒仙桥路10号"]
)
XCTAssertEqual(
AIAddressExtraction.lines(from: "|三里屯|"),
["|三里屯"]
)
}
func testOriginAndDestination() {
XCTAssertEqual(
AIAddressExtraction.lines(from: "北京南站|三里屯太古里"),
["北京南站|三里屯太古里"]
)
}
func testFirstValidLineOnly() {
let raw = """
NONE
|
|
"""
XCTAssertEqual(AIAddressExtraction.lines(from: raw), ["北京南站|三里屯"])
}
func testSameOriginAndDestinationDropsOrigin() {
XCTAssertEqual(
AIAddressExtraction.lines(from: "故宫|故宫"),
["|故宫"]
)
}
func testRejectsURLDestinations() {
XCTAssertEqual(
AIAddressExtraction.lines(from: "|https://maps.apple.com/?daddr=x"),
[]
)
}
func testStripsBullet() {
XCTAssertEqual(
AIAddressExtraction.lines(from: "- |三里屯"),
["|三里屯"]
)
}
func testWholeClipboardEchoIsRejected() {
let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4)
XCTAssertEqual(
AIAddressExtraction.lines(from: source, sourceClipboard: source),
[]
)
}
func testPromptIncludesPipeContractAndNONE() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.navigateID,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
)
XCTAssertTrue(prompt.contains("起点|终点"))
XCTAssertTrue(prompt.contains("NONE"))
XCTAssertTrue(prompt.contains("不要把整段原文当成一个地点"))
}
func testEnglishPromptIncludesNONE() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.navigateID,
locale: "en",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
)
XCTAssertTrue(prompt.contains("origin|destination"))
XCTAssertTrue(prompt.contains("NONE"))
}
}
final class AIMapNavigationTests: XCTestCase {
private let destinationOnly = AIMapRoute(origin: nil, destination: "三里屯太古里")
private let twoPoint = AIMapRoute(origin: "北京南站", destination: "三里屯太古里")
func testPrefersAmapWhenIosamapOpens() {
let url = AIMapNavigation.url(for: destinationOnly) { $0.scheme == "iosamap" }
XCTAssertEqual(url.scheme, "iosamap")
XCTAssertEqual(url.host, "path")
let items = query(url)
XCTAssertEqual(items["sourceApplication"], "OSGKeyboard")
XCTAssertEqual(items["dname"], "三里屯太古里")
XCTAssertEqual(items["sname"], "我的位置")
XCTAssertEqual(items["t"], "0")
}
func testAmapUsesAmapuriWhenOnlyAmapuriOpens() {
let url = AIMapNavigation.url(for: twoPoint) { $0.scheme == "amapuri" }
XCTAssertEqual(url.scheme, "amapuri")
XCTAssertEqual(url.host, "route")
XCTAssertTrue(url.absoluteString.contains("://route/plan/?"))
let items = query(url)
XCTAssertEqual(items["sname"], "北京南站")
XCTAssertEqual(items["dname"], "三里屯太古里")
}
func testFallsBackToBaiduWhenAmapMissing() {
let url = AIMapNavigation.url(for: twoPoint) { $0.scheme == "baidumap" }
XCTAssertEqual(url.scheme, "baidumap")
XCTAssertEqual(url.host, "map")
XCTAssertEqual(url.path, "/direction")
let items = query(url)
XCTAssertEqual(items["origin"], "name:北京南站")
XCTAssertEqual(items["destination"], "name:三里屯太古里")
XCTAssertEqual(items["mode"], "driving")
}
func testBaiduOmitsOriginWhenStartingFromHere() {
let url = AIMapNavigation.url(for: destinationOnly) { $0.scheme == "baidumap" }
let items = query(url)
XCTAssertNil(items["origin"])
XCTAssertEqual(items["destination"], "name:三里屯太古里")
}
func testFallsBackToAppleMaps() {
let url = AIMapNavigation.url(for: twoPoint) { _ in false }
XCTAssertEqual(url.scheme, "maps")
XCTAssertTrue(url.absoluteString.hasPrefix("maps://"))
let items = query(url)
XCTAssertEqual(items["saddr"], "北京南站")
XCTAssertEqual(items["daddr"], "三里屯太古里")
XCTAssertEqual(items["dirflg"], "d")
}
func testAppleOmitsSaddrWhenStartingFromHere() {
let url = AIMapNavigation.url(for: destinationOnly) { _ in false }
let items = query(url)
XCTAssertNil(items["saddr"])
XCTAssertEqual(items["daddr"], "三里屯太古里")
}
func testShortcutInputEncodesFirstRoute() {
let text = AIMapNavigation.shortcutInput(from: "北京南站|三里屯") { _ in false }
XCTAssertEqual(text?.hasPrefix("maps:"), true)
XCTAssertTrue(text?.contains("daddr") == true)
}
func testShortcutInputRejectsNONE() {
XCTAssertNil(AIMapNavigation.shortcutInput(from: "NONE") { _ in false })
}
func testProviderOrder() {
XCTAssertEqual(AIMapNavigation.provider { $0.scheme == "iosamap" }, .amap)
XCTAssertEqual(AIMapNavigation.provider { $0.scheme == "baidumap" }, .baidu)
XCTAssertEqual(AIMapNavigation.provider { _ in false }, .apple)
}
private func query(_ url: URL) -> [String: String] {
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
return Dictionary(uniqueKeysWithValues: items.compactMap { item in
guard let value = item.value else { return nil }
return (item.name, value)
})
}
}
+60 -3
View File
@@ -101,6 +101,28 @@ final class AIAgentSkillLayoutTests: XCTestCase {
)
}
func testReorderMovesEnabledSkillToIndex() {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
store.moveEnabled(id: AIClipboardSkillCatalog.summarizeID, toIndex: 2)
XCTAssertEqual(
store.layout.enabledIDs,
[
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.summarizeID,
]
)
store.moveEnabled(id: AIClipboardSkillCatalog.summarizeID, toIndex: 0)
XCTAssertEqual(
store.layout.enabledIDs,
[
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.translateID,
]
)
}
func testVisibleEmptyEnabledIDsShowsNoChips() {
XCTAssertEqual(AIClipboardSkillCatalog.visible(enabledIDs: []).map(\.id), [])
}
@@ -183,17 +205,52 @@ final class AIAgentSkillLayoutTests: XCTestCase {
)
XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com")
XCTAssertEqual(skill?.shortcutName, "OSG · 提取待办")
XCTAssertEqual(skill?.shortcutResourceName, "OSGExtractTodos")
}
func testExtractEventsUsesICloudShareLink() {
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractEventsID)
XCTAssertEqual(
skill?.shortcutICloudURL,
AIClipboardSkillCatalog.extractEventsShortcutICloudURL
)
XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com")
XCTAssertEqual(skill?.shortcutName, "OSG · 提取日程")
XCTAssertEqual(
skill?.shortcutResourceName,
"OSGExtractEvents"
)
XCTAssertEqual(skill?.systemImage, "calendar")
XCTAssertFalse(skill?.isDefault ?? true)
XCTAssertEqual(
AIAgentShortcutRun.iCloudShareToken(from: skill!.shortcutICloudURL!),
"1f4afcf7ee22400cbf84e319d969aadf"
)
}
func testNavigateUsesBundledShortcut() {
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.navigateID)
XCTAssertNil(skill?.shortcutICloudURL)
XCTAssertEqual(skill?.shortcutName, "OSG · 导航")
XCTAssertEqual(skill?.shortcutResourceName, "OSGNavigate")
XCTAssertEqual(
skill?.systemImage,
"arrow.triangle.turn.up.right.diamond.fill"
)
XCTAssertFalse(skill?.isDefault ?? true)
XCTAssertEqual(skill?.kind, .export)
XCTAssertTrue(skill?.requiresShortcut ?? false)
}
func testICloudShareLinkMapsToShortcutsInstallURL() {
let share = URL(string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81")!
let share = URL(string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44")!
let url = AIAgentShortcutRun.shortcutsInstallURL(from: share)
XCTAssertEqual(url?.scheme, "shortcuts")
XCTAssertEqual(url?.host, "shortcuts")
XCTAssertEqual(url?.path, "/520317da7ae74759b64d5fb069c71f81")
XCTAssertEqual(url?.path, "/65bf33ba4206484ba78d582eaf1e9c44")
XCTAssertEqual(
AIAgentShortcutRun.iCloudShareToken(from: share),
"520317da7ae74759b64d5fb069c71f81"
"65bf33ba4206484ba78d582eaf1e9c44"
)
}
}
@@ -0,0 +1,143 @@
// AIEventExtractionTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class AIEventExtractionTests: XCTestCase {
private var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")!
return calendar
}
private var now: Date {
date(2026, 8, 13, 20, 24)
}
func testNONEAndEmptyProduceNoItems() {
XCTAssertEqual(lines("NONE"), [])
XCTAssertEqual(lines("没有日期或时间"), [])
XCTAssertEqual(lines("no events"), [])
XCTAssertEqual(lines(" \n "), [])
}
func testAllDayDateOnly() {
XCTAssertEqual(
lines("2026-08-15||提交周报|"),
["2026-08-15|ALLDAY|提交周报|"]
)
}
func testTimedFillsDefaultOneHour() {
XCTAssertEqual(
lines("2026-08-15 14:00||项目评审|"),
["2026-08-15 14:00|2026-08-15 15:00|项目评审|"]
)
}
func testTimedKeepsExplicitEndAndLocation() {
XCTAssertEqual(
lines("2026-08-15 14:00|2026-08-15 16:00|项目评审|3楼会议室"),
["2026-08-15 14:00|2026-08-15 16:00|项目评审|3楼会议室"]
)
}
func testTimeOnlyUsesToday() {
XCTAssertEqual(
lines("15:00||打电话给客户|"),
["2026-08-13 15:00|2026-08-13 16:00|打电话给客户|"]
)
}
func testTimeOnlyEndOnSameDayOvernightRollsForward() {
XCTAssertEqual(
lines("2026-08-15 23:00|01:00|跨夜值班|"),
["2026-08-15 23:00|2026-08-16 01:00|跨夜值班|"]
)
}
func testTwoFieldStartAndTitle() {
XCTAssertEqual(
lines("2026-08-15 14:00|开会"),
["2026-08-15 14:00|2026-08-15 15:00|开会|"]
)
}
func testThreeFieldStartTitleLocationWhenMiddleIsNotATime() {
XCTAssertEqual(
lines("2026-08-15 14:00|开会|会议室A"),
["2026-08-15 14:00|2026-08-15 15:00|开会|会议室A"]
)
}
func testDropsLinesWithoutStartOrTitle() {
let raw = """
|||
2026-08-15 14:00||
2026-08-16|||
"""
XCTAssertEqual(lines(raw), ["2026-08-16|ALLDAY|有效全天|"])
}
func testMultipleEventsCapAtTwenty() {
let raw = (1...25).map { "2026-08-15 10:00||任务\($0)|" }.joined(separator: "\n")
let items = lines(raw)
XCTAssertEqual(items.count, 20)
XCTAssertEqual(items.first, "2026-08-15 10:00|2026-08-15 11:00|任务1|")
XCTAssertEqual(items.last, "2026-08-15 10:00|2026-08-15 11:00|任务20|")
}
func testWholeClipboardEchoIsRejected() {
let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4)
XCTAssertEqual(
AIEventExtraction.lines(
from: "2026-08-15||\(source)|",
sourceClipboard: source,
now: now,
calendar: calendar
),
[]
)
}
func testPromptIncludesClockPipeContractAndNONE() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.extractEventsID,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
now: now
)
XCTAssertTrue(prompt.contains("本地时区"))
XCTAssertTrue(prompt.contains("开始|结束|标题|地点"))
XCTAssertTrue(prompt.contains("NONE"))
XCTAssertTrue(prompt.contains("不要把整段原文当成一条日程"))
}
func testEnglishPromptIncludesClockAndNONE() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.extractEventsID,
locale: "en",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
now: now
)
XCTAssertTrue(prompt.contains("local timezone"))
XCTAssertTrue(prompt.contains("NONE"))
XCTAssertTrue(prompt.contains("all-day"))
}
private func lines(_ raw: String) -> [String] {
AIEventExtraction.lines(from: raw, now: now, calendar: calendar)
}
private func date(_ y: Int, _ m: Int, _ d: Int, _ h: Int, _ min: Int) -> Date {
var components = DateComponents()
components.year = y
components.month = m
components.day = d
components.hour = h
components.minute = min
return calendar.date(from: components)!
}
}
@@ -245,4 +245,24 @@ final class AIClipboardSkillTests: XCTestCase {
XCTAssertTrue(prompt.contains("NONE"))
XCTAssertTrue(prompt.contains("不要把整段原文当成一条待办"))
}
func testExtractEventsAsksForNONEWhenEmpty() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.extractEventsID,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
)
XCTAssertTrue(prompt.contains("NONE"))
XCTAssertTrue(prompt.contains("开始|结束|标题|地点"))
}
func testNavigateAsksForNONEWhenEmpty() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.navigateID,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
)
XCTAssertTrue(prompt.contains("NONE"))
XCTAssertTrue(prompt.contains("起点|终点"))
}
}
@@ -77,6 +77,18 @@ final class AIModeLLMClientTests: XCTestCase {
XCTAssertTrue(client is AIModeSearchFallbackClient)
}
func testFactorySkipsSearchWhenThinkingDisabled() {
let client = AIModeLLMClientFactory.make(
providerId: "openai",
baseURL: "https://api.openai.com/v1",
apiKey: "sk-test",
model: "gpt-5.4-mini",
thinkingEnabled: false
)
XCTAssertFalse(client is AIModeSearchFallbackClient)
XCTAssertTrue(client is OpenAICompatibleClient)
}
func testFactoryPlainForGroq() {
let client = AIModeLLMClientFactory.make(
providerId: "groq",
+189
View File
@@ -0,0 +1,189 @@
// AIUserSkillTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class AIUserSkillTests: XCTestCase {
private let sampleURL = URL(
string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
)!
func testValidICloudShareLink() {
XCTAssertNotNil(AIShortcutShareLink.parse(
"https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
))
XCTAssertNotNil(AIShortcutShareLink.parse(
"https://icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf"
))
XCTAssertNil(AIShortcutShareLink.parse("https://example.com/shortcuts/abc"))
XCTAssertNil(AIShortcutShareLink.parse("https://www.icloud.com/shortcuts/api/records/x"))
XCTAssertNil(AIShortcutShareLink.parse("not a url"))
XCTAssertNil(AIShortcutShareLink.parse("https://www.icloud.com/shortcuts/short"))
}
func testParsesShortcutNameFromRecordsJSON() throws {
let json = """
{"fields":{"name":{"type":"STRING","value":"OSG · 提取待办"}}}
""".data(using: .utf8)!
XCTAssertEqual(try AIShortcutShareMetadata.name(fromRecordsJSON: json), "OSG · 提取待办")
}
func testParsesShortcutNameFromLegacyRecordsArray() throws {
let json = """
{"records":[{"fields":{"name":{"value":"OSG · 提取日程"}}}]}
""".data(using: .utf8)!
XCTAssertEqual(try AIShortcutShareMetadata.name(fromRecordsJSON: json), "OSG · 提取日程")
}
func testRejectsRecordsJSONWithoutName() {
let json = Data(#"{"fields":{}}"#.utf8)
XCTAssertThrowsError(try AIShortcutShareMetadata.name(fromRecordsJSON: json))
}
func testCatalogUpsertKeepsShortcutNameIndependentOfSkillName() throws {
var catalog = AIUserSkillCatalog()
var skill = AIUserSkill(
name: "会议纪要",
summary: "抽要点",
prompt: "提取要点",
shortcutICloudURL: sampleURL,
shortcutName: "OSG · 提取待办"
)
try catalog.upsert(skill)
XCTAssertEqual(catalog.entries.first?.name, "会议纪要")
XCTAssertEqual(catalog.entries.first?.shortcutName, "OSG · 提取待办")
skill.name = "纪要"
skill.shortcutName = "My Tasks"
try catalog.upsert(skill)
XCTAssertEqual(catalog.entries.count, 1)
XCTAssertEqual(catalog.entries.first?.name, "纪要")
XCTAssertEqual(catalog.entries.first?.shortcutName, "My Tasks")
}
func testThinkingDefaultsOffAndBuiltinCannotEnable() {
let user = AIUserSkill(
name: "Custom",
prompt: "Do it",
shortcutICloudURL: sampleURL,
shortcutName: "Run Me"
)
XCTAssertFalse(user.thinkingEnabled)
XCTAssertFalse(user.asClipboardSkill().thinkingEnabled)
let withThinking = AIUserSkill(
name: "Custom",
prompt: "Do it",
shortcutICloudURL: sampleURL,
shortcutName: "Run Me",
thinkingEnabled: true
)
XCTAssertTrue(withThinking.asClipboardSkill().thinkingEnabled)
let builtin = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.replyID)
XCTAssertEqual(builtin?.thinkingEnabled, false)
}
func testInstructionUsesCustomPrompt() {
let skill = AIUserSkill(
name: "Custom",
prompt: "只输出一行标题",
shortcutICloudURL: sampleURL,
shortcutName: "Run Me"
).asClipboardSkill()
XCTAssertEqual(
AIClipboardSkillCatalog.instruction(
for: skill,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
),
"只输出一行标题"
)
}
func testVisibleIncludesUserSkills() throws {
var catalog = AIUserSkillCatalog()
let skill = AIUserSkill(
name: "Custom",
prompt: "Do it",
shortcutICloudURL: sampleURL,
shortcutName: "Run Me"
)
try catalog.upsert(skill)
let visible = AIClipboardSkillCatalog.visible(
enabledIDs: [skill.id],
userCatalog: catalog
)
XCTAssertEqual(visible.map(\.id), [skill.id])
XCTAssertEqual(visible.first?.customName, "Custom")
}
func testSanitizeKeepsConfirmedUserExportSkill() throws {
var catalog = AIUserSkillCatalog()
let skill = AIUserSkill(
name: "Custom",
prompt: "Do it",
shortcutICloudURL: sampleURL,
shortcutName: "Run Me"
)
try catalog.upsert(skill)
let layout = AIAgentSkillLayout(
enabledIDs: [skill.id],
confirmedShortcutIDs: [skill.id]
).sanitized(catalog: AIClipboardSkillCatalog.all(userCatalog: catalog))
XCTAssertEqual(layout.enabledIDs, [skill.id])
}
func testGenericExportSplitsLinesAndHonorsNONE() {
XCTAssertEqual(AIGenericSkillExport.items(from: "NONE"), [])
XCTAssertEqual(AIGenericSkillExport.items(from: "买牛奶\n回邮件"), ["买牛奶", "回邮件"])
XCTAssertEqual(AIGenericSkillExport.items(from: "一段没有换行的结果"), ["一段没有换行的结果"])
}
func testNoUserSkillLimit() throws {
var catalog = AIUserSkillCatalog()
for index in 0..<12 {
try catalog.upsert(
AIUserSkill(
name: "Skill \(index)",
prompt: "Do it",
shortcutICloudURL: sampleURL,
shortcutName: "Run \(index)"
)
)
}
XCTAssertEqual(catalog.entries.count, 12)
}
}
@MainActor
final class AIUserSkillStoreTests: XCTestCase {
private func makeDefaults() -> UserDefaults {
let suite = "group.com.osgkeyboard.shared.tests.userSkills.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
func testChangingShortcutLinkDropsConfirmation() throws {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
let firstURL = URL(string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44")!
let secondURL = URL(string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf")!
var skill = AIUserSkill(
name: "Custom",
prompt: "Do it",
shortcutICloudURL: firstURL,
shortcutName: "One"
)
try store.saveUserSkill(skill)
XCTAssertEqual(store.confirmShortcutAndEnable(skill.id), .enabled)
XCTAssertTrue(store.layout.isEnabled(skill.id))
skill.shortcutICloudURL = secondURL
skill.shortcutName = "Two"
try store.saveUserSkill(skill)
XCTAssertFalse(store.layout.hasConfirmedShortcut(skill.id))
XCTAssertFalse(store.layout.isEnabled(skill.id))
}
}
@@ -323,6 +323,47 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertEqual(decoded, command)
XCTAssertEqual(decoded.aiQuestionText, "总结这段剪贴板内容")
XCTAssertNil(decoded.aiThinkingEnabled)
}
func testSubmitAIQuestionCommandRoundTripsThinkingOverride() throws {
let command = FlowCommand(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 45,
action: .submitAIQuestion,
localeId: "zh-Hans",
utteranceMode: .aiQuestion,
aiConversationID: UUID(),
aiQuestionText: "总结这段剪贴板内容",
aiThinkingEnabled: false
)
let decoded = try JSONDecoder().decode(
FlowCommand.self,
from: JSONEncoder().encode(command)
)
XCTAssertEqual(decoded.aiThinkingEnabled, false)
}
func testFlowCommandDecodesLegacyJSONWithoutThinkingKey() throws {
let command = FlowCommand(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 44,
action: .submitAIQuestion,
localeId: "zh-Hans",
utteranceMode: .aiQuestion,
aiConversationID: UUID(),
aiQuestionText: "总结这段剪贴板内容"
)
var object = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(command)
) as! [String: Any]
object.removeValue(forKey: "aiThinkingEnabled")
let data = try JSONSerialization.data(withJSONObject: object)
let decoded = try JSONDecoder().decode(FlowCommand.self, from: data)
XCTAssertNil(decoded.aiThinkingEnabled)
XCTAssertEqual(decoded.aiQuestionText, "总结这段剪贴板内容")
}
func testSecureFieldContextRedactsText() {
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Build the companion Shortcut for the Extract Events 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
Split Text (|)
Get items 14 (start, end, title, location)
If end == ALLDAY
Add New Event (all-day)
Otherwise
Add New Event (timed)
"""
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 / "OSGExtractEvents.unsigned.shortcut"
SIGNED = OUT_DIR / "OSGExtractEvents.shortcut"
OBJECT_REPLACEMENT = "\ufffc"
TEXT_UUID = "A1B2C3D0-1111-4E00-8A5A-FD7E1B0C396F"
SPLIT_LINES_UUID = "A1B2C3D1-2222-4F11-9A6B-0E8F2C1D4A70"
REPEAT_GROUP = "A1B2C3D2-3333-4E02-B3C4-91F0E6A7B812"
REPEAT_START = "A1B2C3D3-4444-4F13-A4D5-02A1F7B8C923"
SPLIT_FIELDS_UUID = "A1B2C3D4-5555-4014-B5E6-13B2A8C9D034"
START_UUID = "A1B2C3D5-6666-4125-C6F7-24C3B9DAE145"
END_UUID = "A1B2C3D6-7777-4236-D7A8-35D4CAEBF256"
TITLE_UUID = "A1B2C3D7-8888-4347-E8B9-46E5DBF0C367"
LOCATION_UUID = "A1B2C3D8-9999-4458-F9CA-57F6EC01D478"
IF_GROUP = "A1B2C3D9-AAAA-4569-0ADB-6807FD12E589"
IF_START = "A1B2C3DA-BBBB-467A-1BEC-79180E23F69A"
EVENT_ALLDAY_UUID = "A1B2C3DB-CCCC-478B-2CFD-8A291F3407AB"
IF_ELSE = "A1B2C3DC-DDDD-489C-3D0E-9B3A204518BC"
EVENT_TIMED_UUID = "A1B2C3DD-EEEE-49AD-4E1F-AC4B315629CD"
IF_END = "A1B2C3DE-FFFF-4ABE-5F20-BD5C42673ADE"
REPEAT_END = "A1B2C3DF-0000-4BCF-6031-CE6D53784BEF"
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 action_output_attachment(output_name: str, output_uuid: str) -> dict:
return attachment(
{
"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 if_input(output_name: str, output_uuid: str) -> dict:
return {
"Type": "Variable",
"Variable": {
"Value": {
"Aggrandizements": [],
"OutputName": output_name,
"OutputUUID": output_uuid,
"Type": "ActionOutput",
},
"WFSerializationType": "WFTextTokenAttachment",
},
}
def get_item(uuid: str, name: str, index: int) -> dict:
return {
"WFWorkflowActionIdentifier": "is.workflow.actions.getitemfromlist",
"WFWorkflowActionParameters": {
"UUID": uuid,
"CustomOutputName": name,
"WFInput": action_output_attachment("字段", SPLIT_FIELDS_UUID),
"WFItemSpecifier": "Item At Index",
"WFItemIndex": index,
},
}
def add_event(
uuid: str,
*,
all_day: bool,
include_end: bool,
) -> dict:
parameters = {
"UUID": uuid,
"WFCalendarItemTitle": action_output_text("标题", TITLE_UUID),
"WFCalendarItemLocation": action_output_text("地点", LOCATION_UUID),
"WFCalendarItemDates": True,
"WFCalendarItemStartDate": action_output_text("开始", START_UUID),
"WFCalendarItemAllDay": all_day,
"WFCalendarItemShowComposer": False,
"ShowComposeSheet": False,
"WFShowWhenRun": False,
}
if include_end:
parameters["WFCalendarItemEndDate"] = action_output_text("结束", END_UUID)
return {
"WFWorkflowActionIdentifier": "is.workflow.actions.addnewevent",
"WFWorkflowActionParameters": parameters,
}
def workflow() -> dict:
return {
"WFWorkflowClientVersion": "3600.0.4",
"WFWorkflowClientRelease": "26.0",
"WFWorkflowMinimumClientVersion": 900,
"WFWorkflowMinimumClientVersionString": "900",
"WFWorkflowName": "OSG · 提取日程",
"WFWorkflowHasShortcutInputVariables": True,
"WFWorkflowHasOutputFallback": False,
"WFWorkflowImportQuestions": [],
"WFWorkflowTypes": [],
"WFWorkflowInputContentItemClasses": [
"WFStringContentItem",
"WFRichTextContentItem",
],
"WFWorkflowOutputContentItemClasses": [],
"WFWorkflowNoInputBehavior": {
"Name": "WFWorkflowNoInputBehaviorAskForInput",
"Parameters": {
"ItemClass": "WFStringContentItem",
},
},
"WFWorkflowIcon": {
"WFWorkflowIconGlyphNumber": 59675,
"WFWorkflowIconStartColor": 4286828031,
},
"WFWorkflowActions": [
{
"WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
"WFWorkflowActionParameters": {
"UUID": TEXT_UUID,
"CustomOutputName": "快捷指令文本",
"WFTextActionText": shortcut_input_text(),
},
},
{
"WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
"WFWorkflowActionParameters": {
"UUID": SPLIT_LINES_UUID,
"CustomOutputName": "",
"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": action_output_attachment("", SPLIT_LINES_UUID),
},
},
{
"WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
"WFWorkflowActionParameters": {
"UUID": SPLIT_FIELDS_UUID,
"CustomOutputName": "字段",
"text": named_variable_text("Repeat Item"),
"WFTextSeparator": "Custom",
"WFTextCustomSeparator": "|",
},
},
get_item(START_UUID, "开始", 1),
get_item(END_UUID, "结束", 2),
get_item(TITLE_UUID, "标题", 3),
get_item(LOCATION_UUID, "地点", 4),
{
"WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
"WFWorkflowActionParameters": {
"UUID": IF_START,
"GroupingIdentifier": IF_GROUP,
"WFControlFlowMode": 0,
"WFCondition": 4,
"WFConditionalActionString": "ALLDAY",
"WFInput": if_input("结束", END_UUID),
},
},
add_event(EVENT_ALLDAY_UUID, all_day=True, include_end=False),
{
"WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
"WFWorkflowActionParameters": {
"UUID": IF_ELSE,
"GroupingIdentifier": IF_GROUP,
"WFControlFlowMode": 1,
},
},
add_event(EVENT_TIMED_UUID, all_day=False, include_end=True),
{
"WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
"WFWorkflowActionParameters": {
"UUID": IF_END,
"GroupingIdentifier": IF_GROUP,
"WFControlFlowMode": 2,
},
},
{
"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()
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Build the companion Shortcut for the Navigate skill.
Writes an unsigned binary plist that `shortcuts sign` can notarize.
The host already picked 高德 百度 Apple Maps and passes one URL.
This recipe only materializes Shortcut Input as Text and opens it:
Text (Shortcut Input)
Open URLs
"""
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 / "OSGNavigate.unsigned.shortcut"
SIGNED = OUT_DIR / "OSGNavigate.shortcut"
OBJECT_REPLACEMENT = "\ufffc"
TEXT_UUID = "C1D2E3F0-1111-4E00-8A5A-FD7E1B0C396F"
OPEN_UUID = "C1D2E3F1-2222-4F11-9A6B-0E8F2C1D4A70"
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 workflow() -> dict:
return {
"WFWorkflowClientVersion": "3600.0.4",
"WFWorkflowClientRelease": "26.0",
"WFWorkflowMinimumClientVersion": 900,
"WFWorkflowMinimumClientVersionString": "900",
"WFWorkflowName": "OSG · 导航",
"WFWorkflowHasShortcutInputVariables": True,
"WFWorkflowHasOutputFallback": False,
"WFWorkflowImportQuestions": [],
"WFWorkflowTypes": [],
"WFWorkflowInputContentItemClasses": [
"WFStringContentItem",
"WFURLContentItem",
"WFRichTextContentItem",
],
"WFWorkflowOutputContentItemClasses": [],
"WFWorkflowNoInputBehavior": {
"Name": "WFWorkflowNoInputBehaviorAskForInput",
"Parameters": {
"ItemClass": "WFStringContentItem",
},
},
"WFWorkflowIcon": {
"WFWorkflowIconGlyphNumber": 59723,
"WFWorkflowIconStartColor": 4278222847,
},
"WFWorkflowActions": [
{
"WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
"WFWorkflowActionParameters": {
"UUID": TEXT_UUID,
"CustomOutputName": "快捷指令文本",
"WFTextActionText": shortcut_input_text(),
},
},
{
"WFWorkflowActionIdentifier": "is.workflow.actions.openurl",
"WFWorkflowActionParameters": {
"UUID": OPEN_UUID,
"WFURL": action_output_text("快捷指令文本", TEXT_UUID),
"WFShowWhenRun": False,
},
},
],
}
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()
+4
View File
@@ -134,6 +134,10 @@
"OSGKeyboardTests/AIHintPoolTests",
"OSGKeyboardTests/AIHintKeywordExtractorTests",
"OSGKeyboardTests/AIAgentSkillLayoutTests",
"OSGKeyboardTests/AIUserSkillTests",
"OSGKeyboardTests/AIUserSkillStoreTests",
"OSGKeyboardTests/AIEventExtractionTests",
"OSGKeyboardTests/AIAddressExtractionTests",
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
"OSGKeyboardTests/ClipboardHistoryStoreTests"
]
+16 -2
View File
@@ -49,7 +49,7 @@ settings:
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "1.7.5"
CURRENT_PROJECT_VERSION: "67"
CURRENT_PROJECT_VERSION: "69"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target
@@ -88,6 +88,17 @@ targets:
buildPhase: resources
- path: OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
buildPhase: resources
# `.shortcut` is not a XcodeGen-known resource type; list each file
# as an explicit resource source or it never lands in the app bundle.
- path: OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
type: file
buildPhase: resources
- path: OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut
type: file
buildPhase: resources
- path: OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut
type: file
buildPhase: resources
entitlements:
path: OSGKeyboard/OSGKeyboard.entitlements
properties:
@@ -121,7 +132,6 @@ targets:
- path: OSGKeyboard/Resources/Typing/Rime/manifest.json
- path: OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin
- path: OSGKeyboard/Resources/HostCLM/v1/compiled-manifest.json
- path: OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
- path: OSGKeyboard/Resources/LocalASR/local-asr-catalog.json
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
@@ -197,6 +207,10 @@ targets:
- skype
- zoomus
- shortcuts
- iosamap
- amapuri
- baidumap
- maps
- things
- todoist
- evernote