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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user