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
View File
@@ -32,13 +32,9 @@ enum MacMetrics {
/// both this value, so the card breathes evenly. Applied as `VStack(spacing:)`
/// between rows and `.padding(.vertical:)` on the card body.
static let settingsRowGap: CGFloat = Spacing.md
/// Provider menu trigger width (legacy).
static let selectWidth: CGFloat = 200
/// Text-field max width for provider credential rows (narrower than the
/// old 360pt so long keys truncate instead of wrapping when the window shrinks).
static let fieldWidth: CGFloat = 280
/// Legacy alias used by older call sites; prefer `fieldWidth`.
static let controlWidth: CGFloat = fieldWidth
/// Horizontal inset inside settings cards matches History / Dictionary rows.
static let settingsCardInset: CGFloat = Spacing.md
/// Below this row width, provider rows stack label above control.
@@ -266,19 +262,6 @@ struct MacInlineRow<Control: View>: View {
}
}
/// Backwards-compatible alias: title + optional subtitle + trailing control.
struct MacFormSubtitleRow<Control: View>: View {
let title: String
var subtitle: String? = nil
@ViewBuilder var control: () -> Control
var body: some View {
MacInlineRow(title: title, subtitle: subtitle) {
control()
}
}
}
/// Tappable navigation / action row inside a settings card.
struct MacFormLinkRow: View {
@Environment(\.themePalette) private var palette
@@ -324,8 +307,6 @@ struct MacProviderSettingRow<Content: View>: View {
let title: String
var subtitle: String? = nil
/// Retained for source compatibility; the control now fills its column.
var controlMaxWidth: CGFloat? = nil
/// Cross-axis alignment between the label column and the control. Provider
/// rows keep `.top` (model row grows a status line below its field); single
/// control rows can pass `.center` to vertically center label and control.
@@ -431,22 +412,6 @@ struct MacSettingsToolButton: View {
}
}
// MARK: - Legacy setting row
/// Fixed label column + left-aligned control. Prefer `MacProviderSettingRow`
/// for provider configuration cards.
struct MacSettingRow<Content: View>: View {
let title: String
var controlMaxWidth: CGFloat? = nil
@ViewBuilder var content: () -> Content
var body: some View {
MacProviderSettingRow(title: title, controlMaxWidth: controlMaxWidth) {
content()
}
}
}
// MARK: - Page header
/// Capsule-shaped primary action aligned with page-header search controls.
+20 -8
View File
@@ -88,11 +88,16 @@ struct MacSettingsICloudSyncRow: View {
do {
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
} catch let error as PersonalDictionaryCloudSyncError {
MacICloudSyncBootstrap.settingsSync.disableSync()
isEnabled = false
if case .payloadTooLarge = error {
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
} else {
do {
try MacICloudSyncBootstrap.settingsSync.disableSync()
isEnabled = false
if case .payloadTooLarge = error {
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
} else {
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
} catch {
reloadFromStore()
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
@@ -100,7 +105,7 @@ struct MacSettingsICloudSyncRow: View {
}
reloadFromStore()
} catch {
isEnabled = false
reloadFromStore()
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
@@ -108,9 +113,16 @@ struct MacSettingsICloudSyncRow: View {
}
private func disableSync() {
MacICloudSyncBootstrap.settingsSync.disableSync()
isEnabled = false
syncErrorMessage = nil
isApplyingToggle = true
do {
try MacICloudSyncBootstrap.settingsSync.disableSync()
reloadFromStore()
} catch {
reloadFromStore()
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
}
private func syncNow() {
+3 -1
View File
@@ -2,7 +2,9 @@
// OSGKeyboard · Mac
//
// A short first-run setup for the macOS app. It is intentionally separate
// from iOS onboarding because Mac needs Accessibility and optional Sherpa setup.
// from iOS onboarding because Mac needs Accessibility and local-model setup.
// The current local runtime defaults to Qwen3 MLX with Apple Speech fallback;
// Sherpa identifiers and install records are retained only for migration compatibility.
//
// Visual language mirrors the iOS onboarding: an ambient top gradient, a
// glowing hero icon, a large title block, and elongated capsule progress
+119 -27
View File
@@ -272,8 +272,10 @@ struct MacProviderModelRow: View {
let title: String
let placeholder: String
@Binding var model: String
let providerIdentity: String
let endpointIdentity: String
let apiKey: String
let fetchModels: () async throws -> [String]
let makeFetchModelsRequest: @MainActor () -> ProviderToolRequest<[String]>
let language: AppUILanguage
@State private var models: [String] = []
@@ -282,6 +284,7 @@ struct MacProviderModelRow: View {
@State private var failed = false
@State private var isDropdownOpen = false
@State private var fieldWidth: CGFloat = 0
@State private var requestCoordinator = ProviderToolRequestCoordinator()
private let chevronWidth: CGFloat = 28
private let dropdownMaxHeight: CGFloat = 240
@@ -313,7 +316,7 @@ struct MacProviderModelRow: View {
disabled: isRunning || trimmedAPIKey.isEmpty
) {
isDropdownOpen = false
Task { await runFetchModels() }
runFetchModels()
}
if isRunning {
@@ -325,6 +328,11 @@ struct MacProviderModelRow: View {
statusMessage
}
}
.onChange(of: providerIdentity) { _, _ in invalidateRequest() }
.onChange(of: endpointIdentity) { _, _ in invalidateRequest() }
.onChange(of: apiKey) { _, _ in invalidateRequest() }
.onChange(of: model) { _, _ in invalidateRequestIfRunning() }
.onDisappear { invalidateRequest() }
}
/// Editable model id + trailing chevron. Model list is a true popover so the
@@ -332,7 +340,7 @@ struct MacProviderModelRow: View {
private var comboField: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
return ZStack(alignment: .trailing) {
TextField(text: $model, prompt: Text(verbatim: placeholder)) {
TextField(text: editableModelBinding, prompt: Text(verbatim: placeholder)) {
Text(title)
}
.labelsHidden()
@@ -400,6 +408,7 @@ struct MacProviderModelRow: View {
private func dropdownRow(_ modelId: String) -> some View {
let isSelected = modelId == model
return Button {
invalidateRequest()
model = modelId
message = MacL10n.format(
"mac.settings.modelSelected",
@@ -445,31 +454,77 @@ struct MacProviderModelRow: View {
}
}
private var editableModelBinding: Binding<String> {
Binding(
get: { model },
set: { newValue in
invalidateRequest()
model = newValue
}
)
}
@MainActor
private func runFetchModels() async {
private func runFetchModels() {
guard !trimmedAPIKey.isEmpty else {
failed = true
message = SharedL10n.string("providerTools.error.missingAPIKey", language: language)
return
}
let request = makeFetchModelsRequest()
let currentModel = model
let currentLanguage = language
let runningMessage = MacL10n.string("mac.settings.loadingModels", language: currentLanguage)
let emptyMessage = SharedL10n.string("providerTools.error.empty", language: currentLanguage)
isRunning = true
failed = false
defer { isRunning = false }
message = runningMessage
let outcome = await ProviderToolRunner.runFetchModels(
runningMessage: MacL10n.string("mac.settings.loadingModels", language: language),
loadedMessage: { MacL10n.format("mac.settings.modelsLoaded", language: language, $0) },
emptyMessage: SharedL10n.string("providerTools.error.empty", language: language),
currentModel: model,
fetchModels: fetchModels
requestCoordinator.start(
providerIdentity: request.providerIdentity,
operation: {
await ProviderToolRunner.runFetchModels(
runningMessage: runningMessage,
loadedMessage: {
MacL10n.format("mac.settings.modelsLoaded", language: currentLanguage, $0)
},
emptyMessage: emptyMessage,
currentModel: currentModel,
fetchModels: request.operation
)
},
commit: { outcome in
isRunning = false
switch outcome {
case .cancelled:
message = nil
failed = false
case .completed(let state, let selectedModel):
models = state.models
message = state.message
failed = state.failed
if let selectedModel {
model = selectedModel
}
}
}
)
models = outcome.state.models
message = outcome.state.message
failed = outcome.state.failed
if let selected = outcome.selectedModel {
model = selected
}
}
@MainActor
private func invalidateRequest() {
requestCoordinator.invalidate()
isRunning = false
message = nil
failed = false
}
@MainActor
private func invalidateRequestIfRunning() {
guard requestCoordinator.isRunning else { return }
invalidateRequest()
}
}
@@ -479,12 +534,17 @@ struct MacProviderToolsRow: View {
@Environment(\.themePalette) private var palette
let title: String
let validate: () async throws -> Void
let providerIdentity: String
let endpointIdentity: String
let credentialIdentity: String
let modelIdentity: String
let makeValidateRequest: @MainActor () -> ProviderToolRequest<Void>
let language: AppUILanguage
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
@State private var requestCoordinator = ProviderToolRequestCoordinator()
var body: some View {
MacProviderSettingRow(title: title) {
@@ -493,7 +553,7 @@ struct MacProviderToolsRow: View {
title: MacL10n.string("mac.settings.validate", language: language),
disabled: isRunning
) {
Task { await runValidate() }
runValidate()
}
if isRunning {
@@ -508,21 +568,53 @@ struct MacProviderToolsRow: View {
}
}
}
.onChange(of: providerIdentity) { _, _ in invalidateRequest() }
.onChange(of: endpointIdentity) { _, _ in invalidateRequest() }
.onChange(of: credentialIdentity) { _, _ in invalidateRequest() }
.onChange(of: modelIdentity) { _, _ in invalidateRequest() }
.onDisappear { invalidateRequest() }
}
@MainActor
private func runValidate() async {
private func runValidate() {
let request = makeValidateRequest()
let currentLanguage = language
let runningMessage = MacL10n.string("mac.settings.validating", language: currentLanguage)
let successMessage = MacL10n.string("mac.settings.validateSuccess", language: currentLanguage)
isRunning = true
failed = false
defer { isRunning = false }
message = runningMessage
let outcome = await ProviderToolRunner.runValidate(
runningMessage: MacL10n.string("mac.settings.validating", language: language),
successMessage: MacL10n.string("mac.settings.validateSuccess", language: language),
validate: validate
requestCoordinator.start(
providerIdentity: request.providerIdentity,
operation: {
await ProviderToolRunner.runValidate(
runningMessage: runningMessage,
successMessage: successMessage,
validate: request.operation
)
},
commit: { outcome in
isRunning = false
switch outcome {
case .cancelled:
message = nil
failed = false
case .completed(let state):
message = state.message
failed = state.failed
}
}
)
message = outcome.message
failed = outcome.failed
}
@MainActor
private func invalidateRequest() {
requestCoordinator.invalidate()
isRunning = false
message = nil
failed = false
}
}
+67 -37
View File
@@ -131,8 +131,10 @@ struct MacSettingsView: View {
title: MacL10n.string("mac.settings.model", language: lang),
placeholder: currentPolishProvider.defaultModel,
model: $viewModel.config.model,
providerIdentity: viewModel.config.providerId,
endpointIdentity: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
fetchModels: fetchMacLLMModels,
makeFetchModelsRequest: makeMacLLMModelsRequest,
language: lang
)
.id(viewModel.config.providerId)
@@ -143,7 +145,11 @@ struct MacSettingsView: View {
)
MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
validate: validateMacLLM,
providerIdentity: viewModel.config.providerId,
endpointIdentity: viewModel.config.baseURL,
credentialIdentity: viewModel.config.apiKey,
modelIdentity: viewModel.config.model,
makeValidateRequest: makeMacLLMValidateRequest,
language: lang
)
MacProviderSettingRow(title: MacL10n.string("mac.settings.translation", language: lang)) {
@@ -179,7 +185,11 @@ struct MacSettingsView: View {
}
MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
validate: validateMacASR,
providerIdentity: viewModel.config.asrProviderId,
endpointIdentity: viewModel.config.asrBaseURL,
credentialIdentity: viewModel.config.asrApiKey,
modelIdentity: viewModel.config.asrModel,
makeValidateRequest: makeMacASRValidateRequest,
language: lang
)
}
@@ -226,14 +236,6 @@ struct MacSettingsView: View {
isSecret: true
)
}
MacProviderNoteRow(
text: MacL10n.string(
macVolcengineFields.usesAPIKeyAuth
? "mac.settings.volcengineNoteApiKey"
: "mac.settings.volcengineNoteAppToken",
language: lang
)
)
}
@ViewBuilder
@@ -256,8 +258,10 @@ struct MacSettingsView: View {
title: MacL10n.string("mac.settings.asrModel", language: lang),
placeholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId),
model: $viewModel.config.asrModel,
providerIdentity: viewModel.config.asrProviderId,
endpointIdentity: viewModel.config.asrBaseURL,
apiKey: viewModel.config.asrApiKey,
fetchModels: fetchMacASRModels,
makeFetchModelsRequest: makeMacASRModelsRequest,
language: lang
)
.id(viewModel.config.asrProviderId)
@@ -443,39 +447,65 @@ struct MacSettingsView: View {
viewModel.config.asrApiKey = fields.encodedAPIKey
}
private func validateMacLLM() async throws {
let client = LLMClientFactory.make(
providerId: viewModel.config.providerId,
baseURL: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
model: viewModel.config.model,
thinkingEnabled: viewModel.config.llmThinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
@MainActor
private func makeMacLLMValidateRequest() -> ProviderToolRequest<Void> {
let providerID = viewModel.config.providerId
let baseURL = viewModel.config.baseURL
let apiKey = viewModel.config.apiKey
let model = viewModel.config.model
let thinkingEnabled = viewModel.config.llmThinkingEnabled
return ProviderToolRequest(providerIdentity: providerID) {
let client = LLMClientFactory.make(
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
model: model,
thinkingEnabled: thinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
}
}
private func fetchMacLLMModels() async throws -> [String] {
try await ProviderModelService.listLLMModels(
providerId: viewModel.config.providerId,
baseURL: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
currentModel: viewModel.config.model
)
@MainActor
private func makeMacLLMModelsRequest() -> ProviderToolRequest<[String]> {
let providerID = viewModel.config.providerId
let baseURL = viewModel.config.baseURL
let apiKey = viewModel.config.apiKey
let currentModel = viewModel.config.model
return ProviderToolRequest(providerIdentity: providerID) {
try await ProviderModelService.listLLMModels(
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
currentModel: currentModel
)
}
}
private func validateMacASR() async throws {
@MainActor
private func makeMacASRValidateRequest() -> ProviderToolRequest<Void> {
let persisted = AppGroupStore(defaults: viewModel.defaults)
let store = LiveConfigurationStore(config: viewModel.config, fallback: persisted)
try await CloudASRConnectionCheck.validate(store: store)
let providerID = viewModel.config.asrProviderId
return ProviderToolRequest(providerIdentity: providerID) {
try await CloudASRConnectionCheck.validate(store: store)
}
}
private func fetchMacASRModels() async throws -> [String] {
try await ProviderModelService.listASRModels(
providerId: viewModel.config.asrProviderId,
baseURL: viewModel.config.asrBaseURL,
apiKey: viewModel.config.asrApiKey,
currentModel: viewModel.config.asrModel
)
@MainActor
private func makeMacASRModelsRequest() -> ProviderToolRequest<[String]> {
let providerID = viewModel.config.asrProviderId
let baseURL = viewModel.config.asrBaseURL
let apiKey = viewModel.config.asrApiKey
let currentModel = viewModel.config.asrModel
return ProviderToolRequest(providerIdentity: providerID) {
try await ProviderModelService.listASRModels(
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
currentModel: currentModel
)
}
}
// MARK: - Bindings