merge: bring AI agent skills into main
This commit is contained in:
@@ -87,5 +87,6 @@ CHANGES.md
|
|||||||
|
|
||||||
# Local scratch: DerivedData clones, stress logs, QA frames, AI notes, audits
|
# Local scratch: DerivedData clones, stress logs, QA frames, AI notes, audits
|
||||||
.tmp/
|
.tmp/
|
||||||
|
.ai-skills-layout-shots/
|
||||||
.workbuddy-ai/
|
.workbuddy-ai/
|
||||||
reports/
|
reports/
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
|||||||
|
|
||||||
### Version format
|
### 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 |
|
| Field | File | Rule |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
|
|||||||
+25
-1
@@ -7,7 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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 Note’s 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 card’s continuous rounded rect so square white corners no longer show. / **技能拖动圆角**:拖起预览按卡片连续圆角裁剪,去掉圆角外的直角白底。
|
||||||
|
|
||||||
### Changed
|
### 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, I’ve 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 keywords**: chips show the entity (from feed `metadata.title` / city / holiday name) instead of a long sentence; category prefixes like「全网热点:」are dropped, and LLM compression is only a last resort. / **AI 空闲建议关键词**:芯片展示实体(来自 feed 的 `metadata.title` / 城市 / 节日名)而不再是长句;去掉「全网热点:」一类前缀,LLM 压缩仅作兜底。
|
||||||
- **AI idle hint chrome**: each rotating suggestion sits in a Liquid Glass capsule with a category SF Symbol (calendar, weather, news, stocks, trending, search). / **AI 空闲建议样式**:轮播建议放入 Liquid Glass 胶囊,左侧为类型 SF Symbol(日历、天气、新闻、股票、热搜、搜索)。
|
- **AI idle hint chrome**: each rotating suggestion sits in a Liquid Glass capsule with a category SF Symbol (calendar, weather, news, stocks, trending, search). / **AI 空闲建议样式**:轮播建议放入 Liquid Glass 胶囊,左侧为类型 SF Symbol(日历、天气、新闻、股票、热搜、搜索)。
|
||||||
- **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 秒内,空闲态改为小圆形「回复 / 总结 / 翻译」按钮;翻译跟随键盘目标语言,未设置时中英互译。技能来自目录,便于日后扩展。
|
- **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 内边距。
|
- **AI idle capsule size**: rotating suggestion chips keep a 44 pt tap height with 16 pt side padding. / **AI 空闲胶囊尺寸**:轮播建议芯片保持 44 pt 点击高度,左右各 16 pt 内边距。
|
||||||
- **Undo / translation chrome**: voice mic-row undo and translation controls are 52 pt circular Liquid Glass buttons. / **撤销与翻译按钮**:语音麦克风行的撤销、翻译改为 52 pt 圆形 Liquid Glass 按钮。
|
- **Undo / translation chrome**: voice mic-row undo and translation controls are 52 pt circular Liquid Glass buttons. / **撤销与翻译按钮**:语音麦克风行的撤销、翻译改为 52 pt 圆形 Liquid Glass 按钮。
|
||||||
- **Clipboard skill circles**: Reply / Summarize / Translate idle buttons match the same 52 pt circle. / **剪贴板技能圆钮**:回复 / 总结 / 翻译空闲按钮与语音侧键同为 52 pt 圆。
|
- **Clipboard skill circles**: Reply / Summarize / Translate idle buttons match the same 52 pt circle. / **剪贴板技能圆钮**:回复 / 总结 / 翻译空闲按钮与语音侧键同为 52 pt 圆。
|
||||||
- **Home tab selection**: the dock stays Liquid Glass at its original height; the selected tab is a wider green fill capsule with a 5 pt inset, not a second glass chip. Dock items are 24 pt icons with Home / Styles / Settings labels. / **首页 Tab 选中态**:dock 仍是原高度 Liquid Glass;选中项改为更宽的绿色填充胶囊,距栏边 5 pt,不再套第二层玻璃。dock 为 24 pt 图标加「首页 / 风格 / 设置」文字。
|
- **Home tab selection**: the dock stays Liquid Glass at its original height; the selected tab is a wider green fill capsule with a 5 pt inset, not a second glass chip. Dock items are 24 pt icons with Home / Skills / Styles / Settings labels. / **首页 Tab 选中态**:dock 仍是原高度 Liquid Glass;选中项改为更宽的绿色填充胶囊,距栏边 5 pt,不再套第二层玻璃。dock 为 24 pt 图标加「首页 / 技能 / 风格 / 设置」文字。
|
||||||
- **Home library card titles**: History and Personal dictionary headers use a 16 pt icon and 13 pt label. / **首页资料卡标题**:历史与个性词库标题改为 16 pt 图标、13 pt 文字。
|
- **Home library card titles**: History and Personal dictionary headers use a 16 pt icon and 13 pt label. / **首页资料卡标题**:历史与个性词库标题改为 16 pt 图标、13 pt 文字。
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -69,6 +69,11 @@
|
|||||||
<string>zalo</string>
|
<string>zalo</string>
|
||||||
<string>skype</string>
|
<string>skype</string>
|
||||||
<string>zoomus</string>
|
<string>zoomus</string>
|
||||||
|
<string>shortcuts</string>
|
||||||
|
<string>iosamap</string>
|
||||||
|
<string>amapuri</string>
|
||||||
|
<string>baidumap</string>
|
||||||
|
<string>maps</string>
|
||||||
<string>things</string>
|
<string>things</string>
|
||||||
<string>todoist</string>
|
<string>todoist</string>
|
||||||
<string>evernote</string>
|
<string>evernote</string>
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ struct OSGKeyboardApp: App {
|
|||||||
EditDemoView()
|
EditDemoView()
|
||||||
} else if ProcessInfo.processInfo.arguments.contains("--ai-demo") {
|
} else if ProcessInfo.processInfo.arguments.contains("--ai-demo") {
|
||||||
AIKeyboardDemoView()
|
AIKeyboardDemoView()
|
||||||
|
} else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
|
||||||
|
AIClipboardSkillLayoutDemoView()
|
||||||
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
|
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
|
||||||
ClipboardHistoryDemoView()
|
ClipboardHistoryDemoView()
|
||||||
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
|
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,41 @@
|
|||||||
|
// AIAgentShortcutInstaller.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Opening the HTTPS iCloud share page from the app is claimed as a
|
||||||
|
// Universal Link and often lands on Gallery. `shortcuts://shortcuts/TOKEN`
|
||||||
|
// opens the Add sheet in Shortcuts directly.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
enum AIAgentShortcutInstaller {
|
||||||
|
@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 utteranceId = command.utteranceId
|
||||||
let commandSeq = command.commandSeq
|
let commandSeq = command.commandSeq
|
||||||
let sessionId = command.sessionId
|
let sessionId = command.sessionId
|
||||||
|
let thinkingEnabled = command.aiThinkingEnabled ?? true
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
await self?.answerPrefilledAIQuestion(
|
await self?.answerPrefilledAIQuestion(
|
||||||
question: question,
|
question: question,
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
utteranceId: utteranceId,
|
utteranceId: utteranceId,
|
||||||
commandSeq: commandSeq
|
commandSeq: commandSeq,
|
||||||
|
thinkingEnabled: thinkingEnabled
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1385,7 +1387,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
conversationID: UUID?,
|
conversationID: UUID?,
|
||||||
sessionId: UUID,
|
sessionId: UUID,
|
||||||
utteranceId: UUID,
|
utteranceId: UUID,
|
||||||
commandSeq: Int64
|
commandSeq: Int64,
|
||||||
|
thinkingEnabled: Bool
|
||||||
) async {
|
) async {
|
||||||
// Hint-card / prefilled questions never go through `finalizeUtterance`,
|
// Hint-card / prefilled questions never go through `finalizeUtterance`,
|
||||||
// so this path must drop the processing gate itself. Leaving it set
|
// so this path must drop the processing gate itself. Leaving it set
|
||||||
@@ -1415,7 +1418,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let service = try AIQuestionService.configured(
|
let service = try AIQuestionService.configured(
|
||||||
store: pipelineStore,
|
store: pipelineStore,
|
||||||
conversations: aiConversations
|
conversations: aiConversations,
|
||||||
|
thinkingEnabled: thinkingEnabled
|
||||||
)
|
)
|
||||||
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||||
let answer = try await service.answer(
|
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
|
||||||
@@ -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
|
// MinimalTabBar.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
//
|
//
|
||||||
// Bottom tab bar — icon + label for Home / Styles / Settings.
|
// Bottom tab bar — icon + label for Home / Skills / Styles / Settings.
|
||||||
// The dock capsule is iOS 26 Liquid Glass; the selected tab is a green
|
// The dock capsule is iOS 26 Liquid Glass; the selected tab is a green
|
||||||
// fill inside that capsule (Photos-style), not a second glass layer.
|
// fill inside that capsule (Photos-style), not a second glass layer.
|
||||||
// History + dictionary live as Home cards (not dock tabs).
|
// History + dictionary live as Home cards (not dock tabs).
|
||||||
@@ -11,41 +11,41 @@ import OSGKeyboardShared
|
|||||||
|
|
||||||
enum AppTab: Int, CaseIterable {
|
enum AppTab: Int, CaseIterable {
|
||||||
case keyboard
|
case keyboard
|
||||||
|
case skills
|
||||||
case styles
|
case styles
|
||||||
case settings
|
case settings
|
||||||
|
|
||||||
var icon: MaterialIconName {
|
|
||||||
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 {
|
var accessibilityKey: LocalizedStringKey {
|
||||||
switch self {
|
switch self {
|
||||||
case .keyboard: return "tab.keyboard"
|
case .keyboard: return "tab.keyboard"
|
||||||
|
case .skills: return "tab.skills"
|
||||||
case .styles: return "tab.styles"
|
case .styles: return "tab.styles"
|
||||||
case .settings: return "tab.settings"
|
case .settings: return "tab.settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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).
|
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
|
||||||
var sidebarTitle: LocalizedStringKey { accessibilityKey }
|
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 {
|
switch self {
|
||||||
case .keyboard: return "house"
|
case .keyboard: return "house"
|
||||||
case .styles: return "text.badge.star"
|
case .skills: return "wand.and.sparkles"
|
||||||
case .settings: return "gearshape"
|
case .styles: return selected ? "dial.high.fill" : "dial.high"
|
||||||
|
case .settings: return selected ? "gearshape.fill" : "gearshape"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,14 +65,8 @@ struct MinimalTabBar: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Group {
|
Image(systemName: tab.dockSystemImage(selected: selection == tab))
|
||||||
if let sfSymbol = tab.sfSymbol {
|
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
|
||||||
Image(systemName: sfSymbol)
|
|
||||||
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
|
|
||||||
} else {
|
|
||||||
MaterialIcon(name: tab.icon, size: TabBarDockMetrics.iconSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(tab.accessibilityKey)
|
Text(tab.accessibilityKey)
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -110,7 +104,7 @@ struct MinimalTabBar: View {
|
|||||||
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
|
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
|
||||||
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
|
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
|
||||||
.glassEffect(.regular.interactive(), in: .capsule)
|
.glassEffect(.regular.interactive(), in: .capsule)
|
||||||
.frame(maxWidth: 280)
|
.frame(maxWidth: 360)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.bottom, TabBarDockMetrics.bottomPadding)
|
.padding(.bottom, TabBarDockMetrics.bottomPadding)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ extension View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum TabBarDockMetrics {
|
enum TabBarDockMetrics {
|
||||||
static let itemHeight: CGFloat = 52
|
static let itemHeight: CGFloat = 46
|
||||||
static let iconSize: CGFloat = 24
|
static let iconSize: CGFloat = 22
|
||||||
/// Horizontal glass pad is 0: the selected capsule's `selectionInset`
|
/// Horizontal glass pad is 0: the selected capsule's `selectionInset`
|
||||||
/// is the only side gap. `Spacing.md` (16) plus that 5 pt was ~3–4×
|
/// is the only side gap. `Spacing.md` (16) plus that 5 pt was ~3–4×
|
||||||
/// the 5 pt top/bottom gap.
|
/// the 5 pt top/bottom gap.
|
||||||
static let dockInsetHorizontal: CGFloat = 0
|
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.
|
/// Gap between the selected fill and the glass dock / neighbouring tabs.
|
||||||
static let selectionInset: CGFloat = 5
|
static let selectionInset: CGFloat = 5
|
||||||
static let bottomPadding: CGFloat = Spacing.xs
|
static let bottomPadding: CGFloat = Spacing.xs
|
||||||
|
|||||||
@@ -214,6 +214,12 @@ struct MainAppRoot: View {
|
|||||||
switch url.host {
|
switch url.host {
|
||||||
case "startflow":
|
case "startflow":
|
||||||
flowManager.startSession(coldStart: true, reason: "url.startflow")
|
flowManager.startSession(coldStart: true, reason: "url.startflow")
|
||||||
|
case "skill":
|
||||||
|
if url.path.contains("shortcut-result") {
|
||||||
|
AIAgentShortcutRunner.logShortcutCallback(url)
|
||||||
|
} else if url.path.contains("run") {
|
||||||
|
AIAgentShortcutRunner.runPendingIfNeeded()
|
||||||
|
}
|
||||||
case "deployrime":
|
case "deployrime":
|
||||||
// The keyboard sends the user here precisely because typing
|
// The keyboard sends the user here precisely because typing
|
||||||
// resources are missing — deploy without waiting for warmup.
|
// resources are missing — deploy without waiting for warmup.
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ private struct WideSidebarRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage)
|
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage(selected: isSelected))
|
||||||
.labelStyle(SidebarIconColumnLabelStyle())
|
.labelStyle(SidebarIconColumnLabelStyle())
|
||||||
// Slightly larger label + taller vertical padding so each
|
// Slightly larger label + taller vertical padding so each
|
||||||
// sidebar row hits the Apple HIG 44pt touch target on iPad
|
// 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
|
/// (`house`) — enough to push titles apart by ~5pt. `List` reserves that column
|
||||||
/// automatically; this hand-rolled `VStack` sidebar has to do it itself.
|
/// automatically; this hand-rolled `VStack` sidebar has to do it itself.
|
||||||
private struct SidebarIconColumnLabelStyle: LabelStyle {
|
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
|
private static let iconColumnWidth: CGFloat = 22
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ struct MainTabContent: View {
|
|||||||
switch tab {
|
switch tab {
|
||||||
case .keyboard:
|
case .keyboard:
|
||||||
HomeView()
|
HomeView()
|
||||||
|
case .skills:
|
||||||
|
AIAgentSkillsView()
|
||||||
case .styles:
|
case .styles:
|
||||||
PolishStylesView()
|
PolishStylesView()
|
||||||
case .settings:
|
case .settings:
|
||||||
|
|||||||
@@ -142,26 +142,14 @@ struct PolishStylesView: View {
|
|||||||
editingPack = pack
|
editingPack = pack
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: pack.kind == .builtin ? "eye" : "pencil")
|
CatalogCardChrome.editIcon(palette: palette)
|
||||||
.font(.system(size: 13, weight: .semibold))
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
.frame(width: 30, height: 30)
|
|
||||||
.background(palette.background.opacity(0.75), in: Circle())
|
|
||||||
}
|
}
|
||||||
.padding(Spacing.sm)
|
.padding(Spacing.sm)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(Text("polishStyles.edit"))
|
||||||
Text(pack.kind == .builtin ? "polishStyles.viewPrompt" : "polishStyles.edit")
|
|
||||||
)
|
|
||||||
|
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
CatalogCardChrome.checkIcon(palette: palette)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@@ -427,8 +427,74 @@
|
|||||||
"tab.history" = "History";
|
"tab.history" = "History";
|
||||||
"tab.dictionary" = "Dictionary";
|
"tab.dictionary" = "Dictionary";
|
||||||
"tab.styles" = "Styles";
|
"tab.styles" = "Styles";
|
||||||
|
"tab.skills" = "Skills";
|
||||||
"tab.settings" = "Settings";
|
"tab.settings" = "Settings";
|
||||||
|
|
||||||
|
/* AI Agent skills */
|
||||||
|
"skills.title" = "Skills";
|
||||||
|
"skills.enabled.section" = "In use (%d/%d)";
|
||||||
|
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
||||||
|
"skills.available.section" = "Available";
|
||||||
|
"skills.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. Don’t 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. Don’t rename it in the Shortcuts app after adding.";
|
||||||
|
"skills.editor.lookingUp" = "Looking up Shortcut name…";
|
||||||
|
"skills.editor.resolvedName" = "Will run: %@";
|
||||||
|
"skills.editor.lookupFailed" = "Couldn’t 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" = "Couldn’t 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 */
|
/* Polish style packs */
|
||||||
"polishStyles.title" = "Polish styles";
|
"polishStyles.title" = "Polish styles";
|
||||||
"polishStyles.add" = "Add style";
|
"polishStyles.add" = "Add style";
|
||||||
|
|||||||
@@ -426,8 +426,74 @@
|
|||||||
"tab.history" = "历史";
|
"tab.history" = "历史";
|
||||||
"tab.dictionary" = "词库";
|
"tab.dictionary" = "词库";
|
||||||
"tab.styles" = "风格";
|
"tab.styles" = "风格";
|
||||||
|
"tab.skills" = "技能";
|
||||||
"tab.settings" = "设置";
|
"tab.settings" = "设置";
|
||||||
|
|
||||||
|
/* AI Agent 技能 */
|
||||||
|
"skills.title" = "技能";
|
||||||
|
"skills.enabled.section" = "使用中(%d/%d)";
|
||||||
|
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
||||||
|
"skills.available.section" = "可添加";
|
||||||
|
"skills.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.title" = "润色风格";
|
||||||
"polishStyles.add" = "添加风格";
|
"polishStyles.add" = "添加风格";
|
||||||
|
|||||||
@@ -520,6 +520,11 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.submitAIClipboardSkill = { [weak self] skill in
|
state.submitAIClipboardSkill = { [weak self] skill in
|
||||||
self?.aiKeyboardCoordinator.submitClipboardSkill(skill)
|
self?.aiKeyboardCoordinator.submitClipboardSkill(skill)
|
||||||
}
|
}
|
||||||
|
state.runClipboardExportSkill = { [weak self] skillID, titles in
|
||||||
|
AppGroupStore().setPendingShortcutRun(skillID: skillID, titles: titles)
|
||||||
|
AIAgentShortcutRun.trace("keyboard.openHost osgkeyboard://skill/run")
|
||||||
|
self?.openSkillShortcutRun()
|
||||||
|
}
|
||||||
state.openSettings = { [weak self] in self?.openHostApp() }
|
state.openSettings = { [weak self] in self?.openHostApp() }
|
||||||
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
|
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
|
||||||
state.openClipboardSettings = { [weak self] in
|
state.openClipboardSettings = { [weak self] in
|
||||||
@@ -768,10 +773,30 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole {
|
private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole {
|
||||||
switch returnKeyType {
|
switch returnKeyType {
|
||||||
case .send, .go, .search, .join, .route, .google, .yahoo, .continue, .emergencyCall:
|
case .default:
|
||||||
return .send
|
|
||||||
case .default, .next, .done:
|
|
||||||
return .newline
|
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:
|
@unknown default:
|
||||||
return .newline
|
return .newline
|
||||||
}
|
}
|
||||||
@@ -942,6 +967,19 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
// MARK: - Open host app
|
// MARK: - Open host app
|
||||||
|
|
||||||
|
private func openSkillShortcutRun() {
|
||||||
|
guard hasFullAccess else {
|
||||||
|
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let url = URL(string: "osgkeyboard://skill/run") else { return }
|
||||||
|
HostAppLauncher.open(url: url, from: self) { [weak self] success in
|
||||||
|
AIAgentShortcutRun.trace("keyboard.openHost result success=\(success)")
|
||||||
|
if success { return }
|
||||||
|
self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.handoffFailed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func openHostApp(path: String = "settings") {
|
private func openHostApp(path: String = "settings") {
|
||||||
guard hasFullAccess else {
|
guard hasFullAccess else {
|
||||||
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
|
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||||||
|
|||||||
@@ -70,16 +70,32 @@ final class AIKeyboardCoordinator {
|
|||||||
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
||||||
guard canAcceptIdleSubmit else { return }
|
guard canAcceptIdleSubmit else { return }
|
||||||
enterIfNeeded()
|
enterIfNeeded()
|
||||||
|
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||||
|
if skill.kind == .export {
|
||||||
|
state.pendingClipboardSkillID = skill.id
|
||||||
|
state.pendingClipboardSkillSource = material
|
||||||
|
} else {
|
||||||
|
clearPendingExportSkill()
|
||||||
|
}
|
||||||
let instruction = AIClipboardSkillCatalog.instruction(
|
let instruction = AIClipboardSkillCatalog.instruction(
|
||||||
for: skill,
|
for: skill,
|
||||||
locale: AIHintLocaleResolver.packLocale(),
|
locale: AIHintLocaleResolver.packLocale(),
|
||||||
translationTargetLocaleId: state.translationTargetLocaleId
|
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(
|
let resolution = AIClipboardPrompt.resolve(
|
||||||
instruction: instruction,
|
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.
|
/// 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 {
|
guard case .ready(let prompt) = resolution else {
|
||||||
// The clipboard window closed between rendering and this tap.
|
// The clipboard window closed between rendering and this tap.
|
||||||
|
clearPendingExportSkill()
|
||||||
state.aiSession.fail(
|
state.aiSession.fail(
|
||||||
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
|
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
|
||||||
utteranceID: nil
|
utteranceID: nil
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let conversationID = state.aiSession.conversationID else { return }
|
guard let conversationID = state.aiSession.conversationID else {
|
||||||
|
clearPendingExportSkill()
|
||||||
|
return
|
||||||
|
}
|
||||||
let disposition = flow.submitAIQuestion(
|
let disposition = flow.submitAIQuestion(
|
||||||
text: prompt,
|
text: prompt,
|
||||||
conversationID: conversationID
|
conversationID: conversationID,
|
||||||
|
thinkingEnabled: thinkingEnabled
|
||||||
)
|
)
|
||||||
if case .rejected(let rejection) = disposition {
|
if case .rejected(let rejection) = disposition {
|
||||||
|
clearPendingExportSkill()
|
||||||
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
guard state.aiSession.isBusy else { return }
|
guard state.aiSession.isBusy else { return }
|
||||||
|
clearPendingExportSkill()
|
||||||
flow.cancelAIRecording()
|
flow.cancelAIRecording()
|
||||||
state.aiSession.cancelCurrentWork()
|
state.aiSession.cancelCurrentWork()
|
||||||
}
|
}
|
||||||
@@ -132,7 +158,7 @@ final class AIKeyboardCoordinator {
|
|||||||
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
||||||
guard insertAnswer(answer) else { return }
|
guard insertAnswer(answer) else { return }
|
||||||
state.aiSession.markAnswerInserted(
|
state.aiSession.markAnswerInserted(
|
||||||
offersSend: state.returnKeyRole == .send
|
offersSend: state.returnKeyRole.usesActionFill
|
||||||
)
|
)
|
||||||
} else if state.aiSession.canSend {
|
} else if state.aiSession.canSend {
|
||||||
state.aiSession.markAnswerSent()
|
state.aiSession.markAnswerSent()
|
||||||
@@ -181,6 +207,7 @@ final class AIKeyboardCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func receivePartialAnswer(_ draft: String, utteranceID: UUID) {
|
func receivePartialAnswer(_ draft: String, utteranceID: UUID) {
|
||||||
|
if isPendingExportSkill { return }
|
||||||
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
|
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +215,11 @@ final class AIKeyboardCoordinator {
|
|||||||
guard result.resolvedUtteranceMode == .aiQuestion else {
|
guard result.resolvedUtteranceMode == .aiQuestion else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isPendingExportSkill {
|
||||||
|
guard result.aiConversationID == state.aiSession.conversationID else { return }
|
||||||
|
finishExportSkill(answer: result.text ?? "")
|
||||||
|
return
|
||||||
|
}
|
||||||
guard result.aiConversationID == state.aiSession.conversationID,
|
guard result.aiConversationID == state.aiSession.conversationID,
|
||||||
let answer = result.text,
|
let answer = result.text,
|
||||||
!answer.isEmpty else {
|
!answer.isEmpty else {
|
||||||
@@ -201,14 +233,105 @@ final class AIKeyboardCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fail(_ message: String, utteranceID: UUID?) {
|
func fail(_ message: String, utteranceID: UUID?) {
|
||||||
|
clearPendingExportSkill()
|
||||||
state.aiSession.fail(message, utteranceID: utteranceID)
|
state.aiSession.fail(message, utteranceID: utteranceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func endConversationIfNeeded() {
|
private func endConversationIfNeeded() {
|
||||||
|
clearPendingExportSkill()
|
||||||
guard let conversationID = state.aiSession.conversationID else { return }
|
guard let conversationID = state.aiSession.conversationID else { return }
|
||||||
flow.endAIConversation(conversationID)
|
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 {
|
private func message(for rejection: FlowUtteranceStartRejection) -> String {
|
||||||
switch rejection {
|
switch rejection {
|
||||||
case .onboardingIncomplete:
|
case .onboardingIncomplete:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ public struct AppGroupPersistor {
|
|||||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||||
|
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||||
applyAPIKeyAvailability(store: store, into: state)
|
applyAPIKeyAvailability(store: store, into: state)
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ public struct AppGroupPersistor {
|
|||||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||||
|
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||||
applyAPIKeyAvailability(store: store, into: state)
|
applyAPIKeyAvailability(store: store, into: state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -670,12 +670,17 @@ final class KeyboardFlowCoordinator {
|
|||||||
|
|
||||||
func submitAIQuestion(
|
func submitAIQuestion(
|
||||||
text: String,
|
text: String,
|
||||||
conversationID: UUID
|
conversationID: UUID,
|
||||||
|
thinkingEnabled: Bool? = nil
|
||||||
) -> FlowUtteranceStartDisposition {
|
) -> FlowUtteranceStartDisposition {
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
|
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
|
||||||
return startUtterance(
|
return startUtterance(
|
||||||
.aiQuestion(conversationID: conversationID, prefilledQuestion: trimmed)
|
.aiQuestion(
|
||||||
|
conversationID: conversationID,
|
||||||
|
prefilledQuestion: trimmed,
|
||||||
|
thinkingEnabled: thinkingEnabled
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1868,6 +1873,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
utteranceMode: .aiQuestion,
|
utteranceMode: .aiQuestion,
|
||||||
aiConversationID: currentUtteranceRequest?.aiConversationID,
|
aiConversationID: currentUtteranceRequest?.aiConversationID,
|
||||||
aiQuestionText: text,
|
aiQuestionText: text,
|
||||||
|
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
|
||||||
startDeadlineAt: currentStartDeadlineAt
|
startDeadlineAt: currentStartDeadlineAt
|
||||||
)
|
)
|
||||||
FlowSessionBridge.writeCommand(command)
|
FlowSessionBridge.writeCommand(command)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Grid-level UIKit touch tracking for the typing surface:
|
// Grid-level UIKit touch tracking for the typing surface:
|
||||||
// Down highlight → Move reselect → Up commit (letters / space / return),
|
// 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 SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -12,7 +13,7 @@ import OSGKeyboardShared
|
|||||||
struct TypingKeyTouchPad: UIViewRepresentable {
|
struct TypingKeyTouchPad: UIViewRepresentable {
|
||||||
var layout: TypingKeyLayout
|
var layout: TypingKeyLayout
|
||||||
var hapticIntensity: KeyboardHapticIntensity
|
var hapticIntensity: KeyboardHapticIntensity
|
||||||
var onHighlightChange: (String?) -> Void
|
var onHighlightChange: (Set<String>) -> Void
|
||||||
var onCommit: (TypingKeyHitTarget) -> Void
|
var onCommit: (TypingKeyHitTarget) -> Void
|
||||||
var onDeleteFire: () -> Void
|
var onDeleteFire: () -> Void
|
||||||
var onShiftBegan: () -> Void
|
var onShiftBegan: () -> Void
|
||||||
@@ -36,8 +37,7 @@ struct TypingKeyTouchPad: UIViewRepresentable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class Coordinator {
|
final class Coordinator {
|
||||||
var parent: TypingKeyTouchPad
|
var parent: TypingKeyTouchPad
|
||||||
private var activeKeyID: String?
|
private let tracker = TypingTouchTracker()
|
||||||
private var gestureOwnsShift = false
|
|
||||||
private var deleteRepeatTask: Task<Void, Never>?
|
private var deleteRepeatTask: Task<Void, Never>?
|
||||||
private var deleteRepeatStartedAt: Date?
|
private var deleteRepeatStartedAt: Date?
|
||||||
private var isDeleteRepeating = false
|
private var isDeleteRepeating = false
|
||||||
@@ -46,72 +46,24 @@ struct TypingKeyTouchPad: UIViewRepresentable {
|
|||||||
self.parent = parent
|
self.parent = parent
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleBegan(at point: CGPoint) {
|
func handleBegan(id: ObjectIdentifier, at point: CGPoint) {
|
||||||
resetDeleteRepeat()
|
apply(tracker.began(id: id, key: hit(at: point)))
|
||||||
gestureOwnsShift = false
|
|
||||||
guard let key = hit(at: point) else {
|
|
||||||
setHighlight(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activate(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleMoved(at point: CGPoint) {
|
func handleMoved(id: ObjectIdentifier, at point: CGPoint) {
|
||||||
let key = hit(at: point)
|
apply(tracker.moved(id: id, key: hit(at: point)))
|
||||||
if key?.id == activeKeyID { return }
|
|
||||||
|
|
||||||
if activeBehavior == .deleteRepeat {
|
|
||||||
stopDeleteRepeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let key {
|
|
||||||
activate(key)
|
|
||||||
} else {
|
|
||||||
// Outside plane: clear highlight; Shift stays held until ended.
|
|
||||||
setHighlight(nil)
|
|
||||||
activeKeyID = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEnded(at point: CGPoint) {
|
func handleEnded(id: ObjectIdentifier, at point: CGPoint) {
|
||||||
// Outside the key plane → cancel (no commit), per accuracy plan.
|
apply(tracker.ended(id: id, key: hit(at: point)))
|
||||||
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() {
|
func handleCancelled(id: ObjectIdentifier) {
|
||||||
stopDeleteRepeat()
|
apply(tracker.cancelled(id: id))
|
||||||
setHighlight(nil)
|
|
||||||
activeKeyID = nil
|
|
||||||
finishShiftIfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Internals
|
// 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? {
|
private func hit(at point: CGPoint) -> TypingKeyHitTarget? {
|
||||||
let layout = parent.layout
|
let layout = parent.layout
|
||||||
return KeyHitTesting.hitTarget(
|
return KeyHitTesting.hitTarget(
|
||||||
@@ -124,27 +76,29 @@ struct TypingKeyTouchPad: UIViewRepresentable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func activate(_ key: TypingKeyHitTarget) {
|
private func apply(_ effects: TypingTouchEffects) {
|
||||||
activeKeyID = key.id
|
if effects.stopDeleteRepeat {
|
||||||
setHighlight(key.id)
|
stopDeleteRepeat()
|
||||||
playFeedback(for: key)
|
|
||||||
|
|
||||||
switch key.behavior {
|
|
||||||
case .commitOnRelease:
|
|
||||||
break
|
|
||||||
case .deleteRepeat:
|
|
||||||
parent.onDeleteFire()
|
|
||||||
startDeleteRepeat()
|
|
||||||
case .shiftHold:
|
|
||||||
if !gestureOwnsShift {
|
|
||||||
gestureOwnsShift = true
|
|
||||||
parent.onShiftBegan()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
for key in effects.commits {
|
||||||
|
parent.onCommit(key)
|
||||||
private func setHighlight(_ id: String?) {
|
}
|
||||||
parent.onHighlightChange(id)
|
if let key = effects.playFeedback {
|
||||||
|
playFeedback(for: key)
|
||||||
|
}
|
||||||
|
if effects.deleteFire {
|
||||||
|
parent.onDeleteFire()
|
||||||
|
}
|
||||||
|
if effects.startDeleteRepeat {
|
||||||
|
startDeleteRepeat()
|
||||||
|
}
|
||||||
|
if effects.beginShift {
|
||||||
|
parent.onShiftBegan()
|
||||||
|
}
|
||||||
|
if effects.endShift {
|
||||||
|
parent.onShiftEnded()
|
||||||
|
}
|
||||||
|
parent.onHighlightChange(tracker.highlightedKeyIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playFeedback(for key: TypingKeyHitTarget) {
|
private func playFeedback(for key: TypingKeyHitTarget) {
|
||||||
@@ -207,16 +161,6 @@ struct TypingKeyTouchPad: UIViewRepresentable {
|
|||||||
deleteRepeatTask?.cancel()
|
deleteRepeatTask?.cancel()
|
||||||
deleteRepeatTask = nil
|
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) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
backgroundColor = Self.padTint
|
backgroundColor = Self.padTint
|
||||||
isMultipleTouchEnabled = false
|
isMultipleTouchEnabled = true
|
||||||
isExclusiveTouch = true
|
isExclusiveTouch = true
|
||||||
isUserInteractionEnabled = true
|
isUserInteractionEnabled = true
|
||||||
}
|
}
|
||||||
@@ -250,21 +194,30 @@ final class TypingKeyTouchPadUIView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
guard let touch = touches.first else { return }
|
for touch in Self.sorted(touches) {
|
||||||
coordinator?.handleBegan(at: touch.location(in: self))
|
coordinator?.handleBegan(id: ObjectIdentifier(touch), at: touch.location(in: self))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
guard let touch = touches.first else { return }
|
for touch in Self.sorted(touches) {
|
||||||
coordinator?.handleMoved(at: touch.location(in: self))
|
coordinator?.handleMoved(id: ObjectIdentifier(touch), at: touch.location(in: self))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
guard let touch = touches.first else { return }
|
for touch in Self.sorted(touches) {
|
||||||
coordinator?.handleEnded(at: touch.location(in: self))
|
coordinator?.handleEnded(id: ObjectIdentifier(touch), at: touch.location(in: self))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ struct TypingRootView: View {
|
|||||||
/// After the first expand, keep the panel tree mounted and only toggle
|
/// After the first expand, keep the panel tree mounted and only toggle
|
||||||
/// opacity so subsequent ▼/▲ taps stay cheap.
|
/// opacity so subsequent ▼/▲ taps stay cheap.
|
||||||
@State private var candidatePanelMounted = false
|
@State private var candidatePanelMounted = false
|
||||||
/// Key currently under the finger (grid-level touch pad).
|
/// Keys currently under a finger (grid-level touch pad, multi-touch).
|
||||||
@State private var highlightedKeyID: String?
|
@State private var highlightedKeyIDs: Set<String> = []
|
||||||
|
|
||||||
static func totalHeight(isIPad: Bool = false, width: CGFloat = 0) -> CGFloat {
|
static func totalHeight(isIPad: Bool = false, width: CGFloat = 0) -> CGFloat {
|
||||||
TypingLayoutMetrics.contentHeight(isIPad: isIPad, width: width)
|
TypingLayoutMetrics.contentHeight(isIPad: isIPad, width: width)
|
||||||
@@ -398,7 +398,7 @@ struct TypingRootView: View {
|
|||||||
TypingKeyTouchPad(
|
TypingKeyTouchPad(
|
||||||
layout: layout,
|
layout: layout,
|
||||||
hapticIntensity: state.keyboardHapticIntensity,
|
hapticIntensity: state.keyboardHapticIntensity,
|
||||||
onHighlightChange: { highlightedKeyID = $0 },
|
onHighlightChange: { highlightedKeyIDs = $0 },
|
||||||
onCommit: { commitTypingKey($0) },
|
onCommit: { commitTypingKey($0) },
|
||||||
onDeleteFire: { apply(typing.handleKey("⌫")) },
|
onDeleteFire: { apply(typing.handleKey("⌫")) },
|
||||||
onShiftBegan: { typing.beginShiftHold() },
|
onShiftBegan: { typing.beginShiftHold() },
|
||||||
@@ -448,10 +448,10 @@ struct TypingRootView: View {
|
|||||||
let pageLabel = typing.page == .letters ? "123" : "ABC"
|
let pageLabel = typing.page == .letters ? "123" : "ABC"
|
||||||
let spaceLabel = typing.language == .chinese ? "空格" : "space"
|
let spaceLabel = typing.language == .chinese ? "空格" : "space"
|
||||||
let returnLabel: String = {
|
let returnLabel: String = {
|
||||||
switch state.returnKeyRole {
|
if state.returnKeyRole.usesActionFill {
|
||||||
case .newline: return "return"
|
return ExtL10n.string(state.returnKeyRole.titleKey)
|
||||||
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
|
|
||||||
}
|
}
|
||||||
|
return "return"
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// iPad spends its extra width on comma / period like the system
|
// iPad spends its extra width on comma / period like the system
|
||||||
@@ -491,7 +491,7 @@ struct TypingRootView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func visualTypingKey(_ key: TypingKeyHitTarget) -> some View {
|
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 isBottom = key.id.hasPrefix("bottom.")
|
||||||
let isShift = key.label == "⇧"
|
let isShift = key.label == "⇧"
|
||||||
let shiftLit = isShift && typing.isShiftEnabled
|
let shiftLit = isShift && typing.isShiftEnabled
|
||||||
@@ -624,13 +624,14 @@ struct TypingRootView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var returnKeyLabel: some View {
|
private var returnKeyLabel: some View {
|
||||||
switch state.returnKeyRole {
|
if state.returnKeyRole.usesActionFill {
|
||||||
case .newline:
|
|
||||||
Image(systemName: "arrow.turn.down.left")
|
|
||||||
.font(.system(size: 21, weight: .medium))
|
|
||||||
case .send:
|
|
||||||
ExtL10n.text(state.returnKeyRole.titleKey)
|
ExtL10n.text(state.returnKeyRole.titleKey)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.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 {
|
private var returnKeyFill: Color {
|
||||||
switch state.returnKeyRole {
|
state.returnKeyRole.usesActionFill ? sendKeyFill : keyFill
|
||||||
case .newline: return keyFill
|
|
||||||
case .send: return sendKeyFill
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var returnKeyPressedFill: Color {
|
private var returnKeyPressedFill: Color {
|
||||||
switch state.returnKeyRole {
|
state.returnKeyRole.usesActionFill ? sendKeyPressedFill : keyPressedFill
|
||||||
case .newline: return keyPressedFill
|
|
||||||
case .send: return sendKeyPressedFill
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var returnKeyBorder: Color {
|
private var returnKeyBorder: Color {
|
||||||
switch state.returnKeyRole {
|
if state.returnKeyRole.usesActionFill {
|
||||||
case .newline:
|
|
||||||
return palette.divider
|
|
||||||
case .send:
|
|
||||||
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
|
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
|
||||||
}
|
}
|
||||||
|
return palette.divider
|
||||||
}
|
}
|
||||||
|
|
||||||
private var returnKeyAccessibilityLabel: String {
|
private var returnKeyAccessibilityLabel: String {
|
||||||
switch state.returnKeyRole {
|
if state.returnKeyRole.usesActionFill {
|
||||||
case .newline: return "return"
|
return ExtL10n.string(state.returnKeyRole.titleKey)
|
||||||
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
|
|
||||||
}
|
}
|
||||||
|
return "return"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var returnKeyTextColor: Color {
|
private var returnKeyTextColor: Color {
|
||||||
switch state.returnKeyRole {
|
state.returnKeyRole.usesActionFill ? .white : keyTextColor
|
||||||
case .newline:
|
|
||||||
return keyTextColor
|
|
||||||
case .send:
|
|
||||||
return .white
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The send key stays recognizable in both appearances without becoming neon.
|
/// The send key stays recognizable in both appearances without becoming neon.
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ struct AIKeyboardView: View {
|
|||||||
static let actionButtonMaxWidth: CGFloat = 150
|
static let actionButtonMaxWidth: CGFloat = 150
|
||||||
static let statusHeight: CGFloat = 20
|
static let statusHeight: CGFloat = 20
|
||||||
static let carouselInterval: TimeInterval = 4
|
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(\.colorScheme) private var colorScheme
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@ObservedObject var state: KeyboardState
|
@ObservedObject var state: KeyboardState
|
||||||
@@ -55,6 +64,17 @@ struct AIKeyboardView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
|
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
|
||||||
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
|
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
|
||||||
|
.onChange(of: state.enabledClipboardSkillIDs) { _, _ in resetCarousel() }
|
||||||
|
.onChange(of: state.skillTipText) { _, tip in
|
||||||
|
guard let tip, !tip.isEmpty else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 2_800_000_000)
|
||||||
|
if state.skillTipText == tip {
|
||||||
|
state.skillTipText = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(Motion.soft, value: state.skillTipText)
|
||||||
.onReceive(
|
.onReceive(
|
||||||
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
|
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
|
||||||
) { _ in
|
) { _ in
|
||||||
@@ -172,6 +192,19 @@ struct AIKeyboardView: View {
|
|||||||
statusLine
|
statusLine
|
||||||
.frame(height: Layout.statusHeight)
|
.frame(height: Layout.statusHeight)
|
||||||
.padding(.horizontal, Spacing.md)
|
.padding(.horizontal, Spacing.md)
|
||||||
|
|
||||||
|
if let tip = state.skillTipText, !tip.isEmpty {
|
||||||
|
Text(tip)
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.glassEffect(.regular, in: Capsule())
|
||||||
|
.padding(.bottom, Layout.statusHeight + Spacing.sm)
|
||||||
|
.transition(.opacity)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,30 +245,52 @@ struct AIKeyboardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var clipboardSkillRow: some View {
|
private var clipboardSkillRow: some View {
|
||||||
HStack(spacing: Spacing.lg) {
|
let rows = skillRows(from: visibleClipboardSkills)
|
||||||
ForEach(AIClipboardSkillCatalog.visible()) { skill in
|
return VStack(spacing: 0) {
|
||||||
Button {
|
Spacer(minLength: 0)
|
||||||
state.submitAIClipboardSkill(skill)
|
VStack(spacing: Layout.skillRowSpacing) {
|
||||||
} label: {
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, rowSkills in
|
||||||
VStack(spacing: 6) {
|
HStack(spacing: Layout.skillCellSpacing) {
|
||||||
Image(systemName: skill.systemImage)
|
ForEach(rowSkills) { skill in
|
||||||
.font(.system(size: 18, weight: .semibold))
|
skillChip(skill)
|
||||||
.foregroundStyle(palette.textPrimary.opacity(0.85))
|
}
|
||||||
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
|
|
||||||
.glassEffect(.regular.interactive(), in: Circle())
|
|
||||||
Text(clipboardSkillTitle(skill))
|
|
||||||
.font(TypeStyle.caption2)
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
|
|
||||||
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func skillRows(from skills: [AIClipboardSkill]) -> [[AIClipboardSkill]] {
|
||||||
|
stride(from: 0, to: skills.count, by: Layout.skillsPerRow).map { start in
|
||||||
|
Array(skills[start ..< min(start + Layout.skillsPerRow, skills.count)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func skillChip(_ skill: AIClipboardSkill) -> some View {
|
||||||
|
Button {
|
||||||
|
state.submitAIClipboardSkill(skill)
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: skill.systemImage)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(palette.textPrimary.opacity(0.85))
|
||||||
|
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
|
||||||
|
.glassEffect(.regular.interactive(), in: Circle())
|
||||||
|
Text(clipboardSkillTitle(skill))
|
||||||
|
.font(TypeStyle.caption2)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.frame(width: Layout.skillCellWidth)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
|
||||||
|
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Translate follows the keyboard target; Reply / Summarize stay static.
|
/// Translate follows the keyboard target; Reply / Summarize stay static.
|
||||||
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
|
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
|
||||||
if skill.id == AIClipboardSkillCatalog.translateID {
|
if skill.id == AIClipboardSkillCatalog.translateID {
|
||||||
@@ -244,18 +299,40 @@ struct AIKeyboardView: View {
|
|||||||
uiLanguage: AppGroupStore().uiLanguage
|
uiLanguage: AppGroupStore().uiLanguage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!custom.isEmpty {
|
||||||
|
return custom
|
||||||
|
}
|
||||||
return ExtL10n.string(skill.titleKey)
|
return ExtL10n.string(skill.titleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy-then-30s window: skill chips replace the rotating hint.
|
/// Copy-then-30s window: skill chips replace the rotating hint.
|
||||||
private var showsClipboardSkills: Bool {
|
private var showsClipboardSkills: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
|
||||||
|
return showsPlaceholder
|
||||||
|
}
|
||||||
|
#endif
|
||||||
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
|
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
|
||||||
|
guard !visibleClipboardSkills.isEmpty else { return false }
|
||||||
return AIHintPool.isClipboardSkillWindowActive(
|
return AIHintPool.isClipboardSkillWindowActive(
|
||||||
clipboardHistoryEnabled: true,
|
clipboardHistoryEnabled: true,
|
||||||
newestClipboard: clipboardHistory.newestEntry
|
newestClipboard: clipboardHistory.newestEntry
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var visibleClipboardSkills: [AIClipboardSkill] {
|
||||||
|
#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.
|
/// No draft/answer yet — show the centered hint carousel instead of a scroll body.
|
||||||
private var showsPlaceholder: Bool {
|
private var showsPlaceholder: Bool {
|
||||||
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
|
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
|
||||||
@@ -432,6 +509,18 @@ struct AIKeyboardView: View {
|
|||||||
}
|
}
|
||||||
return state.aiSession.transcript
|
return state.aiSession.transcript
|
||||||
case .generating:
|
case .generating:
|
||||||
|
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
|
||||||
|
return ExtL10n.string("keyboard.ai.skill.extracting")
|
||||||
|
}
|
||||||
|
if 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 {
|
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
|
||||||
return ExtL10n.string("keyboard.ai.generating")
|
return ExtL10n.string("keyboard.ai.generating")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ public struct KeyboardRootView: View {
|
|||||||
title: title,
|
title: title,
|
||||||
label: title,
|
label: title,
|
||||||
disabled: disabled,
|
disabled: disabled,
|
||||||
isSend: state.returnKeyRole == .send,
|
isSend: state.returnKeyRole.usesActionFill,
|
||||||
hapticIntensity: state.keyboardHapticIntensity
|
hapticIntensity: state.keyboardHapticIntensity
|
||||||
) {
|
) {
|
||||||
state.insertNewline()
|
state.insertNewline()
|
||||||
|
|||||||
@@ -26,6 +26,14 @@
|
|||||||
"common.space" = "Space";
|
"common.space" = "Space";
|
||||||
"common.newline" = "Return";
|
"common.newline" = "Return";
|
||||||
"common.send" = "Send";
|
"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 footnote (onboarding welcome page) */
|
||||||
"privacy.audio.title" = "On-device transcription";
|
"privacy.audio.title" = "On-device transcription";
|
||||||
@@ -297,3 +305,23 @@
|
|||||||
"keyboard.ai.skill.reply" = "Reply";
|
"keyboard.ai.skill.reply" = "Reply";
|
||||||
"keyboard.ai.skill.summarize" = "Summarize";
|
"keyboard.ai.skill.summarize" = "Summarize";
|
||||||
"keyboard.ai.skill.translate" = "Translate";
|
"keyboard.ai.skill.translate" = "Translate";
|
||||||
|
"keyboard.ai.skill.extractTodos" = "Tasks";
|
||||||
|
"keyboard.ai.skill.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" = "Couldn’t open the app to run the Shortcut";
|
||||||
|
|||||||
@@ -26,6 +26,14 @@
|
|||||||
"common.space" = "空格";
|
"common.space" = "空格";
|
||||||
"common.newline" = "回车";
|
"common.newline" = "回车";
|
||||||
"common.send" = "发送";
|
"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 footnote (onboarding welcome page) */
|
||||||
"privacy.audio.title" = "支持本地转写";
|
"privacy.audio.title" = "支持本地转写";
|
||||||
@@ -297,3 +305,23 @@
|
|||||||
"keyboard.ai.skill.reply" = "回复";
|
"keyboard.ai.skill.reply" = "回复";
|
||||||
"keyboard.ai.skill.summarize" = "总结";
|
"keyboard.ai.skill.summarize" = "总结";
|
||||||
"keyboard.ai.skill.translate" = "翻译";
|
"keyboard.ai.skill.translate" = "翻译";
|
||||||
|
"keyboard.ai.skill.extractTodos" = "待办";
|
||||||
|
"keyboard.ai.skill.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)
|
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)
|
XCTAssertNil(state.clipboardSuggestionText)
|
||||||
XCTAssertEqual(state.clipboardOverlay, .none)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
switch self {
|
||||||
case .dashboard: return "house"
|
case .dashboard: return "house"
|
||||||
case .history: return "clock.arrow.circlepath"
|
case .history: return "clock.arrow.circlepath"
|
||||||
case .dictionary: return "character.book.closed"
|
case .dictionary: return "character.book.closed"
|
||||||
case .styles: return "text.badge.star"
|
case .styles: return selected ? "dial.high.fill" : "dial.high"
|
||||||
case .settings: return "gearshape"
|
case .settings: return selected ? "gearshape.fill" : "gearshape"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,28 +246,22 @@ private struct MacPolishStyleCard: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
// Builtin: eye → view prompt; custom: pencil → edit.
|
// Pencil opens the prompt (built-in, read-only) or the editor (custom).
|
||||||
Button(action: primaryAction) {
|
Button(action: primaryAction) {
|
||||||
Image(systemName: isUserStyle ? "pencil" : "eye")
|
Image(systemName: "pencil")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 15, weight: .semibold))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.frame(width: 28, height: 28)
|
.frame(width: 30, height: 30)
|
||||||
.background(palette.background.opacity(isHovering ? 0.9 : 0.72), in: Circle())
|
.background(palette.background.opacity(isHovering ? 0.9 : 0.72), in: Circle())
|
||||||
}
|
}
|
||||||
.padding(Spacing.sm)
|
.padding(Spacing.sm)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(MacL10n.string("mac.styles.edit", language: language))
|
||||||
MacL10n.string(
|
|
||||||
isUserStyle ? "mac.styles.edit" : "mac.styles.viewPrompt",
|
|
||||||
language: language
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 21, weight: .semibold))
|
.font(.system(size: 21, weight: .semibold))
|
||||||
.foregroundStyle(palette.accent)
|
.foregroundStyle(palette.accent)
|
||||||
.background(palette.surface, in: Circle())
|
|
||||||
.padding(Spacing.sm)
|
.padding(Spacing.sm)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ private struct MacSidebarRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
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))
|
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
|
||||||
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
|
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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
|
||||||
|
}
|
||||||
@@ -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"
|
"config.flowInactivityDuration.migratedToFiveMinuteDefault"
|
||||||
/// Diagnostic switch: when false, local ASR skips the custom language model.
|
/// Diagnostic switch: when false, local ASR skips the custom language model.
|
||||||
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
|
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
|
||||||
|
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
|
||||||
|
public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
|
||||||
|
/// User-created clipboard skills (no cloud sync; App Group only).
|
||||||
|
public static let agentUserSkillCatalog = "config.aiAgentSkills.userCatalog.v1"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Stored fields
|
// MARK: - Stored fields
|
||||||
@@ -113,8 +117,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
public var flowSkipAppSwitch: Bool
|
public var flowSkipAppSwitch: Bool
|
||||||
/// Idle timeout before the Flow session ends; resets on each utterance.
|
/// Idle timeout before the Flow session ends; resets on each utterance.
|
||||||
public var flowInactivityDuration: FlowInactivityDuration
|
public var flowInactivityDuration: FlowInactivityDuration
|
||||||
/// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
|
/// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
|
||||||
public var localASRCustomLanguageModelEnabled: Bool
|
public var localASRCustomLanguageModelEnabled: Bool
|
||||||
|
|
||||||
// MARK: - Derived
|
// MARK: - Derived
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
public let aiConversationID: UUID?
|
public let aiConversationID: UUID?
|
||||||
/// Prefilled question used only by `.submitAIQuestion`.
|
/// Prefilled question used only by `.submitAIQuestion`.
|
||||||
public let aiQuestionText: String?
|
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.
|
/// Absolute wall-clock deadlines survive extension reconstruction.
|
||||||
public let startDeadlineAt: TimeInterval?
|
public let startDeadlineAt: TimeInterval?
|
||||||
public let processingDeadlineAt: TimeInterval?
|
public let processingDeadlineAt: TimeInterval?
|
||||||
@@ -60,6 +62,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
sourceHistoryEntryRevision: Int64? = nil,
|
sourceHistoryEntryRevision: Int64? = nil,
|
||||||
aiConversationID: UUID? = nil,
|
aiConversationID: UUID? = nil,
|
||||||
aiQuestionText: String? = nil,
|
aiQuestionText: String? = nil,
|
||||||
|
aiThinkingEnabled: Bool? = nil,
|
||||||
startDeadlineAt: TimeInterval? = nil,
|
startDeadlineAt: TimeInterval? = nil,
|
||||||
processingDeadlineAt: TimeInterval? = nil
|
processingDeadlineAt: TimeInterval? = nil
|
||||||
) {
|
) {
|
||||||
@@ -77,6 +80,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
||||||
self.aiConversationID = aiConversationID
|
self.aiConversationID = aiConversationID
|
||||||
self.aiQuestionText = aiQuestionText
|
self.aiQuestionText = aiQuestionText
|
||||||
|
self.aiThinkingEnabled = aiThinkingEnabled
|
||||||
self.startDeadlineAt = startDeadlineAt
|
self.startDeadlineAt = startDeadlineAt
|
||||||
self.processingDeadlineAt = processingDeadlineAt
|
self.processingDeadlineAt = processingDeadlineAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
public let aiConversationID: UUID?
|
public let aiConversationID: UUID?
|
||||||
/// When set with `.aiQuestion`, host skips ASR and answers this text.
|
/// When set with `.aiQuestion`, host skips ASR and answers this text.
|
||||||
public let aiQuestionText: String?
|
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)
|
public static let dictation = FlowUtteranceRequest(mode: .dictation)
|
||||||
|
|
||||||
@@ -22,7 +24,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
sourceHistoryEntryID: UUID? = nil,
|
sourceHistoryEntryID: UUID? = nil,
|
||||||
sourceHistoryEntryRevision: Int64? = nil,
|
sourceHistoryEntryRevision: Int64? = nil,
|
||||||
aiConversationID: UUID? = nil,
|
aiConversationID: UUID? = nil,
|
||||||
aiQuestionText: String? = nil
|
aiQuestionText: String? = nil,
|
||||||
|
aiThinkingEnabled: Bool? = nil
|
||||||
) {
|
) {
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.editSourceText = editSourceText
|
self.editSourceText = editSourceText
|
||||||
@@ -30,6 +33,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
||||||
self.aiConversationID = aiConversationID
|
self.aiConversationID = aiConversationID
|
||||||
self.aiQuestionText = aiQuestionText
|
self.aiQuestionText = aiQuestionText
|
||||||
|
self.aiThinkingEnabled = aiThinkingEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func editLastInput(
|
public static func editLastInput(
|
||||||
@@ -48,12 +52,14 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
|
|
||||||
public static func aiQuestion(
|
public static func aiQuestion(
|
||||||
conversationID: UUID,
|
conversationID: UUID,
|
||||||
prefilledQuestion: String? = nil
|
prefilledQuestion: String? = nil,
|
||||||
|
thinkingEnabled: Bool? = nil
|
||||||
) -> FlowUtteranceRequest {
|
) -> FlowUtteranceRequest {
|
||||||
FlowUtteranceRequest(
|
FlowUtteranceRequest(
|
||||||
mode: .aiQuestion,
|
mode: .aiQuestion,
|
||||||
aiConversationID: conversationID,
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,78 @@
|
|||||||
// OSGKeyboard · Shared
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
|
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
|
||||||
// Settings can later persist a subset or permutation without changing the view.
|
// Settings / the Skills tab can persist a subset or permutation without
|
||||||
|
// changing the view. Transform skills insert into the current field;
|
||||||
|
// export skills hand off to the host after the model runs (Shortcut, Maps, or Didi).
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
public enum AIClipboardSkillKind: String, Sendable {
|
||||||
|
/// LLM output is reviewed and inserted into the current text field.
|
||||||
|
case transform
|
||||||
|
/// LLM output is parsed and sent to a companion Shortcut. Never inserted.
|
||||||
|
case export
|
||||||
|
}
|
||||||
|
|
||||||
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let systemImage: String
|
public let systemImage: String
|
||||||
/// Keyboard.strings key for the short button title.
|
/// Keyboard.strings key for the short chip title.
|
||||||
public let titleKey: String
|
public let titleKey: String
|
||||||
|
/// App Localizable key for the Skills-tab card title. Falls back to `titleKey`.
|
||||||
|
public let cardTitleKey: String
|
||||||
|
public let descriptionKey: String
|
||||||
|
public let kind: AIClipboardSkillKind
|
||||||
|
/// Default skills can be turned off but not removed from the catalog.
|
||||||
|
public let isDefault: Bool
|
||||||
|
/// Frozen companion Shortcut name. Nil for transform skills.
|
||||||
|
public let shortcutName: String?
|
||||||
|
/// Optional `icloud.com/shortcuts/` share URL. Nil → open the bundled file.
|
||||||
|
public let shortcutICloudURL: URL?
|
||||||
|
/// 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.id = id
|
||||||
self.systemImage = systemImage
|
self.systemImage = systemImage
|
||||||
self.titleKey = titleKey
|
self.titleKey = titleKey
|
||||||
|
self.cardTitleKey = cardTitleKey
|
||||||
|
self.descriptionKey = descriptionKey
|
||||||
|
self.kind = kind
|
||||||
|
self.isDefault = isDefault
|
||||||
|
self.shortcutName = shortcutName
|
||||||
|
self.shortcutICloudURL = shortcutICloudURL
|
||||||
|
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 replyID = "reply"
|
||||||
public static let summarizeID = "summarize"
|
public static let summarizeID = "summarize"
|
||||||
public static let translateID = "translate"
|
public static let translateID = "translate"
|
||||||
|
public static let extractTodosID = "extractTodos"
|
||||||
|
public static let extractTodosShortcutName = "OSGExtractTodos"
|
||||||
|
public static let extractTodosResourceName = "OSGExtractTodos"
|
||||||
|
|
||||||
/// Default set, in display order. Future skills append here.
|
public static let extractEventsID = "extractEvents"
|
||||||
public static let builtIn: [AIClipboardSkill] = [
|
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(
|
AIClipboardSkill(
|
||||||
id: replyID,
|
id: replyID,
|
||||||
systemImage: "arrowshape.turn.up.left.fill",
|
systemImage: "arrowshape.turn.up.left.fill",
|
||||||
titleKey: "keyboard.ai.skill.reply"
|
titleKey: "keyboard.ai.skill.reply",
|
||||||
|
cardTitleKey: "skills.reply.name",
|
||||||
|
descriptionKey: "skills.reply.description",
|
||||||
|
kind: .transform,
|
||||||
|
isDefault: true
|
||||||
),
|
),
|
||||||
AIClipboardSkill(
|
AIClipboardSkill(
|
||||||
id: summarizeID,
|
id: summarizeID,
|
||||||
systemImage: "doc.text.magnifyingglass",
|
systemImage: "doc.text.magnifyingglass",
|
||||||
titleKey: "keyboard.ai.skill.summarize"
|
titleKey: "keyboard.ai.skill.summarize",
|
||||||
|
cardTitleKey: "skills.summarize.name",
|
||||||
|
descriptionKey: "skills.summarize.description",
|
||||||
|
kind: .transform,
|
||||||
|
isDefault: true
|
||||||
),
|
),
|
||||||
AIClipboardSkill(
|
AIClipboardSkill(
|
||||||
id: translateID,
|
id: translateID,
|
||||||
systemImage: "character.bubble.fill",
|
systemImage: "character.bubble.fill",
|
||||||
titleKey: "keyboard.ai.skill.translate"
|
titleKey: "keyboard.ai.skill.translate",
|
||||||
|
cardTitleKey: "skills.translate.name",
|
||||||
|
descriptionKey: "skills.translate.description",
|
||||||
|
kind: .transform,
|
||||||
|
isDefault: true
|
||||||
|
),
|
||||||
|
AIClipboardSkill(
|
||||||
|
id: extractTodosID,
|
||||||
|
systemImage: "checklist",
|
||||||
|
titleKey: "keyboard.ai.skill.extractTodos",
|
||||||
|
cardTitleKey: "skills.extractTodos.name",
|
||||||
|
descriptionKey: "skills.extractTodos.description",
|
||||||
|
kind: .export,
|
||||||
|
isDefault: false,
|
||||||
|
shortcutName: extractTodosShortcutName,
|
||||||
|
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.
|
/// Legacy alias: the three default transform skills used to be the whole list.
|
||||||
public static func visible(enabledIDs: [String]? = nil) -> [AIClipboardSkill] {
|
public static let builtIn: [AIClipboardSkill] = catalog
|
||||||
guard let enabledIDs, !enabledIDs.isEmpty else { return builtIn }
|
|
||||||
let byID = Dictionary(uniqueKeysWithValues: builtIn.map { ($0.id, $0) })
|
public static func all(userCatalog: AIUserSkillCatalog = .empty) -> [AIClipboardSkill] {
|
||||||
return enabledIDs.compactMap { byID[$0] }
|
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(
|
public static func instruction(
|
||||||
for skill: AIClipboardSkill,
|
for skill: AIClipboardSkill,
|
||||||
locale: String,
|
locale: String,
|
||||||
translationTargetLocaleId: String
|
translationTargetLocaleId: String,
|
||||||
|
now: Date = Date()
|
||||||
) -> String {
|
) -> String {
|
||||||
instruction(
|
if let custom = skill.customPrompt?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!custom.isEmpty {
|
||||||
|
return custom
|
||||||
|
}
|
||||||
|
return instruction(
|
||||||
skillID: skill.id,
|
skillID: skill.id,
|
||||||
locale: locale,
|
locale: locale,
|
||||||
translationTargetLocaleId: translationTargetLocaleId
|
translationTargetLocaleId: translationTargetLocaleId,
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +236,8 @@ public enum AIClipboardSkillCatalog: Sendable {
|
|||||||
public static func instruction(
|
public static func instruction(
|
||||||
skillID: String,
|
skillID: String,
|
||||||
locale: String,
|
locale: String,
|
||||||
translationTargetLocaleId: String
|
translationTargetLocaleId: String,
|
||||||
|
now: Date = Date()
|
||||||
) -> String {
|
) -> String {
|
||||||
let zh = locale == "zh"
|
let zh = locale == "zh"
|
||||||
switch skillID {
|
switch skillID {
|
||||||
@@ -102,6 +254,24 @@ public enum AIClipboardSkillCatalog: Sendable {
|
|||||||
locale: locale,
|
locale: locale,
|
||||||
translationTargetLocaleId: translationTargetLocaleId
|
translationTargetLocaleId: translationTargetLocaleId
|
||||||
)
|
)
|
||||||
|
case extractTodosID:
|
||||||
|
return zh
|
||||||
|
? """
|
||||||
|
请从剪贴板中只提取明确的待办事项。每条一行,只要标题,不要编号、不要项目符号、不要解释。最多 20 条。
|
||||||
|
若没有任何可执行的待办,只输出 NONE,不要把整段原文当成一条待办。
|
||||||
|
若原文本身就是一句短待办(例如「买牛奶」),输出那一句即可。
|
||||||
|
"""
|
||||||
|
: """
|
||||||
|
Extract only explicit to-do items from the clipboard. One title per line; no numbering, bullets, or commentary. Maximum 20 lines.
|
||||||
|
If there are no actionable tasks, output NONE and nothing else. Do not treat the whole clipboard as one task.
|
||||||
|
If the clipboard itself is already one short task (for example "buy milk"), output that single line.
|
||||||
|
"""
|
||||||
|
case extractEventsID:
|
||||||
|
return eventInstruction(zh: zh, now: now)
|
||||||
|
case saveToNotesID:
|
||||||
|
return noteInstruction(zh: zh, now: now)
|
||||||
|
case navigateID:
|
||||||
|
return navigateInstruction(zh: zh)
|
||||||
default:
|
default:
|
||||||
return zh
|
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 个字。
|
||||||
|
标题应能让人在列表里认出这篇笔记,可结合今天的日期或时间(例如「8月13日周会纪要」)。不要改写或重复正文。
|
||||||
|
即使原文很短也要给一个标题。不要输出 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.
|
/// Uses the keyboard translation target when set; otherwise Chinese ↔ English.
|
||||||
private static func translateInstruction(
|
private static func translateInstruction(
|
||||||
locale: String,
|
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
|
import Foundation
|
||||||
|
|
||||||
public enum AIModeLLMClientFactory {
|
public enum AIModeLLMClientFactory {
|
||||||
/// Build an AI-mode client. Thinking is always forced on for this path.
|
/// Build an AI-mode client. Thinking defaults on for spoken questions.
|
||||||
/// When `allowWebSearch` is false, returns the plain polish-compatible client.
|
/// Clipboard skills pass `thinkingEnabled` from the skill (built-in: off).
|
||||||
public static func make(
|
public static func make(
|
||||||
providerId: String,
|
providerId: String,
|
||||||
baseURL: String,
|
baseURL: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
model: String,
|
model: String,
|
||||||
allowWebSearch: Bool = true,
|
allowWebSearch: Bool = true,
|
||||||
|
thinkingEnabled: Bool = true,
|
||||||
session: URLSession = .shared
|
session: URLSession = .shared
|
||||||
) -> any LLMClient {
|
) -> any LLMClient {
|
||||||
let plain = LLMClientFactory.make(
|
let plain = LLMClientFactory.make(
|
||||||
@@ -23,10 +24,10 @@ public enum AIModeLLMClientFactory {
|
|||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
model: model,
|
model: model,
|
||||||
thinkingEnabled: true,
|
thinkingEnabled: thinkingEnabled,
|
||||||
session: session
|
session: session
|
||||||
)
|
)
|
||||||
guard allowWebSearch else { return plain }
|
guard thinkingEnabled, allowWebSearch else { return plain }
|
||||||
|
|
||||||
guard let searching = makeSearchingClient(
|
guard let searching = makeSearchingClient(
|
||||||
providerId: providerId,
|
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(
|
public static func configured(
|
||||||
store: any ConfigurationStore,
|
store: any ConfigurationStore,
|
||||||
conversations: AIConversationStore
|
conversations: AIConversationStore,
|
||||||
|
thinkingEnabled: Bool = true
|
||||||
) throws -> AIQuestionService {
|
) throws -> AIQuestionService {
|
||||||
// Same provider + baseURL + model resolution as dictation polish so the
|
// Same provider + baseURL + model resolution as dictation polish so the
|
||||||
// Settings LLM card is the single source of truth for both modes.
|
// Settings LLM card is the single source of truth for both modes.
|
||||||
@@ -151,7 +152,8 @@ public struct AIQuestionService: Sendable {
|
|||||||
baseURL: endpoint.baseURL,
|
baseURL: endpoint.baseURL,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
model: endpoint.model,
|
model: endpoint.model,
|
||||||
allowWebSearch: true
|
allowWebSearch: true,
|
||||||
|
thinkingEnabled: thinkingEnabled
|
||||||
),
|
),
|
||||||
conversations: conversations,
|
conversations: conversations,
|
||||||
responseLength: store.aiResponseLength
|
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 polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
|
||||||
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
|
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
|
||||||
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
|
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
|
||||||
|
/// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it.
|
||||||
|
public var agentSkillLayout: AIAgentSkillLayout {
|
||||||
|
Self.decodeAgentSkillLayout(from: defaults, userCatalog: agentUserSkillCatalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var agentUserSkillCatalog: AIUserSkillCatalog {
|
||||||
|
Self.decodeUserSkillCatalog(from: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Writes
|
// MARK: - Writes
|
||||||
|
|
||||||
@@ -199,6 +207,94 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
|
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(
|
||||||
|
layout.sanitized(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 {
|
public var hasCompletedOnboarding: Bool {
|
||||||
get { configuration.hasCompletedOnboarding }
|
get { configuration.hasCompletedOnboarding }
|
||||||
set { setHasCompletedOnboarding(newValue) }
|
set { setHasCompletedOnboarding(newValue) }
|
||||||
|
|||||||
@@ -140,6 +140,15 @@ public final class KeyboardState: ObservableObject {
|
|||||||
@Published public var clipboardHistoryEnabled: Bool = false
|
@Published public var clipboardHistoryEnabled: Bool = false
|
||||||
/// Opt-in clipboard suggestion strip (requires history enabled).
|
/// Opt-in clipboard suggestion strip (requires history enabled).
|
||||||
@Published public var clipboardCandidateBarEnabled: Bool = false
|
@Published public var clipboardCandidateBarEnabled: Bool = false
|
||||||
|
/// Skills-tab order for clipboard chips (max 8). Empty → hint carousel.
|
||||||
|
@Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
|
||||||
|
/// Export skill currently waiting on the LLM. Nil for transform skills.
|
||||||
|
@Published public var pendingClipboardSkillID: String?
|
||||||
|
/// 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.
|
/// Host field is a password / secure entry — never read pasteboard.
|
||||||
@Published public var isSecureTextEntry: Bool = false
|
@Published public var isSecureTextEntry: Bool = false
|
||||||
/// Secure fields hide every clipboard-history entry point.
|
/// Secure fields hide every clipboard-history entry point.
|
||||||
@@ -213,13 +222,38 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public enum ReturnKeyRole: Equatable {
|
public enum ReturnKeyRole: Equatable {
|
||||||
case newline
|
case newline
|
||||||
case send
|
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 {
|
public var titleKey: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .newline: return "common.newline"
|
case .newline: return "common.newline"
|
||||||
case .send: return "common.send"
|
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.
|
// Action hooks — injected by the view controller at install time.
|
||||||
@@ -239,8 +273,10 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var sendAIAnswer: () -> Void = {}
|
public var sendAIAnswer: () -> Void = {}
|
||||||
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
||||||
public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
||||||
/// Sends a clipboard skill (reply / summarize / translate / future).
|
/// Sends a clipboard skill (reply / summarize / translate / export).
|
||||||
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
|
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
|
||||||
|
/// Writes extract-todos titles and opens the host to run the Shortcut.
|
||||||
|
public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
|
||||||
public var openSettings: () -> Void = {}
|
public var openSettings: () -> Void = {}
|
||||||
/// Opens the host app straight to input-resource deployment. Used by the
|
/// Opens the host app straight to input-resource deployment. Used by the
|
||||||
/// typing surface when Rime resources have not been deployed yet.
|
/// typing surface when Rime resources have not been deployed yet.
|
||||||
|
|||||||
@@ -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.
|
/// (common in Notes). Capped; reseeds from the proxy when it looks fresh.
|
||||||
private var precedingShadow = ""
|
private var precedingShadow = ""
|
||||||
private static let precedingShadowLimit = 400
|
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(
|
public init(
|
||||||
engine: (@MainActor () -> RimeEngineBridging)? = nil,
|
engine: (@MainActor () -> RimeEngineBridging)? = nil,
|
||||||
@@ -166,6 +169,7 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
page = .letters
|
page = .letters
|
||||||
resetShiftState()
|
resetShiftState()
|
||||||
clearEnglishWordState(keepPrevious: false)
|
clearEnglishWordState(keepPrevious: false)
|
||||||
|
clearPeriodShortcut()
|
||||||
// Drop English lexicon pages when leaving typing (jetsam recovery).
|
// Drop English lexicon pages when leaving typing (jetsam recovery).
|
||||||
EnglishLexicon.shared.unload()
|
EnglishLexicon.shared.unload()
|
||||||
englishStorage = nil
|
englishStorage = nil
|
||||||
@@ -202,6 +206,7 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
engine.setLanguage(newLanguage)
|
engine.setLanguage(newLanguage)
|
||||||
page = .letters
|
page = .letters
|
||||||
isCandidatePanelExpanded = false
|
isCandidatePanelExpanded = false
|
||||||
|
clearPeriodShortcut()
|
||||||
if newLanguage == .english {
|
if newLanguage == .english {
|
||||||
clearEnglishWordState(keepPrevious: false)
|
clearEnglishWordState(keepPrevious: false)
|
||||||
refreshPersonalTerms()
|
refreshPersonalTerms()
|
||||||
@@ -235,6 +240,7 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
public func setPage(_ page: TypingKeyPage) {
|
public func setPage(_ page: TypingKeyPage) {
|
||||||
self.page = page
|
self.page = page
|
||||||
resetShiftState()
|
resetShiftState()
|
||||||
|
clearPeriodShortcut()
|
||||||
if page == .letters {
|
if page == .letters {
|
||||||
syncAutocapitalization()
|
syncAutocapitalization()
|
||||||
}
|
}
|
||||||
@@ -264,6 +270,7 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
|
|
||||||
/// Handle a visible key label.
|
/// Handle a visible key label.
|
||||||
public func handleKey(_ label: String) -> TypingOutput {
|
public func handleKey(_ label: String) -> TypingOutput {
|
||||||
|
clearPeriodShortcut()
|
||||||
switch label {
|
switch label {
|
||||||
case "⇧":
|
case "⇧":
|
||||||
// Tests / non-gesture callers: same as a completed Shift tap.
|
// Tests / non-gesture callers: same as a completed Shift tap.
|
||||||
@@ -317,8 +324,9 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
|
|
||||||
public func handleSpace() -> TypingOutput {
|
public func handleSpace() -> TypingOutput {
|
||||||
if language == .english {
|
if language == .english {
|
||||||
return commitEnglishWord(suffix: " ")
|
return handleEnglishSpace()
|
||||||
}
|
}
|
||||||
|
clearPeriodShortcut()
|
||||||
let text = engine.processSpace() ?? " "
|
let text = engine.processSpace() ?? " "
|
||||||
composition = engine.composition
|
composition = engine.composition
|
||||||
syncCandidatePanelVisibility()
|
syncCandidatePanelVisibility()
|
||||||
@@ -326,6 +334,7 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func handleReturn() -> TypingOutput {
|
public func handleReturn() -> TypingOutput {
|
||||||
|
clearPeriodShortcut()
|
||||||
if language == .english {
|
if language == .english {
|
||||||
return commitEnglishWord(suffix: "\n")
|
return commitEnglishWord(suffix: "\n")
|
||||||
}
|
}
|
||||||
@@ -336,6 +345,7 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func selectCandidate(at index: Int) -> TypingOutput {
|
public func selectCandidate(at index: Int) -> TypingOutput {
|
||||||
|
clearPeriodShortcut()
|
||||||
if language == .english {
|
if language == .english {
|
||||||
return selectEnglishCandidate(at: index)
|
return selectEnglishCandidate(at: index)
|
||||||
}
|
}
|
||||||
@@ -352,6 +362,38 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - English
|
// 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 {
|
private func handleEnglishCharacter(_ ch: Character) -> TypingOutput {
|
||||||
pendingAutocorrection = nil
|
pendingAutocorrection = nil
|
||||||
if ch.isLetter {
|
if ch.isLetter {
|
||||||
@@ -546,6 +588,9 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
/// briefly reports the immediately preceding edit (common in Notes).
|
/// briefly reports the immediately preceding edit (common in Notes).
|
||||||
public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) {
|
public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) {
|
||||||
guard language == .english else { return }
|
guard language == .english else { return }
|
||||||
|
if caretMoved {
|
||||||
|
clearPeriodShortcut()
|
||||||
|
}
|
||||||
guard suggestionsEnabled else {
|
guard suggestionsEnabled else {
|
||||||
clearEnglishWordState(keepPrevious: false)
|
clearEnglishWordState(keepPrevious: false)
|
||||||
composition = .empty
|
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)")
|
OSGLog.keyboardExt.info("\(line, privacy: .public)")
|
||||||
case "flow", "asr":
|
case "flow", "asr":
|
||||||
OSGLog.flow.info("\(line, privacy: .public)")
|
OSGLog.flow.info("\(line, privacy: .public)")
|
||||||
|
case "skills":
|
||||||
|
OSGLog.config.info("\(line, privacy: .public)")
|
||||||
default:
|
default:
|
||||||
OSGLog.config.info("\(line, privacy: .public)")
|
OSGLog.config.info("\(line, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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("概括"))
|
||||||
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)
|
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() {
|
func testFactoryPlainForGroq() {
|
||||||
let client = AIModeLLMClientFactory.make(
|
let client = AIModeLLMClientFactory.make(
|
||||||
providerId: "groq",
|
providerId: "groq",
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
)!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, command)
|
||||||
XCTAssertEqual(decoded.aiQuestionText, "总结这段剪贴板内容")
|
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() {
|
func testSecureFieldContextRedactsText() {
|
||||||
|
|||||||
@@ -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 1–4 (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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -132,6 +132,12 @@
|
|||||||
"OSGKeyboardTests/CursorNavigationTests",
|
"OSGKeyboardTests/CursorNavigationTests",
|
||||||
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
|
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
|
||||||
"OSGKeyboardTests/AIHintPoolTests",
|
"OSGKeyboardTests/AIHintPoolTests",
|
||||||
|
"OSGKeyboardTests/AIHintKeywordExtractorTests",
|
||||||
|
"OSGKeyboardTests/AIAgentSkillLayoutTests",
|
||||||
|
"OSGKeyboardTests/AIUserSkillTests",
|
||||||
|
"OSGKeyboardTests/AIUserSkillStoreTests",
|
||||||
|
"OSGKeyboardTests/AIEventExtractionTests",
|
||||||
|
"OSGKeyboardTests/AIAddressExtractionTests",
|
||||||
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
|
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
|
||||||
"OSGKeyboardTests/ClipboardHistoryStoreTests"
|
"OSGKeyboardTests/ClipboardHistoryStoreTests"
|
||||||
]
|
]
|
||||||
|
|||||||
+18
-1
@@ -49,7 +49,7 @@ settings:
|
|||||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||||
MARKETING_VERSION: "1.7.5"
|
MARKETING_VERSION: "1.7.5"
|
||||||
CURRENT_PROJECT_VERSION: "67"
|
CURRENT_PROJECT_VERSION: "71"
|
||||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||||
|
|
||||||
# 项目级签名 xcconfig,适用于所有 target
|
# 项目级签名 xcconfig,适用于所有 target
|
||||||
@@ -76,6 +76,7 @@ targets:
|
|||||||
# crashes when both are passed. iOS 26 uses Icon Composer only.
|
# crashes when both are passed. iOS 26 uses Icon Composer only.
|
||||||
- "Assets.xcassets/AppIcon.appiconset"
|
- "Assets.xcassets/AppIcon.appiconset"
|
||||||
- "Resources/CustomLanguageModel/**"
|
- "Resources/CustomLanguageModel/**"
|
||||||
|
- "Resources/Shortcuts/**"
|
||||||
# Real keyboard chrome for DEBUG what's-new recording (same UI as Ext).
|
# Real keyboard chrome for DEBUG what's-new recording (same UI as Ext).
|
||||||
- path: OSGKeyboardExt/Utilities/ExtL10n.swift
|
- path: OSGKeyboardExt/Utilities/ExtL10n.swift
|
||||||
- path: OSGKeyboardExt/Views/AIKeyboardView.swift
|
- path: OSGKeyboardExt/Views/AIKeyboardView.swift
|
||||||
@@ -87,6 +88,17 @@ targets:
|
|||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
|
- path: OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
|
||||||
buildPhase: resources
|
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:
|
entitlements:
|
||||||
path: OSGKeyboard/OSGKeyboard.entitlements
|
path: OSGKeyboard/OSGKeyboard.entitlements
|
||||||
properties:
|
properties:
|
||||||
@@ -194,6 +206,11 @@ targets:
|
|||||||
- zalo
|
- zalo
|
||||||
- skype
|
- skype
|
||||||
- zoomus
|
- zoomus
|
||||||
|
- shortcuts
|
||||||
|
- iosamap
|
||||||
|
- amapuri
|
||||||
|
- baidumap
|
||||||
|
- maps
|
||||||
- things
|
- things
|
||||||
- todoist
|
- todoist
|
||||||
- evernote
|
- evernote
|
||||||
|
|||||||
Reference in New Issue
Block a user