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
This commit is contained in:
@@ -27,6 +27,7 @@ public protocol ConfigurationStore: Sendable {
|
||||
|
||||
var engineMode: String { get }
|
||||
var polishIntensity: PolishIntensity { get }
|
||||
var llmThinkingEnabled: Bool { get }
|
||||
var personalDictionary: PersonalDictionary { get }
|
||||
|
||||
/// Foreground-app context for polish prompts (keyboard extension publishes this).
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// LiveConfigurationStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// In-memory settings snapshot for connection checks and model probes.
|
||||
// Reads the values the user is editing — not a delayed Keychain re-fetch.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct LiveConfigurationSnapshot {
|
||||
public let providerId: String
|
||||
public let baseURL: String
|
||||
public let apiKey: String
|
||||
public let model: String
|
||||
public let asrProviderId: String
|
||||
public let asrBaseURL: String
|
||||
public let asrApiKey: String
|
||||
public let asrModel: String
|
||||
public let engineMode: String
|
||||
public let polishIntensity: PolishIntensity
|
||||
public let llmThinkingEnabled: Bool
|
||||
public let personalDictionary: PersonalDictionary
|
||||
public let detectedAppContext: (context: AppContext, observedAt: Date)?
|
||||
public let cloudASRPersistence: UserDefaults
|
||||
|
||||
public init(
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
model: String,
|
||||
asrProviderId: String,
|
||||
asrBaseURL: String,
|
||||
asrApiKey: String,
|
||||
asrModel: String,
|
||||
engineMode: String,
|
||||
polishIntensity: PolishIntensity,
|
||||
llmThinkingEnabled: Bool,
|
||||
personalDictionary: PersonalDictionary,
|
||||
detectedAppContext: (context: AppContext, observedAt: Date)?,
|
||||
cloudASRPersistence: UserDefaults
|
||||
) {
|
||||
self.providerId = providerId
|
||||
self.baseURL = baseURL
|
||||
self.apiKey = apiKey
|
||||
self.model = model
|
||||
self.asrProviderId = asrProviderId
|
||||
self.asrBaseURL = asrBaseURL
|
||||
self.asrApiKey = asrApiKey
|
||||
self.asrModel = asrModel
|
||||
self.engineMode = engineMode
|
||||
self.polishIntensity = polishIntensity
|
||||
self.llmThinkingEnabled = llmThinkingEnabled
|
||||
self.personalDictionary = personalDictionary
|
||||
self.detectedAppContext = detectedAppContext
|
||||
self.cloudASRPersistence = cloudASRPersistence
|
||||
}
|
||||
|
||||
/// Build from live `ProviderConfig` plus persisted App Group extras.
|
||||
public init(config: ProviderConfig, fallback: AppGroupStore) {
|
||||
self.init(
|
||||
providerId: config.providerId,
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
asrProviderId: config.asrProviderId,
|
||||
asrBaseURL: config.asrBaseURL,
|
||||
asrApiKey: config.asrApiKey,
|
||||
asrModel: config.asrModel,
|
||||
engineMode: config.engineMode,
|
||||
polishIntensity: config.polishIntensity,
|
||||
llmThinkingEnabled: config.llmThinkingEnabled,
|
||||
personalDictionary: fallback.personalDictionary,
|
||||
detectedAppContext: fallback.detectedAppContext,
|
||||
cloudASRPersistence: fallback.defaults
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ephemeral `ConfigurationStore` backed by a user-edited snapshot.
|
||||
public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
|
||||
private let snapshot: LiveConfigurationSnapshot
|
||||
|
||||
public init(snapshot: LiveConfigurationSnapshot) {
|
||||
self.snapshot = snapshot
|
||||
}
|
||||
|
||||
public init(config: ProviderConfig, fallback: AppGroupStore) {
|
||||
self.init(snapshot: LiveConfigurationSnapshot(config: config, fallback: fallback))
|
||||
}
|
||||
|
||||
public var providerId: String { snapshot.providerId }
|
||||
public var baseURL: String { snapshot.baseURL }
|
||||
public var apiKey: String { snapshot.apiKey }
|
||||
public var model: String { snapshot.model }
|
||||
public var asrProviderId: String { snapshot.asrProviderId }
|
||||
public var asrBaseURL: String { snapshot.asrBaseURL }
|
||||
public var asrApiKey: String { snapshot.asrApiKey }
|
||||
public var asrModel: String { snapshot.asrModel }
|
||||
public var engineMode: String { snapshot.engineMode }
|
||||
public var polishIntensity: PolishIntensity { snapshot.polishIntensity }
|
||||
public var llmThinkingEnabled: Bool { snapshot.llmThinkingEnabled }
|
||||
public var personalDictionary: PersonalDictionary { snapshot.personalDictionary }
|
||||
public var detectedAppContext: (context: AppContext, observedAt: Date)? { snapshot.detectedAppContext }
|
||||
public var cloudASRPersistence: UserDefaults { snapshot.cloudASRPersistence }
|
||||
|
||||
public func makeClient() -> LLMClient {
|
||||
LLMClientFactory.make(
|
||||
providerId: providerId,
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
thinkingEnabled: llmThinkingEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// SevenDayUsageChart.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// 7-day dictation bar chart. Platform shells wrap this in their own page
|
||||
// layout; the chart itself only needs points + UI language.
|
||||
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
public struct SevenDayUsageChart: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
public let points: [UsageStatisticsStore.DailyUsagePoint]
|
||||
public let language: AppUILanguage
|
||||
/// Bar area height. Phone stacked layout uses a shorter value.
|
||||
public var chartMinHeight: CGFloat
|
||||
/// When true, wrap content in `UsageSurfaceCard` (default). Pass false if
|
||||
/// the caller already provides a surface.
|
||||
public var embedsInCard: Bool
|
||||
/// When true (iPad split), the chart fills all available height. When false
|
||||
/// (phone stacked home), the bar area is a fixed `chartMinHeight` so the card
|
||||
/// stays compact and does not steal space from surrounding content.
|
||||
public var expands: Bool
|
||||
|
||||
public init(
|
||||
points: [UsageStatisticsStore.DailyUsagePoint],
|
||||
language: AppUILanguage,
|
||||
chartMinHeight: CGFloat = 96,
|
||||
embedsInCard: Bool = true,
|
||||
expands: Bool = true
|
||||
) {
|
||||
self.points = points
|
||||
self.language = language
|
||||
self.chartMinHeight = chartMinHeight
|
||||
self.embedsInCard = embedsInCard
|
||||
self.expands = expands
|
||||
}
|
||||
|
||||
private var total: Int {
|
||||
points.reduce(0) { $0 + $1.value }
|
||||
}
|
||||
|
||||
private var maxValue: Int {
|
||||
max(points.map(\.value).max() ?? 0, 1)
|
||||
}
|
||||
|
||||
private var chartLocale: Locale {
|
||||
Locale(identifier: language.resolvedLanguageCode())
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
if embedsInCard {
|
||||
UsageSurfaceCard(padding: Spacing.md) {
|
||||
chartContent
|
||||
}
|
||||
} else {
|
||||
chartContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chartContent: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
header
|
||||
chart
|
||||
.frame(maxWidth: .infinity, maxHeight: expands ? .infinity : nil)
|
||||
}
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: expands ? .infinity : nil,
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(SharedL10n.string("stat.weekChart.title", language: language).uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(SharedL10n.string("stat.weekChart.caption", language: language))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: Spacing.sm)
|
||||
Text(UsageStatisticsStore.formatCount(total, language: language))
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(palette.accent)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: total)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bars
|
||||
|
||||
@ViewBuilder
|
||||
private var chart: some View {
|
||||
if total == 0 {
|
||||
Text(SharedL10n.string("stat.weekChart.empty", language: language))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, maxHeight: expands ? .infinity : nil, alignment: .center)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(height: expands ? nil : chartMinHeight)
|
||||
.frame(minHeight: expands ? chartMinHeight : nil)
|
||||
} else {
|
||||
Chart(points) { point in
|
||||
BarMark(
|
||||
x: .value("day", point.date, unit: .day),
|
||||
y: .value("chars", point.value)
|
||||
)
|
||||
.cornerRadius(4)
|
||||
.foregroundStyle(palette.accent.gradient)
|
||||
}
|
||||
.chartYScale(domain: 0...max(1, Int(ceil(Double(maxValue) * 1.15))))
|
||||
.chartYAxis(.hidden)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: points.map(\.date)) { _ in
|
||||
AxisValueLabel(format: .dateTime.weekday(.narrow))
|
||||
}
|
||||
}
|
||||
.environment(\.locale, chartLocale)
|
||||
.frame(height: expands ? nil : chartMinHeight)
|
||||
.frame(minHeight: expands ? chartMinHeight : nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,10 +162,13 @@ public enum Radius {
|
||||
// MARK: - Typography
|
||||
|
||||
public enum SettingsListMetrics {
|
||||
/// Single-line list rows (provider, footer link, picker).
|
||||
public static let singleLineMinHeight: CGFloat = 52
|
||||
/// Two-line rows (engine option, labeled API field).
|
||||
public static let doubleLineMinHeight: CGFloat = 72
|
||||
/// Floor for settings list rows (slightly above HIG 44pt).
|
||||
/// Row height grows with content; this only enforces a touch-target minimum.
|
||||
public static let singleLineMinHeight: CGFloat = 48
|
||||
/// Horizontal inset inside a settings list row.
|
||||
public static let rowHorizontalPadding: CGFloat = Spacing.md
|
||||
/// Vertical inset inside a settings list row (`Spacing.sm`).
|
||||
public static let rowVerticalPadding: CGFloat = 12
|
||||
/// Space between a section label and its card.
|
||||
public static let sectionLabelSpacing: CGFloat = Spacing.sm
|
||||
}
|
||||
@@ -263,6 +266,19 @@ private struct SecondaryButtonModifier: ViewModifier {
|
||||
}
|
||||
|
||||
public extension View {
|
||||
/// Standard settings list row insets: horizontal + vertical padding and a
|
||||
/// touch-target floor. Height grows with content — do not hand-roll
|
||||
/// per-section padding/`minHeight` for ordinary settings rows.
|
||||
func settingsListRow(
|
||||
minHeight: CGFloat = SettingsListMetrics.singleLineMinHeight,
|
||||
alignment: Alignment = .center
|
||||
) -> some View {
|
||||
self
|
||||
.padding(.horizontal, SettingsListMetrics.rowHorizontalPadding)
|
||||
.padding(.vertical, SettingsListMetrics.rowVerticalPadding)
|
||||
.frame(minHeight: minHeight, alignment: alignment)
|
||||
}
|
||||
|
||||
/// Standard card surface used in the main app.
|
||||
func cardSurface(padding: CGFloat = Spacing.md) -> some View {
|
||||
modifier(CardSurfaceModifier(padding: padding))
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// UsageStatCard.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Single cumulative metric tile. Compact = title / value / caption stack;
|
||||
// prominent = horizontal hero bar for the primary word-count metric.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct UsageStatCard: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
public let title: String
|
||||
public let value: String
|
||||
public let caption: String
|
||||
public var systemImage: String?
|
||||
public var accent: Bool
|
||||
/// Hero metric: wide horizontal layout for the primary word count.
|
||||
public var prominent: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
value: String,
|
||||
caption: String,
|
||||
systemImage: String? = nil,
|
||||
accent: Bool = false,
|
||||
prominent: Bool = false
|
||||
) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.caption = caption
|
||||
self.systemImage = systemImage
|
||||
self.accent = accent
|
||||
self.prominent = prominent
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
UsageSurfaceCard(padding: Spacing.md) {
|
||||
if prominent {
|
||||
prominentBody
|
||||
} else {
|
||||
compactBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactBody: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack {
|
||||
Text(title.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer()
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
Text(value)
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
Text(caption)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
/// Wide "hero bar": icon badge + title/caption left, big number right.
|
||||
private var prominentBody: some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
if let systemImage {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(palette.accentMuted)
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(caption)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: Spacing.md)
|
||||
Text(value)
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// UsageStatsCluster.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Cross-platform home / dashboard stats: 7-day chart + cumulative metrics.
|
||||
// Callers observe their store and pass plain values — Shared stays unbound
|
||||
// from platform singletons.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct UsageStatsCluster: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
public enum Layout: Sendable, Equatable {
|
||||
/// Chart left, 2×2 `UsageStatCard` grid right (Mac / iPad).
|
||||
case split
|
||||
/// Chart above a compact single-card 2×2 grid (iPhone).
|
||||
case stacked
|
||||
}
|
||||
|
||||
/// 手机端 2×2 统计网格的紧凑固定高度(沿用旧版 HomeStatsCard 数值)。
|
||||
static let compactGridHeight: CGFloat = 166
|
||||
|
||||
public let layout: Layout
|
||||
public let language: AppUILanguage
|
||||
public let points: [UsageStatisticsStore.DailyUsagePoint]
|
||||
public let dictationCharacterCount: Int
|
||||
public let dictationDurationSeconds: TimeInterval
|
||||
public let translationCharacterCount: Int
|
||||
public let dictionaryTermCount: Int
|
||||
/// 小屏(如 iPhone SE)收紧 stacked 图表高度,把空间让给下方的输入框。
|
||||
public let compact: Bool
|
||||
|
||||
public init(
|
||||
layout: Layout,
|
||||
language: AppUILanguage,
|
||||
points: [UsageStatisticsStore.DailyUsagePoint],
|
||||
dictationCharacterCount: Int,
|
||||
dictationDurationSeconds: TimeInterval,
|
||||
translationCharacterCount: Int,
|
||||
dictionaryTermCount: Int,
|
||||
compact: Bool = false
|
||||
) {
|
||||
self.layout = layout
|
||||
self.language = language
|
||||
self.points = points
|
||||
self.dictationCharacterCount = dictationCharacterCount
|
||||
self.dictationDurationSeconds = dictationDurationSeconds
|
||||
self.translationCharacterCount = translationCharacterCount
|
||||
self.dictionaryTermCount = dictionaryTermCount
|
||||
self.compact = compact
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
switch layout {
|
||||
case .split:
|
||||
splitBody
|
||||
case .stacked:
|
||||
stackedBody
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Split (Mac / iPad)
|
||||
|
||||
private var splitBody: some View {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
SevenDayUsageChart(points: points, language: language)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
splitStatGrid
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private var splitStatGrid: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
HStack(spacing: Spacing.md) {
|
||||
UsageStatCard(
|
||||
title: SharedL10n.string("stat.words", language: language),
|
||||
value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language),
|
||||
caption: SharedL10n.string("stat.transcribed", language: language),
|
||||
systemImage: "text.alignleft",
|
||||
accent: true
|
||||
)
|
||||
UsageStatCard(
|
||||
title: SharedL10n.string("stat.dictationTime", language: language),
|
||||
value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language),
|
||||
caption: SharedL10n.string("stat.cumulativeDuration", language: language),
|
||||
systemImage: "waveform"
|
||||
)
|
||||
}
|
||||
HStack(spacing: Spacing.md) {
|
||||
UsageStatCard(
|
||||
title: SharedL10n.string("stat.translation", language: language),
|
||||
value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language),
|
||||
caption: SharedL10n.string("stat.cumulativeTranslation", language: language),
|
||||
systemImage: "character.bubble"
|
||||
)
|
||||
UsageStatCard(
|
||||
title: SharedL10n.string("stat.dictionary", language: language),
|
||||
value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language),
|
||||
caption: SharedL10n.string("stat.customTerms", language: language),
|
||||
systemImage: "character.book.closed"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stacked (iPhone)
|
||||
|
||||
private var stackedBody: some View {
|
||||
VStack(spacing: compact ? Spacing.sm : Spacing.md) {
|
||||
SevenDayUsageChart(
|
||||
points: points,
|
||||
language: language,
|
||||
chartMinHeight: compact ? 72 : 96,
|
||||
expands: false
|
||||
)
|
||||
compactStatGrid
|
||||
}
|
||||
}
|
||||
|
||||
/// Phone-friendly 2×2: value + label only, single card with hairline dividers.
|
||||
private var compactStatGrid: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
compactCell(
|
||||
systemImage: "waveform",
|
||||
value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language),
|
||||
label: SharedL10n.string("stat.dictationTime", language: language)
|
||||
)
|
||||
compactDivider
|
||||
compactCell(
|
||||
systemImage: "text.alignleft",
|
||||
value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language),
|
||||
label: SharedL10n.string("stat.words", language: language)
|
||||
)
|
||||
}
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(height: 0.5)
|
||||
HStack(spacing: 0) {
|
||||
compactCell(
|
||||
systemImage: "character.bubble",
|
||||
value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language),
|
||||
label: SharedL10n.string("stat.translation", language: language)
|
||||
)
|
||||
compactDivider
|
||||
compactCell(
|
||||
systemImage: "character.book.closed",
|
||||
value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language),
|
||||
label: SharedL10n.string("stat.dictionary", language: language)
|
||||
)
|
||||
}
|
||||
}
|
||||
// 锁定紧凑固定高度(对齐旧版 HomeStatsCard 的 166pt),避免格子按内容撑高。
|
||||
.frame(height: UsageStatsCluster.compactGridHeight)
|
||||
.background(palette.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var compactDivider: some View {
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(width: 0.5)
|
||||
}
|
||||
|
||||
private func compactCell(systemImage: String, value: String, label: String) -> some View {
|
||||
HStack(alignment: .top, spacing: Spacing.xs) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text(value)
|
||||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
Text(label)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// UsageSurfaceCard.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Flat semantic surface used by home / dashboard stats on every platform.
|
||||
// Deliberately shadowless — hierarchy comes from fill + hairline border.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct UsageSurfaceCard<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
public var padding: CGFloat
|
||||
public var cornerRadius: CGFloat
|
||||
@ViewBuilder public var content: () -> Content
|
||||
|
||||
public init(
|
||||
padding: CGFloat = Spacing.md,
|
||||
cornerRadius: CGFloat = Radius.medium,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.padding = padding
|
||||
self.cornerRadius = cornerRadius
|
||||
self.content = content
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
|
||||
content()
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: shape)
|
||||
.overlay(
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
import Foundation
|
||||
|
||||
public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
/// Default polish LLM for fresh installs (local + cloud pickers).
|
||||
public static let defaultPolishProviderId = "deepseek"
|
||||
/// Default cloud ASR provider for fresh installs (independent from polish).
|
||||
public static let defaultCloudASRProviderId = "volcengine"
|
||||
|
||||
// MARK: - Keys
|
||||
|
||||
public enum Keys {
|
||||
@@ -31,6 +36,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let handednessPreference = "config.handednessPreference"
|
||||
public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
||||
public static let polishIntensity = "config.polishIntensity"
|
||||
public static let llmThinkingEnabled = "config.llmThinkingEnabled"
|
||||
public static let detectedAppContext = "config.detectedAppContext"
|
||||
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||
public static let personalDictionary = "config.personalDictionary.v1"
|
||||
@@ -70,6 +76,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var handednessPreference: HandednessPreference
|
||||
public var cursorDragNavigationEnabled: Bool
|
||||
public var polishIntensity: PolishIntensity
|
||||
/// Enables provider-specific reasoning / thinking controls for polish LLM requests.
|
||||
public var llmThinkingEnabled: Bool
|
||||
public var personalDictionary: PersonalDictionary
|
||||
/// Opt-in iCloud KVS sync for the personal dictionary (main app only).
|
||||
public var personalDictionaryICloudSyncEnabled: Bool
|
||||
@@ -150,7 +158,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
OpenAICompatibleClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model
|
||||
model: model,
|
||||
providerId: providerId,
|
||||
thinkingEnabled: llmThinkingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
@@ -193,8 +203,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
|
||||
/// Loads configuration from a known-available UserDefaults suite.
|
||||
public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration {
|
||||
let storedProviderId = defaults.string(forKey: Keys.providerId)
|
||||
var config = AppGroupConfiguration(
|
||||
providerId: defaults.string(forKey: Keys.providerId) ?? "openai",
|
||||
providerId: storedProviderId ?? defaultPolishProviderId,
|
||||
baseURL: "",
|
||||
model: "",
|
||||
asrProviderId: defaults.string(forKey: Keys.asrProviderId) ?? "",
|
||||
@@ -228,6 +239,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
|
||||
}(),
|
||||
polishIntensity: resolvePolishIntensity(from: defaults),
|
||||
llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled),
|
||||
personalDictionary: decodePersonalDictionary(from: defaults),
|
||||
personalDictionaryICloudSyncEnabled: {
|
||||
if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil {
|
||||
@@ -267,7 +279,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
|
||||
if config.asrProviderId.isEmpty {
|
||||
config.asrProviderId = config.providerId
|
||||
// Pre-split installs only stored `providerId`; copy it so ASR keeps working.
|
||||
config.asrProviderId = storedProviderId ?? defaultCloudASRProviderId
|
||||
defaults.set(config.asrProviderId, forKey: Keys.asrProviderId)
|
||||
}
|
||||
let asrPreset = LLMProvider.provider(id: config.asrProviderId)
|
||||
@@ -279,6 +292,17 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
?? CloudASRModelCatalog.defaultModel(for: config.asrProviderId)
|
||||
}
|
||||
|
||||
// Legacy qwen cloud ASR → bailian realtime (HTTP Flash path removed).
|
||||
if config.asrProviderId == "qwen" {
|
||||
let bailian = LLMProvider.provider(id: "bailian")
|
||||
config.asrProviderId = "bailian"
|
||||
config.asrBaseURL = bailian.defaultBaseURL
|
||||
config.asrModel = CloudASRModelCatalog.alibabaFunASRRealtime
|
||||
defaults.set(config.asrProviderId, forKey: Keys.asrProviderId)
|
||||
defaults.set(config.asrBaseURL, forKey: Keys.asrBaseURL)
|
||||
defaults.set(config.asrModel, forKey: Keys.asrModel)
|
||||
}
|
||||
|
||||
// One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain.
|
||||
_ = resolveAPIKey(
|
||||
defaults: defaults,
|
||||
@@ -311,26 +335,6 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
config.modeId = "polish"
|
||||
defaults.set("polish", forKey: Keys.modeId)
|
||||
}
|
||||
// DeepSeek is local-engine only — never a cloud picker choice.
|
||||
if config.engineMode == "cloud", config.providerId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.providerId = openAI.id
|
||||
config.baseURL = openAI.defaultBaseURL
|
||||
config.model = openAI.defaultModel
|
||||
defaults.set(openAI.id, forKey: Keys.providerId)
|
||||
defaults.set(openAI.defaultBaseURL, forKey: Keys.baseURL)
|
||||
defaults.set(openAI.defaultModel, forKey: Keys.model)
|
||||
}
|
||||
if config.engineMode == "cloud", config.asrProviderId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.asrProviderId = openAI.id
|
||||
config.asrBaseURL = openAI.defaultBaseURL
|
||||
config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id)
|
||||
defaults.set(openAI.id, forKey: Keys.asrProviderId)
|
||||
defaults.set(openAI.defaultBaseURL, forKey: Keys.asrBaseURL)
|
||||
defaults.set(openAI.defaultModel, forKey: Keys.asrModel)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -352,6 +356,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference)
|
||||
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
|
||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
|
||||
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
|
||||
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
|
||||
defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled)
|
||||
|
||||
@@ -10,10 +10,14 @@ import Foundation
|
||||
public enum CloudASRStrategy: String, Sendable, Equatable {
|
||||
/// 智谱 GLM-ASR — `hotwords` + optional `prompt`.
|
||||
case zhipuHotwords
|
||||
/// 阿里百炼 Fun-ASR — managed `vocabulary_id` + context text.
|
||||
case alibabaVocabulary
|
||||
/// OpenAI / 小米 MiMo / 自定义端点 — `prompt` on transcription APIs.
|
||||
/// 百炼 Fun-ASR Realtime — DashScope 经典 inference WebSocket 流式。
|
||||
case bailianStreaming
|
||||
/// OpenAI / Groq / 硅基流动 / Whisper 等 — `prompt` on transcription APIs.
|
||||
case prompt
|
||||
/// OpenRouter `/audio/transcriptions` — JSON body + base64 WAV (not multipart).
|
||||
case openRouterJson
|
||||
/// 火山引擎 SAUC 大模型流式 ASR(WebSocket + binary frame)。
|
||||
case volcengineStreaming
|
||||
/// Moonshot 托管 API 暂无音频转写;云端引擎回退端侧 ASR。
|
||||
case localFallback
|
||||
}
|
||||
@@ -54,8 +58,23 @@ public enum CloudASRError: Error, LocalizedError, Sendable, Equatable {
|
||||
}
|
||||
|
||||
public enum CloudASRModelCatalog {
|
||||
/// Provider ids shown in the cloud ASR picker (explicit allowlist).
|
||||
public static let selectableProviderIds: Set<String> = [
|
||||
"openai",
|
||||
"whisper",
|
||||
"bailian",
|
||||
"zhipu",
|
||||
"groq",
|
||||
"siliconflow",
|
||||
"openrouter",
|
||||
"mimo",
|
||||
"volcengine",
|
||||
"custom",
|
||||
]
|
||||
|
||||
/// Sync Fun-ASR Flash — base64 upload, ≤ 5 min, supports context + vocabulary.
|
||||
public static let alibabaFunASRFlash = "fun-asr-flash-2026-06-15"
|
||||
public static let alibabaFunASRRealtime = "fun-asr-realtime"
|
||||
/// Must match the ASR model used at recognition time.
|
||||
public static let alibabaVocabularyTargetModel = alibabaFunASRFlash
|
||||
|
||||
@@ -63,24 +82,38 @@ public enum CloudASRModelCatalog {
|
||||
public static let openAITranscribe = "gpt-4o-mini-transcribe"
|
||||
public static let openAIWhisper = "whisper-1"
|
||||
public static let mimoASR = "mimo-v2.5-asr"
|
||||
public static let groqWhisper = "whisper-large-v3-turbo"
|
||||
public static let siliconflowASR = "FunAudioLLM/SenseVoiceSmall"
|
||||
public static let openrouterWhisper = "openai/whisper-large-v3-turbo"
|
||||
public static let volcengineDefaultResourceID = "volc.seedasr.sauc.duration"
|
||||
public static let volcengineEndpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"
|
||||
public static let bailianDefaultEndpoint = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/"
|
||||
|
||||
public static let alibabaAPIBase = "https://dashscope.aliyuncs.com/api/v1"
|
||||
public static let alibabaCustomizationPath = "/services/audio/asr/customization"
|
||||
public static let alibabaMultimodalPath = "/services/aigc/multimodal-generation/generation"
|
||||
public static let zhipuTranscriptionPath = "/audio/transcriptions"
|
||||
|
||||
public static func supportsCloudASRSelection(providerId: String) -> Bool {
|
||||
selectableProviderIds.contains(providerId)
|
||||
}
|
||||
|
||||
public static func strategy(for providerId: String) -> CloudASRStrategy {
|
||||
switch providerId {
|
||||
case "zhipu":
|
||||
return .zhipuHotwords
|
||||
case "qwen":
|
||||
return .alibabaVocabulary
|
||||
case "bailian":
|
||||
return .bailianStreaming
|
||||
case "moonshot":
|
||||
return .localFallback
|
||||
case "openai", "mimo", "custom":
|
||||
case "volcengine":
|
||||
return .volcengineStreaming
|
||||
case "openrouter":
|
||||
return .openRouterJson
|
||||
case "openai", "whisper", "mimo", "groq", "siliconflow", "custom":
|
||||
return .prompt
|
||||
default:
|
||||
return .prompt
|
||||
return .localFallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,16 +121,36 @@ public enum CloudASRModelCatalog {
|
||||
switch providerId {
|
||||
case "zhipu":
|
||||
return zhipuGLMASR
|
||||
case "qwen":
|
||||
return alibabaFunASRFlash
|
||||
case "bailian":
|
||||
return alibabaFunASRRealtime
|
||||
case "whisper":
|
||||
return openAIWhisper
|
||||
case "mimo":
|
||||
return mimoASR
|
||||
case "groq":
|
||||
return groqWhisper
|
||||
case "siliconflow":
|
||||
return siliconflowASR
|
||||
case "openrouter":
|
||||
return openrouterWhisper
|
||||
case "volcengine":
|
||||
return volcengineDefaultResourceID
|
||||
case "openai", "custom":
|
||||
return openAITranscribe
|
||||
default:
|
||||
return openAITranscribe
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the ASR settings card should expose a custom endpoint field.
|
||||
public static func showsASREndpointField(for providerId: String) -> Bool {
|
||||
switch strategy(for: providerId) {
|
||||
case .prompt, .openRouterJson, .bailianStreaming:
|
||||
return true
|
||||
case .zhipuHotwords, .volcengineStreaming, .localFallback:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LLMProvider {
|
||||
@@ -112,9 +165,9 @@ extension LLMProvider {
|
||||
/// Official hotwords / vocabulary APIs during cloud ASR (not prompt-only bias).
|
||||
public var supportsPersonalDictionaryCloudASR: Bool {
|
||||
switch cloudASRStrategy {
|
||||
case .zhipuHotwords, .alibabaVocabulary:
|
||||
case .zhipuHotwords:
|
||||
return true
|
||||
case .prompt, .localFallback:
|
||||
case .bailianStreaming, .prompt, .openRouterJson, .volcengineStreaming, .localFallback:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// FlowHandoffPolicy.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Pure decision helpers for keyboard → host handoff. Keeps "session still
|
||||
// alive, ready contract briefly missing" from being treated as a cold start.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Action the keyboard should take when the user presses the mic.
|
||||
public enum FlowMicPressAction: Equatable, Sendable {
|
||||
case startRecording
|
||||
/// Session is alive (or was very recently); poll for ready, then optionally record.
|
||||
case waitForHostReady(recordWhenReady: Bool)
|
||||
/// Host process is gone / no session — open `osgkeyboard://startflow`.
|
||||
case openHostColdStart
|
||||
case ignore
|
||||
}
|
||||
|
||||
/// Whether the host app should show the cold-start overlay for a `startflow`.
|
||||
public enum FlowColdStartOverlayDecision: Equatable, Sendable {
|
||||
/// Do not set handoff flags or show preparing/ready UI.
|
||||
case silence
|
||||
/// Show preparing and run the cold-start / recovery path.
|
||||
case present
|
||||
}
|
||||
|
||||
public enum FlowHandoffPolicy {
|
||||
/// Proactive keyboard auto-launch of the host is intentionally disabled.
|
||||
/// Opening the host must be driven by an explicit mic press (or Live Activity).
|
||||
public static let allowsProactiveHostAutoLaunch = false
|
||||
|
||||
/// Samples of "host truly dead" required before a cold-start jump is allowed
|
||||
/// from a non-press path. Mic press uses `shouldOpenHostColdStart` directly.
|
||||
public static let coldStartDeadSampleThreshold = 2
|
||||
|
||||
/// True when the session contract still implies a living (or recoverable)
|
||||
/// host — so a transient `ready=false` must wait, not jump.
|
||||
public static func shouldTreatHostAsAlive(
|
||||
sessionActive: Bool,
|
||||
hostReachable: Bool,
|
||||
hostStale: Bool,
|
||||
withinReadyGrace: Bool
|
||||
) -> Bool {
|
||||
if hostStale { return false }
|
||||
guard sessionActive else { return false }
|
||||
// Reachable heartbeat, or a recent ready sample, means the process is
|
||||
// still ours — finalize races often look like hostNotReady for one frame.
|
||||
if hostReachable || withinReadyGrace { return true }
|
||||
// Session flag still valid and not past the zombie window: prefer wait.
|
||||
return true
|
||||
}
|
||||
|
||||
/// Whether `osgkeyboard://startflow` is justified for the current host state.
|
||||
public static func shouldOpenHostColdStart(
|
||||
sessionActive: Bool,
|
||||
hostReachable: Bool,
|
||||
hostStale: Bool,
|
||||
withinReadyGrace: Bool
|
||||
) -> Bool {
|
||||
!shouldTreatHostAsAlive(
|
||||
sessionActive: sessionActive,
|
||||
hostReachable: hostReachable,
|
||||
hostStale: hostStale,
|
||||
withinReadyGrace: withinReadyGrace
|
||||
)
|
||||
}
|
||||
|
||||
/// Mic-press routing shared by the keyboard coordinator and unit tests.
|
||||
public static func micPressAction(
|
||||
availability: MicVoiceAvailability,
|
||||
sessionActive: Bool,
|
||||
hostReachable: Bool,
|
||||
hostStale: Bool,
|
||||
withinReadyGrace: Bool
|
||||
) -> FlowMicPressAction {
|
||||
switch availability {
|
||||
case .ready:
|
||||
return .startRecording
|
||||
case .recording, .processing:
|
||||
return .ignore
|
||||
case .unavailable(.missingAPIKey),
|
||||
.unavailable(.noFullAccess),
|
||||
.unavailable(.appGroupUnavailable):
|
||||
// Caller surfaces the specific error UI.
|
||||
return .ignore
|
||||
case .unavailable(.preparingSession):
|
||||
// Session is warming — never cold-start; wait then record.
|
||||
return .waitForHostReady(recordWhenReady: true)
|
||||
case .unavailable(.hostNotReady):
|
||||
if shouldTreatHostAsAlive(
|
||||
sessionActive: sessionActive,
|
||||
hostReachable: hostReachable,
|
||||
hostStale: hostStale,
|
||||
withinReadyGrace: withinReadyGrace
|
||||
) {
|
||||
return .waitForHostReady(recordWhenReady: true)
|
||||
}
|
||||
return .openHostColdStart
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-app gate: a `startflow` against an already-healthy (or busy) session
|
||||
/// must not flash "Voice is ready".
|
||||
public static func coldStartOverlayDecision(
|
||||
sessionIsActive: Bool,
|
||||
hostIsReady: Bool,
|
||||
isUtteranceBusy: Bool
|
||||
) -> FlowColdStartOverlayDecision {
|
||||
guard sessionIsActive else { return .present }
|
||||
if hostIsReady || isUtteranceBusy { return .silence }
|
||||
// Active but not ready and not busy — engine recovery may need UI.
|
||||
return .present
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts consecutive "host truly dead" observations to ignore single-frame races.
|
||||
public struct FlowColdStartDebouncer: Equatable, Sendable {
|
||||
public private(set) var consecutiveDeadSamples: Int = 0
|
||||
|
||||
public init(consecutiveDeadSamples: Int = 0) {
|
||||
self.consecutiveDeadSamples = consecutiveDeadSamples
|
||||
}
|
||||
|
||||
/// Returns true once enough consecutive dead samples have been seen.
|
||||
public mutating func observe(hostTrulyDead: Bool) -> Bool {
|
||||
if hostTrulyDead {
|
||||
consecutiveDeadSamples += 1
|
||||
} else {
|
||||
consecutiveDeadSamples = 0
|
||||
}
|
||||
return consecutiveDeadSamples >= FlowHandoffPolicy.coldStartDeadSampleThreshold
|
||||
}
|
||||
|
||||
public mutating func reset() {
|
||||
consecutiveDeadSamples = 0
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Which hand the user holds the phone with — controls bottom-row key order
|
||||
// on the keyboard (delete ↔ return swap for right-handed use).
|
||||
// on the keyboard (delete ↔ space swap for right-handed use).
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -19,7 +19,7 @@ public enum HandednessPreference: String, CaseIterable, Identifiable, Sendable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-handed preference places return on the left and delete on the right.
|
||||
/// Right-handed preference places space on the left and delete on the right.
|
||||
public var swapsActionKeys: Bool { self == .right }
|
||||
|
||||
public static func fromStored(_ raw: String?) -> HandednessPreference {
|
||||
|
||||
@@ -48,15 +48,21 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
apiKeyURL: URL(string: "https://platform.openai.com/api-keys"),
|
||||
blurb: "GPT-4o mini · 多语言 · Multilingual"
|
||||
),
|
||||
.init(
|
||||
id: "ark",
|
||||
name: "火山方舟 Ark",
|
||||
defaultBaseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
defaultModel: "deepseek-v3-2-251201",
|
||||
apiKeyURL: URL(string: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey"),
|
||||
blurb: "豆包 / DeepSeek · OpenAI 兼容 · OpenAI-compatible"
|
||||
),
|
||||
.init(
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
defaultBaseURL: "https://api.deepseek.com/v1",
|
||||
defaultModel: "deepseek-v4-flash",
|
||||
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"),
|
||||
blurb: "deepseek-v4-flash · 本地引擎内置 · Local engine built-in",
|
||||
// Local engine only — never shown in cloud-engine pickers.
|
||||
isUserSelectable: false
|
||||
blurb: "deepseek-v4-flash · 本地引擎可内置 · Local engine optional built-in"
|
||||
),
|
||||
.init(
|
||||
id: "qwen",
|
||||
@@ -82,6 +88,30 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"),
|
||||
blurb: "Kimi · 长上下文 · Long context"
|
||||
),
|
||||
.init(
|
||||
id: "siliconflow",
|
||||
name: "硅基流动 SiliconFlow",
|
||||
defaultBaseURL: "https://api.siliconflow.cn/v1",
|
||||
defaultModel: "Qwen/Qwen2.5-7B-Instruct",
|
||||
apiKeyURL: URL(string: "https://cloud.siliconflow.cn/account/ak"),
|
||||
blurb: "多模型聚合 · OpenAI 兼容 · OpenAI-compatible"
|
||||
),
|
||||
.init(
|
||||
id: "groq",
|
||||
name: "Groq",
|
||||
defaultBaseURL: "https://api.groq.com/openai/v1",
|
||||
defaultModel: "llama-3.3-70b-versatile",
|
||||
apiKeyURL: URL(string: "https://console.groq.com/keys"),
|
||||
blurb: "超低延迟 LPU · Ultra-low latency"
|
||||
),
|
||||
.init(
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
defaultBaseURL: "https://api.minimaxi.com/v1",
|
||||
defaultModel: "MiniMax-M2.5",
|
||||
apiKeyURL: URL(string: "https://platform.minimaxi.com/user-center/basic-information"),
|
||||
blurb: "MiniMax-M2.5 · 中文优化 · Chinese-optimized"
|
||||
),
|
||||
.init(
|
||||
id: "mimo",
|
||||
name: "小米 MiMo",
|
||||
@@ -90,6 +120,106 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
apiKeyURL: URL(string: "https://platform.xiaomimimo.com"),
|
||||
blurb: "mimo-v2.5 · 中文优化 · Chinese-optimized"
|
||||
),
|
||||
.init(
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
defaultBaseURL: "https://openrouter.ai/api/v1",
|
||||
defaultModel: "qwen/qwen3-coder:free",
|
||||
apiKeyURL: URL(string: "https://openrouter.ai/keys"),
|
||||
blurb: "多模型路由 · Model routing · OpenAI-compatible"
|
||||
),
|
||||
.init(
|
||||
id: "gemini",
|
||||
name: "Google Gemini",
|
||||
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
defaultModel: "gemini-2.5-flash",
|
||||
apiKeyURL: URL(string: "https://aistudio.google.com/apikey"),
|
||||
blurb: "Gemini 2.5 Flash · OpenAI 兼容端点"
|
||||
),
|
||||
.init(
|
||||
id: "anthropic",
|
||||
name: "Anthropic Claude",
|
||||
defaultBaseURL: "https://api.anthropic.com/v1",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
apiKeyURL: URL(string: "https://console.anthropic.com/settings/keys"),
|
||||
blurb: "Claude Sonnet · Messages API"
|
||||
),
|
||||
.init(
|
||||
id: "xai",
|
||||
name: "xAI Grok",
|
||||
defaultBaseURL: "https://api.x.ai/v1",
|
||||
defaultModel: "grok-3-mini",
|
||||
apiKeyURL: URL(string: "https://console.x.ai"),
|
||||
blurb: "Grok · OpenAI 兼容 · OpenAI-compatible"
|
||||
),
|
||||
.init(
|
||||
id: "mistral",
|
||||
name: "Mistral AI",
|
||||
defaultBaseURL: "https://api.mistral.ai/v1",
|
||||
defaultModel: "mistral-small-latest",
|
||||
apiKeyURL: URL(string: "https://console.mistral.ai/api-keys"),
|
||||
blurb: "Mistral Small · 欧洲托管 · EU-hosted"
|
||||
),
|
||||
.init(
|
||||
id: "cometapi",
|
||||
name: "CometAPI",
|
||||
defaultBaseURL: "https://api.cometapi.com/v1",
|
||||
defaultModel: "gpt-4o",
|
||||
apiKeyURL: URL(string: "https://api.cometapi.com"),
|
||||
blurb: "多模型聚合 · OpenAI 兼容"
|
||||
),
|
||||
.init(
|
||||
id: "alibabaCoding",
|
||||
name: "阿里 Coding Plan",
|
||||
defaultBaseURL: "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
defaultModel: "qwen3-coder-plus",
|
||||
apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"),
|
||||
blurb: "通义 Coder · 代码润色 · Coding polish"
|
||||
),
|
||||
.init(
|
||||
id: "codingPlanX",
|
||||
name: "CodingPlanX",
|
||||
defaultBaseURL: "https://api.codingplanx.ai/v1",
|
||||
defaultModel: "gpt-5-mini",
|
||||
apiKeyURL: URL(string: "https://codingplanx.ai"),
|
||||
blurb: "CodingPlanX · OpenAI 兼容"
|
||||
),
|
||||
// MARK: - ASR-only presets (hidden from polish picker)
|
||||
.init(
|
||||
id: "volcengine",
|
||||
name: "火山引擎 Volcengine",
|
||||
defaultBaseURL: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
|
||||
defaultModel: "volc.seedasr.sauc.duration",
|
||||
apiKeyURL: URL(string: "https://console.volcengine.com/speech"),
|
||||
blurb: "流式大模型 ASR · API Key 填 appId:accessToken[:resourceId]",
|
||||
isUserSelectable: false
|
||||
),
|
||||
.init(
|
||||
id: "bailian",
|
||||
name: "百炼实时 ASR",
|
||||
defaultBaseURL: "wss://dashscope.aliyuncs.com/api-ws/v1/inference/",
|
||||
defaultModel: "fun-asr-realtime",
|
||||
apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"),
|
||||
blurb: "Fun-ASR Realtime · 百炼词表",
|
||||
isUserSelectable: false
|
||||
),
|
||||
.init(
|
||||
id: "whisper",
|
||||
name: "Whisper (OpenAI)",
|
||||
defaultBaseURL: "https://api.openai.com/v1",
|
||||
defaultModel: "whisper-1",
|
||||
apiKeyURL: URL(string: "https://platform.openai.com/api-keys"),
|
||||
blurb: "whisper-1 · 经典 Whisper 端点",
|
||||
isUserSelectable: false
|
||||
),
|
||||
.init(
|
||||
id: "codex_oauth",
|
||||
name: "Codex OAuth",
|
||||
defaultBaseURL: "",
|
||||
defaultModel: "gpt-5.3-codex-spark",
|
||||
blurb: "ChatGPT Codex OAuth · 暂不支持",
|
||||
isUserSelectable: false
|
||||
),
|
||||
.init(
|
||||
id: "custom",
|
||||
name: "Custom · 自定义",
|
||||
@@ -103,16 +233,13 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
presets.first(where: { $0.id == id }) ?? .presets[0]
|
||||
}
|
||||
|
||||
/// Presets the user may pick in Settings / onboarding. DeepSeek is
|
||||
/// excluded — it is wired exclusively to the local engine.
|
||||
/// Presets the user may pick in Settings / onboarding.
|
||||
public static var userSelectablePresets: [LLMProvider] {
|
||||
presets.filter(\.isUserSelectable)
|
||||
}
|
||||
|
||||
/// Cloud ASR presets (excludes providers without a cloud transcription API).
|
||||
/// Cloud ASR presets (explicit allowlist — polish-only providers excluded).
|
||||
public static var asrSelectablePresets: [LLMProvider] {
|
||||
userSelectablePresets.filter {
|
||||
CloudASRModelCatalog.strategy(for: $0.id) != .localFallback
|
||||
}
|
||||
presets.filter { CloudASRModelCatalog.supportsCloudASRSelection(providerId: $0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
|
||||
configuration.engineMode = engineMode
|
||||
applyEngineModeSideEffects()
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
@@ -179,7 +178,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
/// Which hand the user holds the phone with — mirrors to the keyboard
|
||||
/// extension so delete / return can swap on the bottom row.
|
||||
/// extension so delete / space can swap on the bottom row.
|
||||
@Published public var handednessPreference: HandednessPreference {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration,
|
||||
@@ -218,6 +217,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables provider-specific reasoning / thinking controls when the
|
||||
/// selected polish LLM supports them.
|
||||
@Published public var llmThinkingEnabled: Bool {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration,
|
||||
llmThinkingEnabled != configuration.llmThinkingEnabled else { return }
|
||||
configuration.llmThinkingEnabled = llmThinkingEnabled
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// When enabled, the host app tries to return to the source app after a cold-start handoff.
|
||||
@Published public var flowSkipAppSwitch: Bool {
|
||||
didSet {
|
||||
@@ -335,6 +345,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
handednessPreference = configuration.handednessPreference
|
||||
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
||||
polishIntensity = configuration.polishIntensity
|
||||
llmThinkingEnabled = configuration.llmThinkingEnabled
|
||||
flowSkipAppSwitch = configuration.flowSkipAppSwitch
|
||||
flowInactivityDuration = configuration.flowInactivityDuration
|
||||
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
|
||||
@@ -347,15 +358,34 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
isApplyingConfiguration = false
|
||||
}
|
||||
|
||||
/// Keep cloud vs local provider choices isolated when the user
|
||||
/// switches engines in Settings / onboarding.
|
||||
private func applyEngineModeSideEffects() {
|
||||
if engineMode == "cloud", providerId == "deepseek" {
|
||||
apply(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
if engineMode == "cloud", asrProviderId == "deepseek" {
|
||||
applyAsr(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
public func reset() {
|
||||
isApplyingConfiguration = true
|
||||
let polishPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultPolishProviderId)
|
||||
let asrPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultCloudASRProviderId)
|
||||
providerId = polishPreset.id
|
||||
baseURL = polishPreset.defaultBaseURL
|
||||
apiKey = ""
|
||||
model = polishPreset.defaultModel
|
||||
asrProviderId = asrPreset.id
|
||||
asrBaseURL = asrPreset.defaultBaseURL
|
||||
asrModel = CloudASRModelCatalog.defaultModel(for: asrPreset.id)
|
||||
asrApiKey = ""
|
||||
handednessPreference = .left
|
||||
localASRCustomLanguageModelEnabled = true
|
||||
llmThinkingEnabled = false
|
||||
hasAcknowledgedCloudSharing = false
|
||||
configuration.providerId = polishPreset.id
|
||||
configuration.baseURL = polishPreset.defaultBaseURL
|
||||
configuration.model = polishPreset.defaultModel
|
||||
configuration.asrProviderId = asrPreset.id
|
||||
configuration.asrBaseURL = asrPreset.defaultBaseURL
|
||||
configuration.asrModel = CloudASRModelCatalog.defaultModel(for: asrPreset.id)
|
||||
configuration.handednessPreference = .left
|
||||
configuration.localASRCustomLanguageModelEnabled = true
|
||||
configuration.llmThinkingEnabled = false
|
||||
configuration.hasAcknowledgedCloudSharing = false
|
||||
isApplyingConfiguration = false
|
||||
persistConfiguration()
|
||||
}
|
||||
|
||||
private func persistConfiguration(postConfigChanged: Bool = false) {
|
||||
@@ -401,6 +431,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
handednessPreference = fresh.handednessPreference
|
||||
cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled
|
||||
polishIntensity = fresh.polishIntensity
|
||||
llmThinkingEnabled = fresh.llmThinkingEnabled
|
||||
flowSkipAppSwitch = fresh.flowSkipAppSwitch
|
||||
flowInactivityDuration = fresh.flowInactivityDuration
|
||||
localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled
|
||||
@@ -455,31 +486,4 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
isApplyingConfiguration = false
|
||||
persistConfiguration()
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
isApplyingConfiguration = true
|
||||
let preset = LLMProvider.provider(id: "openai")
|
||||
providerId = preset.id
|
||||
baseURL = preset.defaultBaseURL
|
||||
apiKey = ""
|
||||
model = preset.defaultModel
|
||||
asrProviderId = preset.id
|
||||
asrBaseURL = preset.defaultBaseURL
|
||||
asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
|
||||
asrApiKey = ""
|
||||
handednessPreference = .left
|
||||
localASRCustomLanguageModelEnabled = true
|
||||
hasAcknowledgedCloudSharing = false
|
||||
configuration.providerId = preset.id
|
||||
configuration.baseURL = preset.defaultBaseURL
|
||||
configuration.model = preset.defaultModel
|
||||
configuration.asrProviderId = preset.id
|
||||
configuration.asrBaseURL = preset.defaultBaseURL
|
||||
configuration.asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
|
||||
configuration.handednessPreference = .left
|
||||
configuration.localASRCustomLanguageModelEnabled = true
|
||||
configuration.hasAcknowledgedCloudSharing = false
|
||||
isApplyingConfiguration = false
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,24 @@ public enum ProviderLogo {
|
||||
/// Asset name for the provider's logo, or `nil` when there is no bundled logo.
|
||||
public static func assetName(for providerId: String) -> String? {
|
||||
switch providerId {
|
||||
case "openai": return "openai"
|
||||
case "openai", "whisper": return "openai"
|
||||
case "deepseek": return "deepseek"
|
||||
case "qwen": return "qwen"
|
||||
case "qwen", "bailian", "alibabaCoding": return "qwen"
|
||||
case "moonshot": return "moonshot"
|
||||
case "zhipu": return "zhipu"
|
||||
case "mimo": return "mimo"
|
||||
case "ark", "volcengine": return "ark"
|
||||
case "siliconflow": return "siliconflow"
|
||||
case "groq": return "groq"
|
||||
case "minimax": return "minimax"
|
||||
case "openrouter": return "openrouter"
|
||||
case "gemini": return "gemini"
|
||||
case "anthropic": return "anthropic"
|
||||
case "xai": return "xai"
|
||||
case "mistral": return "mistral"
|
||||
case "cometapi": return "cometapi"
|
||||
case "codingPlanX": return "codingplanx"
|
||||
case "codex_oauth": return "openai"
|
||||
case "apple": return "apple"
|
||||
case "custom": return "custom"
|
||||
default: return nil
|
||||
|
||||
@@ -27,6 +27,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
public var handednessPreference: SyncedField<HandednessPreference>
|
||||
public var cursorDragNavigationEnabled: SyncedField<Bool>
|
||||
public var polishIntensity: SyncedField<PolishIntensity>
|
||||
public var llmThinkingEnabled: SyncedField<Bool>
|
||||
public var flowSkipAppSwitch: SyncedField<Bool>
|
||||
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
|
||||
@@ -47,6 +48,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
handednessPreference: SyncedField<HandednessPreference>,
|
||||
cursorDragNavigationEnabled: SyncedField<Bool>,
|
||||
polishIntensity: SyncedField<PolishIntensity>,
|
||||
llmThinkingEnabled: SyncedField<Bool>,
|
||||
flowSkipAppSwitch: SyncedField<Bool>,
|
||||
flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
) {
|
||||
@@ -66,6 +68,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
self.handednessPreference = handednessPreference
|
||||
self.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
self.polishIntensity = polishIntensity
|
||||
self.llmThinkingEnabled = llmThinkingEnabled
|
||||
self.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
self.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
@@ -87,6 +90,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
case handednessPreference
|
||||
case cursorDragNavigationEnabled
|
||||
case polishIntensity
|
||||
case llmThinkingEnabled
|
||||
case flowSkipAppSwitch
|
||||
case flowInactivityDuration
|
||||
}
|
||||
@@ -115,6 +119,10 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
forKey: .cursorDragNavigationEnabled
|
||||
)
|
||||
polishIntensity = try container.decode(SyncedField<PolishIntensity>.self, forKey: .polishIntensity)
|
||||
llmThinkingEnabled = try container.decodeIfPresent(
|
||||
SyncedField<Bool>.self,
|
||||
forKey: .llmThinkingEnabled
|
||||
) ?? SyncedField(value: false, updatedAt: polishIntensity.updatedAt, deviceID: polishIntensity.deviceID)
|
||||
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
|
||||
flowInactivityDuration = try container.decode(
|
||||
SyncedField<FlowInactivityDuration>.self,
|
||||
@@ -161,6 +169,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
handednessPreference.updatedAt,
|
||||
cursorDragNavigationEnabled.updatedAt,
|
||||
polishIntensity.updatedAt,
|
||||
llmThinkingEnabled.updatedAt,
|
||||
flowSkipAppSwitch.updatedAt,
|
||||
flowInactivityDuration.updatedAt,
|
||||
].max() ?? .distantPast
|
||||
@@ -197,6 +206,7 @@ public extension SyncedAppSettingsV2 {
|
||||
handednessPreference: field(configuration.handednessPreference),
|
||||
cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled),
|
||||
polishIntensity: field(configuration.polishIntensity),
|
||||
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
|
||||
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
|
||||
flowInactivityDuration: field(configuration.flowInactivityDuration)
|
||||
)
|
||||
@@ -225,6 +235,7 @@ public extension SyncedAppSettingsV2 {
|
||||
handednessPreference: field(legacy.handednessPreference),
|
||||
cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled),
|
||||
polishIntensity: field(legacy.polishIntensity),
|
||||
llmThinkingEnabled: field(false),
|
||||
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
|
||||
flowInactivityDuration: field(legacy.flowInactivityDuration)
|
||||
)
|
||||
@@ -256,6 +267,7 @@ public extension SyncedAppSettingsV2 {
|
||||
remote: remote.cursorDragNavigationEnabled
|
||||
),
|
||||
polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity),
|
||||
llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled),
|
||||
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
|
||||
flowInactivityDuration: .merge(
|
||||
local: local.flowInactivityDuration,
|
||||
@@ -280,6 +292,7 @@ public extension SyncedAppSettingsV2 {
|
||||
configuration.handednessPreference = handednessPreference.value
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value
|
||||
configuration.polishIntensity = polishIntensity.value
|
||||
configuration.llmThinkingEnabled = llmThinkingEnabled.value
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
|
||||
configuration.flowInactivityDuration = flowInactivityDuration.value
|
||||
}
|
||||
@@ -306,6 +319,7 @@ public extension SyncedAppSettingsV2 {
|
||||
patch(©.handednessPreference, value: configuration.handednessPreference)
|
||||
patch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||
patch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
@@ -335,6 +349,7 @@ public extension SyncedAppSettingsV2 {
|
||||
touch(©.handednessPreference, value: configuration.handednessPreference)
|
||||
touch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||
touch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
touch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
|
||||
@@ -5,30 +5,78 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Local-calendar day key (`yyyy-MM-dd`) for daily usage buckets. String keys
|
||||
/// sort lexicographically in chronological order, which keeps pruning and
|
||||
/// range queries index-free.
|
||||
public enum UsageStatisticsDayKey {
|
||||
public static func key(for date: Date, calendar: Calendar = .current) -> String {
|
||||
let c = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0)
|
||||
}
|
||||
|
||||
/// Drops buckets older than `days` so the synced blob stays small even
|
||||
/// after months of use (the chart only ever needs the last 7 days).
|
||||
public static func prune(_ daily: inout [String: Int], keepingDays days: Int, now: Date = Date(), calendar: Calendar = .current) {
|
||||
guard let cutoff = calendar.date(byAdding: .day, value: -days, to: now) else { return }
|
||||
let cutoffKey = key(for: cutoff, calendar: calendar)
|
||||
daily = daily.filter { $0.key >= cutoffKey }
|
||||
}
|
||||
}
|
||||
|
||||
public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
|
||||
public var updatedAt: Date
|
||||
public var dictationDurationSeconds: TimeInterval
|
||||
public var dictationCharacterCount: Int
|
||||
public var translationCharacterCount: Int
|
||||
/// Grow-only per-day dictation character counts, keyed by local `yyyy-MM-dd`.
|
||||
/// Powers the home page's 7-day chart; merged per-key with `max` (each device
|
||||
/// only ever grows its own days) and summed across devices when aggregated.
|
||||
public var dailyDictationCharacters: [String: Int]
|
||||
|
||||
public init(
|
||||
updatedAt: Date = Date(),
|
||||
dictationDurationSeconds: TimeInterval = 0,
|
||||
dictationCharacterCount: Int = 0,
|
||||
translationCharacterCount: Int = 0
|
||||
translationCharacterCount: Int = 0,
|
||||
dailyDictationCharacters: [String: Int] = [:]
|
||||
) {
|
||||
self.updatedAt = updatedAt
|
||||
self.dictationDurationSeconds = dictationDurationSeconds
|
||||
self.dictationCharacterCount = dictationCharacterCount
|
||||
self.translationCharacterCount = translationCharacterCount
|
||||
self.dailyDictationCharacters = dailyDictationCharacters
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case updatedAt
|
||||
case dictationDurationSeconds
|
||||
case dictationCharacterCount
|
||||
case translationCharacterCount
|
||||
case dailyDictationCharacters
|
||||
}
|
||||
|
||||
// Custom decode so slices written before the daily-buckets field still load
|
||||
// (the missing key defaults to an empty map rather than failing the decode).
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
dictationDurationSeconds = try container.decode(TimeInterval.self, forKey: .dictationDurationSeconds)
|
||||
dictationCharacterCount = try container.decode(Int.self, forKey: .dictationCharacterCount)
|
||||
translationCharacterCount = try container.decode(Int.self, forKey: .translationCharacterCount)
|
||||
dailyDictationCharacters = try container.decodeIfPresent([String: Int].self, forKey: .dailyDictationCharacters) ?? [:]
|
||||
}
|
||||
|
||||
public static func merge(local: UsageStatisticsDeviceSlice, remote: UsageStatisticsDeviceSlice) -> UsageStatisticsDeviceSlice {
|
||||
UsageStatisticsDeviceSlice(
|
||||
var mergedDaily = local.dailyDictationCharacters
|
||||
for (day, value) in remote.dailyDictationCharacters {
|
||||
mergedDaily[day] = max(mergedDaily[day] ?? 0, value)
|
||||
}
|
||||
return UsageStatisticsDeviceSlice(
|
||||
updatedAt: max(local.updatedAt, remote.updatedAt),
|
||||
dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds),
|
||||
dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount),
|
||||
translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount)
|
||||
translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount),
|
||||
dailyDictationCharacters: mergedDaily
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +123,17 @@ public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
/// Cross-device daily dictation characters (summed per `yyyy-MM-dd`).
|
||||
public var aggregatedDailyDictationCharacters: [String: Int] {
|
||||
var result: [String: Int] = [:]
|
||||
for slice in devices.values {
|
||||
for (day, value) in slice.dailyDictationCharacters {
|
||||
result[day, default: 0] += value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public static func merge(local: SyncedUsageStatisticsV2, remote: SyncedUsageStatisticsV2) -> SyncedUsageStatisticsV2 {
|
||||
var mergedDevices = local.devices
|
||||
for (deviceID, remoteSlice) in remote.devices {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// VolcengineASRFields.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Parse / encode Volcengine SAUC credentials stored in the ASR API key field.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct VolcengineASRFields: Sendable, Equatable {
|
||||
public var appID: String
|
||||
public var accessToken: String
|
||||
public var resourceID: String
|
||||
|
||||
public init(
|
||||
appID: String = "",
|
||||
accessToken: String = "",
|
||||
resourceID: String = CloudASRModelCatalog.defaultModel(for: "volcengine")
|
||||
) {
|
||||
self.appID = appID
|
||||
self.accessToken = accessToken
|
||||
self.resourceID = resourceID
|
||||
}
|
||||
|
||||
public var encodedAPIKey: String {
|
||||
let object = [
|
||||
"app_id": appID,
|
||||
"access_token": accessToken,
|
||||
"resource_id": resourceID,
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: object),
|
||||
let string = String(data: data, encoding: .utf8) else {
|
||||
return [appID, accessToken, resourceID].joined(separator: ":")
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
public static func parse(apiKey: String, resourceFallback: String) -> VolcengineASRFields {
|
||||
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var fields = VolcengineASRFields(
|
||||
appID: "",
|
||||
accessToken: "",
|
||||
resourceID: resourceFallback.isEmpty
|
||||
? CloudASRModelCatalog.defaultModel(for: "volcengine")
|
||||
: resourceFallback
|
||||
)
|
||||
|
||||
if let data = trimmed.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
fields.appID = string(json, keys: ["app_id", "appId", "appid"]) ?? ""
|
||||
fields.accessToken = string(json, keys: ["access_token", "accessToken", "token"]) ?? ""
|
||||
fields.resourceID = string(json, keys: ["resource_id", "resourceId", "resource"]) ?? fields.resourceID
|
||||
return fields
|
||||
}
|
||||
|
||||
let parts = trimmed
|
||||
.components(separatedBy: CharacterSet(charactersIn: ":\n,"))
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if parts.indices.contains(0) { fields.appID = parts[0] }
|
||||
if parts.indices.contains(1) { fields.accessToken = parts[1] }
|
||||
if parts.indices.contains(2) { fields.resourceID = parts[2] }
|
||||
return fields
|
||||
}
|
||||
|
||||
private static func string(_ json: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = json[key] as? String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// AnthropicLLMClient.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Anthropic Messages API client for polish / translation prompts.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AnthropicMessagesClient: LLMClient {
|
||||
public let apiKey: String
|
||||
public let model: String
|
||||
public let session: URLSession
|
||||
public let requestTimeout: TimeInterval = 15
|
||||
|
||||
public init(
|
||||
apiKey: String,
|
||||
model: String,
|
||||
session: URLSession = .shared
|
||||
) {
|
||||
self.apiKey = apiKey
|
||||
self.model = model
|
||||
self.session = session
|
||||
}
|
||||
|
||||
public func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
|
||||
guard !apiKey.isEmpty else { throw LLMError.noAPIKey }
|
||||
|
||||
let url = URL(string: "https://api.anthropic.com/v1/messages")!
|
||||
let body: [String: Any] = [
|
||||
"model": model,
|
||||
"max_tokens": 4_096,
|
||||
"system": systemPrompt,
|
||||
"messages": [
|
||||
["role": "user", "content": text],
|
||||
],
|
||||
]
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
|
||||
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
|
||||
request.timeoutInterval = timeout ?? requestTimeout
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw LLMError.transport("non-HTTP response")
|
||||
}
|
||||
if !(200..<300).contains(http.statusCode) {
|
||||
if http.statusCode == 429 { throw LLMError.rateLimited }
|
||||
throw LLMError.http(status: http.statusCode)
|
||||
}
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let content = json["content"] as? [[String: Any]],
|
||||
let first = content.first,
|
||||
let textBlock = first["text"] as? String else {
|
||||
throw LLMError.decoding("anthropic content")
|
||||
}
|
||||
return textBlock.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} catch let err as LLMError {
|
||||
throw err
|
||||
} catch is CancellationError {
|
||||
throw LLMError.cancelled
|
||||
} catch {
|
||||
throw LLMError.transport(String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
public var handednessPreference: HandednessPreference { configuration.handednessPreference }
|
||||
public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled }
|
||||
public var polishIntensity: PolishIntensity { configuration.polishIntensity }
|
||||
public var llmThinkingEnabled: Bool { configuration.llmThinkingEnabled }
|
||||
public var isTranslationEffective: Bool { configuration.isTranslationEffective }
|
||||
public var isLocalEngine: Bool { configuration.isLocalEngine }
|
||||
public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline }
|
||||
@@ -87,21 +88,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
}
|
||||
|
||||
public func setEngineMode(_ mode: String) {
|
||||
mutateConfiguration { config in
|
||||
config.engineMode = mode
|
||||
if mode == "cloud", config.providerId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.providerId = openAI.id
|
||||
config.baseURL = openAI.defaultBaseURL
|
||||
config.model = openAI.defaultModel
|
||||
}
|
||||
if mode == "cloud", config.asrProviderId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.asrProviderId = openAI.id
|
||||
config.asrBaseURL = openAI.defaultBaseURL
|
||||
config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id)
|
||||
}
|
||||
}
|
||||
mutateConfiguration { $0.engineMode = mode }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
@@ -134,6 +121,11 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
mutateConfiguration { $0.polishIntensity = intensity }
|
||||
}
|
||||
|
||||
public func setLLMThinkingEnabled(_ enabled: Bool) {
|
||||
mutateConfiguration { $0.llmThinkingEnabled = enabled }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) {
|
||||
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
// BailianRealtimeASRClient.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Alibaba Cloud Bailian / DashScope realtime ASR over the classic inference
|
||||
// WebSocket (`/api-ws/v1/inference`). Matches OpenLess' `bailian.rs` wire
|
||||
// protocol: run-task → PCM binary frames → finish-task → result events.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct BailianRealtimeASRClient: CloudASRTranscribing {
|
||||
let apiKey: String
|
||||
let endpoint: String
|
||||
let model: String
|
||||
let vocabularyID: String?
|
||||
let session: URLSession
|
||||
|
||||
/// 100 ms of 16 kHz / 16-bit / mono PCM.
|
||||
private static let targetChunkBytes = 3_200
|
||||
private static let startTimeout: TimeInterval = 8
|
||||
private static let finalTimeout: TimeInterval = 12
|
||||
private static let sessionTimeout: TimeInterval = startTimeout + finalTimeout + 4
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {}
|
||||
|
||||
func transcribe(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
locale: Locale,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
|
||||
guard sampleRate == 16_000 else {
|
||||
throw CloudASRError.transport("Bailian realtime expects 16 kHz audio")
|
||||
}
|
||||
guard !samples.isEmpty else { throw CloudASRError.emptyTranscript }
|
||||
|
||||
let url = try resolvedEndpointURL()
|
||||
let pcm = Self.pcm16Data(samples: samples)
|
||||
let taskID = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
let resolvedModel = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? CloudASRModelCatalog.alibabaFunASRRealtime
|
||||
: model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 8
|
||||
request.setValue(
|
||||
"bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))",
|
||||
forHTTPHeaderField: "Authorization"
|
||||
)
|
||||
|
||||
let wsTask = session.webSocketTask(with: request)
|
||||
wsTask.resume()
|
||||
|
||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
||||
let events = BailianEventStream(task: wsTask)
|
||||
|
||||
group.addTask {
|
||||
defer { events.cancel() }
|
||||
return try await Self.runSession(
|
||||
taskID: taskID,
|
||||
model: resolvedModel,
|
||||
pcm: pcm,
|
||||
wsTask: wsTask,
|
||||
events: events
|
||||
)
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(Self.sessionTimeout * 1_000_000_000))
|
||||
events.cancel()
|
||||
wsTask.cancel(with: .goingAway, reason: nil)
|
||||
throw CloudASRError.transport("session timed out")
|
||||
}
|
||||
|
||||
guard let result = try await group.next() else {
|
||||
throw CloudASRError.emptyTranscript
|
||||
}
|
||||
group.cancelAll()
|
||||
return result.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings connection probe: handshake to `task-started` only.
|
||||
///
|
||||
/// Reaching `task-started` proves endpoint + `Authorization` + model are
|
||||
/// all valid — which is exactly what "validate connection" must check.
|
||||
/// It deliberately sends NO audio: DashScope realtime rejects a short
|
||||
/// silent probe with a `task-failed: emptyAudio`, which is a false
|
||||
/// negative for a connectivity test. A real auth/quota/model failure
|
||||
/// still arrives as `task-failed` before `task-started` and surfaces.
|
||||
func probeConnection() async throws {
|
||||
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
|
||||
|
||||
let url = try resolvedEndpointURL()
|
||||
let taskID = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
let resolvedModel = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? CloudASRModelCatalog.alibabaFunASRRealtime
|
||||
: model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 8
|
||||
request.setValue(
|
||||
"bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))",
|
||||
forHTTPHeaderField: "Authorization"
|
||||
)
|
||||
|
||||
let wsTask = session.webSocketTask(with: request)
|
||||
wsTask.resume()
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
let events = BailianEventStream(task: wsTask)
|
||||
|
||||
group.addTask {
|
||||
defer { events.cancel() }
|
||||
try await Self.sendText(
|
||||
Self.runTaskMessage(taskID: taskID, model: resolvedModel, vocabularyID: nil),
|
||||
task: wsTask
|
||||
)
|
||||
try await events.waitForStarted(timeout: Self.startTimeout)
|
||||
// Politely end the task; the connection is already proven.
|
||||
try? await Self.sendText(Self.finishTaskMessage(taskID: taskID), task: wsTask)
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(Self.startTimeout * 1_000_000_000))
|
||||
events.cancel()
|
||||
wsTask.cancel(with: .goingAway, reason: nil)
|
||||
throw CloudASRError.transport("connection probe timed out")
|
||||
}
|
||||
|
||||
_ = try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
private static func runSession(
|
||||
taskID: String,
|
||||
model: String,
|
||||
pcm: Data,
|
||||
wsTask: URLSessionWebSocketTask,
|
||||
events: BailianEventStream
|
||||
) async throws -> String {
|
||||
try await sendText(
|
||||
runTaskMessage(taskID: taskID, model: model, vocabularyID: nil),
|
||||
task: wsTask
|
||||
)
|
||||
|
||||
try await events.waitForStarted(timeout: startTimeout)
|
||||
|
||||
var offset = 0
|
||||
while offset < pcm.count {
|
||||
let end = min(offset + targetChunkBytes, pcm.count)
|
||||
try await sendBinary(pcm.subdata(in: offset..<end), task: wsTask)
|
||||
offset = end
|
||||
}
|
||||
|
||||
// Let the server register the final frames before ending the task.
|
||||
// Sending `finish-task` in the same instant as the last binary frame
|
||||
// races the server's audio buffering (root cause of `emptyAudio` on
|
||||
// very short clips).
|
||||
try? await Task.sleep(nanoseconds: 120_000_000)
|
||||
|
||||
try await sendText(finishTaskMessage(taskID: taskID), task: wsTask)
|
||||
return try await events.waitForFinalText(timeout: finalTimeout)
|
||||
}
|
||||
|
||||
private func resolvedEndpointURL() throws -> URL {
|
||||
let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? CloudASRModelCatalog.bailianDefaultEndpoint
|
||||
: endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: raw) else { throw CloudASRError.invalidURL }
|
||||
return url
|
||||
}
|
||||
|
||||
private static func sendText(_ text: String, task: URLSessionWebSocketTask) async throws {
|
||||
do {
|
||||
try await task.send(.string(text))
|
||||
} catch {
|
||||
throw CloudASRError.transport(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func sendBinary(_ data: Data, task: URLSessionWebSocketTask) async throws {
|
||||
do {
|
||||
try await task.send(.data(data))
|
||||
} catch {
|
||||
throw CloudASRError.transport(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func pcm16Data(samples: [Float]) -> Data {
|
||||
var data = Data()
|
||||
data.reserveCapacity(samples.count * 2)
|
||||
for sample in samples {
|
||||
let scaled = sample * 32_767.0
|
||||
let clipped = Swift.max(-32_768.0, Swift.min(32_767.0, scaled))
|
||||
var littleEndian = Int16(clipped.rounded()).littleEndian
|
||||
withUnsafeBytes(of: &littleEndian) { data.append(contentsOf: $0) }
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// Overlap-aware join to avoid cumulative duplicate text from interim replays.
|
||||
static func mergeSegments(_ segments: [String]) -> String {
|
||||
var result = ""
|
||||
for segment in segments {
|
||||
if result.isEmpty {
|
||||
result = segment
|
||||
continue
|
||||
}
|
||||
let resultChars = Array(result)
|
||||
let segmentChars = Array(segment)
|
||||
let maxOverlap = min(resultChars.count, segmentChars.count)
|
||||
var overlap = 0
|
||||
if maxOverlap >= 2 {
|
||||
for length in stride(from: maxOverlap, through: 2, by: -1) {
|
||||
let tail = resultChars.suffix(length)
|
||||
let head = segmentChars.prefix(length)
|
||||
if tail.elementsEqual(head) {
|
||||
overlap = length
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result.append(contentsOf: segmentChars.dropFirst(overlap))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static func runTaskMessage(taskID: String, model: String, vocabularyID: String?) -> String {
|
||||
var parameters: [String: Any] = [
|
||||
"sample_rate": 16_000,
|
||||
"format": "pcm",
|
||||
]
|
||||
if let vocabularyID = vocabularyID?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!vocabularyID.isEmpty {
|
||||
parameters["vocabulary_id"] = vocabularyID
|
||||
}
|
||||
let body: [String: Any] = [
|
||||
"header": [
|
||||
"action": "run-task",
|
||||
"task_id": taskID,
|
||||
"streaming": "duplex",
|
||||
],
|
||||
"payload": [
|
||||
"task_group": "audio",
|
||||
"task": "asr",
|
||||
"function": "recognition",
|
||||
"model": model,
|
||||
"parameters": parameters,
|
||||
"input": [:] as [String: Any],
|
||||
],
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: body),
|
||||
let json = String(data: data, encoding: .utf8) else {
|
||||
return "{}"
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
static func finishTaskMessage(taskID: String) -> String {
|
||||
let body: [String: Any] = [
|
||||
"header": [
|
||||
"action": "finish-task",
|
||||
"task_id": taskID,
|
||||
"streaming": "duplex",
|
||||
],
|
||||
"payload": ["input": [:] as [String: Any]],
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: body),
|
||||
let json = String(data: data, encoding: .utf8) else {
|
||||
return "{}"
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Concurrent read loop
|
||||
|
||||
private final class BailianEventStream: @unchecked Sendable {
|
||||
private let task: URLSessionWebSocketTask
|
||||
private let lock = NSLock()
|
||||
private var started = false
|
||||
private var finalText: String?
|
||||
private var failure: Error?
|
||||
private var readTask: Task<Void, Never>?
|
||||
|
||||
init(task: URLSessionWebSocketTask) {
|
||||
self.task = task
|
||||
readTask = Task { [weak self] in
|
||||
await self?.readLoop()
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
readTask?.cancel()
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
|
||||
func waitForStarted(timeout: TimeInterval) async throws {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let failure = snapshotFailure() { throw failure }
|
||||
if snapshotStarted() { return }
|
||||
try await Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
cancel()
|
||||
throw CloudASRError.transport("task-started timed out")
|
||||
}
|
||||
|
||||
func waitForFinalText(timeout: TimeInterval) async throws -> String {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let failure = snapshotFailure() { throw failure }
|
||||
if let text = snapshotFinalText() { return text }
|
||||
try await Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
cancel()
|
||||
throw CloudASRError.transport("final result timed out")
|
||||
}
|
||||
|
||||
private func snapshotStarted() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return started
|
||||
}
|
||||
|
||||
private func snapshotFinalText() -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return finalText
|
||||
}
|
||||
|
||||
private func snapshotFailure() -> Error? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return failure
|
||||
}
|
||||
|
||||
private func readLoop() async {
|
||||
var finalSegments: [Int64: String] = [:]
|
||||
var partialSegments: [Int64: String] = [:]
|
||||
var lastResultText = ""
|
||||
|
||||
while !Task.isCancelled {
|
||||
let message: URLSessionWebSocketTask.Message
|
||||
do {
|
||||
message = try await task.receive()
|
||||
} catch {
|
||||
publishFailure(CloudASRError.transport(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
|
||||
let text: String
|
||||
switch message {
|
||||
case .string(let value):
|
||||
text = value
|
||||
case .data(let data):
|
||||
text = String(data: data, encoding: .utf8) ?? ""
|
||||
@unknown default:
|
||||
continue
|
||||
}
|
||||
guard !text.isEmpty else { continue }
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any],
|
||||
let header = json["header"] as? [String: Any] else {
|
||||
continue
|
||||
}
|
||||
let event = header["event"] as? String ?? ""
|
||||
|
||||
switch event {
|
||||
case "task-started":
|
||||
publishStarted()
|
||||
case "result-generated":
|
||||
guard let payload = json["payload"] as? [String: Any],
|
||||
let output = payload["output"] as? [String: Any],
|
||||
let sentenceObj = output["sentence"] as? [String: Any] else {
|
||||
continue
|
||||
}
|
||||
if sentenceObj["heartbeat"] as? Bool == true { continue }
|
||||
guard let rawText = sentenceObj["text"] as? String else { continue }
|
||||
let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
|
||||
lastResultText = trimmed
|
||||
let sentenceID = sentenceObj["sentence_id"] as? Int64 ?? 0
|
||||
let sentenceEndValue = sentenceObj["sentence_end"]
|
||||
let sentenceEnd = sentenceEndValue as? Bool ?? false
|
||||
let endTime = sentenceObj["end_time"] as? Int64 ?? 0
|
||||
let isFinal = sentenceEndValue != nil ? sentenceEnd : endTime > 0
|
||||
|
||||
if isFinal {
|
||||
finalSegments[sentenceID] = trimmed
|
||||
partialSegments.removeValue(forKey: sentenceID)
|
||||
} else {
|
||||
partialSegments[sentenceID] = trimmed
|
||||
}
|
||||
case "task-finished":
|
||||
if finalSegments.isEmpty {
|
||||
publishFinal(lastResultText)
|
||||
} else {
|
||||
let ordered = finalSegments.keys.sorted().compactMap { finalSegments[$0] }
|
||||
publishFinal(BailianRealtimeASRClient.mergeSegments(ordered))
|
||||
}
|
||||
return
|
||||
case "task-failed":
|
||||
let message = header["error_message"] as? String ?? "task failed"
|
||||
publishFailure(CloudASRError.transport(message))
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func publishStarted() {
|
||||
lock.lock()
|
||||
started = true
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private func publishFinal(_ text: String) {
|
||||
lock.lock()
|
||||
finalText = text
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private func publishFailure(_ error: Error) {
|
||||
lock.lock()
|
||||
failure = error
|
||||
lock.unlock()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,28 @@ public protocol CloudASRTranscribing: Sendable {
|
||||
locale: Locale,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String
|
||||
|
||||
/// Settings "validate connection" probe. Verifies transport + auth only.
|
||||
func probeConnection() async throws
|
||||
}
|
||||
|
||||
extension CloudASRTranscribing {
|
||||
/// Default probe: transcribe ~1 s of near-silence. An empty transcript
|
||||
/// counts as success — HTTP/streaming providers only need to prove that
|
||||
/// transport + auth work. Providers whose service rejects silent/short
|
||||
/// audio (e.g. DashScope realtime returns `emptyAudio`) override this.
|
||||
public func probeConnection() async throws {
|
||||
do {
|
||||
_ = try await transcribe(
|
||||
samples: [Float](repeating: 0.01, count: 16_000),
|
||||
sampleRate: 16_000,
|
||||
locale: Locale(identifier: "zh-CN"),
|
||||
dictionary: .empty
|
||||
)
|
||||
} catch CloudASRError.emptyTranscript {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum CloudASRClientFactory {
|
||||
@@ -29,11 +51,12 @@ public enum CloudASRClientFactory {
|
||||
model: asrModel,
|
||||
session: session
|
||||
)
|
||||
case .alibabaVocabulary:
|
||||
return AlibabaFunASRClient(
|
||||
case .bailianStreaming:
|
||||
return BailianRealtimeASRClient(
|
||||
apiKey: store.asrApiKey,
|
||||
endpoint: store.asrBaseURL,
|
||||
model: asrModel,
|
||||
persistence: store.cloudASRPersistence,
|
||||
vocabularyID: nil,
|
||||
session: session
|
||||
)
|
||||
case .prompt:
|
||||
@@ -44,6 +67,22 @@ public enum CloudASRClientFactory {
|
||||
model: asrModel,
|
||||
session: session
|
||||
)
|
||||
case .openRouterJson:
|
||||
return PromptCloudASRClient(
|
||||
providerId: providerId,
|
||||
baseURL: store.asrBaseURL,
|
||||
apiKey: store.asrApiKey,
|
||||
model: asrModel,
|
||||
session: session,
|
||||
requestFormat: .openRouterJson
|
||||
)
|
||||
case .volcengineStreaming:
|
||||
return VolcengineCloudASRClient(
|
||||
apiKey: store.asrApiKey,
|
||||
endpoint: store.asrBaseURL,
|
||||
resourceID: asrModel,
|
||||
session: session
|
||||
)
|
||||
case .localFallback:
|
||||
return UnsupportedCloudASRClient(providerId: providerId)
|
||||
}
|
||||
@@ -151,25 +190,14 @@ struct ZhipuCloudASRClient: CloudASRTranscribing {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alibaba Fun-ASR Flash (vocabulary_id + context)
|
||||
// MARK: - Alibaba Fun-ASR Flash (HTTP sync, context text bias)
|
||||
|
||||
/// `UserDefaults` is not `Sendable`; we only touch `persistence` on the
|
||||
/// actor-isolated cloud ASR path, same as the previous `AppGroupStore` holder.
|
||||
struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
|
||||
struct AlibabaFunASRClient: CloudASRTranscribing {
|
||||
let apiKey: String
|
||||
let model: String
|
||||
let persistence: UserDefaults
|
||||
let session: URLSession
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {
|
||||
_ = try await AlibabaVocabularySync.ensureVocabularyID(
|
||||
dictionary: dictionary,
|
||||
apiKey: apiKey,
|
||||
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
|
||||
defaults: persistence,
|
||||
session: session
|
||||
)
|
||||
}
|
||||
func prepare(dictionary: PersonalDictionary) async throws {}
|
||||
|
||||
func transcribe(
|
||||
samples: [Float],
|
||||
@@ -179,14 +207,6 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
|
||||
) async throws -> String {
|
||||
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
|
||||
|
||||
let vocabularyID = try await AlibabaVocabularySync.ensureVocabularyID(
|
||||
dictionary: dictionary,
|
||||
apiKey: apiKey,
|
||||
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
|
||||
defaults: persistence,
|
||||
session: session
|
||||
)
|
||||
|
||||
let dataURI = PCMSampleWavEncoder.dataURI(samples: samples, sampleRate: sampleRate)
|
||||
let urlString = CloudASRModelCatalog.alibabaAPIBase + CloudASRModelCatalog.alibabaMultimodalPath
|
||||
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
|
||||
@@ -211,13 +231,10 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
|
||||
],
|
||||
])
|
||||
|
||||
var parameters: [String: Any] = [
|
||||
let parameters: [String: Any] = [
|
||||
"format": "wav",
|
||||
"sample_rate": "\(sampleRate)",
|
||||
]
|
||||
if let vocabularyID, !vocabularyID.isEmpty {
|
||||
parameters["vocabulary_id"] = vocabularyID
|
||||
}
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": model,
|
||||
@@ -235,11 +252,11 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
|
||||
guard let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty else {
|
||||
throw CloudASRError.emptyTranscript
|
||||
if let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
return text
|
||||
}
|
||||
return text
|
||||
return ""
|
||||
}
|
||||
|
||||
private static func parseText(from data: Data) -> String? {
|
||||
@@ -256,7 +273,13 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prompt-biased transcription (OpenAI / MiMo / custom)
|
||||
// MARK: - Prompt-biased transcription (OpenAI / MiMo / Groq / custom)
|
||||
|
||||
enum PromptCloudASRRequestFormat: Sendable {
|
||||
case multipart
|
||||
/// OpenRouter expects JSON `{ model, input_audio: { data, format } }`.
|
||||
case openRouterJson
|
||||
}
|
||||
|
||||
struct PromptCloudASRClient: CloudASRTranscribing {
|
||||
let providerId: String
|
||||
@@ -264,6 +287,26 @@ struct PromptCloudASRClient: CloudASRTranscribing {
|
||||
let apiKey: String
|
||||
let model: String
|
||||
let session: URLSession
|
||||
var requestFormat: PromptCloudASRRequestFormat = .multipart
|
||||
|
||||
/// Groq / OpenRouter batch uploads cap around 30 s per request.
|
||||
private static let whisperCompatibleMaxDurationSeconds: TimeInterval = 30
|
||||
|
||||
init(
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
model: String,
|
||||
session: URLSession,
|
||||
requestFormat: PromptCloudASRRequestFormat = .multipart
|
||||
) {
|
||||
self.providerId = providerId
|
||||
self.baseURL = baseURL
|
||||
self.apiKey = apiKey
|
||||
self.model = model
|
||||
self.session = session
|
||||
self.requestFormat = requestFormat
|
||||
}
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {}
|
||||
|
||||
@@ -281,6 +324,13 @@ struct PromptCloudASRClient: CloudASRTranscribing {
|
||||
dictionary: dictionary
|
||||
)
|
||||
}
|
||||
if requestFormat == .openRouterJson {
|
||||
return try await transcribeOpenRouterJSON(
|
||||
samples: samples,
|
||||
sampleRate: sampleRate,
|
||||
dictionary: dictionary
|
||||
)
|
||||
}
|
||||
return try await transcribeOpenAIStyle(
|
||||
samples: samples,
|
||||
sampleRate: sampleRate,
|
||||
@@ -288,11 +338,21 @@ struct PromptCloudASRClient: CloudASRTranscribing {
|
||||
)
|
||||
}
|
||||
|
||||
private func enforceWhisperDuration(samples: [Float], sampleRate: Int) throws {
|
||||
let duration = Double(samples.count) / Double(sampleRate)
|
||||
guard duration <= Self.whisperCompatibleMaxDurationSeconds else {
|
||||
throw CloudASRError.audioTooLong
|
||||
}
|
||||
}
|
||||
|
||||
private func transcribeOpenAIStyle(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
if providerId == "groq" || providerId == "openai" || providerId == "custom" {
|
||||
try enforceWhisperDuration(samples: samples, sampleRate: sampleRate)
|
||||
}
|
||||
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
|
||||
let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
|
||||
let urlString = "\(trimmedBase)/audio/transcriptions"
|
||||
@@ -335,6 +395,45 @@ struct PromptCloudASRClient: CloudASRTranscribing {
|
||||
return text
|
||||
}
|
||||
|
||||
private func transcribeOpenRouterJSON(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
try enforceWhisperDuration(samples: samples, sampleRate: sampleRate)
|
||||
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
|
||||
let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
|
||||
let urlString = "\(trimmedBase)/audio/transcriptions"
|
||||
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
|
||||
|
||||
var body: [String: Any] = [
|
||||
"model": model,
|
||||
"input_audio": [
|
||||
"data": wav.base64EncodedString(),
|
||||
"format": "wav",
|
||||
],
|
||||
]
|
||||
let prompt = dictionary.asrPromptBias(maxCharacters: 600)
|
||||
if !prompt.isEmpty {
|
||||
body["prompt"] = prompt
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
request.timeoutInterval = 90
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
|
||||
guard let text = Self.parseOpenAIText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty else {
|
||||
throw CloudASRError.emptyTranscript
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private func transcribeMiMo(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// CloudASRConnectionCheck.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Settings "validate connection" probe shared by iOS and macOS.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum CloudASRConnectionCheck {
|
||||
/// Verifies the active cloud ASR client can connect + authenticate.
|
||||
///
|
||||
/// Each backend decides how to probe (see `CloudASRTranscribing`):
|
||||
/// HTTP/batch providers transcribe a short silence clip and treat an
|
||||
/// empty transcript as success; DashScope realtime only handshakes to
|
||||
/// `task-started` (pushing fake audio makes it fail with `emptyAudio`).
|
||||
public static func validate(store: any ConfigurationStore) async throws {
|
||||
let client = CloudASRClientFactory.make(store: store)
|
||||
try await client.probeConnection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// VolcengineCloudASRClient.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Volcengine SAUC bigmodel ASR client. The service uses a WebSocket with a
|
||||
// small custom binary frame wrapper; this file keeps that protocol isolated
|
||||
// from the HTTP-style cloud ASR clients.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct VolcengineCloudASRClient: CloudASRTranscribing {
|
||||
let apiKey: String
|
||||
let endpoint: String
|
||||
let resourceID: String
|
||||
let session: URLSession
|
||||
|
||||
private static let targetChunkBytes = 6_400 // 200 ms @ 16 kHz, 16-bit, mono.
|
||||
private static let finalTimeout: TimeInterval = 12
|
||||
private static let hotwordCap = 80
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {}
|
||||
|
||||
func transcribe(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
locale: Locale,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
guard !samples.isEmpty else { throw CloudASRError.emptyTranscript }
|
||||
let credentials = try VolcengineCredentials.parse(
|
||||
apiKey: apiKey,
|
||||
fallbackResourceID: resolvedResourceID
|
||||
)
|
||||
let url = try resolvedEndpointURL()
|
||||
let pcm = Self.pcm16Data(samples: samples)
|
||||
let connectID = UUID().uuidString
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 8
|
||||
request.setValue(credentials.appID, forHTTPHeaderField: "X-Api-App-Key")
|
||||
request.setValue(credentials.accessToken, forHTTPHeaderField: "X-Api-Access-Key")
|
||||
request.setValue(credentials.resourceID, forHTTPHeaderField: "X-Api-Resource-Id")
|
||||
request.setValue(connectID, forHTTPHeaderField: "X-Api-Connect-Id")
|
||||
|
||||
let task = session.webSocketTask(with: request)
|
||||
task.resume()
|
||||
defer {
|
||||
task.cancel(with: .normalClosure, reason: nil)
|
||||
}
|
||||
|
||||
let firstPayload = try Self.firstFramePayload(connectID: connectID, dictionary: dictionary)
|
||||
try await send(
|
||||
VolcengineFrame.build(
|
||||
messageType: .fullClientRequest,
|
||||
flags: .positiveSequence,
|
||||
serialization: .json,
|
||||
payload: firstPayload,
|
||||
sequence: 1
|
||||
),
|
||||
task: task
|
||||
)
|
||||
|
||||
var sequence = 2
|
||||
var offset = 0
|
||||
while offset < pcm.count {
|
||||
let end = min(offset + Self.targetChunkBytes, pcm.count)
|
||||
try await send(
|
||||
VolcengineFrame.build(
|
||||
messageType: .audioOnlyRequest,
|
||||
flags: .positiveSequence,
|
||||
serialization: .none,
|
||||
payload: pcm.subdata(in: offset..<end),
|
||||
sequence: Int32(sequence)
|
||||
),
|
||||
task: task
|
||||
)
|
||||
sequence += 1
|
||||
offset = end
|
||||
}
|
||||
|
||||
try await send(
|
||||
VolcengineFrame.build(
|
||||
messageType: .audioOnlyRequest,
|
||||
flags: .negativeSequence,
|
||||
serialization: .none,
|
||||
payload: Data(),
|
||||
sequence: -Int32(sequence)
|
||||
),
|
||||
task: task
|
||||
)
|
||||
|
||||
let text = try await receiveFinalText(task: task)
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw CloudASRError.emptyTranscript }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private var resolvedResourceID: String {
|
||||
resourceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? CloudASRModelCatalog.volcengineDefaultResourceID
|
||||
: resourceID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func resolvedEndpointURL() throws -> URL {
|
||||
let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? CloudASRModelCatalog.volcengineEndpoint
|
||||
: endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: raw) else { throw CloudASRError.invalidURL }
|
||||
return url
|
||||
}
|
||||
|
||||
private func send(_ data: Data, task: URLSessionWebSocketTask) async throws {
|
||||
do {
|
||||
try await task.send(.data(data))
|
||||
} catch {
|
||||
throw CloudASRError.transport(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveFinalText(task: URLSessionWebSocketTask) async throws -> String {
|
||||
try await withThrowingTaskGroup(of: String.self) { group in
|
||||
group.addTask {
|
||||
var lastPartial = ""
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
let data: Data
|
||||
switch message {
|
||||
case .data(let payload):
|
||||
data = payload
|
||||
case .string(let string):
|
||||
data = Data(string.utf8)
|
||||
@unknown default:
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
throw CloudASRError.transport("ASR error \(code): \(body)")
|
||||
}
|
||||
guard frame.messageType == .fullServerResponse else { continue }
|
||||
let parsedText = Self.text(from: frame.payload)
|
||||
if !parsedText.isEmpty {
|
||||
lastPartial = parsedText
|
||||
}
|
||||
if frame.isFinal {
|
||||
return parsedText.isEmpty ? lastPartial : parsedText
|
||||
}
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(Self.finalTimeout * 1_000_000_000))
|
||||
throw CloudASRError.transport("Volcengine final result timed out")
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private static func firstFramePayload(
|
||||
connectID: String,
|
||||
dictionary: PersonalDictionary
|
||||
) throws -> Data {
|
||||
var request: [String: Any] = [
|
||||
"model_name": "bigmodel",
|
||||
"enable_itn": true,
|
||||
"enable_punc": true,
|
||||
"show_utterances": true,
|
||||
"enable_speaker_info": true,
|
||||
]
|
||||
if let context = hotwordContext(dictionary: dictionary) {
|
||||
request["context"] = context
|
||||
}
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"user": ["uid": connectID],
|
||||
"audio": [
|
||||
"format": "pcm",
|
||||
"rate": 16_000,
|
||||
"bits": 16,
|
||||
"channel": 1,
|
||||
"codec": "raw",
|
||||
],
|
||||
"request": request,
|
||||
]
|
||||
return try JSONSerialization.data(withJSONObject: payload)
|
||||
}
|
||||
|
||||
private static func hotwordContext(dictionary: PersonalDictionary) -> String? {
|
||||
var seen: [String] = []
|
||||
for word in dictionary.asrHotwords() {
|
||||
let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
guard !seen.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) else {
|
||||
continue
|
||||
}
|
||||
seen.append(trimmed)
|
||||
if seen.count >= hotwordCap { break }
|
||||
}
|
||||
guard !seen.isEmpty else { return nil }
|
||||
let words = seen.map { ["word": $0] }
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: ["hotwords": words]) else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func pcm16Data(samples: [Float]) -> Data {
|
||||
var data = Data()
|
||||
data.reserveCapacity(samples.count * 2)
|
||||
for sample in samples {
|
||||
let scaled = sample * 32_767.0
|
||||
let clipped = Swift.max(-32_768.0, Swift.min(32_767.0, scaled))
|
||||
var littleEndian = Int16(clipped.rounded()).littleEndian
|
||||
withUnsafeBytes(of: &littleEndian) { data.append(contentsOf: $0) }
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private static func text(from payload: Data) -> String {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
||||
let result = normalizedResult(from: json) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
if let utterances = result["utterances"] as? [[String: Any]], !utterances.isEmpty {
|
||||
let pieces = utterances.compactMap { $0["text"] as? String }
|
||||
let joined = pieces.joined()
|
||||
if !joined.isEmpty { return joined }
|
||||
}
|
||||
return result["text"] as? String ?? ""
|
||||
}
|
||||
|
||||
private static func normalizedResult(from json: [String: Any]) -> [String: Any]? {
|
||||
if let result = json["result"] as? [String: Any] {
|
||||
return result
|
||||
}
|
||||
if let results = json["result"] as? [[String: Any]] {
|
||||
return results.first
|
||||
}
|
||||
if json["text"] as? String != nil {
|
||||
return json
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct VolcengineCredentials {
|
||||
let appID: String
|
||||
let accessToken: String
|
||||
let resourceID: String
|
||||
|
||||
static func parse(apiKey: String, fallbackResourceID: String) throws -> VolcengineCredentials {
|
||||
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw CloudASRError.noAPIKey }
|
||||
|
||||
if let data = trimmed.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
let appID = string(json, keys: ["app_id", "appId", "appid"])
|
||||
let token = string(json, keys: ["access_token", "accessToken", "token"])
|
||||
let resourceID = string(json, keys: ["resource_id", "resourceId", "resource"])
|
||||
?? fallbackResourceID
|
||||
guard let appID, let token, !resourceID.isEmpty else { throw CloudASRError.noAPIKey }
|
||||
return VolcengineCredentials(appID: appID, accessToken: token, resourceID: resourceID)
|
||||
}
|
||||
|
||||
let separators = CharacterSet(charactersIn: ":\n,")
|
||||
let parts = trimmed
|
||||
.components(separatedBy: separators)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard parts.count >= 2 else { throw CloudASRError.noAPIKey }
|
||||
let resourceID = parts.count >= 3 ? parts[2] : fallbackResourceID
|
||||
return VolcengineCredentials(appID: parts[0], accessToken: parts[1], resourceID: resourceID)
|
||||
}
|
||||
|
||||
private static func string(_ json: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = json[key] as? String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private enum VolcengineMessageType: UInt8 {
|
||||
case fullClientRequest = 0b0001
|
||||
case audioOnlyRequest = 0b0010
|
||||
case fullServerResponse = 0b1001
|
||||
case errorMessage = 0b1111
|
||||
}
|
||||
|
||||
private enum VolcengineFlags: UInt8 {
|
||||
case none = 0b0000
|
||||
case positiveSequence = 0b0001
|
||||
case lastPacket = 0b0010
|
||||
case negativeSequence = 0b0011
|
||||
}
|
||||
|
||||
private enum VolcengineSerialization: UInt8 {
|
||||
case none = 0b0000
|
||||
case json = 0b0001
|
||||
}
|
||||
|
||||
private struct VolcengineFrame {
|
||||
let messageType: VolcengineMessageType?
|
||||
let flags: UInt8
|
||||
let sequence: Int32?
|
||||
let errorCode: UInt32?
|
||||
let payload: Data
|
||||
|
||||
var isFinal: Bool {
|
||||
flags == VolcengineFlags.lastPacket.rawValue
|
||||
|| flags == VolcengineFlags.negativeSequence.rawValue
|
||||
|| (sequence ?? 0) < 0
|
||||
}
|
||||
|
||||
static func build(
|
||||
messageType: VolcengineMessageType,
|
||||
flags: VolcengineFlags,
|
||||
serialization: VolcengineSerialization,
|
||||
payload: Data,
|
||||
sequence: Int32?
|
||||
) -> Data {
|
||||
var data = Data()
|
||||
data.append(0x11)
|
||||
data.append((messageType.rawValue << 4) | flags.rawValue)
|
||||
data.append(serialization.rawValue << 4)
|
||||
data.append(0x00)
|
||||
|
||||
if flags == .positiveSequence || flags == .negativeSequence, let sequence {
|
||||
data.appendBE32(UInt32(bitPattern: sequence))
|
||||
}
|
||||
data.appendBE32(UInt32(payload.count))
|
||||
data.append(payload)
|
||||
return data
|
||||
}
|
||||
|
||||
static func parse(_ data: Data) -> VolcengineFrame? {
|
||||
guard data.count >= 8 else { return nil }
|
||||
let bytes = [UInt8](data)
|
||||
let headerSize = Int(bytes[0] & 0x0F) * 4
|
||||
guard headerSize >= 4, data.count >= headerSize + 4 else { return nil }
|
||||
|
||||
let typeRaw = (bytes[1] >> 4) & 0x0F
|
||||
let messageType = VolcengineMessageType(rawValue: typeRaw)
|
||||
let flags = bytes[1] & 0x0F
|
||||
let compression = bytes[2] & 0x0F
|
||||
guard compression == 0 else { return nil }
|
||||
|
||||
var offset = headerSize
|
||||
var sequence: Int32?
|
||||
if flags == VolcengineFlags.positiveSequence.rawValue
|
||||
|| flags == VolcengineFlags.negativeSequence.rawValue {
|
||||
guard let value = data.readBE32(at: offset) else { return nil }
|
||||
sequence = Int32(bitPattern: value)
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if messageType == .errorMessage {
|
||||
guard let code = data.readBE32(at: offset),
|
||||
let size = data.readBE32(at: offset + 4) else { return nil }
|
||||
offset += 8
|
||||
guard data.count >= offset + Int(size) else { return nil }
|
||||
return VolcengineFrame(
|
||||
messageType: messageType,
|
||||
flags: flags,
|
||||
sequence: sequence,
|
||||
errorCode: code,
|
||||
payload: data.subdata(in: offset..<(offset + Int(size)))
|
||||
)
|
||||
}
|
||||
|
||||
guard let size = data.readBE32(at: offset) else { return nil }
|
||||
offset += 4
|
||||
guard data.count >= offset + Int(size) else { return nil }
|
||||
return VolcengineFrame(
|
||||
messageType: messageType,
|
||||
flags: flags,
|
||||
sequence: sequence,
|
||||
errorCode: nil,
|
||||
payload: data.subdata(in: offset..<(offset + Int(size)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
mutating func appendBE32(_ value: UInt32) {
|
||||
var bigEndian = value.bigEndian
|
||||
Swift.withUnsafeBytes(of: &bigEndian) { append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
func readBE32(at offset: Int) -> UInt32? {
|
||||
guard count >= offset + 4 else { return nil }
|
||||
return self[offset..<(offset + 4)].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,12 @@ public final class AppCloudSync {
|
||||
let store = makeStore()
|
||||
ICloudSyncPreferences.migrateLegacyTogglesIfNeeded(kvs: kvs, store: store)
|
||||
|
||||
let toggles = ICloudSyncPreferences.load(from: kvs, store: store)
|
||||
var toggles = ICloudSyncPreferences.load(from: kvs, store: store)
|
||||
// Merged UI toggle: settings sync implies dictionary sync.
|
||||
if toggles.settings, !toggles.dictionary {
|
||||
ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs)
|
||||
toggles.dictionary = true
|
||||
}
|
||||
ICloudSyncPreferences.cacheToAppGroup(
|
||||
settingsEnabled: toggles.settings,
|
||||
dictionaryEnabled: toggles.dictionary,
|
||||
|
||||
@@ -62,9 +62,10 @@ public final class SettingsCloudSync {
|
||||
public func enableSync() async throws {
|
||||
let store = makeStore()
|
||||
ICloudSyncPreferences.pushSettingsEnabled(true, kvs: kvs)
|
||||
ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs)
|
||||
ICloudSyncPreferences.cacheToAppGroup(
|
||||
settingsEnabled: true,
|
||||
dictionaryEnabled: store.personalDictionaryICloudSyncEnabled,
|
||||
dictionaryEnabled: true,
|
||||
store: store
|
||||
)
|
||||
|
||||
@@ -93,7 +94,9 @@ public final class SettingsCloudSync {
|
||||
public func disableSync() {
|
||||
let store = makeStore()
|
||||
ICloudSyncPreferences.pushSettingsEnabled(false, kvs: kvs)
|
||||
ICloudSyncPreferences.pushDictionaryEnabled(false, kvs: kvs)
|
||||
store.setSettingsICloudSyncEnabled(false)
|
||||
store.setPersonalDictionaryICloudSyncEnabled(false)
|
||||
}
|
||||
|
||||
public func pullAndMerge(store: AppGroupStore) async {
|
||||
|
||||
@@ -107,8 +107,11 @@ public final class KeyboardState: ObservableObject {
|
||||
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
|
||||
/// state on first install.
|
||||
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
/// Mirrored from App Group — swaps delete / return on the bottom row.
|
||||
/// Mirrored from App Group — swaps delete / space on the bottom row.
|
||||
@Published public var handednessPreference: HandednessPreference = .left
|
||||
/// Mirrors the host field's return-key intent. The action stays a newline
|
||||
/// insert; host apps decide whether that submits or creates a line break.
|
||||
@Published public var returnKeyRole: ReturnKeyRole = .newline
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
@Published public var cursorDragNavigationEnabled: Bool = true
|
||||
/// `true` while a cursor-drag pad is being pressed — drives the hint
|
||||
@@ -151,6 +154,18 @@ public final class KeyboardState: ObservableObject {
|
||||
case openSettings
|
||||
}
|
||||
|
||||
public enum ReturnKeyRole: Equatable {
|
||||
case newline
|
||||
case send
|
||||
|
||||
public var titleKey: String {
|
||||
switch self {
|
||||
case .newline: return "common.newline"
|
||||
case .send: return "common.send"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Temporary Flow debug (remove after orange-mic investigation)
|
||||
|
||||
/// Mirrored from `KeyboardFlowCoordinator` for the on-screen debug panel.
|
||||
|
||||
@@ -61,6 +61,8 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
public let baseURL: String
|
||||
public let apiKey: String
|
||||
public let model: String
|
||||
public let providerId: String
|
||||
public let thinkingEnabled: Bool
|
||||
public let session: URLSession
|
||||
|
||||
/// Canonical request timeout for a single LLM HTTP round-trip. Both
|
||||
@@ -73,11 +75,15 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
model: String,
|
||||
providerId: String = "",
|
||||
thinkingEnabled: Bool = false,
|
||||
session: URLSession = .shared
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.apiKey = apiKey
|
||||
self.model = model
|
||||
self.providerId = providerId
|
||||
self.thinkingEnabled = thinkingEnabled
|
||||
self.session = session
|
||||
}
|
||||
|
||||
@@ -107,8 +113,13 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
// the baseline when the caller does not supply one.
|
||||
req.timeoutInterval = timeout ?? requestTimeout
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
req.httpBody = try encoder.encode(request)
|
||||
req.httpBody = try Self.encodedBody(
|
||||
request,
|
||||
providerId: providerId,
|
||||
baseURL: baseURL,
|
||||
model: model,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: req)
|
||||
@@ -140,6 +151,27 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
throw LLMError.transport(String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
private static func encodedBody(
|
||||
_ request: LLMRequest,
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
model: String,
|
||||
thinkingEnabled: Bool
|
||||
) throws -> Data {
|
||||
let encoded = try JSONEncoder().encode(request)
|
||||
guard var body = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else {
|
||||
return encoded
|
||||
}
|
||||
LLMThinkingControl.apply(
|
||||
to: &body,
|
||||
providerId: providerId,
|
||||
baseURL: baseURL,
|
||||
model: model,
|
||||
enabled: thinkingEnabled
|
||||
)
|
||||
return try JSONSerialization.data(withJSONObject: body)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Factory
|
||||
@@ -147,13 +179,51 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
public enum LLMClientFactory {
|
||||
/// Build a client from the current `ProviderConfig`.
|
||||
public static func make(from config: ProviderConfig) -> LLMClient {
|
||||
OpenAICompatibleClient(
|
||||
make(
|
||||
providerId: config.providerId,
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model
|
||||
model: config.model,
|
||||
thinkingEnabled: config.llmThinkingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
/// Provider-aware factory used by `PolishingService`.
|
||||
public static func make(
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
model: String,
|
||||
thinkingEnabled: Bool = false,
|
||||
session: URLSession = .shared
|
||||
) -> LLMClient {
|
||||
switch providerId {
|
||||
case "anthropic":
|
||||
return AnthropicMessagesClient(apiKey: apiKey, model: model, session: session)
|
||||
default:
|
||||
let resolvedBase = resolvedOpenAICompatibleBaseURL(providerId: providerId, baseURL: baseURL)
|
||||
return OpenAICompatibleClient(
|
||||
baseURL: resolvedBase,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
providerId: providerId,
|
||||
thinkingEnabled: thinkingEnabled,
|
||||
session: session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gemini exposes an OpenAI-compatible shim under `/v1beta/openai`.
|
||||
private static func resolvedOpenAICompatibleBaseURL(providerId: String, baseURL: String) -> String {
|
||||
if !baseURL.isEmpty { return baseURL }
|
||||
switch providerId {
|
||||
case "gemini":
|
||||
return "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
default:
|
||||
return baseURL
|
||||
}
|
||||
}
|
||||
|
||||
/// Single source of truth for the LLM request timeout, shared by
|
||||
/// `LLMClient.requestTimeout` implementations and any caller that
|
||||
/// wants to bound total time spent waiting on the LLM (e.g.
|
||||
@@ -163,3 +233,100 @@ public enum LLMClientFactory {
|
||||
OpenAICompatibleClient(baseURL: "", apiKey: "", model: "").requestTimeout
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider-specific thinking controls
|
||||
//
|
||||
// Cloud polish defaults to thinking OFF (`llmThinkingEnabled == false`).
|
||||
// DeepSeek V4 thinking defaults to *enabled* server-side, so we must send an
|
||||
// explicit `thinking: { type: "disabled" }` — merely omitting the field (or
|
||||
// sending `reasoning_effort: "low"`, which DeepSeek maps to `high`) leaves
|
||||
// CoT on and makes polish appear stuck.
|
||||
|
||||
enum LLMThinkingControl {
|
||||
static func apply(
|
||||
to body: inout [String: Any],
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
model: String,
|
||||
enabled: Bool
|
||||
) {
|
||||
switch control(providerId: providerId, baseURL: baseURL, model: model) {
|
||||
case .deepSeek:
|
||||
// Official toggle; do not send reasoning_effort when disabled —
|
||||
// DeepSeek maps low/medium → high while thinking stays on.
|
||||
body["thinking"] = ["type": enabled ? "enabled" : "disabled"]
|
||||
if enabled {
|
||||
body["reasoning_effort"] = "high"
|
||||
} else {
|
||||
body.removeValue(forKey: "reasoning_effort")
|
||||
}
|
||||
case .miniMax:
|
||||
body["thinking"] = ["type": enabled ? "adaptive" : "disabled"]
|
||||
case .gemini:
|
||||
body["thinking_config"] = [
|
||||
"thinking_budget": enabled ? -1 : 0
|
||||
]
|
||||
case .openAIReasoning:
|
||||
// o-series / gpt-5: only touch the field when the user opts in,
|
||||
// or when disabling an always-on reasoner with the lowest effort.
|
||||
if enabled {
|
||||
body["reasoning_effort"] = "medium"
|
||||
} else {
|
||||
body["reasoning_effort"] = "low"
|
||||
}
|
||||
case .none:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private enum Control {
|
||||
/// DeepSeek / Ark: explicit thinking type toggle.
|
||||
case deepSeek
|
||||
case miniMax
|
||||
case gemini
|
||||
case openAIReasoning
|
||||
}
|
||||
|
||||
private static func control(
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
model: String
|
||||
) -> Control? {
|
||||
switch providerId {
|
||||
case "deepseek", "ark":
|
||||
return .deepSeek
|
||||
case "minimax":
|
||||
return .miniMax
|
||||
case "gemini":
|
||||
return .gemini
|
||||
case "openai":
|
||||
return isOpenAIReasoningModel(model) ? .openAIReasoning : nil
|
||||
default:
|
||||
return control(baseURL: baseURL, model: model)
|
||||
}
|
||||
}
|
||||
|
||||
private static func control(baseURL: String, model: String) -> Control? {
|
||||
let lower = baseURL.lowercased()
|
||||
if lower.contains("minimax") || lower.contains("minimaxi") {
|
||||
return .miniMax
|
||||
}
|
||||
if lower.contains("generativelanguage.googleapis.com") {
|
||||
return .gemini
|
||||
}
|
||||
// Hosted DeepSeek (SiliconFlow / OpenRouter / custom proxies).
|
||||
if lower.contains("deepseek") || model.lowercased().contains("deepseek") {
|
||||
return .deepSeek
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isOpenAIReasoningModel(_ model: String) -> Bool {
|
||||
let lower = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return lower.hasPrefix("o1")
|
||||
|| lower.hasPrefix("o3")
|
||||
|| lower.hasPrefix("o4")
|
||||
|| lower.hasPrefix("gpt-5")
|
||||
|| lower.contains("reasoning")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,13 @@ public actor PolishingService {
|
||||
} else {
|
||||
apiKey = store.apiKey
|
||||
}
|
||||
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
|
||||
client = LLMClientFactory.make(
|
||||
providerId: effectiveProviderId,
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
thinkingEnabled: store.llmThinkingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
let prompt: String
|
||||
@@ -350,7 +356,7 @@ public actor PolishingService {
|
||||
|
||||
private func shouldUseChineseGuidance(providerId: String) -> Bool {
|
||||
switch providerId {
|
||||
case "zhipu", "moonshot", "qwen", "deepseek":
|
||||
case "zhipu", "moonshot", "qwen", "deepseek", "ark", "minimax", "siliconflow", "mimo":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -391,7 +397,7 @@ public actor PolishingService {
|
||||
PreconfiguredKeys.isDeepseekConfigured {
|
||||
return "deepseek"
|
||||
}
|
||||
return id == "deepseek" && store.engineMode == "cloud" ? "openai" : id
|
||||
return id
|
||||
}
|
||||
|
||||
internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// ProviderModelService.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Lightweight provider tools used by Settings to validate endpoints and fetch
|
||||
// model ids without coupling the UI to each vendor's response shape.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ProviderModelServiceError: Error, LocalizedError, Sendable {
|
||||
case invalidURL
|
||||
case missingAPIKey
|
||||
case http(Int)
|
||||
case empty
|
||||
case decoding
|
||||
case transport(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return SharedL10n.string("providerTools.error.invalidURL")
|
||||
case .missingAPIKey:
|
||||
return SharedL10n.string("providerTools.error.missingAPIKey")
|
||||
case .http(let status):
|
||||
return SharedL10n.format("providerTools.error.http", status)
|
||||
case .empty:
|
||||
return SharedL10n.string("providerTools.error.empty")
|
||||
case .decoding:
|
||||
return SharedL10n.string("providerTools.error.decoding")
|
||||
case .transport:
|
||||
return SharedL10n.string("providerTools.error.transport")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ProviderModelService {
|
||||
public static func listLLMModels(
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
currentModel: String,
|
||||
session: URLSession = .shared
|
||||
) async throws -> [String] {
|
||||
if providerId == "anthropic" {
|
||||
return try await fetchModels(
|
||||
baseURL: "https://api.anthropic.com/v1",
|
||||
apiKey: apiKey,
|
||||
authorization: .anthropic,
|
||||
session: session
|
||||
)
|
||||
}
|
||||
return try await fetchModels(
|
||||
baseURL: resolvedLLMBaseURL(providerId: providerId, baseURL: baseURL),
|
||||
apiKey: apiKey,
|
||||
authorization: .bearer,
|
||||
session: session,
|
||||
fallback: currentModel
|
||||
)
|
||||
}
|
||||
|
||||
public static func listASRModels(
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
currentModel: String,
|
||||
session: URLSession = .shared
|
||||
) async throws -> [String] {
|
||||
switch CloudASRModelCatalog.strategy(for: providerId) {
|
||||
case .volcengineStreaming, .bailianStreaming:
|
||||
return singleModel(currentModel, fallback: CloudASRModelCatalog.defaultModel(for: providerId))
|
||||
case .localFallback:
|
||||
return []
|
||||
case .prompt, .openRouterJson, .zhipuHotwords:
|
||||
return try await fetchModels(
|
||||
baseURL: baseURL.isEmpty ? LLMProvider.provider(id: providerId).defaultBaseURL : baseURL,
|
||||
apiKey: apiKey,
|
||||
authorization: .bearer,
|
||||
session: session,
|
||||
fallback: currentModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum Authorization {
|
||||
case bearer
|
||||
case anthropic
|
||||
}
|
||||
|
||||
private static func fetchModels(
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
authorization: Authorization,
|
||||
session: URLSession,
|
||||
fallback: String = ""
|
||||
) async throws -> [String] {
|
||||
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw ProviderModelServiceError.missingAPIKey
|
||||
}
|
||||
guard let url = URL(string: modelsEndpoint(baseURL: baseURL)) else {
|
||||
throw ProviderModelServiceError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 12
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
switch authorization {
|
||||
case .bearer:
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
case .anthropic:
|
||||
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
|
||||
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw ProviderModelServiceError.transport("non-HTTP response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw ProviderModelServiceError.http(http.statusCode)
|
||||
}
|
||||
let models = try parseModels(from: data)
|
||||
let resolved = models.isEmpty ? singleModel(fallback, fallback: "") : models
|
||||
guard !resolved.isEmpty else { throw ProviderModelServiceError.empty }
|
||||
return resolved
|
||||
} catch let error as ProviderModelServiceError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ProviderModelServiceError.transport(String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseModels(from data: Data) throws -> [String] {
|
||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw ProviderModelServiceError.decoding
|
||||
}
|
||||
if let data = root["data"] as? [[String: Any]] {
|
||||
return normalize(data.compactMap { $0["id"] as? String ?? $0["name"] as? String })
|
||||
}
|
||||
if let models = root["models"] as? [[String: Any]] {
|
||||
return normalize(models.compactMap { $0["id"] as? String ?? $0["name"] as? String })
|
||||
}
|
||||
if let models = root["models"] as? [String] {
|
||||
return normalize(models)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func normalize(_ models: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return models
|
||||
.map { model in
|
||||
model
|
||||
.replacingOccurrences(of: "models/", with: "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
.filter { !$0.isEmpty }
|
||||
.filter { seen.insert($0).inserted }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
private static func modelsEndpoint(baseURL: String) -> String {
|
||||
let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasSuffix("/models") { return trimmed }
|
||||
return trimmed.hasSuffix("/") ? "\(trimmed)models" : "\(trimmed)/models"
|
||||
}
|
||||
|
||||
private static func resolvedLLMBaseURL(providerId: String, baseURL: String) -> String {
|
||||
if !baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return baseURL }
|
||||
if providerId == "gemini" {
|
||||
return "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
}
|
||||
return LLMProvider.provider(id: providerId).defaultBaseURL
|
||||
}
|
||||
|
||||
private static func singleModel(_ model: String, fallback: String) -> [String] {
|
||||
let resolved = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? fallback
|
||||
: model
|
||||
return resolved.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [resolved]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// ProviderToolRunnerState.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Pure state machine for Settings provider tool rows (validate / fetch models).
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct ProviderToolRunnerState: Equatable, Sendable {
|
||||
public var isRunning: Bool
|
||||
public var message: String?
|
||||
public var failed: Bool
|
||||
public var models: [String]
|
||||
|
||||
public init(
|
||||
isRunning: Bool = false,
|
||||
message: String? = nil,
|
||||
failed: Bool = false,
|
||||
models: [String] = []
|
||||
) {
|
||||
self.isRunning = isRunning
|
||||
self.message = message
|
||||
self.failed = failed
|
||||
self.models = models
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public enum ProviderToolRunner {
|
||||
public static func runValidate(
|
||||
runningMessage: String,
|
||||
successMessage: String,
|
||||
validate: () async throws -> Void
|
||||
) async -> ProviderToolRunnerState {
|
||||
var state = ProviderToolRunnerState(isRunning: true, message: runningMessage, failed: false)
|
||||
do {
|
||||
try await validate()
|
||||
state.isRunning = false
|
||||
state.message = successMessage
|
||||
state.failed = false
|
||||
} catch {
|
||||
state.isRunning = false
|
||||
state.failed = true
|
||||
state.message = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
public static func runFetchModels(
|
||||
runningMessage: String,
|
||||
loadedMessage: (Int) -> String,
|
||||
emptyMessage: String,
|
||||
currentModel: String,
|
||||
fetchModels: () async throws -> [String]
|
||||
) async -> (state: ProviderToolRunnerState, selectedModel: String?) {
|
||||
var state = ProviderToolRunnerState(isRunning: true, message: runningMessage, failed: false)
|
||||
do {
|
||||
let fetched = try await fetchModels()
|
||||
guard !fetched.isEmpty else {
|
||||
state.isRunning = false
|
||||
state.failed = true
|
||||
state.message = emptyMessage
|
||||
state.models = []
|
||||
return (state, nil)
|
||||
}
|
||||
|
||||
var resolved = fetched
|
||||
let trimmed = currentModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty, !resolved.contains(trimmed) {
|
||||
resolved.insert(trimmed, at: 0)
|
||||
}
|
||||
state.models = resolved
|
||||
state.isRunning = false
|
||||
state.failed = false
|
||||
state.message = loadedMessage(resolved.count)
|
||||
|
||||
let selected: String?
|
||||
if trimmed.isEmpty, let first = resolved.first {
|
||||
selected = first
|
||||
} else {
|
||||
selected = nil
|
||||
}
|
||||
return (state, selected)
|
||||
} catch {
|
||||
state.isRunning = false
|
||||
state.failed = true
|
||||
state.message = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
state.models = []
|
||||
return (state, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum HardTimeout {
|
||||
/// Returns the first completed result; the losing task is cancelled.
|
||||
public static func run<T: Sendable>(
|
||||
seconds: TimeInterval,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
) async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw CancellationError()
|
||||
}
|
||||
guard let result = try await group.next() else {
|
||||
throw CancellationError()
|
||||
}
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-throwing variant for tasks that should fall back when time elapses.
|
||||
public static func value<T: Sendable>(
|
||||
seconds: TimeInterval,
|
||||
operation: @escaping @Sendable () async -> T,
|
||||
onTimeout: @escaping @Sendable () -> T
|
||||
) async -> T {
|
||||
await withTaskGroup(of: T.self) { group in
|
||||
group.addTask { await operation() }
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
return onTimeout()
|
||||
}
|
||||
let result = await group.next() ?? onTimeout()
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,13 @@ public final class UsageStatisticsStore: ObservableObject {
|
||||
@Published public private(set) var dictationDurationSeconds: TimeInterval = 0
|
||||
@Published public private(set) var dictationCharacterCount: Int = 0
|
||||
@Published public private(set) var translationCharacterCount: Int = 0
|
||||
/// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by
|
||||
/// the home page's 7-day chart.
|
||||
@Published public private(set) var dailyDictationCharacters: [String: Int] = [:]
|
||||
|
||||
/// How many days of daily buckets to retain on disk. Well beyond the 7-day
|
||||
/// chart window so a device that syncs in late still contributes recent days.
|
||||
private static let dailyRetentionDays = 90
|
||||
|
||||
public let defaults: UserDefaults
|
||||
|
||||
@@ -53,6 +60,9 @@ public final class UsageStatisticsStore: ObservableObject {
|
||||
slice.translationCharacterCount += count
|
||||
} else {
|
||||
slice.dictationCharacterCount += count
|
||||
let dayKey = UsageStatisticsDayKey.key(for: Date())
|
||||
slice.dailyDictationCharacters[dayKey, default: 0] += count
|
||||
UsageStatisticsDayKey.prune(&slice.dailyDictationCharacters, keepingDays: Self.dailyRetentionDays)
|
||||
}
|
||||
slice.dictationDurationSeconds += max(0, duration)
|
||||
slice.updatedAt = Date()
|
||||
@@ -69,10 +79,42 @@ public final class UsageStatisticsStore: ObservableObject {
|
||||
/// aggregated cross-device sum and NEVER writes it back (writing would
|
||||
/// corrupt the per-device slices — see `recordUtterance`).
|
||||
public func reloadFromDisk() {
|
||||
let aggregated = SyncedUsageStatisticsStorage.load(from: defaults).aggregated
|
||||
let payload = SyncedUsageStatisticsStorage.load(from: defaults)
|
||||
let aggregated = payload.aggregated
|
||||
dictationDurationSeconds = aggregated.dictationDurationSeconds
|
||||
dictationCharacterCount = aggregated.dictationCharacterCount
|
||||
translationCharacterCount = aggregated.translationCharacterCount
|
||||
dailyDictationCharacters = payload.aggregatedDailyDictationCharacters
|
||||
}
|
||||
|
||||
// MARK: - 7-day chart data
|
||||
|
||||
/// One day's dictation total for the home page chart.
|
||||
public struct DailyUsagePoint: Identifiable, Equatable, Sendable {
|
||||
public let date: Date
|
||||
public let value: Int
|
||||
public var id: Date { date }
|
||||
}
|
||||
|
||||
/// The trailing 7 local days (oldest → newest), zero-filled for days with no
|
||||
/// dictation, so the chart always renders a full week.
|
||||
public var last7Days: [DailyUsagePoint] {
|
||||
Self.last7Days(from: dailyDictationCharacters)
|
||||
}
|
||||
|
||||
public static func last7Days(
|
||||
from daily: [String: Int],
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current
|
||||
) -> [DailyUsagePoint] {
|
||||
let startOfToday = calendar.startOfDay(for: now)
|
||||
var points: [DailyUsagePoint] = []
|
||||
for offset in stride(from: 6, through: 0, by: -1) {
|
||||
guard let day = calendar.date(byAdding: .day, value: -offset, to: startOfToday) else { continue }
|
||||
let key = UsageStatisticsDayKey.key(for: day, calendar: calendar)
|
||||
points.append(DailyUsagePoint(date: day, value: daily[key] ?? 0))
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/// One-time cleanup: the pre-fix code overwrote a device slice with the
|
||||
|
||||
@@ -11,11 +11,27 @@
|
||||
|
||||
/* LLM providers */
|
||||
"provider.openai" = "OpenAI";
|
||||
"provider.ark" = "Volcengine Ark";
|
||||
"provider.deepseek" = "DeepSeek";
|
||||
"provider.qwen" = "Qwen (DashScope)";
|
||||
"provider.zhipu" = "Zhipu GLM";
|
||||
"provider.moonshot" = "Moonshot";
|
||||
"provider.siliconflow" = "SiliconFlow";
|
||||
"provider.groq" = "Groq";
|
||||
"provider.minimax" = "MiniMax";
|
||||
"provider.mimo" = "Xiaomi MiMo";
|
||||
"provider.openrouter" = "OpenRouter";
|
||||
"provider.gemini" = "Google Gemini";
|
||||
"provider.anthropic" = "Anthropic Claude";
|
||||
"provider.xai" = "xAI Grok";
|
||||
"provider.mistral" = "Mistral AI";
|
||||
"provider.cometapi" = "CometAPI";
|
||||
"provider.alibabaCoding" = "Alibaba Coding";
|
||||
"provider.codingPlanX" = "CodingPlanX";
|
||||
"provider.volcengine" = "Volcengine ASR";
|
||||
"provider.bailian" = "Bailian Realtime ASR";
|
||||
"provider.whisper" = "Whisper (OpenAI)";
|
||||
"provider.codex_oauth" = "Codex OAuth";
|
||||
"provider.custom" = "Custom";
|
||||
|
||||
/* LLM errors */
|
||||
@@ -44,6 +60,15 @@
|
||||
"error.cloudASR.emptyTranscript" = "Cloud ASR returned an empty transcript.";
|
||||
"error.cloudASR.audioTooLong" = "Audio segment is too long for this cloud ASR provider.";
|
||||
"error.cloudASR.providerUnsupported" = "This provider does not support cloud speech recognition yet.";
|
||||
"error.cloudASR.streamingNotImplemented" = "This provider requires streaming ASR (WebSocket), which is not available in this build yet. Try Qwen, Zhipu, Groq, or OpenAI.";
|
||||
|
||||
/* Provider tools */
|
||||
"providerTools.error.invalidURL" = "Invalid model endpoint.";
|
||||
"providerTools.error.missingAPIKey" = "API Key is missing.";
|
||||
"providerTools.error.http" = "Model endpoint returned HTTP %lld.";
|
||||
"providerTools.error.empty" = "No models returned.";
|
||||
"providerTools.error.decoding" = "Failed to parse model list.";
|
||||
"providerTools.error.transport" = "Network error while loading models.";
|
||||
|
||||
/* Polish scenarios */
|
||||
"polishScenario.daily_chat" = "Daily Chat";
|
||||
@@ -120,14 +145,17 @@
|
||||
"mac.status.pasted" = "Inserted into front app";
|
||||
"mac.status.copiedAndPasted" = "Copied and inserted";
|
||||
"mac.status.deliveryWithNote" = "%@ — %@";
|
||||
"mac.stat.dictationTime" = "Dictation Time";
|
||||
"mac.stat.words" = "Dictation Chars";
|
||||
"mac.stat.translation" = "Translation Chars";
|
||||
"mac.stat.dictionary" = "Dictionary";
|
||||
"mac.stat.cumulativeDuration" = "Total time";
|
||||
"mac.stat.transcribed" = "Transcribed";
|
||||
"mac.stat.cumulativeTranslation" = "Translated";
|
||||
"mac.stat.customTerms" = "Custom terms";
|
||||
"stat.dictationTime" = "Dictation Time";
|
||||
"stat.words" = "Dictation Chars";
|
||||
"stat.translation" = "Translation Chars";
|
||||
"stat.dictionary" = "Dictionary";
|
||||
"stat.cumulativeDuration" = "Total time";
|
||||
"stat.transcribed" = "Transcribed";
|
||||
"stat.cumulativeTranslation" = "Translated";
|
||||
"stat.customTerms" = "Custom terms";
|
||||
"stat.weekChart.title" = "Last 7 days";
|
||||
"stat.weekChart.caption" = "Dictation chars";
|
||||
"stat.weekChart.empty" = "No dictation yet this week";
|
||||
"mac.status.chipReady" = "Ready";
|
||||
"mac.status.chipProcessing" = "Processing";
|
||||
"mac.overlay.listening" = "Listening";
|
||||
@@ -173,6 +201,25 @@
|
||||
"mac.settings.service" = "Service";
|
||||
"mac.settings.apiKey" = "API Key";
|
||||
"mac.settings.model" = "Model";
|
||||
"mac.settings.modelFetchHint" = "Enter an API Key, then fetch models.";
|
||||
"mac.settings.connectionCheck" = "Connection check";
|
||||
"mac.settings.validate" = "Validate";
|
||||
"mac.settings.fetchModels" = "Fetch models";
|
||||
"mac.settings.validating" = "Validating…";
|
||||
"mac.settings.validateSuccess" = "Success · Retry";
|
||||
"mac.settings.validateFailure" = "Failed · Retry";
|
||||
"mac.settings.loadingModels" = "Loading models…";
|
||||
"mac.settings.modelsLoaded" = "%lld models loaded.";
|
||||
"mac.settings.selectModel" = "Select model";
|
||||
"mac.settings.modelSelected" = "Selected %@";
|
||||
"mac.settings.modelsEmptyHint" = "Tap refresh to load models";
|
||||
"mac.settings.thinking" = "Thinking";
|
||||
"mac.settings.thinkingSubtitle" = "Slower, higher quality — recommended off";
|
||||
"mac.settings.thinkingHint" = "Off by default. Enable only for slower, deeper reasoning.";
|
||||
"mac.settings.volcengineAppId" = "APP ID";
|
||||
"mac.settings.volcengineAccessToken" = "Access Token";
|
||||
"mac.settings.volcengineResourceId" = "Resource ID";
|
||||
"mac.settings.volcengineNote" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration.";
|
||||
"mac.settings.recognition" = "RECOGNITION METHOD";
|
||||
"mac.settings.cloudEngine" = "Cloud Engine & AI Refinement";
|
||||
"mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing.";
|
||||
@@ -209,7 +256,7 @@
|
||||
"mac.settings.mlxModelMissing" = "Select a Qwen3 MLX model folder below, or choose another installed model.";
|
||||
"mac.settings.selectedModelMissing" = "%@ is not installed — using Apple Speech for now.";
|
||||
"mac.settings.localModelFallbackApple" = "No local model is ready — using Apple Speech for now.";
|
||||
"mac.settings.accessibility" = "Accessibility";
|
||||
"mac.settings.accessibility" = "Accessibility Permission";
|
||||
"mac.settings.accessibilityDesc" = "Required for global shortcut and auto-paste.";
|
||||
"mac.settings.openAccessibility" = "Open System Settings";
|
||||
"mac.settings.appearance" = "Appearance";
|
||||
@@ -300,8 +347,8 @@
|
||||
"mac.localASR.phase.completed" = "Completed";
|
||||
"mac.error.accessibilityRequired" = "Enable Accessibility for OSGKeyboard in System Settings";
|
||||
"mac.foregroundApp" = "Front app: %@";
|
||||
"mac.sync.settingsTitle" = "iCloud Sync";
|
||||
"mac.sync.settingsSubtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud.";
|
||||
"mac.sync.settingsTitle" = "Cross-Device iCloud Sync";
|
||||
"mac.sync.settingsSubtitle" = "Sync settings, history, and API keys across devices via iCloud.";
|
||||
"mac.sync.syncNow" = "Sync Now";
|
||||
"mac.sync.dictTitle" = "Personal dictionary iCloud sync";
|
||||
"mac.sync.dictSubtitle" = "Keep your dictionary in sync across all devices.";
|
||||
|
||||
@@ -11,11 +11,27 @@
|
||||
|
||||
/* LLM providers */
|
||||
"provider.openai" = "OpenAI";
|
||||
"provider.ark" = "火山方舟 Ark";
|
||||
"provider.deepseek" = "DeepSeek";
|
||||
"provider.qwen" = "通义千问";
|
||||
"provider.zhipu" = "智谱 GLM";
|
||||
"provider.moonshot" = "月之暗面";
|
||||
"provider.siliconflow" = "硅基流动";
|
||||
"provider.groq" = "Groq";
|
||||
"provider.minimax" = "MiniMax";
|
||||
"provider.mimo" = "小米 MiMo";
|
||||
"provider.openrouter" = "OpenRouter";
|
||||
"provider.gemini" = "Google Gemini";
|
||||
"provider.anthropic" = "Anthropic Claude";
|
||||
"provider.xai" = "xAI Grok";
|
||||
"provider.mistral" = "Mistral AI";
|
||||
"provider.cometapi" = "CometAPI";
|
||||
"provider.alibabaCoding" = "阿里 Coding";
|
||||
"provider.codingPlanX" = "CodingPlanX";
|
||||
"provider.volcengine" = "火山引擎 ASR";
|
||||
"provider.bailian" = "百炼实时 ASR";
|
||||
"provider.whisper" = "Whisper (OpenAI)";
|
||||
"provider.codex_oauth" = "Codex OAuth";
|
||||
"provider.custom" = "自定义";
|
||||
|
||||
/* LLM errors */
|
||||
@@ -44,6 +60,15 @@
|
||||
"error.cloudASR.emptyTranscript" = "云端识别返回了空文本。";
|
||||
"error.cloudASR.audioTooLong" = "音频片段超过该云端识别服务的时长限制。";
|
||||
"error.cloudASR.providerUnsupported" = "该服务商暂不支持云端语音识别。";
|
||||
"error.cloudASR.streamingNotImplemented" = "该服务商需要流式 ASR(WebSocket),当前版本尚未接入。可改用通义、智谱、Groq 或 OpenAI。";
|
||||
|
||||
/* 服务商工具 */
|
||||
"providerTools.error.invalidURL" = "模型接口地址无效。";
|
||||
"providerTools.error.missingAPIKey" = "未填写 API Key。";
|
||||
"providerTools.error.http" = "模型接口返回 HTTP %lld。";
|
||||
"providerTools.error.empty" = "未返回可用模型。";
|
||||
"providerTools.error.decoding" = "解析模型列表失败。";
|
||||
"providerTools.error.transport" = "拉取模型时发生网络错误。";
|
||||
|
||||
/* 润色场景 */
|
||||
"polishScenario.daily_chat" = "日常聊天";
|
||||
@@ -120,14 +145,17 @@
|
||||
"mac.status.pasted" = "已插入前台应用";
|
||||
"mac.status.copiedAndPasted" = "已复制并插入";
|
||||
"mac.status.deliveryWithNote" = "%@ — %@";
|
||||
"mac.stat.dictationTime" = "听写时长";
|
||||
"mac.stat.words" = "听写字数";
|
||||
"mac.stat.translation" = "翻译字数";
|
||||
"mac.stat.dictionary" = "词库";
|
||||
"mac.stat.cumulativeDuration" = "累计时长";
|
||||
"mac.stat.transcribed" = "累计转写";
|
||||
"mac.stat.cumulativeTranslation" = "累计翻译";
|
||||
"mac.stat.customTerms" = "自定义词条";
|
||||
"stat.dictationTime" = "听写时长";
|
||||
"stat.words" = "听写字数";
|
||||
"stat.translation" = "翻译字数";
|
||||
"stat.dictionary" = "词库";
|
||||
"stat.cumulativeDuration" = "累计时长";
|
||||
"stat.transcribed" = "累计转写";
|
||||
"stat.cumulativeTranslation" = "累计翻译";
|
||||
"stat.customTerms" = "自定义词条";
|
||||
"stat.weekChart.title" = "近 7 天";
|
||||
"stat.weekChart.caption" = "听写字数";
|
||||
"stat.weekChart.empty" = "本周还没有听写记录";
|
||||
"mac.status.chipReady" = "就绪";
|
||||
"mac.status.chipProcessing" = "处理中";
|
||||
"mac.overlay.listening" = "聆听中";
|
||||
@@ -173,6 +201,25 @@
|
||||
"mac.settings.service" = "服务商";
|
||||
"mac.settings.apiKey" = "API 密钥";
|
||||
"mac.settings.model" = "模型";
|
||||
"mac.settings.modelFetchHint" = "填写 API Key 后拉取模型。";
|
||||
"mac.settings.connectionCheck" = "连接检查";
|
||||
"mac.settings.validate" = "验证";
|
||||
"mac.settings.fetchModels" = "拉取模型";
|
||||
"mac.settings.validating" = "正在验证…";
|
||||
"mac.settings.validateSuccess" = "成功 · 重试";
|
||||
"mac.settings.validateFailure" = "失败 · 重试";
|
||||
"mac.settings.loadingModels" = "正在拉取模型…";
|
||||
"mac.settings.modelsLoaded" = "已拉取 %lld 个模型。";
|
||||
"mac.settings.selectModel" = "选择模型";
|
||||
"mac.settings.modelSelected" = "已选择 %@";
|
||||
"mac.settings.modelsEmptyHint" = "先点右侧刷新拉取模型";
|
||||
"mac.settings.thinking" = "思考";
|
||||
"mac.settings.thinkingSubtitle" = "速度更慢、质量更高,建议关闭";
|
||||
"mac.settings.thinkingHint" = "默认关闭。仅在需要更慢、更深的推理时开启。";
|
||||
"mac.settings.volcengineAppId" = "APP ID";
|
||||
"mac.settings.volcengineAccessToken" = "Access Token";
|
||||
"mac.settings.volcengineResourceId" = "Resource ID";
|
||||
"mac.settings.volcengineNote" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。";
|
||||
"mac.settings.recognition" = "识别方式";
|
||||
"mac.settings.cloudEngine" = "云端引擎与 AI 润色";
|
||||
"mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。";
|
||||
@@ -209,7 +256,7 @@
|
||||
"mac.settings.mlxModelMissing" = "请在下方选择 Qwen3 MLX 模型目录,或改用其他已安装的模型。";
|
||||
"mac.settings.selectedModelMissing" = "「%@」尚未安装,暂时使用 Apple Speech。";
|
||||
"mac.settings.localModelFallbackApple" = "没有可用的本地模型,暂时使用 Apple Speech。";
|
||||
"mac.settings.accessibility" = "辅助功能";
|
||||
"mac.settings.accessibility" = "辅助功能权限";
|
||||
"mac.settings.accessibilityDesc" = "全局快捷键与自动粘贴需要此权限。";
|
||||
"mac.settings.openAccessibility" = "打开系统设置";
|
||||
"mac.settings.appearance" = "外观";
|
||||
@@ -300,8 +347,8 @@
|
||||
"mac.localASR.phase.completed" = "已完成";
|
||||
"mac.error.accessibilityRequired" = "请在系统设置中为 OSGKeyboard 启用辅助功能";
|
||||
"mac.foregroundApp" = "前台应用:%@";
|
||||
"mac.sync.settingsTitle" = "iCloud 同步";
|
||||
"mac.sync.settingsSubtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。";
|
||||
"mac.sync.settingsTitle" = "跨设备iCloud 同步";
|
||||
"mac.sync.settingsSubtitle" = "通过 iCloud 跨设备同步设置、历史记录、API Key";
|
||||
"mac.sync.syncNow" = "立即同步";
|
||||
"mac.sync.dictTitle" = "个人词库 iCloud 同步";
|
||||
"mac.sync.dictSubtitle" = "在所有设备间同步个人词库。";
|
||||
|
||||
Reference in New Issue
Block a user