merge: bring AI agent skills into main

This commit is contained in:
Rocky
2026-08-14 18:23:32 +08:00
78 changed files with 14281 additions and 262 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 71)**. Releases use stable SemVer:
| Field | File | Rule |
|-------|------|------|
+25 -1
View File
@@ -7,7 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Overlapping key presses**: the typing grid tracks multiple fingers, so the next key can go down before the previous lifts. Pending letters commit in press order (not release order); Shift can be held with one finger while another types. / **叠指连打**:打字网格跟踪多指,上一键未松开也可按下下一键。未提交的字母按按下顺序出字(而非抬手顺序);一只手指按住 Shift 时另一只可打字。
- **Period shortcut**: in English, a second Space shortly after a Space that follows a word becomes `. ` and arms sentence Shift, matching the system "." Shortcut. / **句号快捷**:英文下,在单词后的空格上短时间内再按一次空格会变成 `. ` 并点亮句首 Shift,对齐系统「句号快捷」。
- **Return key labels**: Go / Search / Send / Done / Next / Join and the other `UIReturnKeyType` values show their system captions on the green action key instead of collapsing to Send or a return arrow. / **回车键文案**:前往 / 搜索 / 发送 / 完成 / 下一项 / 加入等 `UIReturnKeyType` 在绿色动作键上显示系统对应文案,不再一律变成「发送」或换行箭头。
- **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 the bundled companion Shortcut `OSGExtractTodos` 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. / **提取待办技能**:技能页会打开 App 内置的配套捷径 `OSGExtractTodos` 系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。
- **Extract events skill**: the Skills tab opens the bundled companion Shortcut `OSGExtractEvents` 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. / **提取日程技能**:技能页会打开 App 内置的配套捷径 `OSGExtractEvents` 系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。
- **Navigate skill**: copying text and tapping Navigate asks the LLM for one address (or origin → destination) and the host app opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No companion Shortcut. No address stays in the current app with a keyboard tip. / **导航技能**:复制文字并点「导航」会抽取一条地址(或起点→终点),由 App 直接打开驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。不需要配套捷径。没有地址则留在当前 App,键盘上给出提示。
- **Save to Notes skill**: the Skills tab opens a ready-made companion Shortcut named `OSGSaveToNotes` on the system Add page (one new Apple Note with an explicit title and body). After you tap Add, copying text and tapping Notes asks the LLM for a short title from the time and content; the body stays the original clipboard. / **存入备忘录技能**:技能页会打开已做好的配套捷径 `OSGSaveToNotes` 的系统添加页(新建一条带标题和正文的苹果备忘录)。点添加后,复制文字并点「备忘录」会按时间和内容生成短标题;正文保持剪贴板原文。
- **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
- **Save to Notes Shortcut**: bind the combined first-line title and clipboard body through iPhone Create Notes real `WFCreateNoteInput` field. The previous `contents` binding was ignored and left an enter-content sheet or a title-only note. / **存入备忘录捷径**:通过 iPhone「创建备忘录」真正的 `WFCreateNoteInput` 字段绑定首行标题与剪贴板正文;旧版 `contents` 绑定会被忽略,导致弹出内容填写框或只生成标题。
- **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
- **Bundled Shortcut names**: Tasks and Events now install their signed in-app resources as `OSGExtractTodos` and `OSGExtractEvents`, matching `OSGSaveToNotes`; existing Chinese-named copies must be replaced from the Skills tab. / **内置捷径名称**:待办与日程改为安装 App 内签名资源 `OSGExtractTodos``OSGExtractEvents`,与 `OSGSaveToNotes` 保持一致;已有中文名称版本需从技能页重新安装。
- **Navigation icons**: Skills uses a wand; Styles uses a dial. The phone dock and iPad sidebar (except Home) use outline when idle and fill when selected. Mac Styles / Settings follow the same pairing. iPad and Mac Home stay the house icon. / **导航图标**:技能改为魔杖,风格改为旋钮。手机 Dock 与 iPad 侧栏(除首页外)未选中描边、选中填充;Mac 的风格 / 设置同样切换。iPad / Mac 首页仍用房子图标。
- **Phone dock size**: slightly smaller — 22 pt icons, 46 pt rows, 8 pt glass padding. / **手机 Dock 尺寸**:略缩小,图标 22 pt、行高 46 pt、玻璃内边距 8 pt。
- **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 秒内,空闲态改为小圆形「回复 / 总结 / 翻译」按钮;翻译跟随键盘目标语言,未设置时中英互译。技能来自目录,便于日后扩展。
@@ -16,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **AI idle capsule size**: rotating suggestion chips keep a 44 pt tap height with 16 pt side padding. / **AI 空闲胶囊尺寸**:轮播建议芯片保持 44 pt 点击高度,左右各 16 pt 内边距。
- **Undo / translation chrome**: voice mic-row undo and translation controls are 52 pt circular Liquid Glass buttons. / **撤销与翻译按钮**:语音麦克风行的撤销、翻译改为 52 pt 圆形 Liquid Glass 按钮。
- **Clipboard skill circles**: Reply / Summarize / Translate idle buttons match the same 52 pt circle. / **剪贴板技能圆钮**:回复 / 总结 / 翻译空闲按钮与语音侧键同为 52 pt 圆。
- **Home tab selection**: the dock stays Liquid Glass at its original height; the selected tab is a wider green fill capsule with a 5 pt inset, not a second glass chip. Dock items are 24 pt icons with Home / Styles / Settings labels. / **首页 Tab 选中态**dock 仍是原高度 Liquid Glass;选中项改为更宽的绿色填充胶囊,距栏边 5 pt,不再套第二层玻璃。dock 为 24 pt 图标加「首页 / 风格 / 设置」文字。
- **Home tab selection**: the dock stays Liquid Glass at its original height; the selected tab is a wider green fill capsule with a 5 pt inset, not a second glass chip. Dock items are 24 pt icons with Home / Skills / Styles / Settings labels. / **首页 Tab 选中态**dock 仍是原高度 Liquid Glass;选中项改为更宽的绿色填充胶囊,距栏边 5 pt,不再套第二层玻璃。dock 为 24 pt 图标加「首页 / 技能 / 风格 / 设置」文字。
- **Home library card titles**: History and Personal dictionary headers use a 16 pt icon and 13 pt label. / **首页资料卡标题**:历史与个性词库标题改为 16 pt 图标、13 pt 文字。
### Fixed
+5
View File
@@ -69,6 +69,11 @@
<string>zalo</string>
<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") {
@@ -0,0 +1,41 @@
// AIAgentShortcutInstaller.swift
// OSGKeyboard · Main App
//
// Opening the HTTPS iCloud share page from the app is claimed as a
// Universal Link and often lands on Gallery. `shortcuts://shortcuts/TOKEN`
// opens the Add sheet in Shortcuts directly.
import Foundation
import UIKit
import OSGKeyboardShared
enum AIAgentShortcutInstaller {
@MainActor
static func openInstallPage(for skill: AIClipboardSkill) {
if let shareURL = skill.shortcutICloudURL,
let installURL = AIAgentShortcutRun.shortcutsInstallURL(from: shareURL) {
UIApplication.shared.open(installURL)
return
}
openBundledShortcut(for: skill)
}
@MainActor
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("\(fileName).shortcut")
try? FileManager.default.removeItem(at: tmp)
do {
try FileManager.default.copyItem(at: bundled, to: tmp)
UIApplication.shared.open(tmp)
} catch {
UIApplication.shared.open(bundled)
}
}
}
@@ -0,0 +1,99 @@
// AIAgentShortcutRunner.swift
// OSGKeyboard · Main App
//
// Consumes the keyboard's pending export-skill payload. Navigate opens a
// map URL in the host. Other exports open the companion Shortcut.
import UIKit
import OSGKeyboardShared
enum AIAgentShortcutRunner {
@MainActor
static func runPendingIfNeeded() {
AIAgentShortcutRun.trace("host.runPending begin")
guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
let catalog = AppGroupStore().agentUserSkillCatalog
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog) else {
AIAgentShortcutRun.trace(
"host.runPending skip unknownSkill=\(payload.skillID)"
)
return
}
if skill.id == AIClipboardSkillCatalog.navigateID {
openMap(for: payload)
return
}
guard let name = skill.shortcutName else {
AIAgentShortcutRun.trace(
"host.runPending skip missingShortcut skill=\(payload.skillID)"
)
return
}
let text = payload.joinedTitles
AIAgentShortcutRun.traceBody("host.titlesToShortcut", text)
guard let url = shortcutsURL(name: name, text: text) else {
AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)")
return
}
let textItem = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first { $0.name == "text" }?
.value
AIAgentShortcutRun.trace(
"host.openShortcuts scheme=\(url.scheme ?? "") host=\(url.host ?? "") "
+ "path=\(url.path) urlChars=\(url.absoluteString.count) "
+ "textQueryChars=\(textItem?.count ?? -1) name=\(name)"
)
AIAgentShortcutRun.traceBody("host.textQuery", textItem ?? "")
UIApplication.shared.open(url) { success in
AIAgentShortcutRun.trace(
"host.openShortcuts result success=\(success) "
+ "(iOS accepted the URL; not proof Reminders were created)"
)
}
}
static func logShortcutCallback(_ url: URL) {
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
let status = items.first { $0.name == "status" }?.value ?? "unknown"
let errorMessage = items.first { $0.name == "errorMessage" }?.value
let errorCode = items.first { $0.name == "errorCode" }?.value
AIAgentShortcutRun.trace(
"host.shortcutCallback status=\(status) errorCode=\(errorCode ?? "-") "
+ "errorMessage=\(errorMessage ?? "-") "
+ "(Shortcuts finished; does not prove reminder rows exist)"
)
}
/// Apple Maps. Do not bounce through Shortcuts.
private static func openMap(for payload: AIAgentShortcutRunPayload) {
guard let urlString = AIMapNavigation.shortcutInput(
from: payload.joinedTitles,
canOpen: { UIApplication.shared.canOpenURL($0) }
), let url = URL(string: urlString) else {
AIAgentShortcutRun.trace("host.runPending skip navigateURLBuildFailed")
return
}
AIAgentShortcutRun.trace(
"host.openMap scheme=\(url.scheme ?? "") host=\(url.host ?? "")"
)
AIAgentShortcutRun.traceBody("host.mapURL", urlString)
UIApplication.shared.open(url) { success in
AIAgentShortcutRun.trace("host.openMap result success=\(success)")
}
}
private static func shortcutsURL(name: String, text: String) -> URL? {
#if DEBUG
return AIAgentShortcutRun.shortcutsRunURL(
name: name,
text: text,
xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
xError: "osgkeyboard://skill/shortcut-result?status=error",
xCancel: "osgkeyboard://skill/shortcut-result?status=cancel"
)
#else
return AIAgentShortcutRun.shortcutsRunURL(name: name, text: text)
#endif
}
}
@@ -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)
}
}
@@ -1,7 +1,7 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
// Bottom tab bar icon + label for Home / Styles / Settings.
// Bottom tab bar icon + label for Home / Skills / Styles / Settings.
// The dock capsule is iOS 26 Liquid Glass; the selected tab is a green
// fill inside that capsule (Photos-style), not a second glass layer.
// History + dictionary live as Home cards (not dock tabs).
@@ -11,41 +11,41 @@ import OSGKeyboardShared
enum AppTab: Int, CaseIterable {
case keyboard
case skills
case styles
case settings
var icon: MaterialIconName {
switch self {
case .keyboard: return .keyboard
case .styles: return .menuBook // unused styles uses SF Symbol
case .settings: return .settings
}
}
/// SF Symbol overrides shared with the Mac and iPad sidebars.
var sfSymbol: String? {
switch self {
case .styles: return "text.badge.star"
default: return nil
}
}
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
case .skills: return "tab.skills"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
}
/// Phone dock: outline when idle, fill when selected (system tab-bar
/// convention). `wand.and.sparkles` has no `.fill` pair.
func dockSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return selected ? "keyboard.fill" : "keyboard"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
var sidebarTitle: LocalizedStringKey { accessibilityKey }
var sidebarSystemImage: String {
/// iPad sidebar. Home stays `house` in both states; other tabs follow
/// the same outline / fill pairing as the phone dock.
func sidebarSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return "house"
case .styles: return "text.badge.star"
case .settings: return "gearshape"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
}
@@ -65,14 +65,8 @@ struct MinimalTabBar: View {
}
} label: {
VStack(spacing: 2) {
Group {
if let sfSymbol = tab.sfSymbol {
Image(systemName: sfSymbol)
Image(systemName: tab.dockSystemImage(selected: selection == tab))
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
} else {
MaterialIcon(name: tab.icon, size: TabBarDockMetrics.iconSize)
}
}
Text(tab.accessibilityKey)
.font(TypeStyle.caption2)
.lineLimit(1)
@@ -110,7 +104,7 @@ struct MinimalTabBar: View {
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
.glassEffect(.regular.interactive(), in: .capsule)
.frame(maxWidth: 280)
.frame(maxWidth: 360)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, TabBarDockMetrics.bottomPadding)
}
@@ -52,13 +52,13 @@ extension View {
}
enum TabBarDockMetrics {
static let itemHeight: CGFloat = 52
static let iconSize: CGFloat = 24
static let itemHeight: CGFloat = 46
static let iconSize: CGFloat = 22
/// Horizontal glass pad is 0: the selected capsule's `selectionInset`
/// is the only side gap. `Spacing.md` (16) plus that 5 pt was ~34×
/// the 5 pt top/bottom gap.
static let dockInsetHorizontal: CGFloat = 0
static let dockInsetVertical: CGFloat = Spacing.sm
static let dockInsetVertical: CGFloat = Spacing.xs
/// Gap between the selected fill and the glass dock / neighbouring tabs.
static let selectionInset: CGFloat = 5
static let bottomPadding: CGFloat = Spacing.xs
+6
View File
@@ -214,6 +214,12 @@ struct MainAppRoot: View {
switch url.host {
case "startflow":
flowManager.startSession(coldStart: true, reason: "url.startflow")
case "skill":
if url.path.contains("shortcut-result") {
AIAgentShortcutRunner.logShortcutCallback(url)
} else if url.path.contains("run") {
AIAgentShortcutRunner.runPendingIfNeeded()
}
case "deployrime":
// The keyboard sends the user here precisely because typing
// resources are missing deploy without waiting for warmup.
+2 -2
View File
@@ -110,7 +110,7 @@ private struct WideSidebarRow: View {
var body: some View {
Button(action: action) {
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage)
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage(selected: isSelected))
.labelStyle(SidebarIconColumnLabelStyle())
// Slightly larger label + taller vertical padding so each
// sidebar row hits the Apple HIG 44pt touch target on iPad
@@ -144,7 +144,7 @@ private struct WideSidebarRow: View {
/// (`house`) enough to push titles apart by ~5pt. `List` reserves that column
/// automatically; this hand-rolled `VStack` sidebar has to do it itself.
private struct SidebarIconColumnLabelStyle: LabelStyle {
/// Wider than the widest symbol in `AppTab.sidebarSystemImage`.
/// Wider than the widest symbol in `AppTab.sidebarSystemImage(selected:)`.
private static let iconColumnWidth: CGFloat = 22
func makeBody(configuration: Configuration) -> some View {
+2
View File
@@ -14,6 +14,8 @@ struct MainTabContent: View {
switch tab {
case .keyboard:
HomeView()
case .skills:
AIAgentSkillsView()
case .styles:
PolishStylesView()
case .settings:
+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(
+66
View File
@@ -427,8 +427,74 @@
"tab.history" = "History";
"tab.dictionary" = "Dictionary";
"tab.styles" = "Styles";
"tab.skills" = "Skills";
"tab.settings" = "Settings";
/* AI Agent skills */
"skills.title" = "Skills";
"skills.enabled.section" = "In use (%d/%d)";
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
"skills.available.section" = "Available";
"skills.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";
"skills.action.turnOffHint" = "Removes it from the keyboard only.";
"skills.action.addToKeyboard" = "Add to keyboard";
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
"skills.full.title" = "Skill slots full";
"skills.full.message" = "You can enable up to 8 skills. Turn one off, then add this skill from its card.";
"skills.reply.name" = "Reply";
"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
"skills.summarize.name" = "Summarize";
"skills.summarize.description" = "Keep the key facts and conclusions, without rewriting it as a chat message.";
"skills.translate.name" = "Translate";
"skills.translate.description" = "Translate the copied text using your keyboard language setting.";
"skills.extractTodos.name" = "Extract tasks";
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
"skills.extractEvents.name" = "Extract events";
"skills.extractEvents.description" = "Extract events from copied text into Calendar.";
"skills.saveToNotes.name" = "Save to Notes";
"skills.saveToNotes.description" = "Create a new note from the copied text. The title comes from the time and content; the body stays as copied.";
"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";
"polishStyles.add" = "Add style";
@@ -426,8 +426,74 @@
"tab.history" = "历史";
"tab.dictionary" = "词库";
"tab.styles" = "风格";
"tab.skills" = "技能";
"tab.settings" = "设置";
/* AI Agent 技能 */
"skills.title" = "技能";
"skills.enabled.section" = "使用中(%d/%d";
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
"skills.available.section" = "可添加";
"skills.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" = "关闭";
"skills.action.turnOffHint" = "只从键盘拿掉,不会删除捷径。";
"skills.action.addToKeyboard" = "添加到键盘";
"skills.action.reinstallShortcut" = "重新安装捷径";
"skills.full.title" = "技能已满";
"skills.full.message" = "最多同时启用 8 个技能。请先关闭一个,再从卡片手动添加。";
"skills.reply.name" = "回复";
"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
"skills.summarize.name" = "总结";
"skills.summarize.description" = "保留关键事实与结论,不会改写成可发送的短消息。";
"skills.translate.name" = "翻译";
"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
"skills.extractTodos.name" = "提取待办";
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
"skills.extractEvents.name" = "提取日程";
"skills.extractEvents.description" = "从复制内容提取日程,写入日历。";
"skills.saveToNotes.name" = "存入备忘录";
"skills.saveToNotes.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" = "润色风格";
"polishStyles.add" = "添加风格";
+41 -3
View File
@@ -520,6 +520,11 @@ public final class KeyboardViewController: UIInputViewController {
state.submitAIClipboardSkill = { [weak self] skill in
self?.aiKeyboardCoordinator.submitClipboardSkill(skill)
}
state.runClipboardExportSkill = { [weak self] skillID, titles in
AppGroupStore().setPendingShortcutRun(skillID: skillID, titles: titles)
AIAgentShortcutRun.trace("keyboard.openHost osgkeyboard://skill/run")
self?.openSkillShortcutRun()
}
state.openSettings = { [weak self] in self?.openHostApp() }
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
state.openClipboardSettings = { [weak self] in
@@ -768,10 +773,30 @@ public final class KeyboardViewController: UIInputViewController {
private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole {
switch returnKeyType {
case .send, .go, .search, .join, .route, .google, .yahoo, .continue, .emergencyCall:
return .send
case .default, .next, .done:
case .default:
return .newline
case .go:
return .go
case .google:
return .google
case .join:
return .join
case .next:
return .next
case .route:
return .route
case .search:
return .search
case .send:
return .send
case .yahoo:
return .yahoo
case .done:
return .done
case .emergencyCall:
return .emergencyCall
case .continue:
return .continue
@unknown default:
return .newline
}
@@ -942,6 +967,19 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - Open host app
private func openSkillShortcutRun() {
guard hasFullAccess else {
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
return
}
guard let url = URL(string: "osgkeyboard://skill/run") else { return }
HostAppLauncher.open(url: url, from: self) { [weak self] success in
AIAgentShortcutRun.trace("keyboard.openHost result success=\(success)")
if success { return }
self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.handoffFailed")
}
}
private func openHostApp(path: String = "settings") {
guard hasFullAccess else {
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
@@ -70,16 +70,32 @@ final class AIKeyboardCoordinator {
func submitClipboardSkill(_ skill: AIClipboardSkill) {
guard canAcceptIdleSubmit else { return }
enterIfNeeded()
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
if skill.kind == .export {
state.pendingClipboardSkillID = skill.id
state.pendingClipboardSkillSource = material
} else {
clearPendingExportSkill()
}
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: AIHintLocaleResolver.packLocale(),
translationTargetLocaleId: state.translationTargetLocaleId
)
AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
if let material {
AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
} else {
AIAgentShortcutRun.trace("keyboard.clipboard missing")
}
let resolution = AIClipboardPrompt.resolve(
instruction: instruction,
material: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
material: material
)
submitResolvedPrompt(resolution)
if case .materialUnavailable = resolution {
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
}
submitResolvedPrompt(resolution, thinkingEnabled: skill.thinkingEnabled)
}
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
@@ -103,27 +119,37 @@ 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.
clearPendingExportSkill()
state.aiSession.fail(
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
utteranceID: nil
)
return
}
guard let conversationID = state.aiSession.conversationID else { return }
guard let conversationID = state.aiSession.conversationID else {
clearPendingExportSkill()
return
}
let disposition = flow.submitAIQuestion(
text: prompt,
conversationID: conversationID
conversationID: conversationID,
thinkingEnabled: thinkingEnabled
)
if case .rejected(let rejection) = disposition {
clearPendingExportSkill()
state.aiSession.fail(message(for: rejection), utteranceID: nil)
}
}
func cancel() {
guard state.aiSession.isBusy else { return }
clearPendingExportSkill()
flow.cancelAIRecording()
state.aiSession.cancelCurrentWork()
}
@@ -132,7 +158,7 @@ final class AIKeyboardCoordinator {
if state.aiSession.canInsert, let answer = state.aiSession.answer {
guard insertAnswer(answer) else { return }
state.aiSession.markAnswerInserted(
offersSend: state.returnKeyRole == .send
offersSend: state.returnKeyRole.usesActionFill
)
} else if state.aiSession.canSend {
state.aiSession.markAnswerSent()
@@ -181,6 +207,7 @@ final class AIKeyboardCoordinator {
}
func receivePartialAnswer(_ draft: String, utteranceID: UUID) {
if isPendingExportSkill { return }
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
}
@@ -188,6 +215,11 @@ final class AIKeyboardCoordinator {
guard result.resolvedUtteranceMode == .aiQuestion else {
return
}
if isPendingExportSkill {
guard result.aiConversationID == state.aiSession.conversationID else { return }
finishExportSkill(answer: result.text ?? "")
return
}
guard result.aiConversationID == state.aiSession.conversationID,
let answer = result.text,
!answer.isEmpty else {
@@ -201,14 +233,105 @@ final class AIKeyboardCoordinator {
}
func fail(_ message: String, utteranceID: UUID?) {
clearPendingExportSkill()
state.aiSession.fail(message, utteranceID: utteranceID)
}
private func endConversationIfNeeded() {
clearPendingExportSkill()
guard let conversationID = state.aiSession.conversationID else { return }
flow.endAIConversation(conversationID)
}
private func clearPendingExportSkill() {
state.pendingClipboardSkillID = nil
state.pendingClipboardSkillSource = nil
}
private var isPendingExportSkill: Bool {
guard let id = state.pendingClipboardSkillID else { return false }
return resolvedSkill(id: id)?.kind == .export
}
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 (Shortcut, Maps, or Didi).
private func finishExportSkill(answer: String) {
let source = state.pendingClipboardSkillSource
?? ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
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"
case AIClipboardSkillCatalog.saveToNotesID:
items = AINoteExport.items(
from: answer,
sourceClipboard: source,
locale: AIHintLocaleResolver.packLocale()
)
emptyTipKey = "keyboard.ai.skill.noNote"
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)"
)
#if DEBUG
if !items.isEmpty {
AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
}
#endif
clearPendingExportSkill()
if state.aiSession.isBusy {
state.aiSession.cancelCurrentWork()
}
if state.aiSession.isActive {
state.aiSession.resetConversationPreservingAnswer()
}
guard !items.isEmpty else {
AIAgentShortcutRun.trace("keyboard.parse empty — skip Shortcuts")
state.skillTipText = ExtL10n.string(emptyTipKey)
return
}
guard let skillID, let skill = resolvedSkill(id: skillID) else {
AIAgentShortcutRun.trace("keyboard.parse missingSkill")
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
return
}
if skill.requiresShortcut, skill.shortcutName == nil {
AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID)")
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
return
}
AIAgentShortcutRun.trace("keyboard.handoffToHost skill=\(skillID) items=\(items.count)")
let tipKey: String
switch skillID {
case AIClipboardSkillCatalog.navigateID:
tipKey = "keyboard.ai.skill.openingMaps"
default:
tipKey = "keyboard.ai.skill.runningShortcut"
}
state.skillTipText = ExtL10n.string(tipKey)
state.runClipboardExportSkill(skillID, items)
}
private func message(for rejection: FlowUtteranceStartRejection) -> String {
switch rejection {
case .onboardingIncomplete:
@@ -43,6 +43,7 @@ public struct AppGroupPersistor {
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
@@ -98,6 +99,7 @@ public struct AppGroupPersistor {
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
}
@@ -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)
+46 -93
View File
@@ -3,7 +3,8 @@
//
// Grid-level UIKit touch tracking for the typing surface:
// Down highlight Move reselect Up commit (letters / space / return),
// delete repeats on down, Shift holds while the gesture owns it.
// overlapping fingers commit in press order, delete repeats on down,
// Shift holds while that finger owns it.
import SwiftUI
import UIKit
@@ -12,7 +13,7 @@ import OSGKeyboardShared
struct TypingKeyTouchPad: UIViewRepresentable {
var layout: TypingKeyLayout
var hapticIntensity: KeyboardHapticIntensity
var onHighlightChange: (String?) -> Void
var onHighlightChange: (Set<String>) -> Void
var onCommit: (TypingKeyHitTarget) -> Void
var onDeleteFire: () -> Void
var onShiftBegan: () -> Void
@@ -36,8 +37,7 @@ struct TypingKeyTouchPad: UIViewRepresentable {
@MainActor
final class Coordinator {
var parent: TypingKeyTouchPad
private var activeKeyID: String?
private var gestureOwnsShift = false
private let tracker = TypingTouchTracker()
private var deleteRepeatTask: Task<Void, Never>?
private var deleteRepeatStartedAt: Date?
private var isDeleteRepeating = false
@@ -46,72 +46,24 @@ struct TypingKeyTouchPad: UIViewRepresentable {
self.parent = parent
}
func handleBegan(at point: CGPoint) {
resetDeleteRepeat()
gestureOwnsShift = false
guard let key = hit(at: point) else {
setHighlight(nil)
return
}
activate(key)
func handleBegan(id: ObjectIdentifier, at point: CGPoint) {
apply(tracker.began(id: id, key: hit(at: point)))
}
func handleMoved(at point: CGPoint) {
let key = hit(at: point)
if key?.id == activeKeyID { return }
if activeBehavior == .deleteRepeat {
stopDeleteRepeat()
func handleMoved(id: ObjectIdentifier, at point: CGPoint) {
apply(tracker.moved(id: id, key: hit(at: point)))
}
if let key {
activate(key)
} else {
// Outside plane: clear highlight; Shift stays held until ended.
setHighlight(nil)
activeKeyID = nil
}
func handleEnded(id: ObjectIdentifier, at point: CGPoint) {
apply(tracker.ended(id: id, key: hit(at: point)))
}
func handleEnded(at point: CGPoint) {
// Outside the key plane cancel (no commit), per accuracy plan.
let key = hit(at: point)
stopDeleteRepeat()
defer {
setHighlight(nil)
activeKeyID = nil
finishShiftIfNeeded()
}
guard let key else { return }
switch key.behavior {
case .commitOnRelease:
parent.onCommit(key)
case .deleteRepeat:
// Already fired on down / while held.
break
case .shiftHold:
// endShiftHold decides tap vs hold-with-type.
break
}
}
func handleCancelled() {
stopDeleteRepeat()
setHighlight(nil)
activeKeyID = nil
finishShiftIfNeeded()
func handleCancelled(id: ObjectIdentifier) {
apply(tracker.cancelled(id: id))
}
// MARK: - Internals
private var activeBehavior: TypingKeyTouchBehavior? {
guard let activeKeyID else { return nil }
return parent.layout.key(id: activeKeyID)?.behavior
}
private func hit(at point: CGPoint) -> TypingKeyHitTarget? {
let layout = parent.layout
return KeyHitTesting.hitTarget(
@@ -124,27 +76,29 @@ struct TypingKeyTouchPad: UIViewRepresentable {
)
}
private func activate(_ key: TypingKeyHitTarget) {
activeKeyID = key.id
setHighlight(key.id)
private func apply(_ effects: TypingTouchEffects) {
if effects.stopDeleteRepeat {
stopDeleteRepeat()
}
for key in effects.commits {
parent.onCommit(key)
}
if let key = effects.playFeedback {
playFeedback(for: key)
switch key.behavior {
case .commitOnRelease:
break
case .deleteRepeat:
}
if effects.deleteFire {
parent.onDeleteFire()
}
if effects.startDeleteRepeat {
startDeleteRepeat()
case .shiftHold:
if !gestureOwnsShift {
gestureOwnsShift = true
}
if effects.beginShift {
parent.onShiftBegan()
}
if effects.endShift {
parent.onShiftEnded()
}
}
private func setHighlight(_ id: String?) {
parent.onHighlightChange(id)
parent.onHighlightChange(tracker.highlightedKeyIDs)
}
private func playFeedback(for key: TypingKeyHitTarget) {
@@ -207,16 +161,6 @@ struct TypingKeyTouchPad: UIViewRepresentable {
deleteRepeatTask?.cancel()
deleteRepeatTask = nil
}
private func resetDeleteRepeat() {
stopDeleteRepeat()
}
private func finishShiftIfNeeded() {
guard gestureOwnsShift else { return }
gestureOwnsShift = false
parent.onShiftEnded()
}
}
}
@@ -239,7 +183,7 @@ final class TypingKeyTouchPadUIView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = Self.padTint
isMultipleTouchEnabled = false
isMultipleTouchEnabled = true
isExclusiveTouch = true
isUserInteractionEnabled = true
}
@@ -250,21 +194,30 @@ final class TypingKeyTouchPadUIView: UIView {
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
coordinator?.handleBegan(at: touch.location(in: self))
for touch in Self.sorted(touches) {
coordinator?.handleBegan(id: ObjectIdentifier(touch), at: touch.location(in: self))
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
coordinator?.handleMoved(at: touch.location(in: self))
for touch in Self.sorted(touches) {
coordinator?.handleMoved(id: ObjectIdentifier(touch), at: touch.location(in: self))
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
coordinator?.handleEnded(at: touch.location(in: self))
for touch in Self.sorted(touches) {
coordinator?.handleEnded(id: ObjectIdentifier(touch), at: touch.location(in: self))
}
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
coordinator?.handleCancelled()
for touch in Self.sorted(touches) {
coordinator?.handleCancelled(id: ObjectIdentifier(touch))
}
}
private static func sorted(_ touches: Set<UITouch>) -> [UITouch] {
touches.sorted { $0.timestamp < $1.timestamp }
}
}
+21 -33
View File
@@ -47,8 +47,8 @@ struct TypingRootView: View {
/// After the first expand, keep the panel tree mounted and only toggle
/// opacity so subsequent / taps stay cheap.
@State private var candidatePanelMounted = false
/// Key currently under the finger (grid-level touch pad).
@State private var highlightedKeyID: String?
/// Keys currently under a finger (grid-level touch pad, multi-touch).
@State private var highlightedKeyIDs: Set<String> = []
static func totalHeight(isIPad: Bool = false, width: CGFloat = 0) -> CGFloat {
TypingLayoutMetrics.contentHeight(isIPad: isIPad, width: width)
@@ -398,7 +398,7 @@ struct TypingRootView: View {
TypingKeyTouchPad(
layout: layout,
hapticIntensity: state.keyboardHapticIntensity,
onHighlightChange: { highlightedKeyID = $0 },
onHighlightChange: { highlightedKeyIDs = $0 },
onCommit: { commitTypingKey($0) },
onDeleteFire: { apply(typing.handleKey("")) },
onShiftBegan: { typing.beginShiftHold() },
@@ -448,10 +448,10 @@ struct TypingRootView: View {
let pageLabel = typing.page == .letters ? "123" : "ABC"
let spaceLabel = typing.language == .chinese ? "空格" : "space"
let returnLabel: String = {
switch state.returnKeyRole {
case .newline: return "return"
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
if state.returnKeyRole.usesActionFill {
return ExtL10n.string(state.returnKeyRole.titleKey)
}
return "return"
}()
// iPad spends its extra width on comma / period like the system
@@ -491,7 +491,7 @@ struct TypingRootView: View {
@ViewBuilder
private func visualTypingKey(_ key: TypingKeyHitTarget) -> some View {
let pressed = highlightedKeyID == key.id
let pressed = highlightedKeyIDs.contains(key.id)
let isBottom = key.id.hasPrefix("bottom.")
let isShift = key.label == ""
let shiftLit = isShift && typing.isShiftEnabled
@@ -624,13 +624,14 @@ struct TypingRootView: View {
@ViewBuilder
private var returnKeyLabel: some View {
switch state.returnKeyRole {
case .newline:
Image(systemName: "arrow.turn.down.left")
.font(.system(size: 21, weight: .medium))
case .send:
if state.returnKeyRole.usesActionFill {
ExtL10n.text(state.returnKeyRole.titleKey)
.font(.system(size: 15, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.6)
} else {
Image(systemName: "arrow.turn.down.left")
.font(.system(size: 21, weight: .medium))
}
}
@@ -684,42 +685,29 @@ struct TypingRootView: View {
}
private var returnKeyFill: Color {
switch state.returnKeyRole {
case .newline: return keyFill
case .send: return sendKeyFill
}
state.returnKeyRole.usesActionFill ? sendKeyFill : keyFill
}
private var returnKeyPressedFill: Color {
switch state.returnKeyRole {
case .newline: return keyPressedFill
case .send: return sendKeyPressedFill
}
state.returnKeyRole.usesActionFill ? sendKeyPressedFill : keyPressedFill
}
private var returnKeyBorder: Color {
switch state.returnKeyRole {
case .newline:
return palette.divider
case .send:
if state.returnKeyRole.usesActionFill {
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
}
return palette.divider
}
private var returnKeyAccessibilityLabel: String {
switch state.returnKeyRole {
case .newline: return "return"
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
if state.returnKeyRole.usesActionFill {
return ExtL10n.string(state.returnKeyRole.titleKey)
}
return "return"
}
private var returnKeyTextColor: Color {
switch state.returnKeyRole {
case .newline:
return keyTextColor
case .send:
return .white
}
state.returnKeyRole.usesActionFill ? .white : keyTextColor
}
/// The send key stays recognizable in both appearances without becoming neon.
+95 -6
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
@@ -55,6 +64,17 @@ struct AIKeyboardView: View {
}
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
.onChange(of: state.enabledClipboardSkillIDs) { _, _ in resetCarousel() }
.onChange(of: state.skillTipText) { _, tip in
guard let tip, !tip.isEmpty else { return }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 2_800_000_000)
if state.skillTipText == tip {
state.skillTipText = nil
}
}
}
.animation(Motion.soft, value: state.skillTipText)
.onReceive(
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
) { _ in
@@ -172,6 +192,19 @@ struct AIKeyboardView: View {
statusLine
.frame(height: Layout.statusHeight)
.padding(.horizontal, Spacing.md)
if let tip = state.skillTipText, !tip.isEmpty {
Text(tip)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassEffect(.regular, in: Capsule())
.padding(.bottom, Layout.statusHeight + Spacing.sm)
.transition(.opacity)
.allowsHitTesting(false)
}
}
}
@@ -212,8 +245,30 @@ struct AIKeyboardView: View {
}
private var clipboardSkillRow: some View {
HStack(spacing: Spacing.lg) {
ForEach(AIClipboardSkillCatalog.visible()) { skill in
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)
}
}
}
}
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: {
@@ -226,15 +281,15 @@ struct AIKeyboardView: View {
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)))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Translate follows the keyboard target; Reply / Summarize stay static.
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
@@ -244,18 +299,40 @@ 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(
clipboardHistoryEnabled: true,
newestClipboard: clipboardHistory.newestEntry
)
}
private var visibleClipboardSkills: [AIClipboardSkill] {
#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.
private var showsPlaceholder: Bool {
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
@@ -432,6 +509,18 @@ struct AIKeyboardView: View {
}
return state.aiSession.transcript
case .generating:
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 state.pendingClipboardSkillID == AIClipboardSkillCatalog.saveToNotesID {
return ExtL10n.string("keyboard.ai.skill.namingNote")
}
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
return ExtL10n.string("keyboard.ai.generating")
}
+1 -1
View File
@@ -484,7 +484,7 @@ public struct KeyboardRootView: View {
title: title,
label: title,
disabled: disabled,
isSend: state.returnKeyRole == .send,
isSend: state.returnKeyRole.usesActionFill,
hapticIntensity: state.keyboardHapticIntensity
) {
state.insertNewline()
+28
View File
@@ -26,6 +26,14 @@
"common.space" = "Space";
"common.newline" = "Return";
"common.send" = "Send";
"keyboard.return.go" = "Go";
"keyboard.return.search" = "Search";
"keyboard.return.join" = "Join";
"keyboard.return.next" = "Next";
"keyboard.return.route" = "Route";
"keyboard.return.google" = "Google";
"keyboard.return.yahoo" = "Yahoo";
"keyboard.return.emergencyCall" = "Emergency Call";
/* Privacy footnote (onboarding welcome page) */
"privacy.audio.title" = "On-device transcription";
@@ -297,3 +305,23 @@
"keyboard.ai.skill.reply" = "Reply";
"keyboard.ai.skill.summarize" = "Summarize";
"keyboard.ai.skill.translate" = "Translate";
"keyboard.ai.skill.extractTodos" = "Tasks";
"keyboard.ai.skill.extractEvents" = "Events";
"keyboard.ai.skill.saveToNotes" = "Notes";
"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.noNote" = "Nothing to save to Notes";
"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.namingNote" = "Naming the note…";
"keyboard.ai.skill.runningShortcut" = "Running Shortcut…";
"keyboard.ai.skill.openingMaps" = "Opening Maps…";
"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
"keyboard.ai.skill.handoffFailed" = "Couldnt open the app to run the Shortcut";
@@ -26,6 +26,14 @@
"common.space" = "空格";
"common.newline" = "回车";
"common.send" = "发送";
"keyboard.return.go" = "前往";
"keyboard.return.search" = "搜索";
"keyboard.return.join" = "加入";
"keyboard.return.next" = "下一项";
"keyboard.return.route" = "路线";
"keyboard.return.google" = "Google";
"keyboard.return.yahoo" = "Yahoo";
"keyboard.return.emergencyCall" = "紧急电话";
/* Privacy footnote (onboarding welcome page) */
"privacy.audio.title" = "支持本地转写";
@@ -297,3 +305,23 @@
"keyboard.ai.skill.reply" = "回复";
"keyboard.ai.skill.summarize" = "总结";
"keyboard.ai.skill.translate" = "翻译";
"keyboard.ai.skill.extractTodos" = "待办";
"keyboard.ai.skill.extractEvents" = "日程";
"keyboard.ai.skill.saveToNotes" = "备忘录";
"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.noNote" = "剪贴板没有可保存的内容";
"keyboard.ai.skill.noExportItems" = "没有可发给捷径的内容";
"keyboard.ai.skill.extracting" = "正在提取待办…";
"keyboard.ai.skill.extractingEvents" = "正在提取日程…";
"keyboard.ai.skill.extractingAddress" = "正在识别地址…";
"keyboard.ai.skill.namingNote" = "正在生成标题…";
"keyboard.ai.skill.runningShortcut" = "正在运行捷径…";
"keyboard.ai.skill.openingMaps" = "正在打开地图…";
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
@@ -300,4 +300,81 @@ final class EnglishTypingTests: XCTestCase {
XCTAssertEqual(undone.deleteCount, spaced.text.count)
}
}
@MainActor
func testPeriodShortcutReplacesDoubleSpace() {
let typing = TypingSessionController()
_ = typing.setLanguage(.english)
typing.enterTypingMode()
for character in ["h", "e", "l", "l", "o"] {
apply(typing, typing.handleKey(character))
}
let first = typing.handleSpace()
XCTAssertTrue(first.text.hasSuffix(" "), "first Space should insert a space")
apply(typing, first)
let second = typing.handleSpace()
XCTAssertEqual(second, .replace(deleteCount: 1, with: ". "))
apply(typing, second)
XCTAssertTrue(typing.shiftActive, "period+space must arm sentence Shift")
}
@MainActor
func testPeriodShortcutDoesNotFireAfterAnotherLetter() {
let typing = TypingSessionController()
_ = typing.setLanguage(.english)
typing.enterTypingMode()
apply(typing, typing.handleKey("h"))
apply(typing, typing.handleSpace())
apply(typing, typing.handleKey("i"))
let second = typing.handleSpace()
XCTAssertEqual(second, .insert(" "))
}
@MainActor
func testPeriodShortcutDoesNotFireAfterSentenceTerminator() {
let typing = TypingSessionController()
_ = typing.setLanguage(.english)
typing.precedingTextProvider = { "Hello." }
typing.enterTypingMode()
typing.syncAutocapitalization()
apply(typing, typing.handleSpace())
let second = typing.handleSpace()
XCTAssertEqual(second, .insert(" "))
}
@MainActor
func testChineseSpaceDoesNotUsePeriodShortcut() {
let typing = TypingSessionController()
_ = typing.setLanguage(.chinese)
let first = typing.handleSpace()
let second = typing.handleSpace()
XCTAssertEqual(first.text, " ")
XCTAssertEqual(second.text, " ")
XCTAssertEqual(second.deleteCount, 0)
}
func testPeriodShortcutPredicate() {
XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello "))
XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: ""))
XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "v2 "))
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello. "))
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello "))
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello"))
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: " "))
XCTAssertTrue(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello"))
XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello."))
XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello "))
}
@MainActor
private func apply(_ typing: TypingSessionController, _ output: TypingOutput) {
typing.syncAutocapitalization(
accountingForInsert: output.text,
deleteCount: output.deleteCount
)
}
}
@@ -143,4 +143,16 @@ final class KeyboardStateTests: XCTestCase {
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(state.clipboardOverlay, .none)
}
func testReturnKeyRoleActionFillAndTitles() {
XCTAssertFalse(KeyboardState.ReturnKeyRole.newline.usesActionFill)
XCTAssertTrue(KeyboardState.ReturnKeyRole.send.usesActionFill)
XCTAssertTrue(KeyboardState.ReturnKeyRole.search.usesActionFill)
XCTAssertTrue(KeyboardState.ReturnKeyRole.done.usesActionFill)
XCTAssertTrue(KeyboardState.ReturnKeyRole.next.usesActionFill)
XCTAssertEqual(KeyboardState.ReturnKeyRole.search.titleKey, "keyboard.return.search")
XCTAssertEqual(KeyboardState.ReturnKeyRole.next.titleKey, "keyboard.return.next")
XCTAssertEqual(KeyboardState.ReturnKeyRole.done.titleKey, "common.done")
XCTAssertEqual(KeyboardState.ReturnKeyRole.go.titleKey, "keyboard.return.go")
}
}
@@ -0,0 +1,150 @@
// TypingTouchTrackerTests.swift
// OSGKeyboard · Ext unit tests
//
// Multi-finger overlap: press order commits, Shift hold + type, slide reselect.
import XCTest
@testable import OSGKeyboardShared
final class TypingTouchTrackerTests: XCTestCase {
private final class Token {}
private func letter(_ id: String) -> TypingKeyHitTarget {
TypingKeyHitTarget(
id: id,
label: id,
visualFrame: .zero,
behavior: .commitOnRelease
)
}
private func shiftKey() -> TypingKeyHitTarget {
TypingKeyHitTarget(
id: "shift",
label: "",
visualFrame: .zero,
behavior: .shiftHold
)
}
private func deleteKey() -> TypingKeyHitTarget {
TypingKeyHitTarget(
id: "delete",
label: "",
visualFrame: .zero,
behavior: .deleteRepeat
)
}
func testSecondFingerDownCommitsFirstInPressOrder() {
let tracker = TypingTouchTracker()
let a = Token()
let b = Token()
let keyA = letter("a")
let keyB = letter("b")
let downA = tracker.began(id: ObjectIdentifier(a), key: keyA)
XCTAssertTrue(downA.commits.isEmpty)
XCTAssertEqual(tracker.highlightedKeyIDs, ["a"])
let downB = tracker.began(id: ObjectIdentifier(b), key: keyB)
XCTAssertEqual(downB.commits.map(\.id), ["a"])
XCTAssertEqual(tracker.highlightedKeyIDs, ["b"])
let upA = tracker.ended(id: ObjectIdentifier(a), key: keyA)
XCTAssertTrue(upA.commits.isEmpty)
let upB = tracker.ended(id: ObjectIdentifier(b), key: keyB)
XCTAssertEqual(upB.commits.map(\.id), ["b"])
XCTAssertTrue(tracker.highlightedKeyIDs.isEmpty)
}
func testShiftHoldWithOtherFingerDoesNotCommitShift() {
let tracker = TypingTouchTracker()
let shiftFinger = Token()
let letterFinger = Token()
let shift = shiftKey()
let keyA = letter("a")
let downShift = tracker.began(id: ObjectIdentifier(shiftFinger), key: shift)
XCTAssertTrue(downShift.beginShift)
XCTAssertTrue(downShift.commits.isEmpty)
let downA = tracker.began(id: ObjectIdentifier(letterFinger), key: keyA)
XCTAssertTrue(downA.commits.isEmpty)
XCTAssertFalse(downA.beginShift)
let upA = tracker.ended(id: ObjectIdentifier(letterFinger), key: keyA)
XCTAssertEqual(upA.commits.map(\.id), ["a"])
XCTAssertFalse(upA.endShift)
let upShift = tracker.ended(id: ObjectIdentifier(shiftFinger), key: shift)
XCTAssertTrue(upShift.endShift)
XCTAssertTrue(upShift.commits.isEmpty)
}
func testSlideReselectsWithoutCommittingPreviousKey() {
let tracker = TypingTouchTracker()
let finger = Token()
let keyA = letter("a")
let keyS = letter("s")
_ = tracker.began(id: ObjectIdentifier(finger), key: keyA)
let moved = tracker.moved(id: ObjectIdentifier(finger), key: keyS)
XCTAssertTrue(moved.commits.isEmpty)
XCTAssertEqual(moved.playFeedback?.id, "s")
XCTAssertEqual(tracker.highlightedKeyIDs, ["s"])
let ended = tracker.ended(id: ObjectIdentifier(finger), key: keyS)
XCTAssertEqual(ended.commits.map(\.id), ["s"])
}
func testLiftOutsideCancelsPendingKey() {
let tracker = TypingTouchTracker()
let finger = Token()
_ = tracker.began(id: ObjectIdentifier(finger), key: letter("a"))
let ended = tracker.ended(id: ObjectIdentifier(finger), key: nil)
XCTAssertTrue(ended.commits.isEmpty)
}
func testCancelDoesNotCommit() {
let tracker = TypingTouchTracker()
let finger = Token()
_ = tracker.began(id: ObjectIdentifier(finger), key: letter("a"))
let cancelled = tracker.cancelled(id: ObjectIdentifier(finger))
XCTAssertTrue(cancelled.commits.isEmpty)
XCTAssertTrue(tracker.highlightedKeyIDs.isEmpty)
}
func testLetterWhileDeletingStopsRepeat() {
let tracker = TypingTouchTracker()
let delFinger = Token()
let letterFinger = Token()
let downDelete = tracker.began(id: ObjectIdentifier(delFinger), key: deleteKey())
XCTAssertTrue(downDelete.deleteFire)
XCTAssertTrue(downDelete.startDeleteRepeat)
let downLetter = tracker.began(id: ObjectIdentifier(letterFinger), key: letter("a"))
XCTAssertTrue(downLetter.stopDeleteRepeat)
XCTAssertFalse(downLetter.deleteFire)
XCTAssertTrue(downLetter.commits.isEmpty)
}
func testSecondFingerOnSameKeyIsIgnored() {
let tracker = TypingTouchTracker()
let first = Token()
let second = Token()
let keyA = letter("a")
_ = tracker.began(id: ObjectIdentifier(first), key: keyA)
let duplicate = tracker.began(id: ObjectIdentifier(second), key: keyA)
XCTAssertTrue(duplicate.commits.isEmpty)
XCTAssertNil(duplicate.playFeedback)
let upFirst = tracker.ended(id: ObjectIdentifier(first), key: keyA)
XCTAssertEqual(upFirst.commits.map(\.id), ["a"])
let upSecond = tracker.ended(id: ObjectIdentifier(second), key: keyA)
XCTAssertTrue(upSecond.commits.isEmpty)
}
}
+4 -3
View File
@@ -28,13 +28,14 @@ enum MacSection: String, CaseIterable, Identifiable {
}
}
var systemImage: String {
/// Outline when idle, fill when selected except Home, which stays `house`.
func systemImage(selected: Bool) -> String {
switch self {
case .dashboard: return "house"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .styles: return "text.badge.star"
case .settings: return "gearshape"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
}
+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)
+1 -1
View File
@@ -123,7 +123,7 @@ private struct MacSidebarRow: View {
var body: some View {
Button(action: action) {
Label(section.title(language: language), systemImage: section.systemImage)
Label(section.title(language: language), systemImage: section.systemImage(selected: isSelected))
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -0,0 +1,80 @@
// AIAgentSkillLayout.swift
// OSGKeyboard · Shared
//
// Enabled clipboard-skill slots (max 8, ordered) plus which export skills
// the user confirmed a companion Shortcut for. Missing storage hydrates
// to the three default transform skills; an explicit empty list is kept
// so turning every skill off is distinct from a fresh install.
import Foundation
public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
public static let maximumEnabled = 8
public static let defaultEnabledIDs = [
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.translateID,
]
/// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
public var enabledIDs: [String]
/// Export skills whose companion Shortcut the user marked as added.
public var confirmedShortcutIDs: [String]
public static let `default` = AIAgentSkillLayout(
enabledIDs: defaultEnabledIDs,
confirmedShortcutIDs: []
)
public init(enabledIDs: [String], confirmedShortcutIDs: [String]) {
self.enabledIDs = enabledIDs
self.confirmedShortcutIDs = confirmedShortcutIDs
}
public var isFull: Bool {
enabledIDs.count >= Self.maximumEnabled
}
public func isEnabled(_ id: String) -> Bool {
enabledIDs.contains(id)
}
public func hasConfirmedShortcut(_ id: String) -> Bool {
confirmedShortcutIDs.contains(id)
}
/// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
public func sanitized(
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 }
if skill.requiresShortcut {
return confirmedShortcutIDs.contains(id)
}
return true
}
.prefix(Self.maximumEnabled)
var seenConfirmed = Set<String>()
let confirmed = confirmedShortcutIDs.filter { id in
guard known[id]?.requiresShortcut == true else { return false }
return seenConfirmed.insert(id).inserted
}
return AIAgentSkillLayout(
enabledIDs: Array(enabled),
confirmedShortcutIDs: confirmed
)
}
}
public enum AIAgentSkillEnableResult: Equatable, Sendable {
case enabled
case alreadyEnabled
case needsShortcut
case full
case unknown
}
+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 }
}
}
@@ -69,6 +69,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
"config.flowInactivityDuration.migratedToFiveMinuteDefault"
/// Diagnostic switch: when false, local ASR skips the custom language model.
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
/// 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()
}
}
@@ -0,0 +1,143 @@
// AIAgentShortcutRun.swift
// OSGKeyboard · Shared
//
// Ephemeral App Group payload for one keyboard host Shortcuts hop.
// The keyboard writes titles here, then opens `osgkeyboard://skill/run`.
// The host consumes the payload (once) and opens the Shortcuts URL.
import Foundation
public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable {
public let skillID: String
public let titles: [String]
public let createdAt: Date
public init(skillID: String, titles: [String], createdAt: Date = Date()) {
self.skillID = skillID
self.titles = titles
self.createdAt = createdAt
}
public var joinedTitles: String {
titles.joined(separator: "\n")
}
}
public enum AIAgentShortcutRun {
public static let pendingKey = "config.aiAgentSkills.pendingRun.v1"
/// Last skill-handoff lines, readable from the App Group plist on device.
public static let recentTracesKey = "diag.skills.recent.v1"
/// Drop payloads older than this; a leftover write must not fire later.
public static let payloadTTL: TimeInterval = 60
public static func shortcutsRunURL(
name: String,
text: String,
xSuccess: String? = nil,
xError: String? = nil,
xCancel: String? = nil
) -> URL? {
var components = URLComponents()
components.scheme = "shortcuts"
let usesCallback = xSuccess != nil || xError != nil || xCancel != nil
if usesCallback {
components.host = "x-callback-url"
components.path = "/run-shortcut"
} else {
components.host = "run-shortcut"
}
var items = [
URLQueryItem(name: "name", value: name),
URLQueryItem(name: "input", value: "text"),
URLQueryItem(name: "text", value: text),
]
if let xSuccess {
items.append(URLQueryItem(name: "x-success", value: xSuccess))
}
if let xError {
items.append(URLQueryItem(name: "x-error", value: xError))
}
if let xCancel {
items.append(URLQueryItem(name: "x-cancel", value: xCancel))
}
components.queryItems = items
return components.url
}
/// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies.
public static func trace(_ message: String) {
OSGDiag.log(message, category: "skills")
persistTrace(message)
}
/// Keep a short ring so we can copy traces off the phone without root `log collect`.
private static func persistTrace(_ message: String) {
guard let defaults = AppGroup.defaultsIfAvailable else { return }
var lines = defaults.stringArray(forKey: recentTracesKey) ?? []
let stamp = ISO8601DateFormatter().string(from: Date())
lines.append("\(stamp) \(message)")
if lines.count > 60 {
lines = Array(lines.suffix(60))
}
defaults.set(lines, forKey: recentTracesKey)
}
/// Single-line preview so Console keeps the format (`\\n` for newlines).
public static func preview(_ text: String, limit: Int = 1200) -> String {
let escaped = text
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\r\n", with: "\\n")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\n")
.replacingOccurrences(of: "\t", with: "\\t")
if escaped.count <= limit { return escaped }
return String(escaped.prefix(limit)) + "…(chars=\(text.count))"
}
public static func traceBody(_ label: String, _ text: String) {
#if DEBUG
trace("\(label) chars=\(text.count) body=\(preview(text))")
#else
trace("\(label) chars=\(text.count)")
#endif
}
/// Opens the Shortcuts Add sheet for an iCloud share token.
/// Do not `open` the HTTPS share page from the app Universal Links
/// often land on Gallery and drop the token.
public static func shortcutsInstallURL(from shareURL: URL) -> URL? {
guard let token = iCloudShareToken(from: shareURL) else { return nil }
return URL(string: "shortcuts://shortcuts/\(token)")
}
public static func iCloudShareToken(from shareURL: URL) -> String? {
guard let host = shareURL.host, host.contains("icloud.com") else { return nil }
let parts = shareURL.path.split(separator: "/").map(String.init)
guard let index = parts.firstIndex(of: "shortcuts"),
parts.count > index + 1 else { return nil }
let token = parts[index + 1]
guard token != "api", !token.isEmpty else { return nil }
return token
}
public static func openShortcutURL(name: String) -> URL? {
var components = URLComponents()
components.scheme = "shortcuts"
components.host = "open-shortcut"
components.queryItems = [URLQueryItem(name: "name", value: name)]
return components.url
}
public static func encode(_ payload: AIAgentShortcutRunPayload) -> Data? {
try? JSONEncoder().encode(payload)
}
public static func decode(_ data: Data, now: Date = Date()) -> AIAgentShortcutRunPayload? {
guard let payload = try? JSONDecoder().decode(AIAgentShortcutRunPayload.self, from: data) else {
return nil
}
guard now.timeIntervalSince(payload.createdAt) <= payloadTTL else { return nil }
guard !payload.titles.isEmpty else { return nil }
return payload
}
}
@@ -0,0 +1,175 @@
// AIAgentSkillLayoutStore.swift
// OSGKeyboard · Shared
//
// Observable facade over the persisted skill layout. The Skills tab mutates
// this; the keyboard reads the same App Group snapshot on each config poll.
import Foundation
import Combine
@MainActor
public final class AIAgentSkillLayoutStore: ObservableObject {
public static let shared = AIAgentSkillLayoutStore()
@Published public private(set) var layout: AIAgentSkillLayout
@Published public private(set) var userCatalog: AIUserSkillCatalog
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.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.loadUserCatalog = { AppGroupStore().agentUserSkillCatalog }
self.persistUserCatalog = { AppGroupStore().setAgentUserSkillCatalog($0) }
self.loadLayout = { AppGroupStore().agentSkillLayout }
self.persistLayout = { AppGroupStore().setAgentSkillLayout($0) }
}
self.userCatalog = self.loadUserCatalog()
self.layout = self.loadLayout()
}
public func reload() {
userCatalog = loadUserCatalog()
layout = loadLayout()
}
public var mergedCatalog: [AIClipboardSkill] {
AIClipboardSkillCatalog.all(userCatalog: userCatalog)
}
public var enabledSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.visible(
enabledIDs: layout.enabledIDs,
userCatalog: userCatalog
)
}
public var availableSkills: [AIClipboardSkill] {
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(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 }
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs + [id],
confirmedShortcutIDs: current.confirmedShortcutIDs
)
)
return .enabled
}
/// Drops the keyboard slot only. Companion Shortcuts stay installed;
/// the user deletes them in the Shortcuts app if they want them gone.
public func disable(_ id: String) {
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs
)
)
}
/// Marks the companion Shortcut as added, then tries to occupy a slot.
@discardableResult
public func confirmShortcutAndEnable(_ id: String) -> AIAgentSkillEnableResult {
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog),
skill.requiresShortcut else {
return .unknown
}
var current = layout.sanitized(catalog: mergedCatalog)
if !current.confirmedShortcutIDs.contains(id) {
current.confirmedShortcutIDs.append(id)
}
commitLayout(current)
return enable(id)
}
public func moveEnabled(id draggedID: String, onto targetID: String) {
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
)
commitLayout(
AIAgentSkillLayout(
enabledIDs: ids,
confirmedShortcutIDs: layout.confirmedShortcutIDs
)
)
}
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()
}
}
+262 -17
View File
@@ -2,20 +2,78 @@
// OSGKeyboard · Shared
//
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
// Settings can later persist a subset or permutation without changing the view.
// Settings / the Skills tab can persist a subset or permutation without
// changing the view. Transform skills insert into the current field;
// export skills hand off to the host after the model runs (Shortcut, Maps, or Didi).
import Foundation
public enum AIClipboardSkillKind: String, Sendable {
/// LLM output is reviewed and inserted into the current text field.
case transform
/// LLM output is parsed and sent to a companion Shortcut. Never inserted.
case export
}
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public let id: String
public let systemImage: String
/// Keyboard.strings key for the short button title.
/// Keyboard.strings key for the short chip title.
public let titleKey: String
/// App Localizable key for the Skills-tab card title. Falls back to `titleKey`.
public let cardTitleKey: String
public let descriptionKey: String
public let kind: AIClipboardSkillKind
/// Default skills can be turned off but not removed from the catalog.
public let isDefault: Bool
/// Frozen companion Shortcut name. Nil for transform skills.
public let shortcutName: String?
/// Optional `icloud.com/shortcuts/` share URL. Nil open the bundled file.
public let shortcutICloudURL: URL?
/// 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 init(id: String, systemImage: String, titleKey: String) {
/// Reminders, Calendar, and Notes exports need a companion Shortcut.
/// Navigate and Ride hand off to the host (Maps or Didi). No Shortcut.
public var requiresShortcut: Bool { kind == .export && shortcutName != nil }
public var isUserCreated: Bool { id.hasPrefix("user.") }
public init(
id: String,
systemImage: String,
titleKey: String,
cardTitleKey: String,
descriptionKey: String,
kind: AIClipboardSkillKind,
isDefault: Bool,
shortcutName: String? = 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
self.titleKey = titleKey
self.cardTitleKey = cardTitleKey
self.descriptionKey = descriptionKey
self.kind = kind
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
}
}
@@ -23,42 +81,135 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let replyID = "reply"
public static let summarizeID = "summarize"
public static let translateID = "translate"
public static let extractTodosID = "extractTodos"
public static let extractTodosShortcutName = "OSGExtractTodos"
public static let extractTodosResourceName = "OSGExtractTodos"
/// Default set, in display order. Future skills append here.
public static let builtIn: [AIClipboardSkill] = [
public static let extractEventsID = "extractEvents"
public static let extractEventsShortcutName = "OSGExtractEvents"
public static let extractEventsResourceName = "OSGExtractEvents"
public static let saveToNotesID = "saveToNotes"
public static let saveToNotesShortcutName = "OSGSaveToNotes"
public static let saveToNotesResourceName = "OSGSaveToNotes"
public static let navigateID = "navigate"
/// Full built-in catalog, in a stable display order for the Skills tab.
public static let catalog: [AIClipboardSkill] = [
AIClipboardSkill(
id: replyID,
systemImage: "arrowshape.turn.up.left.fill",
titleKey: "keyboard.ai.skill.reply"
titleKey: "keyboard.ai.skill.reply",
cardTitleKey: "skills.reply.name",
descriptionKey: "skills.reply.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: summarizeID,
systemImage: "doc.text.magnifyingglass",
titleKey: "keyboard.ai.skill.summarize"
titleKey: "keyboard.ai.skill.summarize",
cardTitleKey: "skills.summarize.name",
descriptionKey: "skills.summarize.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: translateID,
systemImage: "character.bubble.fill",
titleKey: "keyboard.ai.skill.translate"
titleKey: "keyboard.ai.skill.translate",
cardTitleKey: "skills.translate.name",
descriptionKey: "skills.translate.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: extractTodosID,
systemImage: "checklist",
titleKey: "keyboard.ai.skill.extractTodos",
cardTitleKey: "skills.extractTodos.name",
descriptionKey: "skills.extractTodos.description",
kind: .export,
isDefault: false,
shortcutName: extractTodosShortcutName,
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,
shortcutResourceName: extractEventsResourceName
),
AIClipboardSkill(
id: saveToNotesID,
systemImage: "note.text",
titleKey: "keyboard.ai.skill.saveToNotes",
cardTitleKey: "skills.saveToNotes.name",
descriptionKey: "skills.saveToNotes.description",
kind: .export,
isDefault: false,
shortcutName: saveToNotesShortcutName,
shortcutResourceName: saveToNotesResourceName
),
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
),
]
/// `enabledIDs` is the future Settings hook: `nil` keeps the built-in list.
public static func visible(enabledIDs: [String]? = nil) -> [AIClipboardSkill] {
guard let enabledIDs, !enabledIDs.isEmpty else { return builtIn }
let byID = Dictionary(uniqueKeysWithValues: builtIn.map { ($0.id, $0) })
return enabledIDs.compactMap { byID[$0] }
/// Legacy alias: the three default transform skills used to be the whole list.
public static let builtIn: [AIClipboardSkill] = catalog
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,
userCatalog: AIUserSkillCatalog = .empty
) -> [AIClipboardSkill] {
let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
guard !ids.isEmpty else { return [] }
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
)
}
@@ -85,7 +236,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 {
@@ -102,6 +254,24 @@ public enum AIClipboardSkillCatalog: Sendable {
locale: locale,
translationTargetLocaleId: translationTargetLocaleId
)
case extractTodosID:
return zh
? """
20
NONE
"""
: """
Extract only explicit to-do items from the clipboard. One title per line; no numbering, bullets, or commentary. Maximum 20 lines.
If there are no actionable tasks, output NONE and nothing else. Do not treat the whole clipboard as one task.
If the clipboard itself is already one short task (for example "buy milk"), output that single line.
"""
case extractEventsID:
return eventInstruction(zh: zh, now: now)
case saveToNotesID:
return noteInstruction(zh: zh, now: now)
case navigateID:
return navigateInstruction(zh: zh)
default:
return zh
? "请根据剪贴板内容完成用户选择的操作。"
@@ -109,6 +279,81 @@ public enum AIClipboardSkillCatalog: Sendable {
}
}
/// Title only. The original clipboard is the note body; do not ask the
/// model to rewrite it.
private static func noteInstruction(zh: Bool, now: Date) -> String {
let clock = clockContext(now: now, zh: zh)
if zh {
return """
\(clock)
| 40
813
使 NONE
"""
}
return """
\(clock)
Write a short Notes title from the clipboard. One line only; do not output the body. No numbering, quotes, or commentary. No newlines or | in the title. Maximum 40 characters.
The title should identify the note in a list and may include today's date or time (for example "13 Aug standup notes"). Do not rewrite or repeat the body.
Always return a title, even when the clipboard is short. Do not output NONE.
"""
}
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 / save-to-notes 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,125 @@
// AIMapNavigation.swift
// OSGKeyboard · Shared
//
// Builds one turn-by-turn URL. Provider order is Amap () Baidu
// Apple Maps. The host calls `canOpenURL` and opens the URL itself.
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,
@@ -0,0 +1,170 @@
// AINoteExport.swift
// OSGKeyboard · Shared
//
// Builds one Notes payload: generated title + original clipboard body,
// joined by `fieldSeparator` so the companion Shortcut can combine them
// as first-line title + body. Fail open: a missing or unusable model title
// falls back to a dated snippet so the paste still lands in Notes.
import Foundation
public enum AINoteExport: Sendable {
public static let maximumTitleLength = 40
public static let maximumSnippetLength = 24
/// Split token for the companion Shortcut: title, then original body.
/// Newlines stay inside the body, so do not join with `\n`.
/// Avoid `<>` `shortcuts://&text=` treats angle brackets like tags and
/// drops the payload (todos/events work because they only use `|` / newlines).
public static let fieldSeparator = "||OSG_NOTE||"
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有标题", "无标题",
"no title", "no note", "no notes",
]
/// One string for Shortcuts: `title||OSG_NOTE||body`. Empty do not run it.
public static func items(
from answer: String,
sourceClipboard: String?,
now: Date = Date(),
locale: String = "zh",
calendar: Calendar = .current
) -> [String] {
let body = sourceClipboard?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !body.isEmpty else { return [] }
let title = resolvedTitle(
from: answer,
body: body,
now: now,
locale: locale,
calendar: calendar
)
return ["\(title)\(fieldSeparator)\(body)"]
}
// MARK: - Title
private static func resolvedTitle(
from answer: String,
body: String,
now: Date,
locale: String,
calendar: Calendar
) -> String {
if let title = parsedTitle(answer),
!isWholeClipboardEcho(title, source: body) {
return truncate(title, maximumTitleLength)
}
return fallbackTitle(body: body, now: now, locale: locale, calendar: calendar)
}
private static func parsedTitle(_ raw: String) -> String? {
let first = raw
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
.first ?? ""
var title = stripBullet(first)
title = stripWrappingQuotes(title)
if let pipe = title.firstIndex(of: "|") {
title = String(title[..<pipe])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard !title.isEmpty, !isEmptyToken(title) else { return nil }
return title
}
private static func fallbackTitle(
body: String,
now: Date,
locale: String,
calendar: Calendar
) -> String {
let stamp = dateStamp(now, locale: locale, calendar: calendar)
let snippet = truncate(firstLine(body), maximumSnippetLength)
if snippet.isEmpty { return stamp }
return "\(stamp) · \(snippet)"
}
private static func dateStamp(_ now: Date, locale: String, calendar: Calendar) -> String {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.timeZone = calendar.timeZone
formatter.locale = Locale(identifier: locale == "zh" ? "zh_CN" : "en_US_POSIX")
formatter.dateFormat = locale == "zh" ? "M月d日" : "d MMM"
return formatter.string(from: now)
}
// MARK: - Text
private static func firstLine(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
.first?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
private static func truncate(_ text: String, _ limit: Int) -> String {
guard text.count > limit else { return text }
return String(text.prefix(limit))
}
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
}
private static func stripWrappingQuotes(_ line: String) -> String {
let pairs: [(Character, Character)] = [
("\"", "\""),
("", ""),
("", ""),
("", ""),
("'", "'"),
("", ""),
]
var text = line
for (open, close) in pairs where text.count >= 2 {
if text.first == open, text.last == close {
text = String(text.dropFirst().dropLast())
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
return text
}
/// A title that is essentially the whole clipboard is not a title.
private static func isWholeClipboardEcho(_ title: String, source: String) -> Bool {
guard source.count > 80, 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()
}
}
@@ -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
}
}
@@ -0,0 +1,82 @@
// AITodoExtraction.swift
// OSGKeyboard · Shared
//
// Parses the LLM's extract-todos reply into reminder titles. Fail closed:
// empty / NONE / "no tasks" never become a Shortcut run. A single long
// echo of the clipboard is also rejected so the model cannot dump the
// whole paste as one reminder.
import Foundation
public enum AITodoExtraction: Sendable {
public static let maximumItems = 20
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有待办", "没有待办事项", "无待办", "无待办事项",
"no tasks", "no task", "no todos", "no to-dos", "no to-do",
"no actionable items", "no action items",
]
/// Titles to send to the companion Shortcut. Empty do not run it.
public static func items(from raw: String, sourceClipboard: String? = nil) -> [String] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
if isEmptyToken(trimmed) { return [] }
var seen = Set<String>()
var items: [String] = []
for line in trimmed.components(separatedBy: .newlines) {
let title = stripBullet(line)
guard !title.isEmpty, !isEmptyToken(title) else { continue }
let key = title.lowercased()
guard seen.insert(key).inserted else { continue }
items.append(title)
if items.count == maximumItems { break }
}
if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
return []
}
return items
}
private static func isEmptyToken(_ text: String) -> Bool {
let folded = text
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "。.!"))
.lowercased()
return emptyTokens.contains(folded)
}
private static func stripBullet(_ line: String) -> String {
var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
let prefixes = ["- ", "* ", "", ""]
for prefix in prefixes where text.hasPrefix(prefix) {
text = String(text.dropFirst(prefix.count))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
text = String(text[dotted.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return text
}
/// One long line that is essentially the clipboard body is not a todo.
private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
guard let source, source.count > 80, item.count > 80 else { return false }
let a = collapse(item)
let b = collapse(source)
guard !a.isEmpty, !b.isEmpty else { return false }
if a == b { return true }
return a.contains(b) || b.contains(a)
}
private static func collapse(_ text: String) -> String {
text.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
.lowercased()
}
}
@@ -97,6 +97,14 @@ public struct AppGroupStore: @unchecked Sendable {
public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
/// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it.
public var agentSkillLayout: AIAgentSkillLayout {
Self.decodeAgentSkillLayout(from: defaults, userCatalog: agentUserSkillCatalog)
}
public var agentUserSkillCatalog: AIUserSkillCatalog {
Self.decodeUserSkillCatalog(from: defaults)
}
// MARK: - Writes
@@ -199,6 +207,94 @@ public struct AppGroupStore: @unchecked Sendable {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
}
public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
do {
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)")
}
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) {
defaults.set(data, forKey: AIAgentShortcutRun.pendingKey)
AIAgentShortcutRun.trace(
"appGroup.writePending skill=\(skillID) items=\(titles.count) bytes=\(data.count)"
)
} else {
AIAgentShortcutRun.trace("appGroup.writePending FAILED encode skill=\(skillID)")
}
}
public func consumePendingShortcutRun(now: Date = Date()) -> AIAgentShortcutRunPayload? {
let data = defaults.data(forKey: AIAgentShortcutRun.pendingKey)
defaults.removeObject(forKey: AIAgentShortcutRun.pendingKey)
guard let data else {
AIAgentShortcutRun.trace("appGroup.consumePending missing")
return nil
}
guard let payload = AIAgentShortcutRun.decode(data, now: now) else {
AIAgentShortcutRun.trace(
"appGroup.consumePending dropped bytes=\(data.count) (expired or empty titles)"
)
return nil
}
AIAgentShortcutRun.trace(
"appGroup.consumePending ok skill=\(payload.skillID) items=\(payload.titles.count)"
)
return payload
}
private static func decodeAgentSkillLayout(
from defaults: UserDefaults,
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(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) }
+37 -1
View File
@@ -140,6 +140,15 @@ public final class KeyboardState: ObservableObject {
@Published public var clipboardHistoryEnabled: Bool = false
/// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false
/// Skills-tab order for clipboard chips (max 8). Empty hint carousel.
@Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
/// Export skill currently waiting on the LLM. Nil for transform skills.
@Published public var pendingClipboardSkillID: String?
/// Clipboard captured when that export skill was tapped, so the body
/// still exists after the 30-second hint window closes.
public var pendingClipboardSkillSource: String?
/// In-keyboard toast (e.g. no todos). Does not leave the host app.
@Published public var skillTipText: String?
/// Host field is a password / secure entry never read pasteboard.
@Published public var isSecureTextEntry: Bool = false
/// Secure fields hide every clipboard-history entry point.
@@ -213,13 +222,38 @@ public final class KeyboardState: ObservableObject {
public enum ReturnKeyRole: Equatable {
case newline
case send
case go
case search
case join
case done
case next
case `continue`
case route
case google
case yahoo
case emergencyCall
public var titleKey: String {
switch self {
case .newline: return "common.newline"
case .send: return "common.send"
case .go: return "keyboard.return.go"
case .search: return "keyboard.return.search"
case .join: return "keyboard.return.join"
case .done: return "common.done"
case .next: return "keyboard.return.next"
case .continue: return "common.continue"
case .route: return "keyboard.return.route"
case .google: return "keyboard.return.google"
case .yahoo: return "keyboard.return.yahoo"
case .emergencyCall: return "keyboard.return.emergencyCall"
}
}
/// Green action chrome (system uses blue for Go / Search / Send / Done).
public var usesActionFill: Bool {
self != .newline
}
}
// Action hooks injected by the view controller at install time.
@@ -239,8 +273,10 @@ public final class KeyboardState: ObservableObject {
public var sendAIAnswer: () -> Void = {}
/// Sends a tapped idle hint card as the AI question (skip microphone).
public var submitAIHint: (AIHintCard) -> Void = { _ in }
/// Sends a clipboard skill (reply / summarize / translate / future).
/// Sends a clipboard skill (reply / summarize / translate / export).
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
/// Writes extract-todos titles and opens the host to run the Shortcut.
public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
public var openSettings: () -> Void = {}
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.
@@ -0,0 +1,28 @@
// PeriodShortcut.swift
// OSGKeyboard · Shared
//
// iOS "." Shortcut: a second Space shortly after a Space that follows a
// word character becomes ". " and arms sentence Shift.
import Foundation
public enum PeriodShortcut: Sendable {
/// Window for the second Space tap. Slow consecutive spaces stay spaces.
public static let doubleTapInterval: TimeInterval = 0.45
/// Whether `precedingText` (already including the first space) can take
/// the shortcut: `X ` where X is a letter or number, not a terminator.
public static func shouldReplacePreviousSpace(precedingText: String) -> Bool {
guard precedingText.last == " " else { return false }
guard let previous = precedingText.dropLast().last else { return false }
if previous.isWhitespace || previous.isNewline { return false }
return previous.isLetter || previous.isNumber
}
/// After inserting a space, arm only when that space followed a word char.
public static func shouldArm(afterSpaceFollowing precedingBeforeSpace: String) -> Bool {
guard let last = precedingBeforeSpace.last else { return false }
if last.isWhitespace || last.isNewline { return false }
return last.isLetter || last.isNumber
}
}
@@ -79,6 +79,9 @@ public final class TypingSessionController: ObservableObject {
/// (common in Notes). Capped; reseeds from the proxy when it looks fresh.
private var precedingShadow = ""
private static let precedingShadowLimit = 400
/// iOS "." Shortcut: second Space shortly after a Space that followed a word.
private var periodShortcutArmed = false
private var lastSpaceAt: Date?
public init(
engine: (@MainActor () -> RimeEngineBridging)? = nil,
@@ -166,6 +169,7 @@ public final class TypingSessionController: ObservableObject {
page = .letters
resetShiftState()
clearEnglishWordState(keepPrevious: false)
clearPeriodShortcut()
// Drop English lexicon pages when leaving typing (jetsam recovery).
EnglishLexicon.shared.unload()
englishStorage = nil
@@ -202,6 +206,7 @@ public final class TypingSessionController: ObservableObject {
engine.setLanguage(newLanguage)
page = .letters
isCandidatePanelExpanded = false
clearPeriodShortcut()
if newLanguage == .english {
clearEnglishWordState(keepPrevious: false)
refreshPersonalTerms()
@@ -235,6 +240,7 @@ public final class TypingSessionController: ObservableObject {
public func setPage(_ page: TypingKeyPage) {
self.page = page
resetShiftState()
clearPeriodShortcut()
if page == .letters {
syncAutocapitalization()
}
@@ -264,6 +270,7 @@ public final class TypingSessionController: ObservableObject {
/// Handle a visible key label.
public func handleKey(_ label: String) -> TypingOutput {
clearPeriodShortcut()
switch label {
case "":
// Tests / non-gesture callers: same as a completed Shift tap.
@@ -317,8 +324,9 @@ public final class TypingSessionController: ObservableObject {
public func handleSpace() -> TypingOutput {
if language == .english {
return commitEnglishWord(suffix: " ")
return handleEnglishSpace()
}
clearPeriodShortcut()
let text = engine.processSpace() ?? " "
composition = engine.composition
syncCandidatePanelVisibility()
@@ -326,6 +334,7 @@ public final class TypingSessionController: ObservableObject {
}
public func handleReturn() -> TypingOutput {
clearPeriodShortcut()
if language == .english {
return commitEnglishWord(suffix: "\n")
}
@@ -336,6 +345,7 @@ public final class TypingSessionController: ObservableObject {
}
public func selectCandidate(at index: Int) -> TypingOutput {
clearPeriodShortcut()
if language == .english {
return selectEnglishCandidate(at: index)
}
@@ -352,6 +362,38 @@ public final class TypingSessionController: ObservableObject {
// MARK: - English
private func handleEnglishSpace() -> TypingOutput {
let preceding = precedingTextForShortcut()
if periodShortcutArmed,
let stamped = lastSpaceAt,
Date().timeIntervalSince(stamped) <= PeriodShortcut.doubleTapInterval,
PeriodShortcut.shouldReplacePreviousSpace(precedingText: preceding) {
clearPeriodShortcut()
let wordOut = commitEnglishWord(suffix: "")
let deleteCount = wordOut.deleteCount + 1
return TypingOutput(deleteCount: deleteCount, text: wordOut.text + ". ")
}
let output = commitEnglishWord(suffix: " ")
if PeriodShortcut.shouldArm(afterSpaceFollowing: preceding) {
periodShortcutArmed = true
lastSpaceAt = Date()
} else {
clearPeriodShortcut()
}
return output
}
private func precedingTextForShortcut() -> String {
if !precedingShadow.isEmpty { return precedingShadow }
return precedingTextProvider?() ?? ""
}
private func clearPeriodShortcut() {
periodShortcutArmed = false
lastSpaceAt = nil
}
private func handleEnglishCharacter(_ ch: Character) -> TypingOutput {
pendingAutocorrection = nil
if ch.isLetter {
@@ -546,6 +588,9 @@ public final class TypingSessionController: ObservableObject {
/// briefly reports the immediately preceding edit (common in Notes).
public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) {
guard language == .english else { return }
if caretMoved {
clearPeriodShortcut()
}
guard suggestionsEnabled else {
clearEnglishWordState(keepPrevious: false)
composition = .empty
@@ -0,0 +1,181 @@
// TypingTouchTracker.swift
// OSGKeyboard · Shared
//
// Multi-finger typing contract (system-keyboard overlap):
// each finger is independent; a new key-down commits any other pending
// character/space/return so press order, not release order, wins.
// Shift can be held with one finger while another types.
import Foundation
/// Per-step side effects for the UIKit touch pad to apply.
public struct TypingTouchEffects: Equatable, Sendable {
public var commits: [TypingKeyHitTarget] = []
public var playFeedback: TypingKeyHitTarget?
public var deleteFire = false
public var startDeleteRepeat = false
public var stopDeleteRepeat = false
public var beginShift = false
public var endShift = false
public init() {}
}
/// Pure multi-touch state machine. IDs are `ObjectIdentifier` of `UITouch`
/// in the extension, or any unique object in tests.
public final class TypingTouchTracker {
private struct Finger {
let id: ObjectIdentifier
var key: TypingKeyHitTarget?
var committed: Bool
var ownsShift: Bool
var ownsDeleteRepeat: Bool
let order: UInt64
}
private var fingers: [ObjectIdentifier: Finger] = [:]
private var nextOrder: UInt64 = 0
public init() {}
public var highlightedKeyIDs: Set<String> {
Set(
fingers.values.compactMap { finger in
guard !finger.committed else { return nil }
return finger.key?.id
}
)
}
public func began(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
var effects = TypingTouchEffects()
if let key, hasUncommittedFinger(on: key.id) {
return effects
}
if key != nil {
commitPendingCharacterKeys(into: &effects)
stopForeignDeleteRepeats(into: &effects)
}
var finger = Finger(
id: id,
key: key,
committed: false,
ownsShift: false,
ownsDeleteRepeat: false,
order: nextOrder
)
nextOrder += 1
if let key {
effects.playFeedback = key
activate(key, on: &finger, effects: &effects)
}
fingers[id] = finger
return effects
}
public func moved(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
guard var finger = fingers[id], !finger.committed else {
return TypingTouchEffects()
}
if finger.key?.id == key?.id {
return TypingTouchEffects()
}
var effects = TypingTouchEffects()
if finger.ownsDeleteRepeat {
effects.stopDeleteRepeat = true
finger.ownsDeleteRepeat = false
}
finger.key = key
if let key {
effects.playFeedback = key
activate(key, on: &finger, effects: &effects)
}
fingers[id] = finger
return effects
}
public func ended(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
guard let finger = fingers.removeValue(forKey: id) else {
return TypingTouchEffects()
}
return finish(finger, hit: key, commitIfNeeded: true)
}
public func cancelled(id: ObjectIdentifier) -> TypingTouchEffects {
guard let finger = fingers.removeValue(forKey: id) else {
return TypingTouchEffects()
}
return finish(finger, hit: nil, commitIfNeeded: false)
}
// MARK: - Internals
private func activate(
_ key: TypingKeyHitTarget,
on finger: inout Finger,
effects: inout TypingTouchEffects
) {
switch key.behavior {
case .commitOnRelease:
break
case .deleteRepeat:
effects.deleteFire = true
effects.startDeleteRepeat = true
finger.ownsDeleteRepeat = true
case .shiftHold:
if !finger.ownsShift, !fingers.values.contains(where: { $0.ownsShift }) {
effects.beginShift = true
finger.ownsShift = true
}
}
}
/// Press order = typing order: flush other uncommitted character keys.
private func commitPendingCharacterKeys(into effects: inout TypingTouchEffects) {
let pending = fingers.values
.filter { !$0.committed && $0.key?.behavior == .commitOnRelease }
.sorted { $0.order < $1.order }
for finger in pending {
if let key = finger.key {
effects.commits.append(key)
}
fingers[finger.id]?.committed = true
}
}
/// Holding delete + tapping a letter must stop the repeat.
private func stopForeignDeleteRepeats(into effects: inout TypingTouchEffects) {
for (id, finger) in fingers where finger.ownsDeleteRepeat {
effects.stopDeleteRepeat = true
fingers[id]?.ownsDeleteRepeat = false
}
}
private func hasUncommittedFinger(on keyID: String) -> Bool {
fingers.values.contains { !$0.committed && $0.key?.id == keyID }
}
private func finish(
_ finger: Finger,
hit: TypingKeyHitTarget?,
commitIfNeeded: Bool
) -> TypingTouchEffects {
var effects = TypingTouchEffects()
if finger.ownsDeleteRepeat {
effects.stopDeleteRepeat = true
}
if finger.ownsShift {
effects.endShift = true
}
if commitIfNeeded, !finger.committed, let hit, hit.behavior == .commitOnRelease {
effects.commits.append(hit)
}
return effects
}
}
@@ -18,6 +18,8 @@ public enum OSGDiag {
OSGLog.keyboardExt.info("\(line, privacy: .public)")
case "flow", "asr":
OSGLog.flow.info("\(line, privacy: .public)")
case "skills":
OSGLog.config.info("\(line, privacy: .public)")
default:
OSGLog.config.info("\(line, privacy: .public)")
}
@@ -0,0 +1,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)
})
}
}
@@ -0,0 +1,284 @@
// AIAgentSkillLayoutTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
@MainActor
final class AIAgentSkillLayoutTests: XCTestCase {
private func makeDefaults() -> UserDefaults {
let suite = "group.com.osgkeyboard.shared.tests.skills.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
func testFreshInstallEnablesDefaultTransformSkills() {
let defaults = makeDefaults()
let layout = AppGroupStore(defaults: defaults).agentSkillLayout
XCTAssertEqual(layout.enabledIDs, AIAgentSkillLayout.defaultEnabledIDs)
XCTAssertTrue(layout.confirmedShortcutIDs.isEmpty)
}
func testEmptyEnabledListIsPreserved() {
let defaults = makeDefaults()
let store = AppGroupStore(defaults: defaults)
store.setAgentSkillLayout(
AIAgentSkillLayout(enabledIDs: [], confirmedShortcutIDs: [])
)
XCTAssertEqual(store.agentSkillLayout.enabledIDs, [])
}
func testCannotEnableExportSkillBeforeShortcutConfirmation() {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
XCTAssertEqual(
store.enable(AIClipboardSkillCatalog.extractTodosID),
.needsShortcut
)
XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.extractTodosID))
}
func testConfirmShortcutAutoEnablesWhenSlotAvailable() {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
XCTAssertEqual(
store.confirmShortcutAndEnable(AIClipboardSkillCatalog.extractTodosID),
.enabled
)
XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.extractTodosID))
XCTAssertEqual(
store.layout.enabledIDs.last,
AIClipboardSkillCatalog.extractTodosID
)
}
func testSanitizedDropsUnconfirmedExportAndUnknownIDs() {
let layout = AIAgentSkillLayout(
enabledIDs: ["reply", "extractTodos", "unknown"],
confirmedShortcutIDs: []
).sanitized()
XCTAssertEqual(layout.enabledIDs, ["reply"])
}
func testSanitizedKeepsConfirmedExport() {
let layout = AIAgentSkillLayout(
enabledIDs: ["reply", "extractTodos"],
confirmedShortcutIDs: ["extractTodos"]
).sanitized()
XCTAssertEqual(layout.enabledIDs, ["reply", "extractTodos"])
}
func testIsFullUsesEnabledCount() {
let full = AIAgentSkillLayout(
enabledIDs: (0..<AIAgentSkillLayout.maximumEnabled).map(String.init),
confirmedShortcutIDs: []
)
XCTAssertTrue(full.isFull)
XCTAssertEqual(AIAgentSkillLayout.maximumEnabled, 8)
}
func testDisableKeepsShortcutConfirmation() {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
_ = store.confirmShortcutAndEnable(AIClipboardSkillCatalog.extractTodosID)
store.disable(AIClipboardSkillCatalog.extractTodosID)
XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.extractTodosID))
XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.extractTodosID))
XCTAssertEqual(store.enable(AIClipboardSkillCatalog.extractTodosID), .enabled)
}
func testReorderMovesEnabledSkill() {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
store.moveEnabled(
id: AIClipboardSkillCatalog.translateID,
onto: AIClipboardSkillCatalog.replyID
)
XCTAssertEqual(
store.layout.enabledIDs,
[
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID,
]
)
}
func 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), [])
}
func testNONEAndEmptyProduceNoItems() {
XCTAssertEqual(AITodoExtraction.items(from: "NONE"), [])
XCTAssertEqual(AITodoExtraction.items(from: "没有待办事项"), [])
XCTAssertEqual(AITodoExtraction.items(from: " \n "), [])
XCTAssertEqual(AITodoExtraction.items(from: "no tasks"), [])
}
func testStripsBulletsAndCapsAtTwenty() {
let lines = (1...25).map { "- 任务\($0)" }.joined(separator: "\n")
let items = AITodoExtraction.items(from: lines)
XCTAssertEqual(items.count, 20)
XCTAssertEqual(items.first, "任务1")
}
func testSingleShortTaskIsKept() {
XCTAssertEqual(AITodoExtraction.items(from: "买牛奶"), ["买牛奶"])
}
func testWholeClipboardEchoIsRejected() {
let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4)
XCTAssertEqual(
AITodoExtraction.items(from: source, sourceClipboard: source),
[]
)
}
func testPendingShortcutPayloadExpires() {
let old = AIAgentShortcutRunPayload(
skillID: "extractTodos",
titles: ["买牛奶"],
createdAt: Date(timeIntervalSinceNow: -120)
)
let data = AIAgentShortcutRun.encode(old)!
XCTAssertNil(AIAgentShortcutRun.decode(data))
}
func testShortcutsRunURLPreservesNotesFieldSeparator() {
let text = "周会纪要\(AINoteExport.fieldSeparator)第一项\n第二项"
let url = AIAgentShortcutRun.shortcutsRunURL(name: "OSGSaveToNotes", text: text)
let raw = url?.absoluteString ?? ""
XCTAssertFalse(raw.contains("<"), "angle brackets in the URL get stripped by Shortcuts")
XCTAssertFalse(raw.contains(">"))
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertEqual(items.first { $0.name == "text" }?.value, text)
XCTAssertTrue(text.contains("||OSG_NOTE||"))
}
func testShortcutsRunURLIncludesNameAndText() {
let shortcutName = AIClipboardSkillCatalog.extractTodosShortcutName
let url = AIAgentShortcutRun.shortcutsRunURL(name: shortcutName, text: "买牛奶\n回邮件")
XCTAssertEqual(url?.scheme, "shortcuts")
XCTAssertEqual(url?.host, "run-shortcut")
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSGExtractTodos")
XCTAssertEqual(items.first { $0.name == "input" }?.value, "text")
XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶\n回邮件")
XCTAssertNil(items.first { $0.name == "x-success" })
}
func testXCallbackRunURLUsesCallbackHost() {
let url = AIAgentShortcutRun.shortcutsRunURL(
name: AIClipboardSkillCatalog.extractTodosShortcutName,
text: "买牛奶",
xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
xError: "osgkeyboard://skill/shortcut-result?status=error",
xCancel: "osgkeyboard://skill/shortcut-result?status=cancel"
)
XCTAssertEqual(url?.scheme, "shortcuts")
XCTAssertEqual(url?.host, "x-callback-url")
XCTAssertEqual(url?.path, "/run-shortcut")
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶")
XCTAssertEqual(
items.first { $0.name == "x-success" }?.value,
"osgkeyboard://skill/shortcut-result?status=success"
)
}
func testPreviewEscapesNewlines() {
XCTAssertEqual(AIAgentShortcutRun.preview("买牛奶\n回邮件"), "买牛奶\\n回邮件")
}
func testExtractTodosUsesBundledShortcut() {
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractTodosID)
XCTAssertNil(skill?.shortcutICloudURL)
XCTAssertEqual(skill?.shortcutName, "OSGExtractTodos")
XCTAssertEqual(skill?.shortcutResourceName, "OSGExtractTodos")
}
func testExtractEventsUsesBundledShortcut() {
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractEventsID)
XCTAssertNil(skill?.shortcutICloudURL)
XCTAssertEqual(skill?.shortcutName, "OSGExtractEvents")
XCTAssertEqual(
skill?.shortcutResourceName,
"OSGExtractEvents"
)
XCTAssertEqual(skill?.systemImage, "calendar")
XCTAssertFalse(skill?.isDefault ?? true)
}
func testNavigateDoesNotRequireShortcut() {
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.navigateID)
XCTAssertNil(skill?.shortcutName)
XCTAssertNil(skill?.shortcutICloudURL)
XCTAssertNil(skill?.shortcutResourceName)
XCTAssertEqual(
skill?.systemImage,
"arrow.triangle.turn.up.right.diamond.fill"
)
XCTAssertFalse(skill?.isDefault ?? true)
XCTAssertEqual(skill?.kind, .export)
XCTAssertFalse(skill?.requiresShortcut ?? true)
}
func testNavigateEnablesWithoutShortcutConfirmation() {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
XCTAssertEqual(
store.enable(AIClipboardSkillCatalog.navigateID),
.enabled
)
XCTAssertTrue(store.layout.isEnabled(AIClipboardSkillCatalog.navigateID))
}
func testSaveToNotesUsesBundledShortcutUntilICloudShareExists() {
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.saveToNotesID)
XCTAssertNil(skill?.shortcutICloudURL)
XCTAssertEqual(skill?.shortcutName, "OSGSaveToNotes")
XCTAssertEqual(skill?.shortcutResourceName, "OSGSaveToNotes")
XCTAssertEqual(skill?.systemImage, "note.text")
XCTAssertFalse(skill?.isDefault ?? true)
XCTAssertTrue(skill?.requiresShortcut ?? false)
}
func testCannotEnableSaveToNotesBeforeShortcutConfirmation() {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
XCTAssertEqual(
store.enable(AIClipboardSkillCatalog.saveToNotesID),
.needsShortcut
)
XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.saveToNotesID))
}
func testICloudShareLinkMapsToShortcutsInstallURL() {
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, "/65bf33ba4206484ba78d582eaf1e9c44")
XCTAssertEqual(
AIAgentShortcutRun.iCloudShareToken(from: share),
"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)!
}
}
@@ -235,4 +235,45 @@ final class AIClipboardSkillTests: XCTestCase {
XCTAssertTrue(prompt.contains("概括"))
XCTAssertTrue(prompt.contains("不要改写成可发送的短消息"))
}
func testExtractTodosAsksForNONEWhenEmpty() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.extractTodosID,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
)
XCTAssertTrue(prompt.contains("NONE"))
XCTAssertTrue(prompt.contains("不要把整段原文当成一条待办"))
}
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("起点|终点"))
}
func testSaveToNotesAsksForTitleNotBody() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.saveToNotesID,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
)
XCTAssertTrue(prompt.contains("标题"))
XCTAssertTrue(prompt.contains("不要输出正文"))
XCTAssertTrue(prompt.contains("不要输出 NONE"))
}
}
@@ -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",
+150
View File
@@ -0,0 +1,150 @@
// AINoteExportTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class AINoteExportTests: 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, 23, 56)
}
func testEmptyClipboardProducesNoItems() {
XCTAssertEqual(items(from: "周会纪要", clipboard: nil), [])
XCTAssertEqual(items(from: "周会纪要", clipboard: " \n "), [])
}
func testUsesModelTitleAndKeepsOriginalBody() {
XCTAssertEqual(
items(from: "8月13日周会纪要", clipboard: "第一项\n第二项"),
["8月13日周会纪要\(AINoteExport.fieldSeparator)第一项\n第二项"]
)
}
func testIgnoresModelBodyAfterFirstLine() {
let answer = """
"""
XCTAssertEqual(
items(from: answer, clipboard: "买牛奶\n买鸡蛋"),
["购物清单\(AINoteExport.fieldSeparator)买牛奶\n买鸡蛋"]
)
}
func testTakesTitleBeforePipe() {
XCTAssertEqual(
items(from: "会议纪要|请忽略这段模型正文", clipboard: "原文"),
["会议纪要\(AINoteExport.fieldSeparator)原文"]
)
}
func testNONEFallsBackToDatedSnippet() {
XCTAssertEqual(
items(from: "NONE", clipboard: "买牛奶"),
["8月13日 · 买牛奶\(AINoteExport.fieldSeparator)买牛奶"]
)
XCTAssertEqual(
items(from: "没有标题", clipboard: "买牛奶"),
["8月13日 · 买牛奶\(AINoteExport.fieldSeparator)买牛奶"]
)
}
func testEnglishFallbackUsesMonthDay() {
XCTAssertEqual(
items(from: "NONE", clipboard: "Buy milk", locale: "en"),
["13 Aug · Buy milk\(AINoteExport.fieldSeparator)Buy milk"]
)
}
func testStripsQuotesAndBullets() {
XCTAssertEqual(
items(from: "「周会纪要」", clipboard: "纪要正文"),
["周会纪要\(AINoteExport.fieldSeparator)纪要正文"]
)
XCTAssertEqual(
items(from: "- 周会纪要", clipboard: "纪要正文"),
["周会纪要\(AINoteExport.fieldSeparator)纪要正文"]
)
}
func testKeepsTitleAndBodyWhenTheyMatch() {
XCTAssertEqual(
items(from: "买牛奶", clipboard: "买牛奶"),
["买牛奶\(AINoteExport.fieldSeparator)买牛奶"]
)
}
func testTruncatesLongTitle() {
let title = String(repeating: "", count: 50)
let items = items(from: title, clipboard: "正文")
XCTAssertEqual(items.count, 1)
let fields = items[0].components(separatedBy: AINoteExport.fieldSeparator)
XCTAssertEqual(fields.count, 2)
XCTAssertEqual(fields[0].count, AINoteExport.maximumTitleLength)
XCTAssertEqual(fields[1], "正文")
}
func testWholeClipboardEchoFallsBack() {
let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4)
let items = items(from: source, clipboard: source)
XCTAssertEqual(items.count, 1)
XCTAssertTrue(items[0].hasPrefix("8月13日 · "))
XCTAssertTrue(items[0].hasSuffix("\(AINoteExport.fieldSeparator)\(source)"))
}
func testPromptAsksForTitleOnly() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.saveToNotesID,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
now: now
)
XCTAssertTrue(prompt.contains("标题"))
XCTAssertTrue(prompt.contains("不要输出正文"))
XCTAssertTrue(prompt.contains("不要改写"))
}
func testEnglishPromptAsksForTitleOnly() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.saveToNotesID,
locale: "en",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
now: now
)
XCTAssertTrue(prompt.contains("title"))
XCTAssertTrue(prompt.contains("do not output the body"))
}
private func items(
from answer: String,
clipboard: String?,
locale: String = "zh"
) -> [String] {
AINoteExport.items(
from: answer,
sourceClipboard: clipboard,
now: now,
locale: locale,
calendar: calendar
)
}
private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date {
calendar.date(
from: DateComponents(
year: year,
month: month,
day: day,
hour: hour,
minute: minute
)
)!
}
}
+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() {
+273
View File
@@ -0,0 +1,273 @@
#!/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"
SHORTCUT_NAME = "OSGExtractEvents"
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": SHORTCUT_NAME,
"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()
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Build the companion Shortcut for the Extract Tasks skill.
Writes an unsigned binary plist that `shortcuts sign` can notarize.
Workflow (must materialize Shortcut Input as Text first Split Text's
WFInput/ExtensionInput binding finishes the run without iterating):
Text (Shortcut Input)
Split Text (new lines)
Repeat Each
Add New Reminder (title = Repeat Item, default list, no composer)
"""
from __future__ import annotations
import plistlib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts"
UNSIGNED = OUT_DIR / "OSGExtractTodos.unsigned.shortcut"
SIGNED = OUT_DIR / "OSGExtractTodos.shortcut"
SHORTCUT_NAME = "OSGExtractTodos"
OBJECT_REPLACEMENT = "\ufffc"
TEXT_UUID = "6A2D0A80-3B1C-4E00-8A5A-FD7E1B0C396F"
SPLIT_UUID = "7A3E1B90-4C2D-4F11-9A6B-0E8F2C1D4A70"
REPEAT_GROUP = "2B9C8D11-55AA-4E02-B3C4-91F0E6A7B812"
REPEAT_START = "3C0D9E22-66BB-4F13-A4D5-02A1F7B8C923"
REMINDER_UUID = "4D1E0F33-77CC-4014-B5E6-13B2A8C9D034"
REPEAT_END = "5E2F1044-88DD-4125-C6F7-24C3B9DAE145"
def attachment(value: dict) -> dict:
return {"Value": value, "WFSerializationType": "WFTextTokenAttachment"}
def token_string(attachment_value: dict) -> dict:
return {
"Value": {
"attachmentsByRange": {"{0, 1}": attachment_value},
"string": OBJECT_REPLACEMENT,
},
"WFSerializationType": "WFTextTokenString",
}
def shortcut_input_text() -> dict:
return token_string({"Type": "ExtensionInput"})
def action_output_text(output_name: str, output_uuid: str) -> dict:
return token_string(
{
"Aggrandizements": [],
"OutputName": output_name,
"OutputUUID": output_uuid,
"Type": "ActionOutput",
}
)
def named_variable_text(variable_name: str) -> dict:
return token_string(
{
"Aggrandizements": [],
"Type": "Variable",
"VariableName": variable_name,
}
)
def workflow() -> dict:
return {
"WFWorkflowClientVersion": "3600.0.4",
"WFWorkflowClientRelease": "26.0",
"WFWorkflowMinimumClientVersion": 900,
"WFWorkflowMinimumClientVersionString": "900",
"WFWorkflowName": SHORTCUT_NAME,
"WFWorkflowHasShortcutInputVariables": True,
"WFWorkflowHasOutputFallback": False,
"WFWorkflowImportQuestions": [],
# Empty types: a normal library shortcut. QuickActions/ActionExtension
# made `run-shortcut?name=` open Shortcuts without running it.
"WFWorkflowTypes": [],
"WFWorkflowInputContentItemClasses": [
"WFStringContentItem",
"WFRichTextContentItem",
],
"WFWorkflowOutputContentItemClasses": [],
"WFWorkflowNoInputBehavior": {
"Name": "WFWorkflowNoInputBehaviorAskForInput",
"Parameters": {
"ItemClass": "WFStringContentItem",
},
},
"WFWorkflowIcon": {
"WFWorkflowIconGlyphNumber": 59511,
"WFWorkflowIconStartColor": 2071128575,
},
"WFWorkflowActions": [
{
"WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
"WFWorkflowActionParameters": {
"UUID": TEXT_UUID,
"CustomOutputName": "快捷指令文本",
"WFTextActionText": shortcut_input_text(),
},
},
{
"WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
"WFWorkflowActionParameters": {
"UUID": SPLIT_UUID,
# Split Text reads `text`, not `WFInput`.
"text": action_output_text("快捷指令文本", TEXT_UUID),
"WFTextSeparator": "New Lines",
},
},
{
"WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
"WFWorkflowActionParameters": {
"UUID": REPEAT_START,
"GroupingIdentifier": REPEAT_GROUP,
"WFControlFlowMode": 0,
"WFInput": attachment(
{
"Aggrandizements": [],
"OutputName": "Split Text",
"OutputUUID": SPLIT_UUID,
"Type": "ActionOutput",
}
),
},
},
{
"WFWorkflowActionIdentifier": "is.workflow.actions.addnewreminder",
"WFWorkflowActionParameters": {
"UUID": REMINDER_UUID,
"WFCalendarItemTitle": named_variable_text("Repeat Item"),
"WFCalendarItemShowComposer": False,
"ShowComposeSheet": False,
"WFShowWhenRun": False,
},
},
{
"WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
"WFWorkflowActionParameters": {
"UUID": REPEAT_END,
"GroupingIdentifier": REPEAT_GROUP,
"WFControlFlowMode": 2,
},
},
],
}
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY))
print(f"wrote {UNSIGNED}")
print(
"sign with: shortcuts sign --mode anyone "
f"--input {UNSIGNED} --output {SIGNED}"
)
if __name__ == "__main__":
main()
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Build the companion Shortcut for the Save to Notes skill.
Writes an unsigned binary plist that `shortcuts sign` can notarize.
On iPhone, `com.apple.mobilenotes.SharingExtension` binds note content through
`WFCreateNoteInput`. The similarly named `contents` App Intent parameter is
ignored by this legacy action and leaves an unresolved Content placeholder.
Text (Shortcut Input)
Split Text (`||OSG_NOTE||`)
Get item 1 (title) and item 2 (body)
Text (title + blank line + body)
Create Note (WFCreateNoteInput = Text output, silent)
"""
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 / "OSGSaveToNotes.unsigned.shortcut"
SIGNED = OUT_DIR / "OSGSaveToNotes.shortcut"
OBJECT_REPLACEMENT = "\ufffc"
FIELD_SEPARATOR = "||OSG_NOTE||"
TEXT_UUID = "B2C3D4E0-1111-4E00-8A5A-FD7E1B0C396F"
SPLIT_UUID = "B2C3D4E2-3333-4F11-9A6B-0E8F2C1D4A70"
TITLE_UUID = "B2C3D4E3-4444-4014-B5E6-13B2A8C9D034"
BODY_UUID = "B2C3D4E4-5555-4125-C6F7-24C3B9DAE145"
NOTE_TEXT_UUID = "B2C3D4E5-6666-4236-D7A8-35D4CAEBF256"
NOTE_UUID = "B2C3D4E1-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 action_output_attachment(output_name: str, output_uuid: str) -> dict:
return {
"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_UUID),
"WFItemSpecifier": "Item At Index",
"WFItemIndex": index,
},
}
def note_text() -> dict:
return {
"Value": {
"attachmentsByRange": {
"{0, 1}": {
"Aggrandizements": [],
"OutputName": "标题",
"OutputUUID": TITLE_UUID,
"Type": "ActionOutput",
},
"{3, 1}": {
"Aggrandizements": [],
"OutputName": "正文",
"OutputUUID": BODY_UUID,
"Type": "ActionOutput",
},
},
"string": f"{OBJECT_REPLACEMENT}\n\n{OBJECT_REPLACEMENT}",
},
"WFSerializationType": "WFTextTokenString",
}
def create_note_action() -> dict:
# The Create Note editor must show the `备忘录文本` magic-variable pill.
# A generic `内容` placeholder means this key or value was not recognized.
return {
"WFWorkflowActionIdentifier": "com.apple.mobilenotes.SharingExtension",
"WFWorkflowActionParameters": {
"UUID": NOTE_UUID,
"CustomOutputName": "备忘录",
"IntentAppIdentifier": "com.apple.mobilenotes",
"WFCreateNoteInput": action_output_text("备忘录文本", NOTE_TEXT_UUID),
"ShowWhenRun": False,
"WFShowWhenRun": False,
},
}
def workflow() -> dict:
return {
"WFWorkflowClientVersion": "3600.0.4",
"WFWorkflowClientRelease": "26.0",
"WFWorkflowMinimumClientVersion": 900,
"WFWorkflowMinimumClientVersionString": "900",
"WFWorkflowName": "OSGSaveToNotes",
"WFWorkflowHasShortcutInputVariables": True,
"WFWorkflowHasOutputFallback": False,
"WFWorkflowImportQuestions": [],
"WFWorkflowTypes": [],
"WFWorkflowInputContentItemClasses": [
"WFStringContentItem",
"WFRichTextContentItem",
],
"WFWorkflowOutputContentItemClasses": [],
"WFWorkflowNoInputBehavior": {
"Name": "WFWorkflowNoInputBehaviorAskForInput",
"Parameters": {
"ItemClass": "WFStringContentItem",
},
},
"WFWorkflowIcon": {
"WFWorkflowIconGlyphNumber": 61440,
"WFWorkflowIconStartColor": 2555137535,
},
"WFWorkflowActions": [
{
"WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
"WFWorkflowActionParameters": {
"UUID": TEXT_UUID,
"CustomOutputName": "快捷指令文本",
"WFTextActionText": shortcut_input_text(),
},
},
{
"WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
"WFWorkflowActionParameters": {
"UUID": SPLIT_UUID,
"CustomOutputName": "字段",
"text": action_output_text("快捷指令文本", TEXT_UUID),
"WFTextSeparator": "Custom",
"WFTextCustomSeparator": FIELD_SEPARATOR,
},
},
get_item(TITLE_UUID, "标题", 1),
get_item(BODY_UUID, "正文", 2),
{
"WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
"WFWorkflowActionParameters": {
"UUID": NOTE_TEXT_UUID,
"CustomOutputName": "备忘录文本",
"WFTextActionText": note_text(),
},
},
create_note_action(),
],
}
def validate(data: dict) -> None:
"""Reject bindings that make Create Note prompt for missing content."""
actions = data["WFWorkflowActions"]
create_note = actions[-1]
parameters = create_note["WFWorkflowActionParameters"]
note_input = parameters.get("WFCreateNoteInput")
if create_note["WFWorkflowActionIdentifier"] != "com.apple.mobilenotes.SharingExtension":
raise ValueError("last action must be Create Note")
if "contents" in parameters or not note_input:
raise ValueError("Create Note must use WFCreateNoteInput, not contents")
if note_input.get("WFSerializationType") != "WFTextTokenString":
raise ValueError("WFCreateNoteInput must be a Text magic-variable token")
attachments = note_input["Value"]["attachmentsByRange"].values()
if not any(item.get("OutputUUID") == NOTE_TEXT_UUID for item in attachments):
raise ValueError("WFCreateNoteInput must reference the combined note text")
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
data = workflow()
validate(data)
UNSIGNED.write_bytes(plistlib.dumps(data, fmt=plistlib.FMT_BINARY))
print(f"wrote {UNSIGNED}")
print(
"sign with: shortcuts sign --mode anyone "
f"--input {UNSIGNED} --output {SIGNED}"
)
if __name__ == "__main__":
main()
+6
View File
@@ -132,6 +132,12 @@
"OSGKeyboardTests/CursorNavigationTests",
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
"OSGKeyboardTests/AIHintPoolTests",
"OSGKeyboardTests/AIHintKeywordExtractorTests",
"OSGKeyboardTests/AIAgentSkillLayoutTests",
"OSGKeyboardTests/AIUserSkillTests",
"OSGKeyboardTests/AIUserSkillStoreTests",
"OSGKeyboardTests/AIEventExtractionTests",
"OSGKeyboardTests/AIAddressExtractionTests",
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
"OSGKeyboardTests/ClipboardHistoryStoreTests"
]
+18 -1
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: "71"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target
@@ -76,6 +76,7 @@ targets:
# crashes when both are passed. iOS 26 uses Icon Composer only.
- "Assets.xcassets/AppIcon.appiconset"
- "Resources/CustomLanguageModel/**"
- "Resources/Shortcuts/**"
# Real keyboard chrome for DEBUG what's-new recording (same UI as Ext).
- path: OSGKeyboardExt/Utilities/ExtL10n.swift
- path: OSGKeyboardExt/Views/AIKeyboardView.swift
@@ -87,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/OSGSaveToNotes.shortcut
type: file
buildPhase: resources
entitlements:
path: OSGKeyboard/OSGKeyboard.entitlements
properties:
@@ -194,6 +206,11 @@ targets:
- zalo
- skype
- zoomus
- shortcuts
- iosamap
- amapuri
- baidumap
- maps
- things
- todoist
- evernote