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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user