Files
OSGKeyboard/OSGKeyboard/Views/SettingsProviderControls.swift
T
Rocky cc8dd1070a feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding
- Add macOS menu-bar dictation app with local ASR models (SenseVoice/Qwen3),
  global Option hotkey, and bottom overlay
- Add cloud ASR/LLM providers (Anthropic, Volcengine, Bailian, and more) with
  provider logos, model listing, and connection checks
- Add shared 7-day usage stats UI (UsageStatsCluster / SevenDayUsageChart)
- Add iOS onboarding step 6 for polish LLM setup; hide custom-language-model
  diagnostic toggle behind DEBUG
- Unify iOS onboarding tagline with the macOS brand line ("开口即文字。")
- Rewrite README (Chinese-first, product-oriented) and refresh GitHub Pages
2026-07-11 19:10:20 +08:00

317 lines
11 KiB
Swift

// SettingsProviderControls.swift
// OSGKeyboard · Main App
//
// OpenLess-style setting rows for provider credentials and tools. Compact iOS
// stacks label above control; regular-width iPad keeps label/control in one row.
import SwiftUI
import OSGKeyboardShared
struct SettingsProviderRow<Content: View>: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let title: String
@ViewBuilder var content: () -> Content
var body: some View {
if horizontalSizeClass == .compact {
VStack(alignment: .leading, spacing: 8) {
label
content()
.frame(maxWidth: .infinity, alignment: .leading)
}
.settingsListRow()
} else {
HStack(alignment: .center, spacing: Spacing.lg) {
label
.frame(width: 150, alignment: .leading)
content()
.frame(maxWidth: .infinity, alignment: .leading)
}
.settingsListRow()
}
}
private var label: some View {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
}
struct SettingsCredentialRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: String
let placeholder: String
@Binding var text: String
var isSecret: Bool = false
var isMonospaced: Bool = false
var defaultValue: String?
var trailing: AnyView?
@State private var revealed = false
var body: some View {
SettingsProviderRow(title: title) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: Spacing.xs) {
input
if let defaultValue, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
iconButton(systemName: "checkmark", label: "settings.provider.fillDefault") {
text = defaultValue
}
}
if let trailing {
trailing
}
if isSecret {
iconButton(systemName: revealed ? "eye.slash" : "eye", label: revealed ? "api.key.hide" : "api.key.show") {
revealed.toggle()
}
}
}
}
}
}
@ViewBuilder
private var input: some View {
Group {
if isSecret && !revealed {
SecureField(placeholder, text: $text)
} else {
TextField(placeholder, text: $text)
}
}
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(isMonospaced ? TypeStyle.mono : TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 38)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
private func iconButton(systemName: String, label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(width: 38, height: 38)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.accessibilityLabel(label)
}
}
// MARK: - Editable model field + fetch icon
/// Model id: type freely, or fetch then pick from the trailing dropdown.
struct SettingsModelPickerRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: String
let placeholder: String
@Binding var model: String
let fetchModels: () async throws -> [String]
@State private var models: [String] = []
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
private let controlHeight: CGFloat = 38
var body: some View {
SettingsProviderRow(title: title) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: Spacing.xs) {
comboField
refreshButton
}
if let message {
Text(message)
.font(TypeStyle.caption2)
.foregroundStyle(failed ? palette.danger : palette.accent)
.lineLimit(3)
}
}
}
}
/// Editable model id + trailing menu chevron in one well (same chrome as
/// Mac `MacPickerFieldBox`). Chevron is overlaid so it stays inside the border.
private var comboField: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
let chevronWidth: CGFloat = 28
return ZStack(alignment: .trailing) {
TextField(placeholder, text: $model)
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(TypeStyle.mono)
.foregroundStyle(palette.textPrimary)
.padding(.leading, Spacing.sm)
.padding(.trailing, chevronWidth + Spacing.sm)
.frame(maxWidth: .infinity, minHeight: controlHeight, alignment: .leading)
.background(palette.surfaceElevated, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
Menu {
if models.isEmpty {
Button(AppL10n.string("settings.provider.modelsEmptyHint")) {}
.disabled(true)
} else {
ForEach(models, id: \.self) { modelId in
Button {
model = modelId
message = AppL10n.format("settings.provider.modelSelected", modelId)
failed = false
} label: {
if modelId == model {
Label(modelId, systemImage: "checkmark")
} else {
Text(modelId)
}
}
}
}
} label: {
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
.frame(width: chevronWidth, height: controlHeight)
.contentShape(Rectangle())
}
.padding(.trailing, Spacing.sm)
.accessibilityLabel(AppL10n.string("settings.provider.selectModel"))
}
}
private var refreshButton: some View {
Button {
Task { await runFetchModels() }
} label: {
Group {
if isRunning {
ProgressView()
.controlSize(.mini)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary)
}
}
.frame(width: controlHeight, height: controlHeight)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.disabled(isRunning)
.accessibilityLabel(AppL10n.string("settings.provider.fetchModels"))
}
@MainActor
private func runFetchModels() async {
isRunning = true
failed = false
defer { isRunning = false }
let outcome = await ProviderToolRunner.runFetchModels(
runningMessage: AppL10n.string("settings.provider.loadingModels"),
loadedMessage: { AppL10n.format("settings.provider.modelsLoaded", $0) },
emptyMessage: SharedL10n.string("providerTools.error.empty"),
currentModel: model,
fetchModels: fetchModels
)
models = outcome.state.models
message = outcome.state.message
failed = outcome.state.failed
if let selected = outcome.selectedModel {
model = selected
}
}
}
// MARK: - Connection validate only
struct SettingsProviderToolsRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let validate: () async throws -> Void
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
var body: some View {
HStack(alignment: .center, spacing: Spacing.sm) {
Text(AppL10n.string("settings.provider.tools"))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
if isRunning {
ProgressView()
.controlSize(.mini)
} else if let message {
Text(message)
.font(TypeStyle.caption2)
.foregroundStyle(failed ? palette.danger : palette.accent)
.lineLimit(3)
.truncationMode(.tail)
}
Spacer(minLength: 0)
Button {
Task { await runValidate() }
} label: {
Text(AppL10n.string("settings.provider.validate"))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.md)
.frame(minHeight: 34)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.disabled(isRunning)
}
.settingsListRow()
}
@MainActor
private func runValidate() async {
isRunning = true
failed = false
defer { isRunning = false }
let outcome = await ProviderToolRunner.runValidate(
runningMessage: AppL10n.string("api.test.running"),
successMessage: AppL10n.string("api.test.success"),
validate: validate
)
message = outcome.message
failed = outcome.failed
}
}