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