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
@@ -1,5 +1,5 @@
// AlibabaVocabularySync.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Syncs PersonalDictionary DashScope custom vocabulary (Fun-ASR Flash).
@@ -1,5 +1,5 @@
// BailianRealtimeASRClient.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Alibaba Cloud Bailian / DashScope realtime ASR over the classic inference
// WebSocket (`/api-ws/v1/inference`). Utterance-level duplex session with
@@ -145,6 +145,8 @@ struct BailianRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable
static func sendText(_ text: String, task: URLSessionWebSocketTask) async throws {
do {
try await task.send(.string(text))
} catch where ProviderToolCancellation.matches(error) {
throw CancellationError()
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
@@ -153,6 +155,8 @@ struct BailianRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable
static func sendBinary(_ data: Data, task: URLSessionWebSocketTask) async throws {
do {
try await task.send(.data(data))
} catch where ProviderToolCancellation.matches(error) {
throw CancellationError()
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
@@ -1,5 +1,5 @@
// CloudASRClients.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Provider-specific cloud ASR backends with personal-dictionary bias.
@@ -1,5 +1,5 @@
// CloudASRConnectionCheck.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Settings "validate connection" probe shared by iOS and macOS.
@@ -1,5 +1,5 @@
// CloudASRService.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Cloud-engine ASR: uploads PCM to the user's configured provider with
// personal-dictionary bias. Streaming-capable providers use one utterance
@@ -11,6 +11,10 @@ import os
import OSGKeyboardShared
#endif
/// Uploads PCM only on the user-selected cloud engine path; provider clients
/// reject missing credentials before network transmission. Personal dictionary
/// entries are sent as recognition bias. Mutable client/cancellation state is
/// lock-protected, which is the basis for `@unchecked Sendable`.
public final class CloudASRService: ASRService, @unchecked Sendable {
private let store: any ConfigurationStore
private let session: URLSession
@@ -56,7 +60,9 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
do {
try await client.prepare(dictionary: store.personalDictionary)
} catch {
OSGLog.asr.warning("cloud ASR vocabulary prepare failed: \(error.localizedDescription, privacy: .public)")
OSGLog.asr.warning(
"cloud ASR vocabulary prepare failed: \(CloudASRLogMetadata.describe(error), privacy: .public)"
)
}
}
@@ -89,7 +95,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
+ "rms=\(FlowTrace.rms(samples)) elapsed=\(FlowTrace.seconds(since: startedAt))s"
)
return trimmed.isEmpty ? .success("") : .success(trimmed)
} catch is CancellationError {
} catch where ProviderToolCancellation.matches(error) {
FlowTrace.asr("cloud.chunk.cancelled", "samples=\(samples.count)")
return .cancelled
} catch {
@@ -97,7 +103,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
"asr.cloud.chunk.failed",
"provider=\(store.asrProviderId) samples=\(samples.count) "
+ "rms=\(FlowTrace.rms(samples)) elapsed=\(FlowTrace.seconds(since: startedAt))s "
+ "error=\(error.localizedDescription)"
+ "\(CloudASRLogMetadata.describe(error))"
)
return .failure(error.localizedDescription)
}
@@ -129,9 +135,11 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
dictionary: store.personalDictionary,
onPartial: onPartial
)
} catch where ProviderToolCancellation.matches(error) {
return .cancelled
} catch {
OSGLog.asr.warning(
"streaming ASR session open failed, using chunked batch: \(error.localizedDescription, privacy: .public)"
"streaming ASR session open failed, using chunked batch: \(CloudASRLogMetadata.describe(error), privacy: .public)"
)
let pipeline = ChunkedUtterancePipeline(asr: self, locale: locale)
return await pipeline.transcribe(stream: stream, onPartial: onPartial)
@@ -1,5 +1,5 @@
// CloudASRStreaming.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Utterance-scoped cloud ASR sessions: one long-lived connection per press,
// streaming PCM up and interim text down. Chunked batch ASR remains the
@@ -10,6 +10,35 @@ import Foundation
import OSGKeyboardShared
#endif
enum CloudASRLogMetadata {
static func describe(_ error: Error) -> String {
if let cloudError = error as? CloudASRError {
switch cloudError {
case .noAPIKey:
return "category=noAPIKey"
case .invalidURL:
return "category=invalidURL"
case .http(let status, let message):
return "category=http status=\(status) detailBytes=\(message?.utf8.count ?? 0)"
case .decoding(let detail):
return "category=decoding detailBytes=\(detail.utf8.count)"
case .transport(let detail):
return "category=transport detailBytes=\(detail.utf8.count)"
case .emptyTranscript:
return "category=emptyTranscript"
case .audioTooLong:
return "category=audioTooLong"
case .providerUnsupported:
return "category=providerUnsupported"
}
}
if let urlError = error as? URLError {
return "category=url code=\(urlError.code.rawValue)"
}
return "category=\(String(reflecting: type(of: error)))"
}
}
/// Long-lived cloud ASR session for one Flow utterance.
public protocol CloudASRStreamingSession: Sendable {
/// Append 16 kHz mono Float32 PCM captured while the mic is open.
@@ -125,7 +154,7 @@ public actor StreamingUtterancePipeline {
+ "elapsed=\(FlowTrace.seconds(since: startedAt))s"
)
return .success(ChunkedUtteranceSuccess(text: finalText))
} catch is CancellationError {
} catch where ProviderToolCancellation.matches(error) {
activeSession?.cancel()
activeSession = nil
FlowTrace.asr("cloud.stream.cancelled", "uploadedSamples=\(uploadedSamples)")
@@ -138,7 +167,7 @@ public actor StreamingUtterancePipeline {
"asr.cloud.stream.failed",
"uploadedSamples=\(uploadedSamples) "
+ "elapsed=\(FlowTrace.seconds(since: startedAt))s "
+ "error=\(error.localizedDescription)"
+ "\(CloudASRLogMetadata.describe(error))"
)
return .failure(error.localizedDescription)
}
@@ -1,5 +1,5 @@
// OpenAIRealtimeASRClient.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// OpenAI Realtime transcription (WebSocket). Streams PCM and transcript
// deltas for utterance-level ASR. Batch `/audio/transcriptions` remains the
@@ -94,10 +94,17 @@ struct OpenAIRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable {
)
session.cancel()
} catch {
guard Self.shouldFallbackToBatch(afterProbeError: error) else {
throw CancellationError()
}
try await batchClient.probeConnection()
}
}
static func shouldFallbackToBatch(afterProbeError error: Error) -> Bool {
!ProviderToolCancellation.matches(error)
}
private var resolvedRealtimeModel: String {
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed.hasPrefix("gpt-4o") || trimmed == "whisper-1" {
@@ -326,6 +333,8 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
}
do {
try await wsTask.send(.string(string))
} catch where ProviderToolCancellation.matches(error) {
throw CancellationError()
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
@@ -1,5 +1,5 @@
// VolcengineCloudASRClient.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Volcengine SAUC bigmodel ASR client. Utterance-level WebSocket session with
// enable_nonstream (official two-pass): interim text for on-screen partials,
@@ -363,9 +363,12 @@ private final class VolcengineStreamingSession: CloudASRStreamingSession, @unche
guard let frame = VolcengineFrame.parse(data) else { continue }
if frame.messageType == .errorMessage {
let body = String(data: frame.payload, encoding: .utf8) ?? ""
let code = frame.errorCode ?? 0
publishFailure(CloudASRError.transport("ASR error \(code): \(body)"))
publishFailure(
CloudASRError.transport(
"ASR error code=\(code) responseBytes=\(frame.payload.count)"
)
)
return
}
guard frame.messageType == .fullServerResponse else { continue }
@@ -397,6 +400,8 @@ private final class VolcengineStreamingSession: CloudASRStreamingSession, @unche
private func send(_ data: Data) async throws {
do {
try await wsTask.send(.data(data))
} catch where ProviderToolCancellation.matches(error) {
throw CancellationError()
} catch {
throw CloudASRError.transport(error.localizedDescription)
}