feat(keyboard): add Notes skill and system-style typing

Add save-to-Notes export and direct map handoff while aligning multi-touch typing, period shortcuts, return actions, and navigation visuals with system behavior.
This commit is contained in:
Rocky
2026-08-14 18:22:41 +08:00
parent 0a60006d2d
commit 320dc777c9
46 changed files with 1429 additions and 376 deletions
@@ -25,6 +25,8 @@ public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable {
public enum AIAgentShortcutRun {
public static let pendingKey = "config.aiAgentSkills.pendingRun.v1"
/// Last skill-handoff lines, readable from the App Group plist on device.
public static let recentTracesKey = "diag.skills.recent.v1"
/// Drop payloads older than this; a leftover write must not fire later.
public static let payloadTTL: TimeInterval = 60
@@ -65,6 +67,19 @@ public enum AIAgentShortcutRun {
/// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies.
public static func trace(_ message: String) {
OSGDiag.log(message, category: "skills")
persistTrace(message)
}
/// Keep a short ring so we can copy traces off the phone without root `log collect`.
private static func persistTrace(_ message: String) {
guard let defaults = AppGroup.defaultsIfAvailable else { return }
var lines = defaults.stringArray(forKey: recentTracesKey) ?? []
let stamp = ISO8601DateFormatter().string(from: Date())
lines.append("\(stamp) \(message)")
if lines.count > 60 {
lines = Array(lines.suffix(60))
}
defaults.set(lines, forKey: recentTracesKey)
}
/// Single-line preview so Console keeps the format (`\\n` for newlines).
@@ -4,7 +4,7 @@
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
// Settings / the Skills tab can persist a subset or permutation without
// changing the view. Transform skills insert into the current field;
// export skills hand off to a companion Shortcut after the model runs.
// export skills hand off to the host after the model runs (Shortcut, Maps, or Didi).
import Foundation
@@ -39,7 +39,9 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
/// Built-in skills are always false. Custom skills default off.
public let thinkingEnabled: Bool
public var requiresShortcut: Bool { kind == .export }
/// Reminders, Calendar, and Notes exports need a companion Shortcut.
/// Navigate and Ride hand off to the host (Maps or Didi). No Shortcut.
public var requiresShortcut: Bool { kind == .export && shortcutName != nil }
public var isUserCreated: Bool { id.hasPrefix("user.") }
public init(
@@ -80,22 +82,18 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let summarizeID = "summarize"
public static let translateID = "translate"
public static let extractTodosID = "extractTodos"
public static let extractTodosShortcutName = "OSG · 提取待办"
public static let extractTodosShortcutICloudURL = URL(
string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
)!
public static let extractTodosShortcutName = "OSGExtractTodos"
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 extractEventsShortcutName = "OSGExtractEvents"
public static let extractEventsResourceName = "OSGExtractEvents"
public static let saveToNotesID = "saveToNotes"
public static let saveToNotesShortcutName = "OSGSaveToNotes"
public static let saveToNotesResourceName = "OSGSaveToNotes"
public static let navigateID = "navigate"
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] = [
@@ -135,7 +133,6 @@ public enum AIClipboardSkillCatalog: Sendable {
kind: .export,
isDefault: false,
shortcutName: extractTodosShortcutName,
shortcutICloudURL: extractTodosShortcutICloudURL,
shortcutResourceName: extractTodosResourceName
),
AIClipboardSkill(
@@ -147,9 +144,19 @@ public enum AIClipboardSkillCatalog: Sendable {
kind: .export,
isDefault: false,
shortcutName: extractEventsShortcutName,
shortcutICloudURL: extractEventsShortcutICloudURL,
shortcutResourceName: extractEventsResourceName
),
AIClipboardSkill(
id: saveToNotesID,
systemImage: "note.text",
titleKey: "keyboard.ai.skill.saveToNotes",
cardTitleKey: "skills.saveToNotes.name",
descriptionKey: "skills.saveToNotes.description",
kind: .export,
isDefault: false,
shortcutName: saveToNotesShortcutName,
shortcutResourceName: saveToNotesResourceName
),
AIClipboardSkill(
id: navigateID,
systemImage: "arrow.triangle.turn.up.right.diamond.fill",
@@ -157,9 +164,7 @@ public enum AIClipboardSkillCatalog: Sendable {
cardTitleKey: "skills.navigate.name",
descriptionKey: "skills.navigate.description",
kind: .export,
isDefault: false,
shortcutName: navigateShortcutName,
shortcutResourceName: navigateResourceName
isDefault: false
),
]
@@ -263,6 +268,8 @@ public enum AIClipboardSkillCatalog: Sendable {
"""
case extractEventsID:
return eventInstruction(zh: zh, now: now)
case saveToNotesID:
return noteInstruction(zh: zh, now: now)
case navigateID:
return navigateInstruction(zh: zh)
default:
@@ -272,6 +279,26 @@ public enum AIClipboardSkillCatalog: Sendable {
}
}
/// Title only. The original clipboard is the note body; do not ask the
/// model to rewrite it.
private static func noteInstruction(zh: Bool, now: Date) -> String {
let clock = clockContext(now: now, zh: zh)
if zh {
return """
\(clock)
请根据剪贴板正文写一个简短备忘录标题。只要一行标题,不要输出正文,不要编号、不要引号、不要解释。标题中不要出现换行或 |。最多 40 个字。
标题应能让人在列表里认出这篇笔记,可结合今天的日期或时间(例如「8月13日周会纪要」)。不要改写或重复正文。
即使原文很短也要给一个标题。不要输出 NONE。
"""
}
return """
\(clock)
Write a short Notes title from the clipboard. One line only; do not output the body. No numbering, quotes, or commentary. No newlines or | in the title. Maximum 40 characters.
The title should identify the note in a list and may include today's date or time (for example "13 Aug standup notes"). Do not rewrite or repeat the body.
Always return a title, even when the clipboard is short. Do not output NONE.
"""
}
private static func navigateInstruction(zh: Bool) -> String {
if zh {
return """
@@ -2,7 +2,7 @@
// OSGKeyboard · Shared
//
// Line-oriented Shortcut input for user-created export skills. Built-in
// extract-todos / extract-events keep their dedicated parsers.
// extract-todos / extract-events / save-to-notes keep their dedicated parsers.
import Foundation
@@ -2,8 +2,7 @@
// 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.
// Apple Maps. The host calls `canOpenURL` and opens the URL itself.
import Foundation
@@ -0,0 +1,170 @@
// AINoteExport.swift
// OSGKeyboard · Shared
//
// Builds one Notes payload: generated title + original clipboard body,
// joined by `fieldSeparator` so the companion Shortcut can combine them
// as first-line title + body. Fail open: a missing or unusable model title
// falls back to a dated snippet so the paste still lands in Notes.
import Foundation
public enum AINoteExport: Sendable {
public static let maximumTitleLength = 40
public static let maximumSnippetLength = 24
/// Split token for the companion Shortcut: title, then original body.
/// Newlines stay inside the body, so do not join with `\n`.
/// Avoid `<>` `shortcuts://&text=` treats angle brackets like tags and
/// drops the payload (todos/events work because they only use `|` / newlines).
public static let fieldSeparator = "||OSG_NOTE||"
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有标题", "无标题",
"no title", "no note", "no notes",
]
/// One string for Shortcuts: `title||OSG_NOTE||body`. Empty do not run it.
public static func items(
from answer: String,
sourceClipboard: String?,
now: Date = Date(),
locale: String = "zh",
calendar: Calendar = .current
) -> [String] {
let body = sourceClipboard?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !body.isEmpty else { return [] }
let title = resolvedTitle(
from: answer,
body: body,
now: now,
locale: locale,
calendar: calendar
)
return ["\(title)\(fieldSeparator)\(body)"]
}
// MARK: - Title
private static func resolvedTitle(
from answer: String,
body: String,
now: Date,
locale: String,
calendar: Calendar
) -> String {
if let title = parsedTitle(answer),
!isWholeClipboardEcho(title, source: body) {
return truncate(title, maximumTitleLength)
}
return fallbackTitle(body: body, now: now, locale: locale, calendar: calendar)
}
private static func parsedTitle(_ raw: String) -> String? {
let first = raw
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
.first ?? ""
var title = stripBullet(first)
title = stripWrappingQuotes(title)
if let pipe = title.firstIndex(of: "|") {
title = String(title[..<pipe])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard !title.isEmpty, !isEmptyToken(title) else { return nil }
return title
}
private static func fallbackTitle(
body: String,
now: Date,
locale: String,
calendar: Calendar
) -> String {
let stamp = dateStamp(now, locale: locale, calendar: calendar)
let snippet = truncate(firstLine(body), maximumSnippetLength)
if snippet.isEmpty { return stamp }
return "\(stamp) · \(snippet)"
}
private static func dateStamp(_ now: Date, locale: String, calendar: Calendar) -> String {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.timeZone = calendar.timeZone
formatter.locale = Locale(identifier: locale == "zh" ? "zh_CN" : "en_US_POSIX")
formatter.dateFormat = locale == "zh" ? "M月d日" : "d MMM"
return formatter.string(from: now)
}
// MARK: - Text
private static func firstLine(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
.first?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
private static func truncate(_ text: String, _ limit: Int) -> String {
guard text.count > limit else { return text }
return String(text.prefix(limit))
}
private static func isEmptyToken(_ text: String) -> Bool {
let folded = text
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "。.!"))
.lowercased()
return emptyTokens.contains(folded)
}
private static func stripBullet(_ line: String) -> String {
var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
let prefixes = ["- ", "* ", "", ""]
for prefix in prefixes where text.hasPrefix(prefix) {
text = String(text.dropFirst(prefix.count))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
text = String(text[dotted.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return text
}
private static func stripWrappingQuotes(_ line: String) -> String {
let pairs: [(Character, Character)] = [
("\"", "\""),
("", ""),
("", ""),
("", ""),
("'", "'"),
("", ""),
]
var text = line
for (open, close) in pairs where text.count >= 2 {
if text.first == open, text.last == close {
text = String(text.dropFirst().dropLast())
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
return text
}
/// A title that is essentially the whole clipboard is not a title.
private static func isWholeClipboardEcho(_ title: String, source: String) -> Bool {
guard source.count > 80, title.count > 80 else { return false }
let a = collapse(title)
let b = collapse(source)
guard !a.isEmpty, !b.isEmpty else { return false }
if a == b { return true }
return a.contains(b) || b.contains(a)
}
private static func collapse(_ text: String) -> String {
text.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
.lowercased()
}
}
+29 -1
View File
@@ -144,6 +144,9 @@ public final class KeyboardState: ObservableObject {
@Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
/// Export skill currently waiting on the LLM. Nil for transform skills.
@Published public var pendingClipboardSkillID: String?
/// Clipboard captured when that export skill was tapped, so the body
/// still exists after the 30-second hint window closes.
public var pendingClipboardSkillSource: String?
/// In-keyboard toast (e.g. no todos). Does not leave the host app.
@Published public var skillTipText: String?
/// Host field is a password / secure entry never read pasteboard.
@@ -219,13 +222,38 @@ public final class KeyboardState: ObservableObject {
public enum ReturnKeyRole: Equatable {
case newline
case send
case go
case search
case join
case done
case next
case `continue`
case route
case google
case yahoo
case emergencyCall
public var titleKey: String {
switch self {
case .newline: return "common.newline"
case .send: return "common.send"
case .send: return "common.send"
case .go: return "keyboard.return.go"
case .search: return "keyboard.return.search"
case .join: return "keyboard.return.join"
case .done: return "common.done"
case .next: return "keyboard.return.next"
case .continue: return "common.continue"
case .route: return "keyboard.return.route"
case .google: return "keyboard.return.google"
case .yahoo: return "keyboard.return.yahoo"
case .emergencyCall: return "keyboard.return.emergencyCall"
}
}
/// Green action chrome (system uses blue for Go / Search / Send / Done).
public var usesActionFill: Bool {
self != .newline
}
}
// Action hooks injected by the view controller at install time.
@@ -0,0 +1,28 @@
// PeriodShortcut.swift
// OSGKeyboard · Shared
//
// iOS "." Shortcut: a second Space shortly after a Space that follows a
// word character becomes ". " and arms sentence Shift.
import Foundation
public enum PeriodShortcut: Sendable {
/// Window for the second Space tap. Slow consecutive spaces stay spaces.
public static let doubleTapInterval: TimeInterval = 0.45
/// Whether `precedingText` (already including the first space) can take
/// the shortcut: `X ` where X is a letter or number, not a terminator.
public static func shouldReplacePreviousSpace(precedingText: String) -> Bool {
guard precedingText.last == " " else { return false }
guard let previous = precedingText.dropLast().last else { return false }
if previous.isWhitespace || previous.isNewline { return false }
return previous.isLetter || previous.isNumber
}
/// After inserting a space, arm only when that space followed a word char.
public static func shouldArm(afterSpaceFollowing precedingBeforeSpace: String) -> Bool {
guard let last = precedingBeforeSpace.last else { return false }
if last.isWhitespace || last.isNewline { return false }
return last.isLetter || last.isNumber
}
}
@@ -79,6 +79,9 @@ public final class TypingSessionController: ObservableObject {
/// (common in Notes). Capped; reseeds from the proxy when it looks fresh.
private var precedingShadow = ""
private static let precedingShadowLimit = 400
/// iOS "." Shortcut: second Space shortly after a Space that followed a word.
private var periodShortcutArmed = false
private var lastSpaceAt: Date?
public init(
engine: (@MainActor () -> RimeEngineBridging)? = nil,
@@ -166,6 +169,7 @@ public final class TypingSessionController: ObservableObject {
page = .letters
resetShiftState()
clearEnglishWordState(keepPrevious: false)
clearPeriodShortcut()
// Drop English lexicon pages when leaving typing (jetsam recovery).
EnglishLexicon.shared.unload()
englishStorage = nil
@@ -202,6 +206,7 @@ public final class TypingSessionController: ObservableObject {
engine.setLanguage(newLanguage)
page = .letters
isCandidatePanelExpanded = false
clearPeriodShortcut()
if newLanguage == .english {
clearEnglishWordState(keepPrevious: false)
refreshPersonalTerms()
@@ -235,6 +240,7 @@ public final class TypingSessionController: ObservableObject {
public func setPage(_ page: TypingKeyPage) {
self.page = page
resetShiftState()
clearPeriodShortcut()
if page == .letters {
syncAutocapitalization()
}
@@ -264,6 +270,7 @@ public final class TypingSessionController: ObservableObject {
/// Handle a visible key label.
public func handleKey(_ label: String) -> TypingOutput {
clearPeriodShortcut()
switch label {
case "":
// Tests / non-gesture callers: same as a completed Shift tap.
@@ -317,8 +324,9 @@ public final class TypingSessionController: ObservableObject {
public func handleSpace() -> TypingOutput {
if language == .english {
return commitEnglishWord(suffix: " ")
return handleEnglishSpace()
}
clearPeriodShortcut()
let text = engine.processSpace() ?? " "
composition = engine.composition
syncCandidatePanelVisibility()
@@ -326,6 +334,7 @@ public final class TypingSessionController: ObservableObject {
}
public func handleReturn() -> TypingOutput {
clearPeriodShortcut()
if language == .english {
return commitEnglishWord(suffix: "\n")
}
@@ -336,6 +345,7 @@ public final class TypingSessionController: ObservableObject {
}
public func selectCandidate(at index: Int) -> TypingOutput {
clearPeriodShortcut()
if language == .english {
return selectEnglishCandidate(at: index)
}
@@ -352,6 +362,38 @@ public final class TypingSessionController: ObservableObject {
// MARK: - English
private func handleEnglishSpace() -> TypingOutput {
let preceding = precedingTextForShortcut()
if periodShortcutArmed,
let stamped = lastSpaceAt,
Date().timeIntervalSince(stamped) <= PeriodShortcut.doubleTapInterval,
PeriodShortcut.shouldReplacePreviousSpace(precedingText: preceding) {
clearPeriodShortcut()
let wordOut = commitEnglishWord(suffix: "")
let deleteCount = wordOut.deleteCount + 1
return TypingOutput(deleteCount: deleteCount, text: wordOut.text + ". ")
}
let output = commitEnglishWord(suffix: " ")
if PeriodShortcut.shouldArm(afterSpaceFollowing: preceding) {
periodShortcutArmed = true
lastSpaceAt = Date()
} else {
clearPeriodShortcut()
}
return output
}
private func precedingTextForShortcut() -> String {
if !precedingShadow.isEmpty { return precedingShadow }
return precedingTextProvider?() ?? ""
}
private func clearPeriodShortcut() {
periodShortcutArmed = false
lastSpaceAt = nil
}
private func handleEnglishCharacter(_ ch: Character) -> TypingOutput {
pendingAutocorrection = nil
if ch.isLetter {
@@ -546,6 +588,9 @@ public final class TypingSessionController: ObservableObject {
/// briefly reports the immediately preceding edit (common in Notes).
public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) {
guard language == .english else { return }
if caretMoved {
clearPeriodShortcut()
}
guard suggestionsEnabled else {
clearEnglishWordState(keepPrevious: false)
composition = .empty
@@ -0,0 +1,181 @@
// TypingTouchTracker.swift
// OSGKeyboard · Shared
//
// Multi-finger typing contract (system-keyboard overlap):
// each finger is independent; a new key-down commits any other pending
// character/space/return so press order, not release order, wins.
// Shift can be held with one finger while another types.
import Foundation
/// Per-step side effects for the UIKit touch pad to apply.
public struct TypingTouchEffects: Equatable, Sendable {
public var commits: [TypingKeyHitTarget] = []
public var playFeedback: TypingKeyHitTarget?
public var deleteFire = false
public var startDeleteRepeat = false
public var stopDeleteRepeat = false
public var beginShift = false
public var endShift = false
public init() {}
}
/// Pure multi-touch state machine. IDs are `ObjectIdentifier` of `UITouch`
/// in the extension, or any unique object in tests.
public final class TypingTouchTracker {
private struct Finger {
let id: ObjectIdentifier
var key: TypingKeyHitTarget?
var committed: Bool
var ownsShift: Bool
var ownsDeleteRepeat: Bool
let order: UInt64
}
private var fingers: [ObjectIdentifier: Finger] = [:]
private var nextOrder: UInt64 = 0
public init() {}
public var highlightedKeyIDs: Set<String> {
Set(
fingers.values.compactMap { finger in
guard !finger.committed else { return nil }
return finger.key?.id
}
)
}
public func began(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
var effects = TypingTouchEffects()
if let key, hasUncommittedFinger(on: key.id) {
return effects
}
if key != nil {
commitPendingCharacterKeys(into: &effects)
stopForeignDeleteRepeats(into: &effects)
}
var finger = Finger(
id: id,
key: key,
committed: false,
ownsShift: false,
ownsDeleteRepeat: false,
order: nextOrder
)
nextOrder += 1
if let key {
effects.playFeedback = key
activate(key, on: &finger, effects: &effects)
}
fingers[id] = finger
return effects
}
public func moved(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
guard var finger = fingers[id], !finger.committed else {
return TypingTouchEffects()
}
if finger.key?.id == key?.id {
return TypingTouchEffects()
}
var effects = TypingTouchEffects()
if finger.ownsDeleteRepeat {
effects.stopDeleteRepeat = true
finger.ownsDeleteRepeat = false
}
finger.key = key
if let key {
effects.playFeedback = key
activate(key, on: &finger, effects: &effects)
}
fingers[id] = finger
return effects
}
public func ended(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
guard let finger = fingers.removeValue(forKey: id) else {
return TypingTouchEffects()
}
return finish(finger, hit: key, commitIfNeeded: true)
}
public func cancelled(id: ObjectIdentifier) -> TypingTouchEffects {
guard let finger = fingers.removeValue(forKey: id) else {
return TypingTouchEffects()
}
return finish(finger, hit: nil, commitIfNeeded: false)
}
// MARK: - Internals
private func activate(
_ key: TypingKeyHitTarget,
on finger: inout Finger,
effects: inout TypingTouchEffects
) {
switch key.behavior {
case .commitOnRelease:
break
case .deleteRepeat:
effects.deleteFire = true
effects.startDeleteRepeat = true
finger.ownsDeleteRepeat = true
case .shiftHold:
if !finger.ownsShift, !fingers.values.contains(where: { $0.ownsShift }) {
effects.beginShift = true
finger.ownsShift = true
}
}
}
/// Press order = typing order: flush other uncommitted character keys.
private func commitPendingCharacterKeys(into effects: inout TypingTouchEffects) {
let pending = fingers.values
.filter { !$0.committed && $0.key?.behavior == .commitOnRelease }
.sorted { $0.order < $1.order }
for finger in pending {
if let key = finger.key {
effects.commits.append(key)
}
fingers[finger.id]?.committed = true
}
}
/// Holding delete + tapping a letter must stop the repeat.
private func stopForeignDeleteRepeats(into effects: inout TypingTouchEffects) {
for (id, finger) in fingers where finger.ownsDeleteRepeat {
effects.stopDeleteRepeat = true
fingers[id]?.ownsDeleteRepeat = false
}
}
private func hasUncommittedFinger(on keyID: String) -> Bool {
fingers.values.contains { !$0.committed && $0.key?.id == keyID }
}
private func finish(
_ finger: Finger,
hit: TypingKeyHitTarget?,
commitIfNeeded: Bool
) -> TypingTouchEffects {
var effects = TypingTouchEffects()
if finger.ownsDeleteRepeat {
effects.stopDeleteRepeat = true
}
if finger.ownsShift {
effects.endShift = true
}
if commitIfNeeded, !finger.committed, let hit, hit.behavior == .commitOnRelease {
effects.commits.append(hit)
}
return effects
}
}