feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
@@ -35,15 +35,18 @@ public enum DictationTextComposer {
}
}
let normalizedAnchor = normalizeForOverlap(anchor)
let normalizedLive = normalizeForOverlap(live)
let normalizedAnchor = TranscriptOverlapUtilities.normalized(anchor)
let normalizedLive = TranscriptOverlapUtilities.normalized(live)
let anchorNormChars = Array(normalizedAnchor)
let liveNormChars = Array(normalizedLive)
let normProbe = min(64, anchorNormChars.count, liveNormChars.count)
if normProbe > 0 {
for length in stride(from: normProbe, through: 3, by: -1) {
if anchorNormChars.suffix(length).elementsEqual(liveNormChars.prefix(length)) {
let drop = rawDropCount(in: live, normalizedPrefixLength: length)
let drop = TranscriptOverlapUtilities.rawDropCount(
in: live,
normalizedPrefixLength: length
)
return anchor + String(live.dropFirst(drop))
}
}
@@ -57,7 +60,7 @@ public enum DictationTextComposer {
let first = live.unicodeScalars.first else {
return false
}
return isCJK(last) && isCJK(first)
return HanScript.isIdeograph(last) && HanScript.isIdeograph(first)
}
/// Separator to place between existing document text and an inserted
@@ -71,7 +74,7 @@ public enum DictationTextComposer {
return ""
}
if CharacterSet.whitespacesAndNewlines.contains(last) { return "" }
if isCJK(last) || isCJK(first) { return "" }
if HanScript.isIdeograph(last) || HanScript.isIdeograph(first) { return "" }
// No space after opening brackets/quotes ("(", "[", "", """).
if CharacterSet(charactersIn: "([{\u{201C}\u{2018}\u{300C}\u{300E}\u{3010}\u{FF08}").contains(last) {
return ""
@@ -80,33 +83,4 @@ public enum DictationTextComposer {
if CharacterSet.punctuationCharacters.contains(first) { return "" }
return " "
}
static func normalizeForOverlap(_ text: String) -> String {
text.unicodeScalars.filter {
!CharacterSet.whitespacesAndNewlines.contains($0)
&& !CharacterSet.punctuationCharacters.contains($0)
}.map { Character($0) }.reduce(into: "") { $0.append($1) }
}
private static func rawDropCount(in text: String, normalizedPrefixLength: Int) -> Int {
var normalizedCount = 0
var rawIndex = text.startIndex
while rawIndex < text.endIndex, normalizedCount < normalizedPrefixLength {
let character = text[rawIndex]
if !character.isWhitespace, !character.isPunctuation {
normalizedCount += 1
}
rawIndex = text.index(after: rawIndex)
}
return text.distance(from: text.startIndex, to: rawIndex)
}
private static func isCJK(_ scalar: UnicodeScalar) -> Bool {
switch scalar.value {
case 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xF900...0xFAFF:
return true
default:
return false
}
}
}
+5 -11
View File
@@ -11,9 +11,8 @@
// stages sortable, which matters because the pipeline spans two processes
// (main app captures and recognises, keyboard extension inserts).
//
// Transcript payloads are logged in the clear only in DEBUG builds. Release
// builds mark them `.private` so recognised speech never lands in a sysdiagnose
// the user shares with a third party.
// Transcript payloads are never logged. Both DEBUG and Release retain only
// structural metadata so recognised speech cannot land in console archives.
import Foundation
import os
@@ -55,23 +54,18 @@ public enum FlowTrace {
// MARK: - Transcript payloads
/// Logs recognised / polished text plus its length.
/// Logs structural metadata for recognised / polished text.
///
/// `step` names the point in the path (`asr.chunk`, `asr.final`,
/// `polish.input`, `polish.output`, `keyboard.insert`), so a diff between
/// two adjacent `text.*` lines shows exactly which stage changed the text.
/// The payload itself is intentionally omitted in every build configuration.
public static func transcript(_ step: String, _ text: String, _ detail: String = "") {
let length = text.count
let empty = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
#if DEBUG
OSGLog.asr.info(
"[trace] stage=text.\(step, privacy: .public) len=\(length, privacy: .public) empty=\(empty, privacy: .public) \(detail, privacy: .public) text=\(text, privacy: .public)"
"[trace] stage=text.\(step, privacy: .public) len=\(length, privacy: .public) empty=\(empty, privacy: .public) \(detail, privacy: .public)"
)
#else
OSGLog.asr.info(
"[trace] stage=text.\(step, privacy: .public) len=\(length, privacy: .public) empty=\(empty, privacy: .public) \(detail, privacy: .public) text=\(text, privacy: .private)"
)
#endif
}
// MARK: - Formatting helpers
@@ -0,0 +1,19 @@
// HanScript.swift
// OSGKeyboard · Shared
//
// Canonical BMP Han ideograph predicate used by text-processing features.
enum HanScript {
static func isIdeograph(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xF900...0xFAFF:
return true
default:
return false
}
}
static func containsIdeograph(in text: String) -> Bool {
text.unicodeScalars.contains(where: isIdeograph)
}
}
@@ -72,8 +72,8 @@ public struct ProgressiveDictationTranscriptAccumulator: Sendable {
candidate: String,
startDelta: Double
) -> String? {
let normalizedPrevious = DictationTextComposer.normalizeForOverlap(previous)
let normalizedCandidate = DictationTextComposer.normalizeForOverlap(candidate)
let normalizedPrevious = TranscriptOverlapUtilities.normalized(previous)
let normalizedCandidate = TranscriptOverlapUtilities.normalized(candidate)
guard !normalizedPrevious.isEmpty, !normalizedCandidate.isEmpty else {
return nil
}
@@ -0,0 +1,17 @@
// PromptXMLEscaping.swift
// OSGKeyboard · Shared
//
// Escapes untrusted prompt data embedded in XML-like text nodes.
import Foundation
enum PromptXMLEscaping {
static func escapeTextContent(_ text: String) -> String {
text
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
}
@@ -19,7 +19,7 @@ public enum TranscriptLanguageDetector: Sendable {
continue
}
meaningfulCount += 1
if isHan(scalar) {
if HanScript.isIdeograph(scalar) {
hanCount += 1
}
}
@@ -32,13 +32,4 @@ public enum TranscriptLanguageDetector: Sendable {
public static func prefersChineseGuidance(_ text: String) -> Bool {
cjkRatio(text) >= 0.15
}
private static func isHan(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x4E00...0x9FFF, 0x3400...0x4DBF, 0xF900...0xFAFF:
return true
default:
return false
}
}
}
@@ -0,0 +1,28 @@
// TranscriptOverlapUtilities.swift
// OSGKeyboard · Shared
//
// Shared normalization and raw-prefix mapping for transcript overlap checks.
import Foundation
enum TranscriptOverlapUtilities {
static func normalized(_ text: String) -> String {
text.unicodeScalars.filter {
!CharacterSet.whitespacesAndNewlines.contains($0)
&& !CharacterSet.punctuationCharacters.contains($0)
}.map { Character($0) }.reduce(into: "") { $0.append($1) }
}
static func rawDropCount(in text: String, normalizedPrefixLength: Int) -> Int {
var normalizedCount = 0
var rawIndex = text.startIndex
while rawIndex < text.endIndex, normalizedCount < normalizedPrefixLength {
let character = text[rawIndex]
if !character.isWhitespace, !character.isPunctuation {
normalizedCount += 1
}
rawIndex = text.index(after: rawIndex)
}
return text.distance(from: text.startIndex, to: rawIndex)
}
}
@@ -105,8 +105,8 @@ public struct UtteranceTranscriptStitcher: Sendable {
}
// Punctuation-insensitive CJK overlap (e.g. "" + "").
let normalizedPrev = normalizeForOverlap(previous)
let normalizedNext = normalizeForOverlap(trimmedNext)
let normalizedPrev = TranscriptOverlapUtilities.normalized(previous)
let normalizedNext = TranscriptOverlapUtilities.normalized(trimmedNext)
let nPrev = Array(normalizedPrev)
let nNext = Array(normalizedNext)
let normProbe = min(64, nPrev.count, nNext.count)
@@ -114,7 +114,10 @@ public struct UtteranceTranscriptStitcher: Sendable {
for length in stride(from: normProbe, through: 2, by: -1) {
if nPrev.suffix(length).elementsEqual(nNext.prefix(length)) {
// Map normalized overlap length back to raw `next` drop count.
let drop = overlapDropCount(in: trimmedNext, normalizedPrefixLength: length)
let drop = TranscriptOverlapUtilities.rawDropCount(
in: trimmedNext,
normalizedPrefixLength: length
)
return previous + String(trimmedNext.dropFirst(drop))
}
}
@@ -140,27 +143,6 @@ public struct UtteranceTranscriptStitcher: Sendable {
return DictationTextComposer.compose(anchor: previous, live: trimmedNext)
}
private static func normalizeForOverlap(_ text: String) -> String {
text.unicodeScalars.filter {
!CharacterSet.whitespacesAndNewlines.contains($0)
&& !CharacterSet.punctuationCharacters.contains($0)
}.map { Character($0) }.reduce(into: "") { $0.append($1) }
}
/// How many raw characters to drop from `next` given a normalized-prefix overlap length.
private static func overlapDropCount(in next: String, normalizedPrefixLength: Int) -> Int {
var normalizedCount = 0
var rawIndex = next.startIndex
while rawIndex < next.endIndex, normalizedCount < normalizedPrefixLength {
let scalar = next[rawIndex]
if !scalar.isWhitespace, !scalar.isPunctuation {
normalizedCount += 1
}
rawIndex = next.index(after: rawIndex)
}
return next.distance(from: next.startIndex, to: rawIndex)
}
private func naiveWithPauseMarks(threshold: Double) -> String {
var pieces: [String] = []
for (offset, segment) in segments.enumerated() {