feat(keyboard): add custom skills plus events and navigate Shortcuts
Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69.
This commit is contained in:
@@ -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 69)**. Releases use stable SemVer:
|
||||||
|
|
||||||
| Field | File | Rule |
|
| Field | File | Rule |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
|
|||||||
@@ -10,11 +10,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
- **Skills tab**: Home dock and iPad sidebar add Skills between Home and Styles; cards list default Reply / Summarize / Translate plus installable export skills (max 8 enabled, long-press drag to reorder). / **技能 Tab**:首页 Dock 与 iPad 侧栏在「首页」和「风格」之间新增「技能」;卡片列出默认的回复 / 总结 / 翻译以及可安装的出口技能(最多启用 8 个,长按拖动排序)。
|
- **Skills tab**: Home dock and iPad sidebar add Skills between Home and Styles; cards list default Reply / Summarize / Translate plus installable export skills (max 8 enabled, long-press drag to reorder). / **技能 Tab**:首页 Dock 与 iPad 侧栏在「首页」和「风格」之间新增「技能」;卡片列出默认的回复 / 总结 / 翻译以及可安装的出口技能(最多启用 8 个,长按拖动排序)。
|
||||||
- **Extract tasks skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取待办` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开已做好的配套捷径 `OSG · 提取待办` 的系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。
|
- **Extract tasks skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取待办` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开已做好的配套捷径 `OSG · 提取待办` 的系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。
|
||||||
|
- **Extract events skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取日程` on the system Add page (multiple events into Calendar; date-only → all-day, time-only → today, optional end time and location). After you tap Add, copying text and tapping Events asks the LLM for events and silently adds them; no date or time stays in the current app with a keyboard tip. / **提取日程技能**:技能页会打开已做好的配套捷径 `OSG · 提取日程` 的系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。
|
||||||
|
- **Navigate skill**: the Skills tab opens a companion Shortcut named `OSG · 导航`. After you add it, copying text and tapping Navigate asks the LLM for one address (or origin → destination) and opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No address stays in the current app with a keyboard tip. / **导航技能**:技能页打开配套捷径 `OSG · 导航`。添加后,复制文字并点「导航」会抽取一条地址(或起点→终点)并开始驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。没有地址则留在当前 App,键盘上给出提示。
|
||||||
|
- **Skills clipboard guide**: when Clipboard History is off, the Skills tab shows a card that jumps to in-app Clipboard settings and to iOS Settings for paste authorization. / **技能页剪贴板指引**:未开启剪贴板历史时,技能页展示可点击卡片,分别跳转 App 内剪贴板设置和系统设置以完成粘贴授权。
|
||||||
|
- **Custom skills**: Skills tab `+` adds a user skill (name, about, SF Symbol, prompt, required iCloud Shortcut link with name lookup, independent Shortcut name, thinking off by default). Built-in thinking stays off and disabled. No cap on how many custom skills you can save; the keyboard still holds at most 8. / **自定义技能**:技能页右上角 `+` 可添加用户技能(名称、介绍、SF Symbol、提示词、必填 iCloud 捷径链接并自动读取名称、可与技能名分开的捷径名、思考默认关)。内置技能思考固定关闭且不可开。自定义数量不设上限,键盘仍最多启用 8 个。
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Extract tasks Shortcut**: receive Shortcut Input as Text, then split lines and add each title to Reminders — the previous recipe could finish successfully without creating items. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。
|
- **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
|
||||||
|
- **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 秒内,空闲态改为小圆形「回复 / 总结 / 翻译」按钮;翻译跟随键盘目标语言,未设置时中英互译。技能来自目录,便于日后扩展。
|
||||||
|
|||||||
@@ -70,6 +70,10 @@
|
|||||||
<string>skype</string>
|
<string>skype</string>
|
||||||
<string>zoomus</string>
|
<string>zoomus</string>
|
||||||
<string>shortcuts</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.
@@ -10,8 +10,6 @@ import UIKit
|
|||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
|
||||||
enum AIAgentShortcutInstaller {
|
enum AIAgentShortcutInstaller {
|
||||||
static let bundledResourceName = "OSGExtractTodos"
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func openInstallPage(for skill: AIClipboardSkill) {
|
static func openInstallPage(for skill: AIClipboardSkill) {
|
||||||
if let shareURL = skill.shortcutICloudURL,
|
if let shareURL = skill.shortcutICloudURL,
|
||||||
@@ -19,17 +17,19 @@ enum AIAgentShortcutInstaller {
|
|||||||
UIApplication.shared.open(installURL)
|
UIApplication.shared.open(installURL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
openBundledShortcut()
|
openBundledShortcut(for: skill)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private static func openBundledShortcut() {
|
private static func openBundledShortcut(for skill: AIClipboardSkill) {
|
||||||
guard let bundled = Bundle.main.url(
|
guard let resource = skill.shortcutResourceName,
|
||||||
forResource: bundledResourceName,
|
let bundled = Bundle.main.url(
|
||||||
withExtension: "shortcut"
|
forResource: resource,
|
||||||
) else { return }
|
withExtension: "shortcut"
|
||||||
|
) else { return }
|
||||||
|
let fileName = skill.shortcutName ?? resource
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("\(AIClipboardSkillCatalog.extractTodosShortcutName).shortcut")
|
.appendingPathComponent("\(fileName).shortcut")
|
||||||
try? FileManager.default.removeItem(at: tmp)
|
try? FileManager.default.removeItem(at: tmp)
|
||||||
do {
|
do {
|
||||||
try FileManager.default.copyItem(at: bundled, to: tmp)
|
try FileManager.default.copyItem(at: bundled, to: tmp)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// AIAgentShortcutRunner.swift
|
// AIAgentShortcutRunner.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
//
|
//
|
||||||
// Consumes the keyboard's pending extract-todos payload and opens the
|
// Consumes the keyboard's pending export-skill payload and opens the
|
||||||
// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add
|
// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add
|
||||||
// x-callback URLs so Console can record success / error / cancel.
|
// x-callback URLs so Console can record success / error / cancel.
|
||||||
|
|
||||||
@@ -13,15 +13,22 @@ enum AIAgentShortcutRunner {
|
|||||||
static func runPendingIfNeeded() {
|
static func runPendingIfNeeded() {
|
||||||
AIAgentShortcutRun.trace("host.runPending begin")
|
AIAgentShortcutRun.trace("host.runPending begin")
|
||||||
guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
|
guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
|
||||||
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID),
|
let catalog = AppGroupStore().agentUserSkillCatalog
|
||||||
|
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog),
|
||||||
let name = skill.shortcutName else {
|
let name = skill.shortcutName else {
|
||||||
AIAgentShortcutRun.trace(
|
AIAgentShortcutRun.trace(
|
||||||
"host.runPending skip unknownSkill=\(payload.skillID)"
|
"host.runPending skip unknownSkill=\(payload.skillID)"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
AIAgentShortcutRun.traceBody("host.titlesToShortcut", payload.joinedTitles)
|
guard let text = shortcutText(for: skill, payload: payload) else {
|
||||||
guard let url = shortcutsURL(name: name, text: payload.joinedTitles) else {
|
AIAgentShortcutRun.trace(
|
||||||
|
"host.runPending skip textBuildFailed skill=\(payload.skillID)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AIAgentShortcutRun.traceBody("host.titlesToShortcut", text)
|
||||||
|
guard let url = shortcutsURL(name: name, text: text) else {
|
||||||
AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)")
|
AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -55,6 +62,21 @@ enum AIAgentShortcutRunner {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigate: pick 高德 → 百度 → Apple Maps, pass that URL to the Shortcut.
|
||||||
|
/// Other export skills send the parsed lines unchanged.
|
||||||
|
private static func shortcutText(
|
||||||
|
for skill: AIClipboardSkill,
|
||||||
|
payload: AIAgentShortcutRunPayload
|
||||||
|
) -> String? {
|
||||||
|
if skill.id == AIClipboardSkillCatalog.navigateID {
|
||||||
|
return AIMapNavigation.shortcutInput(
|
||||||
|
from: payload.joinedTitles,
|
||||||
|
canOpen: { UIApplication.shared.canOpenURL($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return payload.joinedTitles
|
||||||
|
}
|
||||||
|
|
||||||
private static func shortcutsURL(name: String, text: String) -> URL? {
|
private static func shortcutsURL(name: String, text: String) -> URL? {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
return AIAgentShortcutRun.shortcutsRunURL(
|
return AIAgentShortcutRun.shortcutsRunURL(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -435,7 +435,10 @@
|
|||||||
"skills.enabled.section" = "In use (%d/%d)";
|
"skills.enabled.section" = "In use (%d/%d)";
|
||||||
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
||||||
"skills.available.section" = "Available";
|
"skills.available.section" = "Available";
|
||||||
"skills.clipboardHistory.banner" = "Turn on Clipboard History in Settings to use these skills after you copy text.";
|
"skills.clipboard.guide.title" = "Clipboard access needed";
|
||||||
|
"skills.clipboard.guide.body" = "Turn on Clipboard History in the app, then allow paste access in iOS Settings, so these skills can run after you copy text.";
|
||||||
|
"skills.clipboard.guide.openAppSettings" = "Open Clipboard settings";
|
||||||
|
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
|
||||||
"skills.detail.title" = "Skill";
|
"skills.detail.title" = "Skill";
|
||||||
"skills.badge.default" = "Default skill";
|
"skills.badge.default" = "Default skill";
|
||||||
"skills.action.turnOff" = "Turn off";
|
"skills.action.turnOff" = "Turn off";
|
||||||
@@ -452,9 +455,43 @@
|
|||||||
"skills.translate.description" = "Translate the copied text using your keyboard language setting.";
|
"skills.translate.description" = "Translate the copied text using your keyboard language setting.";
|
||||||
"skills.extractTodos.name" = "Extract tasks";
|
"skills.extractTodos.name" = "Extract tasks";
|
||||||
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
|
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
|
||||||
|
"skills.extractEvents.name" = "Extract events";
|
||||||
|
"skills.extractEvents.description" = "Extract events from copied text into Calendar.";
|
||||||
|
"skills.navigate.name" = "Navigate";
|
||||||
|
"skills.navigate.description" = "Find an address in the copied text and start driving directions. Uses Amap if installed, then Baidu Maps, then Apple Maps.";
|
||||||
"skills.install.lead" = "Add it, then confirm. Don’t rename it.";
|
"skills.install.lead" = "Add it, then confirm. Don’t rename it.";
|
||||||
"skills.install.openShortcuts" = "Add Shortcut";
|
"skills.install.openShortcuts" = "Add Shortcut";
|
||||||
"skills.install.confirmAdded" = "I've added it";
|
"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";
|
||||||
|
|||||||
@@ -434,7 +434,10 @@
|
|||||||
"skills.enabled.section" = "使用中(%d/%d)";
|
"skills.enabled.section" = "使用中(%d/%d)";
|
||||||
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
||||||
"skills.available.section" = "可添加";
|
"skills.available.section" = "可添加";
|
||||||
"skills.clipboardHistory.banner" = "请先在设置中打开剪贴板历史,复制文字后才能在键盘上使用这些技能。";
|
"skills.clipboard.guide.title" = "需要剪贴板权限";
|
||||||
|
"skills.clipboard.guide.body" = "请先在 App 里打开剪贴板历史,再到系统设置中允许粘贴授权,复制后才能使用这些技能。";
|
||||||
|
"skills.clipboard.guide.openAppSettings" = "打开剪贴板设置";
|
||||||
|
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
|
||||||
"skills.detail.title" = "技能";
|
"skills.detail.title" = "技能";
|
||||||
"skills.badge.default" = "默认技能";
|
"skills.badge.default" = "默认技能";
|
||||||
"skills.action.turnOff" = "关闭";
|
"skills.action.turnOff" = "关闭";
|
||||||
@@ -451,9 +454,43 @@
|
|||||||
"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
|
"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
|
||||||
"skills.extractTodos.name" = "提取待办";
|
"skills.extractTodos.name" = "提取待办";
|
||||||
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
|
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
|
||||||
|
"skills.extractEvents.name" = "提取日程";
|
||||||
|
"skills.extractEvents.description" = "从复制内容提取日程,写入日历。";
|
||||||
|
"skills.navigate.name" = "导航";
|
||||||
|
"skills.navigate.description" = "从复制内容识别地址并开始驾车导航。优先高德,其次百度,最后 Apple 地图。";
|
||||||
"skills.install.lead" = "添加后点「我已添加」,请勿改名。";
|
"skills.install.lead" = "添加后点「我已添加」,请勿改名。";
|
||||||
"skills.install.openShortcuts" = "添加捷径";
|
"skills.install.openShortcuts" = "添加捷径";
|
||||||
"skills.install.confirmAdded" = "我已添加";
|
"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" = "润色风格";
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ final class AIKeyboardCoordinator {
|
|||||||
if case .materialUnavailable = resolution {
|
if case .materialUnavailable = resolution {
|
||||||
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
|
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
|
||||||
}
|
}
|
||||||
submitResolvedPrompt(resolution)
|
submitResolvedPrompt(resolution, thinkingEnabled: skill.thinkingEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
||||||
@@ -114,7 +114,10 @@ final class AIKeyboardCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) {
|
private func submitResolvedPrompt(
|
||||||
|
_ resolution: AIClipboardPrompt.Resolution,
|
||||||
|
thinkingEnabled: Bool? = nil
|
||||||
|
) {
|
||||||
guard case .ready(let prompt) = resolution else {
|
guard case .ready(let prompt) = resolution else {
|
||||||
// The clipboard window closed between rendering and this tap.
|
// The clipboard window closed between rendering and this tap.
|
||||||
state.pendingClipboardSkillID = nil
|
state.pendingClipboardSkillID = nil
|
||||||
@@ -130,7 +133,8 @@ final class AIKeyboardCoordinator {
|
|||||||
}
|
}
|
||||||
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 {
|
||||||
state.pendingClipboardSkillID = nil
|
state.pendingClipboardSkillID = nil
|
||||||
@@ -236,14 +240,37 @@ final class AIKeyboardCoordinator {
|
|||||||
|
|
||||||
private var isPendingExportSkill: Bool {
|
private var isPendingExportSkill: Bool {
|
||||||
guard let id = state.pendingClipboardSkillID else { return false }
|
guard let id = state.pendingClipboardSkillID else { return false }
|
||||||
return AIClipboardSkillCatalog.skill(id: id)?.kind == .export
|
return resolvedSkill(id: id)?.kind == .export
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse extract-todos. Empty → in-keyboard tip, stay in the host app.
|
private func resolvedSkill(id: String) -> AIClipboardSkill? {
|
||||||
/// Titles → hand off to the host to run the companion Shortcut.
|
AIClipboardSkillCatalog.skill(
|
||||||
|
id: id,
|
||||||
|
userCatalog: AppGroupStore().agentUserSkillCatalog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an export skill. Empty → in-keyboard tip, stay in the host app.
|
||||||
|
/// Lines → hand off to the host to run the companion Shortcut.
|
||||||
private func finishExportSkill(answer: String) {
|
private func finishExportSkill(answer: String) {
|
||||||
let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||||
let items = AITodoExtraction.items(from: answer, sourceClipboard: source)
|
let skillID = state.pendingClipboardSkillID
|
||||||
|
let items: [String]
|
||||||
|
let emptyTipKey: String
|
||||||
|
switch skillID {
|
||||||
|
case AIClipboardSkillCatalog.extractEventsID:
|
||||||
|
items = AIEventExtraction.lines(from: answer, sourceClipboard: source)
|
||||||
|
emptyTipKey = "keyboard.ai.skill.noEvents"
|
||||||
|
case AIClipboardSkillCatalog.extractTodosID:
|
||||||
|
items = AITodoExtraction.items(from: answer, sourceClipboard: source)
|
||||||
|
emptyTipKey = "keyboard.ai.skill.noTodos"
|
||||||
|
case AIClipboardSkillCatalog.navigateID:
|
||||||
|
items = AIAddressExtraction.lines(from: answer, sourceClipboard: source)
|
||||||
|
emptyTipKey = "keyboard.ai.skill.noAddress"
|
||||||
|
default:
|
||||||
|
items = AIGenericSkillExport.items(from: answer)
|
||||||
|
emptyTipKey = "keyboard.ai.skill.noExportItems"
|
||||||
|
}
|
||||||
AIAgentShortcutRun.traceBody("keyboard.llmRaw", answer)
|
AIAgentShortcutRun.traceBody("keyboard.llmRaw", answer)
|
||||||
AIAgentShortcutRun.trace(
|
AIAgentShortcutRun.trace(
|
||||||
"keyboard.parse items=\(items.count) clipboardChars=\(source?.count ?? 0)"
|
"keyboard.parse items=\(items.count) clipboardChars=\(source?.count ?? 0)"
|
||||||
@@ -253,7 +280,6 @@ final class AIKeyboardCoordinator {
|
|||||||
AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
|
AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
let skillID = state.pendingClipboardSkillID
|
|
||||||
state.pendingClipboardSkillID = nil
|
state.pendingClipboardSkillID = nil
|
||||||
if state.aiSession.isBusy {
|
if state.aiSession.isBusy {
|
||||||
state.aiSession.cancelCurrentWork()
|
state.aiSession.cancelCurrentWork()
|
||||||
@@ -263,11 +289,11 @@ final class AIKeyboardCoordinator {
|
|||||||
}
|
}
|
||||||
guard !items.isEmpty else {
|
guard !items.isEmpty else {
|
||||||
AIAgentShortcutRun.trace("keyboard.parse empty — skip Shortcuts")
|
AIAgentShortcutRun.trace("keyboard.parse empty — skip Shortcuts")
|
||||||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.noTodos")
|
state.skillTipText = ExtL10n.string(emptyTipKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let skillID,
|
guard let skillID,
|
||||||
AIClipboardSkillCatalog.skill(id: skillID)?.shortcutName != nil else {
|
resolvedSkill(id: skillID)?.shortcutName != nil else {
|
||||||
AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")")
|
AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")")
|
||||||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
|
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -236,40 +245,52 @@ struct AIKeyboardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var clipboardSkillRow: some View {
|
private var clipboardSkillRow: some View {
|
||||||
let skills = visibleClipboardSkills
|
let rows = skillRows(from: visibleClipboardSkills)
|
||||||
let row = HStack(spacing: Spacing.lg) {
|
return VStack(spacing: 0) {
|
||||||
ForEach(skills) { skill in
|
Spacer(minLength: 0)
|
||||||
Button {
|
VStack(spacing: Layout.skillRowSpacing) {
|
||||||
state.submitAIClipboardSkill(skill)
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, rowSkills in
|
||||||
} label: {
|
HStack(spacing: Layout.skillCellSpacing) {
|
||||||
VStack(spacing: 6) {
|
ForEach(rowSkills) { skill in
|
||||||
Image(systemName: skill.systemImage)
|
skillChip(skill)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
|
|
||||||
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Group {
|
|
||||||
if skills.count > 4 {
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
row.padding(.horizontal, Spacing.md)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
row
|
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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 {
|
||||||
@@ -278,11 +299,20 @@ 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 }
|
guard !visibleClipboardSkills.isEmpty else { return false }
|
||||||
return AIHintPool.isClipboardSkillWindowActive(
|
return AIHintPool.isClipboardSkillWindowActive(
|
||||||
@@ -292,7 +322,15 @@ struct AIKeyboardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var visibleClipboardSkills: [AIClipboardSkill] {
|
private var visibleClipboardSkills: [AIClipboardSkill] {
|
||||||
AIClipboardSkillCatalog.visible(enabledIDs: state.enabledClipboardSkillIDs)
|
#if DEBUG
|
||||||
|
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
|
||||||
|
return preview
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return AIClipboardSkillCatalog.visible(
|
||||||
|
enabledIDs: state.enabledClipboardSkillIDs,
|
||||||
|
userCatalog: AppGroupStore().agentUserSkillCatalog
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No draft/answer yet — show the centered hint carousel instead of a scroll body.
|
/// No draft/answer yet — show the centered hint carousel instead of a scroll body.
|
||||||
@@ -474,6 +512,12 @@ struct AIKeyboardView: View {
|
|||||||
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
|
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
|
||||||
return ExtL10n.string("keyboard.ai.skill.extracting")
|
return ExtL10n.string("keyboard.ai.skill.extracting")
|
||||||
}
|
}
|
||||||
|
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractEventsID {
|
||||||
|
return ExtL10n.string("keyboard.ai.skill.extractingEvents")
|
||||||
|
}
|
||||||
|
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.navigateID {
|
||||||
|
return ExtL10n.string("keyboard.ai.skill.extractingAddress")
|
||||||
|
}
|
||||||
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
|
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
|
||||||
return ExtL10n.string("keyboard.ai.generating")
|
return ExtL10n.string("keyboard.ai.generating")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,8 +298,18 @@
|
|||||||
"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.extractTodos" = "Tasks";
|
||||||
|
"keyboard.ai.skill.extractEvents" = "Events";
|
||||||
|
"keyboard.ai.skill.navigate" = "Navigate";
|
||||||
|
"keyboard.ai.skill.previewPolish" = "Polish";
|
||||||
|
"keyboard.ai.skill.previewIdeas" = "Ideas";
|
||||||
|
"keyboard.ai.skill.previewTone" = "Tone";
|
||||||
"keyboard.ai.skill.noTodos" = "No tasks in the clipboard";
|
"keyboard.ai.skill.noTodos" = "No tasks in the clipboard";
|
||||||
|
"keyboard.ai.skill.noEvents" = "No date or time in the clipboard";
|
||||||
|
"keyboard.ai.skill.noAddress" = "No address in the clipboard";
|
||||||
|
"keyboard.ai.skill.noExportItems" = "Nothing to send to the Shortcut";
|
||||||
"keyboard.ai.skill.extracting" = "Finding tasks…";
|
"keyboard.ai.skill.extracting" = "Finding tasks…";
|
||||||
|
"keyboard.ai.skill.extractingEvents" = "Finding events…";
|
||||||
|
"keyboard.ai.skill.extractingAddress" = "Finding an address…";
|
||||||
"keyboard.ai.skill.runningShortcut" = "Running Shortcut…";
|
"keyboard.ai.skill.runningShortcut" = "Running Shortcut…";
|
||||||
"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
|
"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";
|
"keyboard.ai.skill.handoffFailed" = "Couldn’t open the app to run the Shortcut";
|
||||||
|
|||||||
@@ -298,8 +298,18 @@
|
|||||||
"keyboard.ai.skill.summarize" = "总结";
|
"keyboard.ai.skill.summarize" = "总结";
|
||||||
"keyboard.ai.skill.translate" = "翻译";
|
"keyboard.ai.skill.translate" = "翻译";
|
||||||
"keyboard.ai.skill.extractTodos" = "待办";
|
"keyboard.ai.skill.extractTodos" = "待办";
|
||||||
|
"keyboard.ai.skill.extractEvents" = "日程";
|
||||||
|
"keyboard.ai.skill.navigate" = "导航";
|
||||||
|
"keyboard.ai.skill.previewPolish" = "润色";
|
||||||
|
"keyboard.ai.skill.previewIdeas" = "点子";
|
||||||
|
"keyboard.ai.skill.previewTone" = "语气";
|
||||||
"keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项";
|
"keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项";
|
||||||
|
"keyboard.ai.skill.noEvents" = "剪贴板内容没有日期或时间";
|
||||||
|
"keyboard.ai.skill.noAddress" = "剪贴板内容没有可导航的地址";
|
||||||
|
"keyboard.ai.skill.noExportItems" = "没有可发给捷径的内容";
|
||||||
"keyboard.ai.skill.extracting" = "正在提取待办…";
|
"keyboard.ai.skill.extracting" = "正在提取待办…";
|
||||||
|
"keyboard.ai.skill.extractingEvents" = "正在提取日程…";
|
||||||
|
"keyboard.ai.skill.extractingAddress" = "正在识别地址…";
|
||||||
"keyboard.ai.skill.runningShortcut" = "正在运行捷径…";
|
"keyboard.ai.skill.runningShortcut" = "正在运行捷径…";
|
||||||
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
|
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
|
||||||
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
|
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
|
/// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
|
||||||
public func sanitized() -> AIAgentSkillLayout {
|
public func sanitized(
|
||||||
let known = Dictionary(uniqueKeysWithValues: AIClipboardSkillCatalog.catalog.map { ($0.id, $0) })
|
catalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog
|
||||||
|
) -> AIAgentSkillLayout {
|
||||||
|
let known = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
|
||||||
var seenEnabled = Set<String>()
|
var seenEnabled = Set<String>()
|
||||||
let enabled = enabledIDs.filter { id in
|
let enabled = enabledIDs.filter { id in
|
||||||
guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
|
guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// AIUserSkill.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// User-created clipboard skills. Built-in skills stay in
|
||||||
|
// `AIClipboardSkillCatalog`; this catalog is persisted in App Group so the
|
||||||
|
// keyboard can resolve custom names, prompts, and Shortcut run names.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public var name: String
|
||||||
|
/// Card subtitle. Empty is allowed; the UI shows a generic fallback.
|
||||||
|
public var summary: String
|
||||||
|
public var systemImage: String
|
||||||
|
public var prompt: String
|
||||||
|
public var shortcutICloudURL: URL
|
||||||
|
/// Name used by `shortcuts://run-shortcut?name=`. Independent of `name`.
|
||||||
|
public var shortcutName: String
|
||||||
|
/// Per-skill reasoning. Built-in skills are always off; custom defaults off.
|
||||||
|
public var thinkingEnabled: Bool
|
||||||
|
public let createdAt: Date
|
||||||
|
public var updatedAt: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String = "user.\(UUID().uuidString.lowercased())",
|
||||||
|
name: String,
|
||||||
|
summary: String = "",
|
||||||
|
systemImage: String = AIUserSkillLimits.defaultSystemImage,
|
||||||
|
prompt: String,
|
||||||
|
shortcutICloudURL: URL,
|
||||||
|
shortcutName: String,
|
||||||
|
thinkingEnabled: Bool = false,
|
||||||
|
createdAt: Date = Date(),
|
||||||
|
updatedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.summary = summary
|
||||||
|
self.systemImage = systemImage
|
||||||
|
self.prompt = prompt
|
||||||
|
self.shortcutICloudURL = shortcutICloudURL
|
||||||
|
self.shortcutName = shortcutName
|
||||||
|
self.thinkingEnabled = thinkingEnabled
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt ?? createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isUserCreated: Bool { id.hasPrefix("user.") }
|
||||||
|
|
||||||
|
public func asClipboardSkill() -> AIClipboardSkill {
|
||||||
|
AIClipboardSkill(
|
||||||
|
id: id,
|
||||||
|
systemImage: systemImage,
|
||||||
|
titleKey: "",
|
||||||
|
cardTitleKey: "",
|
||||||
|
descriptionKey: "",
|
||||||
|
kind: .export,
|
||||||
|
isDefault: false,
|
||||||
|
shortcutName: shortcutName,
|
||||||
|
shortcutICloudURL: shortcutICloudURL,
|
||||||
|
customName: name,
|
||||||
|
customSummary: summary,
|
||||||
|
customPrompt: prompt,
|
||||||
|
thinkingEnabled: thinkingEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AIUserSkillLimits {
|
||||||
|
public static let defaultSystemImage = "sparkles"
|
||||||
|
public static let maximumPromptCharacters = 6_000
|
||||||
|
public static let maximumNameCharacters = 40
|
||||||
|
public static let maximumSummaryCharacters = 200
|
||||||
|
public static let newPromptTemplate = """
|
||||||
|
请根据剪贴板内容完成以下操作。
|
||||||
|
只输出结果,不要解释或客套。
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Curated SF Symbols for the skill-icon picker.
|
||||||
|
public static let symbolChoices: [String] = [
|
||||||
|
"sparkles",
|
||||||
|
"wand.and.stars",
|
||||||
|
"text.badge.checkmark",
|
||||||
|
"checklist",
|
||||||
|
"calendar",
|
||||||
|
"envelope.fill",
|
||||||
|
"bubble.left.and.bubble.right.fill",
|
||||||
|
"character.bubble.fill",
|
||||||
|
"doc.text.magnifyingglass",
|
||||||
|
"arrowshape.turn.up.left.fill",
|
||||||
|
"lightbulb.fill",
|
||||||
|
"star.fill",
|
||||||
|
"heart.fill",
|
||||||
|
"flag.fill",
|
||||||
|
"tag.fill",
|
||||||
|
"folder.fill",
|
||||||
|
"list.bullet",
|
||||||
|
"square.and.pencil",
|
||||||
|
"scissors",
|
||||||
|
"globe",
|
||||||
|
"paperplane.fill",
|
||||||
|
"clock.fill",
|
||||||
|
"bell.fill",
|
||||||
|
"bookmark.fill",
|
||||||
|
"person.fill",
|
||||||
|
"link",
|
||||||
|
"number",
|
||||||
|
"at",
|
||||||
|
"tray.fill",
|
||||||
|
"quote.bubble.fill",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AIUserSkillValidationError: Error, Equatable, Sendable {
|
||||||
|
case emptyName
|
||||||
|
case emptyPrompt
|
||||||
|
case emptyShortcutName
|
||||||
|
case invalidShortcutLink
|
||||||
|
case emptyIcon
|
||||||
|
case promptTooLong(maximum: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AIUserSkillCatalog: Codable, Equatable, Sendable {
|
||||||
|
public var entries: [AIUserSkill]
|
||||||
|
|
||||||
|
public init(entries: [AIUserSkill] = []) {
|
||||||
|
self.entries = entries.filter(\.isUserCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let empty = AIUserSkillCatalog()
|
||||||
|
|
||||||
|
public func skill(id: String) -> AIUserSkill? {
|
||||||
|
entries.first { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func upsert(_ skill: AIUserSkill, at date: Date = Date()) throws {
|
||||||
|
let name = skill.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let summary = skill.summary.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let prompt = skill.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let shortcutName = skill.shortcutName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let icon = skill.systemImage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
guard skill.isUserCreated else { throw AIUserSkillValidationError.emptyName }
|
||||||
|
guard !name.isEmpty else { throw AIUserSkillValidationError.emptyName }
|
||||||
|
guard !prompt.isEmpty else { throw AIUserSkillValidationError.emptyPrompt }
|
||||||
|
guard prompt.count <= AIUserSkillLimits.maximumPromptCharacters else {
|
||||||
|
throw AIUserSkillValidationError.promptTooLong(
|
||||||
|
maximum: AIUserSkillLimits.maximumPromptCharacters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard !shortcutName.isEmpty else { throw AIUserSkillValidationError.emptyShortcutName }
|
||||||
|
guard AIShortcutShareLink.isValid(skill.shortcutICloudURL) else {
|
||||||
|
throw AIUserSkillValidationError.invalidShortcutLink
|
||||||
|
}
|
||||||
|
guard !icon.isEmpty else { throw AIUserSkillValidationError.emptyIcon }
|
||||||
|
|
||||||
|
var saved = skill
|
||||||
|
saved.name = String(name.prefix(AIUserSkillLimits.maximumNameCharacters))
|
||||||
|
saved.summary = String(summary.prefix(AIUserSkillLimits.maximumSummaryCharacters))
|
||||||
|
saved.prompt = prompt
|
||||||
|
saved.shortcutName = shortcutName
|
||||||
|
saved.systemImage = icon
|
||||||
|
saved.thinkingEnabled = skill.thinkingEnabled
|
||||||
|
saved.updatedAt = date
|
||||||
|
|
||||||
|
if let index = entries.firstIndex(where: { $0.id == skill.id }) {
|
||||||
|
entries[index] = saved
|
||||||
|
} else {
|
||||||
|
entries.append(saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func remove(id: String) {
|
||||||
|
entries.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
|
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
|
||||||
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
|
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
|
||||||
public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,46 +12,65 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
|||||||
public static let shared = AIAgentSkillLayoutStore()
|
public static let shared = AIAgentSkillLayoutStore()
|
||||||
|
|
||||||
@Published public private(set) var layout: AIAgentSkillLayout
|
@Published public private(set) var layout: AIAgentSkillLayout
|
||||||
|
@Published public private(set) var userCatalog: AIUserSkillCatalog
|
||||||
|
|
||||||
private let defaults: UserDefaults?
|
private let persistLayout: (AIAgentSkillLayout) -> Void
|
||||||
private let persist: (AIAgentSkillLayout) -> Void
|
private let persistUserCatalog: (AIUserSkillCatalog) -> Void
|
||||||
private let load: () -> AIAgentSkillLayout
|
private let loadLayout: () -> AIAgentSkillLayout
|
||||||
|
private let loadUserCatalog: () -> AIUserSkillCatalog
|
||||||
|
|
||||||
public init(defaults: UserDefaults? = nil) {
|
public init(defaults: UserDefaults? = nil) {
|
||||||
if let defaults {
|
if let defaults {
|
||||||
self.defaults = defaults
|
self.loadUserCatalog = { AppGroupStore(defaults: defaults).agentUserSkillCatalog }
|
||||||
self.load = { AppGroupStore(defaults: defaults).agentSkillLayout }
|
self.persistUserCatalog = { AppGroupStore(defaults: defaults).setAgentUserSkillCatalog($0) }
|
||||||
self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
|
self.loadLayout = { AppGroupStore(defaults: defaults).agentSkillLayout }
|
||||||
|
self.persistLayout = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
|
||||||
} else {
|
} else {
|
||||||
self.defaults = nil
|
self.loadUserCatalog = { AppGroupStore().agentUserSkillCatalog }
|
||||||
self.load = { AppGroupStore().agentSkillLayout }
|
self.persistUserCatalog = { AppGroupStore().setAgentUserSkillCatalog($0) }
|
||||||
self.persist = { AppGroupStore().setAgentSkillLayout($0) }
|
self.loadLayout = { AppGroupStore().agentSkillLayout }
|
||||||
|
self.persistLayout = { AppGroupStore().setAgentSkillLayout($0) }
|
||||||
}
|
}
|
||||||
self.layout = self.load()
|
self.userCatalog = self.loadUserCatalog()
|
||||||
|
self.layout = self.loadLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reload() {
|
public func reload() {
|
||||||
layout = load()
|
userCatalog = loadUserCatalog()
|
||||||
|
layout = loadLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var mergedCatalog: [AIClipboardSkill] {
|
||||||
|
AIClipboardSkillCatalog.all(userCatalog: userCatalog)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var enabledSkills: [AIClipboardSkill] {
|
public var enabledSkills: [AIClipboardSkill] {
|
||||||
AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs)
|
AIClipboardSkillCatalog.visible(
|
||||||
|
enabledIDs: layout.enabledIDs,
|
||||||
|
userCatalog: userCatalog
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var availableSkills: [AIClipboardSkill] {
|
public var availableSkills: [AIClipboardSkill] {
|
||||||
AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) }
|
mergedCatalog.filter { !layout.isEnabled($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func userSkill(id: String) -> AIUserSkill? {
|
||||||
|
userCatalog.skill(id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func enable(_ id: String) -> AIAgentSkillEnableResult {
|
public func enable(_ id: String) -> AIAgentSkillEnableResult {
|
||||||
let current = layout.sanitized()
|
let current = layout.sanitized(catalog: mergedCatalog)
|
||||||
guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown }
|
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog) else {
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
if current.isEnabled(id) { return .alreadyEnabled }
|
if current.isEnabled(id) { return .alreadyEnabled }
|
||||||
if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
|
if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
|
||||||
return .needsShortcut
|
return .needsShortcut
|
||||||
}
|
}
|
||||||
if current.isFull { return .full }
|
if current.isFull { return .full }
|
||||||
commit(
|
commitLayout(
|
||||||
AIAgentSkillLayout(
|
AIAgentSkillLayout(
|
||||||
enabledIDs: current.enabledIDs + [id],
|
enabledIDs: current.enabledIDs + [id],
|
||||||
confirmedShortcutIDs: current.confirmedShortcutIDs
|
confirmedShortcutIDs: current.confirmedShortcutIDs
|
||||||
@@ -63,8 +82,8 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
|||||||
/// Drops the keyboard slot only. Companion Shortcuts stay installed;
|
/// Drops the keyboard slot only. Companion Shortcuts stay installed;
|
||||||
/// the user deletes them in the Shortcuts app if they want them gone.
|
/// the user deletes them in the Shortcuts app if they want them gone.
|
||||||
public func disable(_ id: String) {
|
public func disable(_ id: String) {
|
||||||
let current = layout.sanitized()
|
let current = layout.sanitized(catalog: mergedCatalog)
|
||||||
commit(
|
commitLayout(
|
||||||
AIAgentSkillLayout(
|
AIAgentSkillLayout(
|
||||||
enabledIDs: current.enabledIDs.filter { $0 != id },
|
enabledIDs: current.enabledIDs.filter { $0 != id },
|
||||||
confirmedShortcutIDs: current.confirmedShortcutIDs
|
confirmedShortcutIDs: current.confirmedShortcutIDs
|
||||||
@@ -75,27 +94,34 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
|||||||
/// Marks the companion Shortcut as added, then tries to occupy a slot.
|
/// Marks the companion Shortcut as added, then tries to occupy a slot.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func confirmShortcutAndEnable(_ id: String) -> AIAgentSkillEnableResult {
|
public func confirmShortcutAndEnable(_ id: String) -> AIAgentSkillEnableResult {
|
||||||
guard let skill = AIClipboardSkillCatalog.skill(id: id), skill.requiresShortcut else {
|
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog),
|
||||||
|
skill.requiresShortcut else {
|
||||||
return .unknown
|
return .unknown
|
||||||
}
|
}
|
||||||
var current = layout.sanitized()
|
var current = layout.sanitized(catalog: mergedCatalog)
|
||||||
if !current.confirmedShortcutIDs.contains(id) {
|
if !current.confirmedShortcutIDs.contains(id) {
|
||||||
current.confirmedShortcutIDs.append(id)
|
current.confirmedShortcutIDs.append(id)
|
||||||
}
|
}
|
||||||
commit(current)
|
commitLayout(current)
|
||||||
return enable(id)
|
return enable(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func moveEnabled(id draggedID: String, onto targetID: String) {
|
public func moveEnabled(id draggedID: String, onto targetID: String) {
|
||||||
var ids = layout.sanitized().enabledIDs
|
let ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
|
||||||
guard let from = ids.firstIndex(of: draggedID),
|
guard let to = ids.firstIndex(of: targetID) else { return }
|
||||||
let to = ids.firstIndex(of: targetID),
|
moveEnabled(id: draggedID, toIndex: to)
|
||||||
from != to else { return }
|
}
|
||||||
|
|
||||||
|
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(
|
ids.move(
|
||||||
fromOffsets: IndexSet(integer: from),
|
fromOffsets: IndexSet(integer: from),
|
||||||
toOffset: to > from ? to + 1 : to
|
toOffset: to > from ? to + 1 : to
|
||||||
)
|
)
|
||||||
commit(
|
commitLayout(
|
||||||
AIAgentSkillLayout(
|
AIAgentSkillLayout(
|
||||||
enabledIDs: ids,
|
enabledIDs: ids,
|
||||||
confirmedShortcutIDs: layout.confirmedShortcutIDs
|
confirmedShortcutIDs: layout.confirmedShortcutIDs
|
||||||
@@ -103,8 +129,47 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func commit(_ layout: AIAgentSkillLayout) {
|
public func saveUserSkill(_ skill: AIUserSkill) throws {
|
||||||
persist(layout)
|
let previousURL = userCatalog.skill(id: skill.id)?.shortcutICloudURL
|
||||||
self.layout = load()
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,17 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
|||||||
public let shortcutName: String?
|
public let shortcutName: String?
|
||||||
/// Optional `icloud.com/shortcuts/` share URL. Nil → open the bundled file.
|
/// Optional `icloud.com/shortcuts/` share URL. Nil → open the bundled file.
|
||||||
public let shortcutICloudURL: URL?
|
public let shortcutICloudURL: URL?
|
||||||
|
/// Bundled `.shortcut` resource name without extension. Nil → no file fallback.
|
||||||
|
public let shortcutResourceName: String?
|
||||||
|
/// User-created skills store display copy here instead of localization keys.
|
||||||
|
public let customName: String?
|
||||||
|
public let customSummary: String?
|
||||||
|
public let customPrompt: String?
|
||||||
|
/// Built-in skills are always false. Custom skills default off.
|
||||||
|
public let thinkingEnabled: Bool
|
||||||
|
|
||||||
public var requiresShortcut: Bool { kind == .export }
|
public var requiresShortcut: Bool { kind == .export }
|
||||||
|
public var isUserCreated: Bool { id.hasPrefix("user.") }
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -42,7 +51,12 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
|||||||
kind: AIClipboardSkillKind,
|
kind: AIClipboardSkillKind,
|
||||||
isDefault: Bool,
|
isDefault: Bool,
|
||||||
shortcutName: String? = nil,
|
shortcutName: String? = nil,
|
||||||
shortcutICloudURL: URL? = nil
|
shortcutICloudURL: URL? = nil,
|
||||||
|
shortcutResourceName: String? = nil,
|
||||||
|
customName: String? = nil,
|
||||||
|
customSummary: String? = nil,
|
||||||
|
customPrompt: String? = nil,
|
||||||
|
thinkingEnabled: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.systemImage = systemImage
|
self.systemImage = systemImage
|
||||||
@@ -53,6 +67,11 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
|||||||
self.isDefault = isDefault
|
self.isDefault = isDefault
|
||||||
self.shortcutName = shortcutName
|
self.shortcutName = shortcutName
|
||||||
self.shortcutICloudURL = shortcutICloudURL
|
self.shortcutICloudURL = shortcutICloudURL
|
||||||
|
self.shortcutResourceName = shortcutResourceName
|
||||||
|
self.customName = customName
|
||||||
|
self.customSummary = customSummary
|
||||||
|
self.customPrompt = customPrompt
|
||||||
|
self.thinkingEnabled = id.hasPrefix("user.") ? thinkingEnabled : false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +82,20 @@ public enum AIClipboardSkillCatalog: Sendable {
|
|||||||
public static let extractTodosID = "extractTodos"
|
public static let extractTodosID = "extractTodos"
|
||||||
public static let extractTodosShortcutName = "OSG · 提取待办"
|
public static let extractTodosShortcutName = "OSG · 提取待办"
|
||||||
public static let extractTodosShortcutICloudURL = URL(
|
public static let extractTodosShortcutICloudURL = URL(
|
||||||
string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81"
|
string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
|
||||||
)!
|
)!
|
||||||
|
public static let extractTodosResourceName = "OSGExtractTodos"
|
||||||
|
|
||||||
|
public static let extractEventsID = "extractEvents"
|
||||||
|
public static let extractEventsShortcutName = "OSG · 提取日程"
|
||||||
|
public static let extractEventsShortcutICloudURL = URL(
|
||||||
|
string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf"
|
||||||
|
)!
|
||||||
|
public static let extractEventsResourceName = "OSGExtractEvents"
|
||||||
|
|
||||||
|
public static let navigateID = "navigate"
|
||||||
|
public static let navigateShortcutName = "OSG · 导航"
|
||||||
|
public static let navigateResourceName = "OSGNavigate"
|
||||||
|
|
||||||
/// Full built-in catalog, in a stable display order for the Skills tab.
|
/// Full built-in catalog, in a stable display order for the Skills tab.
|
||||||
public static let catalog: [AIClipboardSkill] = [
|
public static let catalog: [AIClipboardSkill] = [
|
||||||
@@ -104,35 +135,76 @@ public enum AIClipboardSkillCatalog: Sendable {
|
|||||||
kind: .export,
|
kind: .export,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
shortcutName: extractTodosShortcutName,
|
shortcutName: extractTodosShortcutName,
|
||||||
shortcutICloudURL: extractTodosShortcutICloudURL
|
shortcutICloudURL: extractTodosShortcutICloudURL,
|
||||||
|
shortcutResourceName: extractTodosResourceName
|
||||||
|
),
|
||||||
|
AIClipboardSkill(
|
||||||
|
id: extractEventsID,
|
||||||
|
systemImage: "calendar",
|
||||||
|
titleKey: "keyboard.ai.skill.extractEvents",
|
||||||
|
cardTitleKey: "skills.extractEvents.name",
|
||||||
|
descriptionKey: "skills.extractEvents.description",
|
||||||
|
kind: .export,
|
||||||
|
isDefault: false,
|
||||||
|
shortcutName: extractEventsShortcutName,
|
||||||
|
shortcutICloudURL: extractEventsShortcutICloudURL,
|
||||||
|
shortcutResourceName: extractEventsResourceName
|
||||||
|
),
|
||||||
|
AIClipboardSkill(
|
||||||
|
id: navigateID,
|
||||||
|
systemImage: "arrow.triangle.turn.up.right.diamond.fill",
|
||||||
|
titleKey: "keyboard.ai.skill.navigate",
|
||||||
|
cardTitleKey: "skills.navigate.name",
|
||||||
|
descriptionKey: "skills.navigate.description",
|
||||||
|
kind: .export,
|
||||||
|
isDefault: false,
|
||||||
|
shortcutName: navigateShortcutName,
|
||||||
|
shortcutResourceName: navigateResourceName
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Legacy alias: the three default transform skills used to be the whole list.
|
/// Legacy alias: the three default transform skills used to be the whole list.
|
||||||
public static let builtIn: [AIClipboardSkill] = catalog
|
public static let builtIn: [AIClipboardSkill] = catalog
|
||||||
|
|
||||||
public static func skill(id: String) -> AIClipboardSkill? {
|
public static func all(userCatalog: AIUserSkillCatalog = .empty) -> [AIClipboardSkill] {
|
||||||
catalog.first { $0.id == id }
|
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.
|
/// `enabledIDs` is the Skills-tab order. `nil` keeps the default three.
|
||||||
/// An explicit empty array shows no chips (carousel fallback).
|
/// An explicit empty array shows no chips (carousel fallback).
|
||||||
public static func visible(enabledIDs: [String]? = nil) -> [AIClipboardSkill] {
|
public static func visible(
|
||||||
|
enabledIDs: [String]? = nil,
|
||||||
|
userCatalog: AIUserSkillCatalog = .empty
|
||||||
|
) -> [AIClipboardSkill] {
|
||||||
let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
|
let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
|
||||||
guard !ids.isEmpty else { return [] }
|
guard !ids.isEmpty else { return [] }
|
||||||
let byID = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
|
let byID = Dictionary(uniqueKeysWithValues: all(userCatalog: userCatalog).map { ($0.id, $0) })
|
||||||
return ids.compactMap { byID[$0] }
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +231,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 {
|
||||||
@@ -188,6 +261,10 @@ public enum AIClipboardSkillCatalog: Sendable {
|
|||||||
If there are no actionable tasks, output NONE and nothing else. Do not treat the whole clipboard as one task.
|
If 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.
|
If the clipboard itself is already one short task (for example "buy milk"), output that single line.
|
||||||
"""
|
"""
|
||||||
|
case extractEventsID:
|
||||||
|
return eventInstruction(zh: zh, now: now)
|
||||||
|
case navigateID:
|
||||||
|
return navigateInstruction(zh: zh)
|
||||||
default:
|
default:
|
||||||
return zh
|
return zh
|
||||||
? "请根据剪贴板内容完成用户选择的操作。"
|
? "请根据剪贴板内容完成用户选择的操作。"
|
||||||
@@ -195,6 +272,61 @@ public enum AIClipboardSkillCatalog: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func navigateInstruction(zh: Bool) -> String {
|
||||||
|
if zh {
|
||||||
|
return """
|
||||||
|
请从剪贴板提取明确的地点用于导航。只输出一行,两段用 | 分隔:起点|终点
|
||||||
|
从当前位置出发则起点留空,但保留竖线,例如 |朝阳区酒仙桥路10号
|
||||||
|
两点都写了则两侧都填,例如 北京南站|三里屯太古里
|
||||||
|
可以是完整地址或常用地名。不要编号、不要解释、不要多行、不要链接。
|
||||||
|
若有多条地址,只输出最明确的一条。
|
||||||
|
没有可导航的地点时,只输出 NONE。不要把整段原文当成一个地点。
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
return """
|
||||||
|
Extract one place for turn-by-turn navigation from the clipboard. One line, two fields separated by | : origin|destination
|
||||||
|
Leave origin empty when starting from the current location, but keep the pipe, for example |10 Jiuxianqiao Road
|
||||||
|
Fill both sides when the source names two places, for example Beijing South|Sanlitun Taikoo Li
|
||||||
|
A full address or a well-known place name is fine. No numbering, commentary, extra lines, or URLs.
|
||||||
|
If there are several addresses, output only the clearest one.
|
||||||
|
If there is no navigable place, output NONE and nothing else. Do not treat the whole clipboard as one place.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clock context so relative phrases (tomorrow, 3pm) resolve to local time.
|
||||||
|
private static func eventInstruction(zh: Bool, now: Date) -> String {
|
||||||
|
let clock = clockContext(now: now, zh: zh)
|
||||||
|
if zh {
|
||||||
|
return """
|
||||||
|
\(clock)
|
||||||
|
请从剪贴板提取明确的日程。每条一行,四段用 | 分隔:开始|结束|标题|地点
|
||||||
|
开始有钟点用 YYYY-MM-DD HH:mm;只有日期(全天)用 YYYY-MM-DD。没有结束时间或地点则该段留空,但保留竖线。标题中不要出现 |。最多 20 条。不要编号、不要解释。
|
||||||
|
只有时刻、没有日期时,使用今天的日期。日期和时间都没有的条目不要输出。
|
||||||
|
原文写了结束时间就填写结束段,否则留空(后续按 1 小时处理)。原文有地点就填写地点段。
|
||||||
|
若没有任何带日期或时间的日程,只输出 NONE,不要把整段原文当成一条日程。
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
return """
|
||||||
|
\(clock)
|
||||||
|
Extract explicit calendar events from the clipboard. One event per line, four fields separated by | : start|end|title|location
|
||||||
|
Timed start uses YYYY-MM-DD HH:mm; date-only (all-day) uses YYYY-MM-DD. Leave end or location empty when unknown, but keep the pipes. Do not put | in the title. Maximum 20 lines. No numbering or commentary.
|
||||||
|
Time without a date uses today. Skip items that have neither a date nor a time.
|
||||||
|
Fill the end field when the source gives an end time; otherwise leave it empty (treated as 1 hour). Fill location when the source names a place.
|
||||||
|
If there are no events with a date or time, output NONE and nothing else. Do not treat the whole clipboard as one event.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func clockContext(now: Date, zh: Bool) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: zh ? "zh_CN" : "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone.current
|
||||||
|
formatter.dateFormat = zh ? "yyyy年M月d日EEEE HH:mm" : "EEEE, d MMMM yyyy, HH:mm"
|
||||||
|
let stamp = formatter.string(from: now)
|
||||||
|
return zh
|
||||||
|
? "现在是\(stamp)(设备本地时区)。"
|
||||||
|
: "It is now \(stamp) (device local timezone)."
|
||||||
|
}
|
||||||
|
|
||||||
/// Uses the keyboard translation target when set; otherwise Chinese ↔ English.
|
/// 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 keep their dedicated parsers.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AIGenericSkillExport {
|
||||||
|
public static let maximumItems = 20
|
||||||
|
|
||||||
|
public static func items(from answer: String) -> [String] {
|
||||||
|
let trimmed = answer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return [] }
|
||||||
|
if trimmed.compare("NONE", options: .caseInsensitive) == .orderedSame {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let lines = trimmed
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
if lines.isEmpty { return [trimmed] }
|
||||||
|
return Array(lines.prefix(maximumItems))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// AIMapNavigation.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Builds one turn-by-turn URL. Provider order is Amap (高德) → Baidu →
|
||||||
|
// Apple Maps. Shortcuts cannot call `canOpenURL`, so the host injects that
|
||||||
|
// check and passes the resulting URL to the companion Shortcut.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AIMapProvider: String, Equatable, Sendable {
|
||||||
|
case amap
|
||||||
|
case baidu
|
||||||
|
case apple
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AIMapNavigation: Sendable {
|
||||||
|
public static let sourceApplication = "OSGKeyboard"
|
||||||
|
public static let baiduSource = "ios.osgkeyboard"
|
||||||
|
/// Amap's usual current-location label when we have no coordinates.
|
||||||
|
public static let amapCurrentLocationName = "我的位置"
|
||||||
|
|
||||||
|
public static func provider(canOpen: (URL) -> Bool) -> AIMapProvider {
|
||||||
|
if canOpen(probe("iosamap")) || canOpen(probe("amapuri")) {
|
||||||
|
return .amap
|
||||||
|
}
|
||||||
|
if canOpen(probe("baidumap")) {
|
||||||
|
return .baidu
|
||||||
|
}
|
||||||
|
return .apple
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func url(for route: AIMapRoute, canOpen: (URL) -> Bool) -> URL {
|
||||||
|
switch provider(canOpen: canOpen) {
|
||||||
|
case .amap:
|
||||||
|
if canOpen(probe("iosamap")) {
|
||||||
|
return amapURL(route, useLegacyScheme: true)
|
||||||
|
}
|
||||||
|
return amapURL(route, useLegacyScheme: false)
|
||||||
|
case .baidu:
|
||||||
|
return baiduURL(route)
|
||||||
|
case .apple:
|
||||||
|
return appleURL(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyboard payload is `origin|destination`. Host turns it into one URL.
|
||||||
|
public static func shortcutInput(
|
||||||
|
from encoded: String,
|
||||||
|
canOpen: (URL) -> Bool
|
||||||
|
) -> String? {
|
||||||
|
guard let route = AIAddressExtraction.route(from: encoded) else { return nil }
|
||||||
|
return url(for: route, canOpen: canOpen).absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Providers
|
||||||
|
|
||||||
|
private static func amapURL(_ route: AIMapRoute, useLegacyScheme: Bool) -> URL {
|
||||||
|
let originName = route.origin ?? amapCurrentLocationName
|
||||||
|
let items = [
|
||||||
|
URLQueryItem(name: "sourceApplication", value: sourceApplication),
|
||||||
|
URLQueryItem(name: "sname", value: originName),
|
||||||
|
URLQueryItem(name: "dname", value: route.destination),
|
||||||
|
URLQueryItem(name: "dev", value: "0"),
|
||||||
|
URLQueryItem(name: "t", value: "0"),
|
||||||
|
]
|
||||||
|
if useLegacyScheme {
|
||||||
|
return makeURL(scheme: "iosamap", host: "path", path: nil, items: items)
|
||||||
|
}
|
||||||
|
let url = makeURL(scheme: "amapuri", host: "route", path: "/plan/", items: items)
|
||||||
|
// URLComponents drops the trailing slash; Amap's documented path is `/plan/`.
|
||||||
|
let withSlash = url.absoluteString.replacingOccurrences(
|
||||||
|
of: "://route/plan?",
|
||||||
|
with: "://route/plan/?"
|
||||||
|
)
|
||||||
|
return URL(string: withSlash) ?? url
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func baiduURL(_ route: AIMapRoute) -> URL {
|
||||||
|
var items = [
|
||||||
|
URLQueryItem(name: "destination", value: "name:\(route.destination)"),
|
||||||
|
URLQueryItem(name: "mode", value: "driving"),
|
||||||
|
URLQueryItem(name: "src", value: baiduSource),
|
||||||
|
]
|
||||||
|
if let origin = route.origin {
|
||||||
|
items.insert(
|
||||||
|
URLQueryItem(name: "origin", value: "name:\(origin)"),
|
||||||
|
at: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return makeURL(scheme: "baidumap", host: "map", path: "/direction", items: items)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func appleURL(_ route: AIMapRoute) -> URL {
|
||||||
|
var items = [
|
||||||
|
URLQueryItem(name: "daddr", value: route.destination),
|
||||||
|
URLQueryItem(name: "dirflg", value: "d"),
|
||||||
|
]
|
||||||
|
if let origin = route.origin {
|
||||||
|
items.insert(URLQueryItem(name: "saddr", value: origin), at: 0)
|
||||||
|
}
|
||||||
|
// `URLComponents(string: "maps://")` keeps the `://` that opening needs.
|
||||||
|
var components = URLComponents(string: "maps://")!
|
||||||
|
components.queryItems = items
|
||||||
|
return components.url ?? URL(string: "maps://")!
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func probe(_ scheme: String) -> URL {
|
||||||
|
URL(string: "\(scheme)://")!
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeURL(
|
||||||
|
scheme: String,
|
||||||
|
host: String?,
|
||||||
|
path: String?,
|
||||||
|
items: [URLQueryItem]
|
||||||
|
) -> URL {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = scheme
|
||||||
|
components.host = host
|
||||||
|
if let path {
|
||||||
|
components.path = path
|
||||||
|
}
|
||||||
|
components.queryItems = items
|
||||||
|
return components.url ?? URL(string: "\(scheme)://")!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,14 +8,15 @@
|
|||||||
import Foundation
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,7 +99,11 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
|
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
|
||||||
/// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it.
|
/// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it.
|
||||||
public var agentSkillLayout: AIAgentSkillLayout {
|
public var agentSkillLayout: AIAgentSkillLayout {
|
||||||
Self.decodeAgentSkillLayout(from: defaults)
|
Self.decodeAgentSkillLayout(from: defaults, userCatalog: agentUserSkillCatalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var agentUserSkillCatalog: AIUserSkillCatalog {
|
||||||
|
Self.decodeUserSkillCatalog(from: defaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Writes
|
// MARK: - Writes
|
||||||
@@ -205,7 +209,9 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
|
|
||||||
public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
|
public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(layout.sanitized())
|
let data = try JSONEncoder().encode(
|
||||||
|
layout.sanitized(catalog: AIClipboardSkillCatalog.all(userCatalog: agentUserSkillCatalog))
|
||||||
|
)
|
||||||
defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
|
defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
|
||||||
} catch {
|
} catch {
|
||||||
OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)")
|
OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)")
|
||||||
@@ -213,6 +219,20 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
AppGroupConfigDarwin.postConfigChanged()
|
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]) {
|
public func setPendingShortcutRun(skillID: String, titles: [String]) {
|
||||||
let payload = AIAgentShortcutRunPayload(skillID: skillID, titles: titles)
|
let payload = AIAgentShortcutRunPayload(skillID: skillID, titles: titles)
|
||||||
if let data = AIAgentShortcutRun.encode(payload) {
|
if let data = AIAgentShortcutRun.encode(payload) {
|
||||||
@@ -244,18 +264,37 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decodeAgentSkillLayout(from defaults: UserDefaults) -> AIAgentSkillLayout {
|
private static func decodeAgentSkillLayout(
|
||||||
|
from defaults: UserDefaults,
|
||||||
|
userCatalog: AIUserSkillCatalog
|
||||||
|
) -> AIAgentSkillLayout {
|
||||||
|
let catalog = AIClipboardSkillCatalog.all(userCatalog: userCatalog)
|
||||||
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
|
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
|
||||||
return .default
|
return .default
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data).sanitized()
|
return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data)
|
||||||
|
.sanitized(catalog: catalog)
|
||||||
} catch {
|
} catch {
|
||||||
OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
|
OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
|
||||||
return .default
|
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) }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,28 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testReorderMovesEnabledSkillToIndex() {
|
||||||
|
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||||
|
store.moveEnabled(id: AIClipboardSkillCatalog.summarizeID, toIndex: 2)
|
||||||
|
XCTAssertEqual(
|
||||||
|
store.layout.enabledIDs,
|
||||||
|
[
|
||||||
|
AIClipboardSkillCatalog.replyID,
|
||||||
|
AIClipboardSkillCatalog.translateID,
|
||||||
|
AIClipboardSkillCatalog.summarizeID,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
store.moveEnabled(id: AIClipboardSkillCatalog.summarizeID, toIndex: 0)
|
||||||
|
XCTAssertEqual(
|
||||||
|
store.layout.enabledIDs,
|
||||||
|
[
|
||||||
|
AIClipboardSkillCatalog.summarizeID,
|
||||||
|
AIClipboardSkillCatalog.replyID,
|
||||||
|
AIClipboardSkillCatalog.translateID,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func testVisibleEmptyEnabledIDsShowsNoChips() {
|
func testVisibleEmptyEnabledIDsShowsNoChips() {
|
||||||
XCTAssertEqual(AIClipboardSkillCatalog.visible(enabledIDs: []).map(\.id), [])
|
XCTAssertEqual(AIClipboardSkillCatalog.visible(enabledIDs: []).map(\.id), [])
|
||||||
}
|
}
|
||||||
@@ -183,17 +205,52 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com")
|
XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com")
|
||||||
XCTAssertEqual(skill?.shortcutName, "OSG · 提取待办")
|
XCTAssertEqual(skill?.shortcutName, "OSG · 提取待办")
|
||||||
|
XCTAssertEqual(skill?.shortcutResourceName, "OSGExtractTodos")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExtractEventsUsesICloudShareLink() {
|
||||||
|
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractEventsID)
|
||||||
|
XCTAssertEqual(
|
||||||
|
skill?.shortcutICloudURL,
|
||||||
|
AIClipboardSkillCatalog.extractEventsShortcutICloudURL
|
||||||
|
)
|
||||||
|
XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com")
|
||||||
|
XCTAssertEqual(skill?.shortcutName, "OSG · 提取日程")
|
||||||
|
XCTAssertEqual(
|
||||||
|
skill?.shortcutResourceName,
|
||||||
|
"OSGExtractEvents"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(skill?.systemImage, "calendar")
|
||||||
|
XCTAssertFalse(skill?.isDefault ?? true)
|
||||||
|
XCTAssertEqual(
|
||||||
|
AIAgentShortcutRun.iCloudShareToken(from: skill!.shortcutICloudURL!),
|
||||||
|
"1f4afcf7ee22400cbf84e319d969aadf"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNavigateUsesBundledShortcut() {
|
||||||
|
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.navigateID)
|
||||||
|
XCTAssertNil(skill?.shortcutICloudURL)
|
||||||
|
XCTAssertEqual(skill?.shortcutName, "OSG · 导航")
|
||||||
|
XCTAssertEqual(skill?.shortcutResourceName, "OSGNavigate")
|
||||||
|
XCTAssertEqual(
|
||||||
|
skill?.systemImage,
|
||||||
|
"arrow.triangle.turn.up.right.diamond.fill"
|
||||||
|
)
|
||||||
|
XCTAssertFalse(skill?.isDefault ?? true)
|
||||||
|
XCTAssertEqual(skill?.kind, .export)
|
||||||
|
XCTAssertTrue(skill?.requiresShortcut ?? false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testICloudShareLinkMapsToShortcutsInstallURL() {
|
func testICloudShareLinkMapsToShortcutsInstallURL() {
|
||||||
let share = URL(string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81")!
|
let share = URL(string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44")!
|
||||||
let url = AIAgentShortcutRun.shortcutsInstallURL(from: share)
|
let url = AIAgentShortcutRun.shortcutsInstallURL(from: share)
|
||||||
XCTAssertEqual(url?.scheme, "shortcuts")
|
XCTAssertEqual(url?.scheme, "shortcuts")
|
||||||
XCTAssertEqual(url?.host, "shortcuts")
|
XCTAssertEqual(url?.host, "shortcuts")
|
||||||
XCTAssertEqual(url?.path, "/520317da7ae74759b64d5fb069c71f81")
|
XCTAssertEqual(url?.path, "/65bf33ba4206484ba78d582eaf1e9c44")
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
AIAgentShortcutRun.iCloudShareToken(from: share),
|
AIAgentShortcutRun.iCloudShareToken(from: share),
|
||||||
"520317da7ae74759b64d5fb069c71f81"
|
"65bf33ba4206484ba78d582eaf1e9c44"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// AIEventExtractionTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
final class AIEventExtractionTests: XCTestCase {
|
||||||
|
private var calendar: Calendar {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")!
|
||||||
|
return calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
private var now: Date {
|
||||||
|
date(2026, 8, 13, 20, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNONEAndEmptyProduceNoItems() {
|
||||||
|
XCTAssertEqual(lines("NONE"), [])
|
||||||
|
XCTAssertEqual(lines("没有日期或时间"), [])
|
||||||
|
XCTAssertEqual(lines("no events"), [])
|
||||||
|
XCTAssertEqual(lines(" \n "), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAllDayDateOnly() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
lines("2026-08-15||提交周报|"),
|
||||||
|
["2026-08-15|ALLDAY|提交周报|"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimedFillsDefaultOneHour() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
lines("2026-08-15 14:00||项目评审|"),
|
||||||
|
["2026-08-15 14:00|2026-08-15 15:00|项目评审|"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimedKeepsExplicitEndAndLocation() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
lines("2026-08-15 14:00|2026-08-15 16:00|项目评审|3楼会议室"),
|
||||||
|
["2026-08-15 14:00|2026-08-15 16:00|项目评审|3楼会议室"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimeOnlyUsesToday() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
lines("15:00||打电话给客户|"),
|
||||||
|
["2026-08-13 15:00|2026-08-13 16:00|打电话给客户|"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimeOnlyEndOnSameDayOvernightRollsForward() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
lines("2026-08-15 23:00|01:00|跨夜值班|"),
|
||||||
|
["2026-08-15 23:00|2026-08-16 01:00|跨夜值班|"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTwoFieldStartAndTitle() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
lines("2026-08-15 14:00|开会"),
|
||||||
|
["2026-08-15 14:00|2026-08-15 15:00|开会|"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testThreeFieldStartTitleLocationWhenMiddleIsNotATime() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
lines("2026-08-15 14:00|开会|会议室A"),
|
||||||
|
["2026-08-15 14:00|2026-08-15 15:00|开会|会议室A"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDropsLinesWithoutStartOrTitle() {
|
||||||
|
let raw = """
|
||||||
|
买牛奶
|
||||||
|
||无开始|
|
||||||
|
2026-08-15 14:00||
|
||||||
|
2026-08-16||有效全天|
|
||||||
|
"""
|
||||||
|
XCTAssertEqual(lines(raw), ["2026-08-16|ALLDAY|有效全天|"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleEventsCapAtTwenty() {
|
||||||
|
let raw = (1...25).map { "2026-08-15 10:00||任务\($0)|" }.joined(separator: "\n")
|
||||||
|
let items = lines(raw)
|
||||||
|
XCTAssertEqual(items.count, 20)
|
||||||
|
XCTAssertEqual(items.first, "2026-08-15 10:00|2026-08-15 11:00|任务1|")
|
||||||
|
XCTAssertEqual(items.last, "2026-08-15 10:00|2026-08-15 11:00|任务20|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWholeClipboardEchoIsRejected() {
|
||||||
|
let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4)
|
||||||
|
XCTAssertEqual(
|
||||||
|
AIEventExtraction.lines(
|
||||||
|
from: "2026-08-15||\(source)|",
|
||||||
|
sourceClipboard: source,
|
||||||
|
now: now,
|
||||||
|
calendar: calendar
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPromptIncludesClockPipeContractAndNONE() {
|
||||||
|
let prompt = AIClipboardSkillCatalog.instruction(
|
||||||
|
skillID: AIClipboardSkillCatalog.extractEventsID,
|
||||||
|
locale: "zh",
|
||||||
|
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
XCTAssertTrue(prompt.contains("本地时区"))
|
||||||
|
XCTAssertTrue(prompt.contains("开始|结束|标题|地点"))
|
||||||
|
XCTAssertTrue(prompt.contains("NONE"))
|
||||||
|
XCTAssertTrue(prompt.contains("不要把整段原文当成一条日程"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnglishPromptIncludesClockAndNONE() {
|
||||||
|
let prompt = AIClipboardSkillCatalog.instruction(
|
||||||
|
skillID: AIClipboardSkillCatalog.extractEventsID,
|
||||||
|
locale: "en",
|
||||||
|
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
XCTAssertTrue(prompt.contains("local timezone"))
|
||||||
|
XCTAssertTrue(prompt.contains("NONE"))
|
||||||
|
XCTAssertTrue(prompt.contains("all-day"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lines(_ raw: String) -> [String] {
|
||||||
|
AIEventExtraction.lines(from: raw, now: now, calendar: calendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func date(_ y: Int, _ m: Int, _ d: Int, _ h: Int, _ min: Int) -> Date {
|
||||||
|
var components = DateComponents()
|
||||||
|
components.year = y
|
||||||
|
components.month = m
|
||||||
|
components.day = d
|
||||||
|
components.hour = h
|
||||||
|
components.minute = min
|
||||||
|
return calendar.date(from: components)!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -245,4 +245,24 @@ final class AIClipboardSkillTests: XCTestCase {
|
|||||||
XCTAssertTrue(prompt.contains("NONE"))
|
XCTAssertTrue(prompt.contains("NONE"))
|
||||||
XCTAssertTrue(prompt.contains("不要把整段原文当成一条待办"))
|
XCTAssertTrue(prompt.contains("不要把整段原文当成一条待办"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testExtractEventsAsksForNONEWhenEmpty() {
|
||||||
|
let prompt = AIClipboardSkillCatalog.instruction(
|
||||||
|
skillID: AIClipboardSkillCatalog.extractEventsID,
|
||||||
|
locale: "zh",
|
||||||
|
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
|
||||||
|
)
|
||||||
|
XCTAssertTrue(prompt.contains("NONE"))
|
||||||
|
XCTAssertTrue(prompt.contains("开始|结束|标题|地点"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNavigateAsksForNONEWhenEmpty() {
|
||||||
|
let prompt = AIClipboardSkillCatalog.instruction(
|
||||||
|
skillID: AIClipboardSkillCatalog.navigateID,
|
||||||
|
locale: "zh",
|
||||||
|
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
|
||||||
|
)
|
||||||
|
XCTAssertTrue(prompt.contains("NONE"))
|
||||||
|
XCTAssertTrue(prompt.contains("起点|终点"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ final class AIModeLLMClientTests: XCTestCase {
|
|||||||
XCTAssertTrue(client is AIModeSearchFallbackClient)
|
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,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,272 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the companion Shortcut for the Extract Events skill.
|
||||||
|
|
||||||
|
Writes an unsigned binary plist that `shortcuts sign` can notarize.
|
||||||
|
|
||||||
|
Workflow (must materialize Shortcut Input as Text first — Split Text's
|
||||||
|
WFInput/ExtensionInput binding finishes the run without iterating):
|
||||||
|
|
||||||
|
Text (Shortcut Input)
|
||||||
|
→ Split Text (new lines)
|
||||||
|
→ Repeat Each
|
||||||
|
→ Split Text (|)
|
||||||
|
→ Get items 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"
|
||||||
|
|
||||||
|
OBJECT_REPLACEMENT = "\ufffc"
|
||||||
|
|
||||||
|
TEXT_UUID = "A1B2C3D0-1111-4E00-8A5A-FD7E1B0C396F"
|
||||||
|
SPLIT_LINES_UUID = "A1B2C3D1-2222-4F11-9A6B-0E8F2C1D4A70"
|
||||||
|
REPEAT_GROUP = "A1B2C3D2-3333-4E02-B3C4-91F0E6A7B812"
|
||||||
|
REPEAT_START = "A1B2C3D3-4444-4F13-A4D5-02A1F7B8C923"
|
||||||
|
SPLIT_FIELDS_UUID = "A1B2C3D4-5555-4014-B5E6-13B2A8C9D034"
|
||||||
|
START_UUID = "A1B2C3D5-6666-4125-C6F7-24C3B9DAE145"
|
||||||
|
END_UUID = "A1B2C3D6-7777-4236-D7A8-35D4CAEBF256"
|
||||||
|
TITLE_UUID = "A1B2C3D7-8888-4347-E8B9-46E5DBF0C367"
|
||||||
|
LOCATION_UUID = "A1B2C3D8-9999-4458-F9CA-57F6EC01D478"
|
||||||
|
IF_GROUP = "A1B2C3D9-AAAA-4569-0ADB-6807FD12E589"
|
||||||
|
IF_START = "A1B2C3DA-BBBB-467A-1BEC-79180E23F69A"
|
||||||
|
EVENT_ALLDAY_UUID = "A1B2C3DB-CCCC-478B-2CFD-8A291F3407AB"
|
||||||
|
IF_ELSE = "A1B2C3DC-DDDD-489C-3D0E-9B3A204518BC"
|
||||||
|
EVENT_TIMED_UUID = "A1B2C3DD-EEEE-49AD-4E1F-AC4B315629CD"
|
||||||
|
IF_END = "A1B2C3DE-FFFF-4ABE-5F20-BD5C42673ADE"
|
||||||
|
REPEAT_END = "A1B2C3DF-0000-4BCF-6031-CE6D53784BEF"
|
||||||
|
|
||||||
|
|
||||||
|
def attachment(value: dict) -> dict:
|
||||||
|
return {"Value": value, "WFSerializationType": "WFTextTokenAttachment"}
|
||||||
|
|
||||||
|
|
||||||
|
def token_string(attachment_value: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"Value": {
|
||||||
|
"attachmentsByRange": {"{0, 1}": attachment_value},
|
||||||
|
"string": OBJECT_REPLACEMENT,
|
||||||
|
},
|
||||||
|
"WFSerializationType": "WFTextTokenString",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def shortcut_input_text() -> dict:
|
||||||
|
return token_string({"Type": "ExtensionInput"})
|
||||||
|
|
||||||
|
|
||||||
|
def action_output_text(output_name: str, output_uuid: str) -> dict:
|
||||||
|
return token_string(
|
||||||
|
{
|
||||||
|
"Aggrandizements": [],
|
||||||
|
"OutputName": output_name,
|
||||||
|
"OutputUUID": output_uuid,
|
||||||
|
"Type": "ActionOutput",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def action_output_attachment(output_name: str, output_uuid: str) -> dict:
|
||||||
|
return attachment(
|
||||||
|
{
|
||||||
|
"Aggrandizements": [],
|
||||||
|
"OutputName": output_name,
|
||||||
|
"OutputUUID": output_uuid,
|
||||||
|
"Type": "ActionOutput",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def named_variable_text(variable_name: str) -> dict:
|
||||||
|
return token_string(
|
||||||
|
{
|
||||||
|
"Aggrandizements": [],
|
||||||
|
"Type": "Variable",
|
||||||
|
"VariableName": variable_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def if_input(output_name: str, output_uuid: str) -> dict:
|
||||||
|
return {
|
||||||
|
"Type": "Variable",
|
||||||
|
"Variable": {
|
||||||
|
"Value": {
|
||||||
|
"Aggrandizements": [],
|
||||||
|
"OutputName": output_name,
|
||||||
|
"OutputUUID": output_uuid,
|
||||||
|
"Type": "ActionOutput",
|
||||||
|
},
|
||||||
|
"WFSerializationType": "WFTextTokenAttachment",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_item(uuid: str, name: str, index: int) -> dict:
|
||||||
|
return {
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.getitemfromlist",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": uuid,
|
||||||
|
"CustomOutputName": name,
|
||||||
|
"WFInput": action_output_attachment("字段", SPLIT_FIELDS_UUID),
|
||||||
|
"WFItemSpecifier": "Item At Index",
|
||||||
|
"WFItemIndex": index,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_event(
|
||||||
|
uuid: str,
|
||||||
|
*,
|
||||||
|
all_day: bool,
|
||||||
|
include_end: bool,
|
||||||
|
) -> dict:
|
||||||
|
parameters = {
|
||||||
|
"UUID": uuid,
|
||||||
|
"WFCalendarItemTitle": action_output_text("标题", TITLE_UUID),
|
||||||
|
"WFCalendarItemLocation": action_output_text("地点", LOCATION_UUID),
|
||||||
|
"WFCalendarItemDates": True,
|
||||||
|
"WFCalendarItemStartDate": action_output_text("开始", START_UUID),
|
||||||
|
"WFCalendarItemAllDay": all_day,
|
||||||
|
"WFCalendarItemShowComposer": False,
|
||||||
|
"ShowComposeSheet": False,
|
||||||
|
"WFShowWhenRun": False,
|
||||||
|
}
|
||||||
|
if include_end:
|
||||||
|
parameters["WFCalendarItemEndDate"] = action_output_text("结束", END_UUID)
|
||||||
|
return {
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.addnewevent",
|
||||||
|
"WFWorkflowActionParameters": parameters,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def workflow() -> dict:
|
||||||
|
return {
|
||||||
|
"WFWorkflowClientVersion": "3600.0.4",
|
||||||
|
"WFWorkflowClientRelease": "26.0",
|
||||||
|
"WFWorkflowMinimumClientVersion": 900,
|
||||||
|
"WFWorkflowMinimumClientVersionString": "900",
|
||||||
|
"WFWorkflowName": "OSG · 提取日程",
|
||||||
|
"WFWorkflowHasShortcutInputVariables": True,
|
||||||
|
"WFWorkflowHasOutputFallback": False,
|
||||||
|
"WFWorkflowImportQuestions": [],
|
||||||
|
"WFWorkflowTypes": [],
|
||||||
|
"WFWorkflowInputContentItemClasses": [
|
||||||
|
"WFStringContentItem",
|
||||||
|
"WFRichTextContentItem",
|
||||||
|
],
|
||||||
|
"WFWorkflowOutputContentItemClasses": [],
|
||||||
|
"WFWorkflowNoInputBehavior": {
|
||||||
|
"Name": "WFWorkflowNoInputBehaviorAskForInput",
|
||||||
|
"Parameters": {
|
||||||
|
"ItemClass": "WFStringContentItem",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"WFWorkflowIcon": {
|
||||||
|
"WFWorkflowIconGlyphNumber": 59675,
|
||||||
|
"WFWorkflowIconStartColor": 4286828031,
|
||||||
|
},
|
||||||
|
"WFWorkflowActions": [
|
||||||
|
{
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": TEXT_UUID,
|
||||||
|
"CustomOutputName": "快捷指令文本",
|
||||||
|
"WFTextActionText": shortcut_input_text(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": SPLIT_LINES_UUID,
|
||||||
|
"CustomOutputName": "行",
|
||||||
|
"text": action_output_text("快捷指令文本", TEXT_UUID),
|
||||||
|
"WFTextSeparator": "New Lines",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": REPEAT_START,
|
||||||
|
"GroupingIdentifier": REPEAT_GROUP,
|
||||||
|
"WFControlFlowMode": 0,
|
||||||
|
"WFInput": action_output_attachment("行", SPLIT_LINES_UUID),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": SPLIT_FIELDS_UUID,
|
||||||
|
"CustomOutputName": "字段",
|
||||||
|
"text": named_variable_text("Repeat Item"),
|
||||||
|
"WFTextSeparator": "Custom",
|
||||||
|
"WFTextCustomSeparator": "|",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
get_item(START_UUID, "开始", 1),
|
||||||
|
get_item(END_UUID, "结束", 2),
|
||||||
|
get_item(TITLE_UUID, "标题", 3),
|
||||||
|
get_item(LOCATION_UUID, "地点", 4),
|
||||||
|
{
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": IF_START,
|
||||||
|
"GroupingIdentifier": IF_GROUP,
|
||||||
|
"WFControlFlowMode": 0,
|
||||||
|
"WFCondition": 4,
|
||||||
|
"WFConditionalActionString": "ALLDAY",
|
||||||
|
"WFInput": if_input("结束", END_UUID),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
add_event(EVENT_ALLDAY_UUID, all_day=True, include_end=False),
|
||||||
|
{
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": IF_ELSE,
|
||||||
|
"GroupingIdentifier": IF_GROUP,
|
||||||
|
"WFControlFlowMode": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
add_event(EVENT_TIMED_UUID, all_day=False, include_end=True),
|
||||||
|
{
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": IF_END,
|
||||||
|
"GroupingIdentifier": IF_GROUP,
|
||||||
|
"WFControlFlowMode": 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": REPEAT_END,
|
||||||
|
"GroupingIdentifier": REPEAT_GROUP,
|
||||||
|
"WFControlFlowMode": 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY))
|
||||||
|
print(f"wrote {UNSIGNED}")
|
||||||
|
print(
|
||||||
|
"sign with: shortcuts sign --mode anyone "
|
||||||
|
f"--input {UNSIGNED} --output {SIGNED}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the companion Shortcut for the Navigate skill.
|
||||||
|
|
||||||
|
Writes an unsigned binary plist that `shortcuts sign` can notarize.
|
||||||
|
|
||||||
|
The host already picked 高德 → 百度 → Apple Maps and passes one URL.
|
||||||
|
This recipe only materializes Shortcut Input as Text and opens it:
|
||||||
|
|
||||||
|
Text (Shortcut Input)
|
||||||
|
→ Open URLs
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import plistlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts"
|
||||||
|
UNSIGNED = OUT_DIR / "OSGNavigate.unsigned.shortcut"
|
||||||
|
SIGNED = OUT_DIR / "OSGNavigate.shortcut"
|
||||||
|
|
||||||
|
OBJECT_REPLACEMENT = "\ufffc"
|
||||||
|
|
||||||
|
TEXT_UUID = "C1D2E3F0-1111-4E00-8A5A-FD7E1B0C396F"
|
||||||
|
OPEN_UUID = "C1D2E3F1-2222-4F11-9A6B-0E8F2C1D4A70"
|
||||||
|
|
||||||
|
|
||||||
|
def token_string(attachment_value: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"Value": {
|
||||||
|
"attachmentsByRange": {"{0, 1}": attachment_value},
|
||||||
|
"string": OBJECT_REPLACEMENT,
|
||||||
|
},
|
||||||
|
"WFSerializationType": "WFTextTokenString",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def shortcut_input_text() -> dict:
|
||||||
|
return token_string({"Type": "ExtensionInput"})
|
||||||
|
|
||||||
|
|
||||||
|
def action_output_text(output_name: str, output_uuid: str) -> dict:
|
||||||
|
return token_string(
|
||||||
|
{
|
||||||
|
"Aggrandizements": [],
|
||||||
|
"OutputName": output_name,
|
||||||
|
"OutputUUID": output_uuid,
|
||||||
|
"Type": "ActionOutput",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def workflow() -> dict:
|
||||||
|
return {
|
||||||
|
"WFWorkflowClientVersion": "3600.0.4",
|
||||||
|
"WFWorkflowClientRelease": "26.0",
|
||||||
|
"WFWorkflowMinimumClientVersion": 900,
|
||||||
|
"WFWorkflowMinimumClientVersionString": "900",
|
||||||
|
"WFWorkflowName": "OSG · 导航",
|
||||||
|
"WFWorkflowHasShortcutInputVariables": True,
|
||||||
|
"WFWorkflowHasOutputFallback": False,
|
||||||
|
"WFWorkflowImportQuestions": [],
|
||||||
|
"WFWorkflowTypes": [],
|
||||||
|
"WFWorkflowInputContentItemClasses": [
|
||||||
|
"WFStringContentItem",
|
||||||
|
"WFURLContentItem",
|
||||||
|
"WFRichTextContentItem",
|
||||||
|
],
|
||||||
|
"WFWorkflowOutputContentItemClasses": [],
|
||||||
|
"WFWorkflowNoInputBehavior": {
|
||||||
|
"Name": "WFWorkflowNoInputBehaviorAskForInput",
|
||||||
|
"Parameters": {
|
||||||
|
"ItemClass": "WFStringContentItem",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"WFWorkflowIcon": {
|
||||||
|
"WFWorkflowIconGlyphNumber": 59723,
|
||||||
|
"WFWorkflowIconStartColor": 4278222847,
|
||||||
|
},
|
||||||
|
"WFWorkflowActions": [
|
||||||
|
{
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": TEXT_UUID,
|
||||||
|
"CustomOutputName": "快捷指令文本",
|
||||||
|
"WFTextActionText": shortcut_input_text(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"WFWorkflowActionIdentifier": "is.workflow.actions.openurl",
|
||||||
|
"WFWorkflowActionParameters": {
|
||||||
|
"UUID": OPEN_UUID,
|
||||||
|
"WFURL": action_output_text("快捷指令文本", TEXT_UUID),
|
||||||
|
"WFShowWhenRun": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY))
|
||||||
|
print(f"wrote {UNSIGNED}")
|
||||||
|
print(
|
||||||
|
"sign with: shortcuts sign --mode anyone "
|
||||||
|
f"--input {UNSIGNED} --output {SIGNED}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -134,6 +134,10 @@
|
|||||||
"OSGKeyboardTests/AIHintPoolTests",
|
"OSGKeyboardTests/AIHintPoolTests",
|
||||||
"OSGKeyboardTests/AIHintKeywordExtractorTests",
|
"OSGKeyboardTests/AIHintKeywordExtractorTests",
|
||||||
"OSGKeyboardTests/AIAgentSkillLayoutTests",
|
"OSGKeyboardTests/AIAgentSkillLayoutTests",
|
||||||
|
"OSGKeyboardTests/AIUserSkillTests",
|
||||||
|
"OSGKeyboardTests/AIUserSkillStoreTests",
|
||||||
|
"OSGKeyboardTests/AIEventExtractionTests",
|
||||||
|
"OSGKeyboardTests/AIAddressExtractionTests",
|
||||||
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
|
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
|
||||||
"OSGKeyboardTests/ClipboardHistoryStoreTests"
|
"OSGKeyboardTests/ClipboardHistoryStoreTests"
|
||||||
]
|
]
|
||||||
|
|||||||
+16
-2
@@ -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: "69"
|
||||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||||
|
|
||||||
# 项目级签名 xcconfig,适用于所有 target
|
# 项目级签名 xcconfig,适用于所有 target
|
||||||
@@ -88,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/OSGNavigate.shortcut
|
||||||
|
type: file
|
||||||
|
buildPhase: resources
|
||||||
entitlements:
|
entitlements:
|
||||||
path: OSGKeyboard/OSGKeyboard.entitlements
|
path: OSGKeyboard/OSGKeyboard.entitlements
|
||||||
properties:
|
properties:
|
||||||
@@ -121,7 +132,6 @@ targets:
|
|||||||
- path: OSGKeyboard/Resources/Typing/Rime/manifest.json
|
- path: OSGKeyboard/Resources/Typing/Rime/manifest.json
|
||||||
- path: OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin
|
- path: OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin
|
||||||
- path: OSGKeyboard/Resources/HostCLM/v1/compiled-manifest.json
|
- path: OSGKeyboard/Resources/HostCLM/v1/compiled-manifest.json
|
||||||
- path: OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
|
|
||||||
- path: OSGKeyboard/Resources/LocalASR/local-asr-catalog.json
|
- path: OSGKeyboard/Resources/LocalASR/local-asr-catalog.json
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
|
||||||
@@ -197,6 +207,10 @@ targets:
|
|||||||
- skype
|
- skype
|
||||||
- zoomus
|
- zoomus
|
||||||
- shortcuts
|
- shortcuts
|
||||||
|
- iosamap
|
||||||
|
- amapuri
|
||||||
|
- baidumap
|
||||||
|
- maps
|
||||||
- things
|
- things
|
||||||
- todoist
|
- todoist
|
||||||
- evernote
|
- evernote
|
||||||
|
|||||||
Reference in New Issue
Block a user