Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions app/Typoless/Domain/Models/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -390,13 +390,11 @@ struct XunfeiASRConfig: Codable, Equatable, Sendable {
/// 小米 MiMo ASR 配置
struct XiaomiMiMoASRConfig: Codable, Equatable, Sendable {
var apiKey: String = ""
var language: String = "auto"
var validationStatus: CloudASRValidationStatus = .unvalidated
var lastValidationError: String?

enum CodingKeys: String, CodingKey {
case apiKey
case language
case validationStatus
case lastValidationError
}
Expand All @@ -406,23 +404,13 @@ struct XiaomiMiMoASRConfig: Codable, Equatable, Sendable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey) ?? ""
let decodedLanguage = try container.decodeIfPresent(String.self, forKey: .language) ?? Self.defaultLanguage
language = Self.normalizedLanguage(decodedLanguage)
validationStatus = try container.decodeIfPresent(CloudASRValidationStatus.self, forKey: .validationStatus) ?? .unvalidated
lastValidationError = try container.decodeIfPresent(String.self, forKey: .lastValidationError)
}

var isComplete: Bool {
!apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}

static let defaultLanguage = "auto"
static let supportedLanguages = ["auto", "zh", "en"]

static func normalizedLanguage(_ language: String) -> String {
let trimmed = language.trimmingCharacters(in: .whitespacesAndNewlines)
return supportedLanguages.contains(trimmed) ? trimmed : defaultLanguage
}
}

extension TencentASRConfig: CloudASRConfigState {
Expand Down
4 changes: 2 additions & 2 deletions app/Typoless/Domain/Models/CloudASRValidation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ struct CloudASRValidationInput: Equatable, Sendable {
case .xunfeiSentence:
return "\(platform.rawValue)\n\(asrConfig.xunfei.appID)\n\(asrConfig.xunfei.apiKey)\n\(asrConfig.xunfei.apiSecret)"
case .xiaomiMiMoASR:
return "\(platform.rawValue)\n\(asrConfig.xiaomiMiMo.apiKey)\n\(asrConfig.xiaomiMiMo.language)"
return "\(platform.rawValue)\n\(asrConfig.xiaomiMiMo.apiKey)"
case .xiaomiMiMoTokenPlanASR:
return "\(platform.rawValue)\n\(asrConfig.xiaomiMiMoTokenPlan.apiKey)\n\(asrConfig.xiaomiMiMoTokenPlan.language)"
return "\(platform.rawValue)\n\(asrConfig.xiaomiMiMoTokenPlan.apiKey)"
}
}
}
2 changes: 0 additions & 2 deletions app/Typoless/Domain/Services/CloudASRValidationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,11 @@ final class CloudASRValidationService {
case .xiaomiMiMoASR:
return XiaomiMiMoASRProvider(
apiKey: input.asrConfig.xiaomiMiMo.apiKey,
language: input.asrConfig.xiaomiMiMo.language,
baseURL: XiaomiMiMoASRProvider.defaultBaseURL
)
case .xiaomiMiMoTokenPlanASR:
return XiaomiMiMoASRProvider(
apiKey: input.asrConfig.xiaomiMiMoTokenPlan.apiKey,
language: input.asrConfig.xiaomiMiMoTokenPlan.language,
baseURL: XiaomiMiMoASRProvider.tokenPlanBaseURL
)
}
Expand Down
6 changes: 2 additions & 4 deletions app/Typoless/Persistence/ConfigStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -402,14 +402,12 @@ final class ConfigStore {
newConfig.xunfei.lastValidationError = nil
}

if oldConfig.xiaomiMiMo.apiKey != newConfig.xiaomiMiMo.apiKey
|| oldConfig.xiaomiMiMo.language != newConfig.xiaomiMiMo.language {
if oldConfig.xiaomiMiMo.apiKey != newConfig.xiaomiMiMo.apiKey {
newConfig.xiaomiMiMo.validationStatus = .unvalidated
newConfig.xiaomiMiMo.lastValidationError = nil
}

if oldConfig.xiaomiMiMoTokenPlan.apiKey != newConfig.xiaomiMiMoTokenPlan.apiKey
|| oldConfig.xiaomiMiMoTokenPlan.language != newConfig.xiaomiMiMoTokenPlan.language {
if oldConfig.xiaomiMiMoTokenPlan.apiKey != newConfig.xiaomiMiMoTokenPlan.apiKey {
newConfig.xiaomiMiMoTokenPlan.validationStatus = .unvalidated
newConfig.xiaomiMiMoTokenPlan.lastValidationError = nil
}
Expand Down
2 changes: 0 additions & 2 deletions app/Typoless/Providers/ASRProviderFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,11 @@ struct ASRProviderFactory {
case .xiaomiMiMoASR:
XiaomiMiMoASRProvider(
apiKey: config.xiaomiMiMo.apiKey,
language: config.xiaomiMiMo.language,
baseURL: XiaomiMiMoASRProvider.defaultBaseURL
)
case .xiaomiMiMoTokenPlanASR:
XiaomiMiMoASRProvider(
apiKey: config.xiaomiMiMoTokenPlan.apiKey,
language: config.xiaomiMiMoTokenPlan.language,
baseURL: XiaomiMiMoASRProvider.tokenPlanBaseURL
)
}
Expand Down
5 changes: 1 addition & 4 deletions app/Typoless/Providers/XiaomiMiMoASRProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,15 @@ final class XiaomiMiMoASRProvider: ASRProvider, CloudASRValidating, @unchecked S
private static let defaultTimeout: TimeInterval = 15

private let apiKey: String
private let language: String
private let recognizeURL: URL
private let httpClient: any XiaomiMiMoASRHTTPClient

init(
apiKey: String,
language: String = XiaomiMiMoASRProvider.defaultLanguage,
baseURL: URL = XiaomiMiMoASRProvider.defaultBaseURL,
httpClient: any XiaomiMiMoASRHTTPClient = URLSession.shared
) {
self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
self.language = XiaomiMiMoASRConfig.normalizedLanguage(language)
self.recognizeURL = Self.chatCompletionsURL(for: baseURL)
self.httpClient = httpClient
}
Expand Down Expand Up @@ -60,7 +57,7 @@ final class XiaomiMiMoASRProvider: ASRProvider, CloudASRValidating, @unchecked S
]
),
],
asrOptions: XiaomiMiMoASROptions(language: language),
asrOptions: XiaomiMiMoASROptions(language: Self.defaultLanguage),
stream: false
)

Expand Down
34 changes: 2 additions & 32 deletions app/Typoless/UI/Settings/ASRSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,7 @@ struct ASRSettingsView: View {
@State private var xunfeiAPISecret: String = ""

@State private var xiaomiMiMoAPIKey: String = ""
@State private var xiaomiMiMoLanguage: String = XiaomiMiMoASRConfig.defaultLanguage
@State private var xiaomiMiMoTokenPlanAPIKey: String = ""
@State private var xiaomiMiMoTokenPlanLanguage: String = XiaomiMiMoASRConfig.defaultLanguage

@State private var isLoaded = false
@State private var hasTriggeredValidation = false
Expand Down Expand Up @@ -151,17 +149,9 @@ struct ASRSettingsView: View {
case .xunfeiSentence:
xunfeiPanel
case .xiaomiMiMoASR:
xiaomiMiMoPanel(
apiKey: $xiaomiMiMoAPIKey,
language: $xiaomiMiMoLanguage,
platform: .xiaomiMiMoASR
)
xiaomiMiMoPanel(apiKey: $xiaomiMiMoAPIKey, platform: .xiaomiMiMoASR)
case .xiaomiMiMoTokenPlanASR:
xiaomiMiMoPanel(
apiKey: $xiaomiMiMoTokenPlanAPIKey,
language: $xiaomiMiMoTokenPlanLanguage,
platform: .xiaomiMiMoTokenPlanASR
)
xiaomiMiMoPanel(apiKey: $xiaomiMiMoTokenPlanAPIKey, platform: .xiaomiMiMoTokenPlanASR)
}
} footer: {
Text(selectedPlatform.cloudConfigSummary)
Expand All @@ -185,9 +175,7 @@ struct ASRSettingsView: View {
.onChange(of: xunfeiAPIKey) { debouncedSaveCloudConfig() }
.onChange(of: xunfeiAPISecret) { debouncedSaveCloudConfig() }
.onChange(of: xiaomiMiMoAPIKey) { debouncedSaveCloudConfig() }
.onChange(of: xiaomiMiMoLanguage) { debouncedSaveCloudConfig() }
.onChange(of: xiaomiMiMoTokenPlanAPIKey) { debouncedSaveCloudConfig() }
.onChange(of: xiaomiMiMoTokenPlanLanguage) { debouncedSaveCloudConfig() }
}

// MARK: - Panels
Expand Down Expand Up @@ -244,11 +232,9 @@ struct ASRSettingsView: View {
@ViewBuilder
private func xiaomiMiMoPanel(
apiKey: Binding<String>,
language: Binding<String>,
platform: ASRPlatform
) -> some View {
cloudSecureField(title: "API Key", text: apiKey)
xiaomiMiMoLanguageRow(language: language)
cloudStatusRow(for: platform)
}

Expand All @@ -266,18 +252,6 @@ struct ASRSettingsView: View {
}
}

private func xiaomiMiMoLanguageRow(language: Binding<String>) -> some View {
SettingsFormRow(title: "识别语言") {
Picker("识别语言", selection: language) {
Text("自动").tag("auto")
Text("中文").tag("zh")
Text("英文").tag("en")
}
.labelsHidden()
.fixedSize()
}
}

private func cloudStatusRow(for platform: ASRPlatform) -> some View {
SettingsFormRow(title: "引擎状态") {
VStack(alignment: .leading, spacing: 4) {
Expand Down Expand Up @@ -385,9 +359,7 @@ struct ASRSettingsView: View {
xunfeiAPISecret = configStore.asrConfig.xunfei.apiSecret

xiaomiMiMoAPIKey = configStore.asrConfig.xiaomiMiMo.apiKey
xiaomiMiMoLanguage = configStore.asrConfig.xiaomiMiMo.language
xiaomiMiMoTokenPlanAPIKey = configStore.asrConfig.xiaomiMiMoTokenPlan.apiKey
xiaomiMiMoTokenPlanLanguage = configStore.asrConfig.xiaomiMiMoTokenPlan.language

hasTriggeredValidation = false
downloadManager = ModelDownloadManager(configStore: configStore)
Expand All @@ -412,9 +384,7 @@ struct ASRSettingsView: View {
config.xunfei.apiKey = xunfeiAPIKey.trimmingCharacters(in: .whitespacesAndNewlines)
config.xunfei.apiSecret = xunfeiAPISecret.trimmingCharacters(in: .whitespacesAndNewlines)
config.xiaomiMiMo.apiKey = xiaomiMiMoAPIKey.trimmingCharacters(in: .whitespacesAndNewlines)
config.xiaomiMiMo.language = XiaomiMiMoASRConfig.normalizedLanguage(xiaomiMiMoLanguage)
config.xiaomiMiMoTokenPlan.apiKey = xiaomiMiMoTokenPlanAPIKey.trimmingCharacters(in: .whitespacesAndNewlines)
config.xiaomiMiMoTokenPlan.language = XiaomiMiMoASRConfig.normalizedLanguage(xiaomiMiMoTokenPlanLanguage)
return config
}

Expand Down
3 changes: 1 addition & 2 deletions app/TypolessTests/ASRConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,9 @@ final class ASRConfigTests: XCTestCase {
XCTAssertEqual(decoded.selectedPlatform, .localSenseVoice)
}

func testXiaomiMiMoConfigNormalizesUnsupportedLanguageField() throws {
func testXiaomiMiMoConfigIgnoresLegacyLanguageField() throws {
let data = Data(#"{"apiKey":"key","language":"ja"}"#.utf8)
let decoded = try JSONDecoder().decode(XiaomiMiMoASRConfig.self, from: data)
XCTAssertEqual(decoded.apiKey, "key")
XCTAssertEqual(decoded.language, "auto")
}
}
9 changes: 3 additions & 6 deletions app/TypolessTests/CloudASRValidationServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,30 +187,27 @@ final class CloudASRValidationServiceTests: XCTestCase {
XCTAssertNil(service.lastErrorMessage)
}

func testXiaomiMiMoValidationInputFingerprintIncludesLanguage() {
func testXiaomiMiMoValidationInputFingerprintUsesAPIKey() {
var config = ASRConfig()
config.xiaomiMiMo.apiKey = "mimo-key"
config.xiaomiMiMo.language = "zh"

let input = CloudASRValidationInput(platform: .xiaomiMiMoASR, asrConfig: config)

XCTAssertTrue(input.isCloudPlatform)
XCTAssertTrue(input.isComplete)
XCTAssertEqual(input.fingerprint, "xiaomiMiMoASR\nmimo-key\nzh")
XCTAssertEqual(input.fingerprint, "xiaomiMiMoASR\nmimo-key")
}

func testXiaomiMiMoTokenPlanValidationInputFingerprintUsesIndependentPlatform() {
var config = ASRConfig()
config.xiaomiMiMo.apiKey = "mimo-key"
config.xiaomiMiMo.language = "zh"
config.xiaomiMiMoTokenPlan.apiKey = "token-plan-key"
config.xiaomiMiMoTokenPlan.language = "en"

let input = CloudASRValidationInput(platform: .xiaomiMiMoTokenPlanASR, asrConfig: config)

XCTAssertTrue(input.isCloudPlatform)
XCTAssertTrue(input.isComplete)
XCTAssertEqual(input.fingerprint, "xiaomiMiMoTokenPlanASR\ntoken-plan-key\nen")
XCTAssertEqual(input.fingerprint, "xiaomiMiMoTokenPlanASR\ntoken-plan-key")
}

@MainActor
Expand Down
22 changes: 0 additions & 22 deletions app/TypolessTests/ConfigStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ final class ConfigStoreTests: XCTestCase {
asrConfig.aliyun.accessKeySecret = "secret"
asrConfig.aliyun.appKey = "app"
asrConfig.xiaomiMiMo.apiKey = "mimo-key"
asrConfig.xiaomiMiMo.language = "zh"
asrConfig.xiaomiMiMoTokenPlan.apiKey = "token-plan-key"
asrConfig.xiaomiMiMoTokenPlan.language = "en"
try firstStore.saveASRConfig(asrConfig)
try firstStore.updateCloudValidationState(for: .volcengineSentence, status: .verified)
try firstStore.updateCloudValidationState(for: .xiaomiMiMoASR, status: .verified)
Expand All @@ -85,10 +83,8 @@ final class ConfigStoreTests: XCTestCase {
XCTAssertEqual(secondStore.asrConfig.aliyun.accessKeySecret, "secret")
XCTAssertEqual(secondStore.asrConfig.aliyun.appKey, "app")
XCTAssertEqual(secondStore.asrConfig.xiaomiMiMo.apiKey, "mimo-key")
XCTAssertEqual(secondStore.asrConfig.xiaomiMiMo.language, "zh")
XCTAssertEqual(secondStore.asrConfig.xiaomiMiMo.validationStatus, .verified)
XCTAssertEqual(secondStore.asrConfig.xiaomiMiMoTokenPlan.apiKey, "token-plan-key")
XCTAssertEqual(secondStore.asrConfig.xiaomiMiMoTokenPlan.language, "en")
XCTAssertEqual(secondStore.asrConfig.xiaomiMiMoTokenPlan.validationStatus, .verified)
}

Expand All @@ -110,24 +106,6 @@ final class ConfigStoreTests: XCTestCase {
XCTAssertNil(store.asrConfig.tencentCloud.lastValidationError)
}

@MainActor
func testChangingXiaomiMiMoTokenPlanLanguageInvalidatesValidationState() throws {
let store = ConfigStore(configDirectory: tempDirectory)
var asrConfig = store.asrConfig
asrConfig.selectedPlatform = .xiaomiMiMoTokenPlanASR
asrConfig.xiaomiMiMoTokenPlan.apiKey = "token-plan-key"
asrConfig.xiaomiMiMoTokenPlan.language = "auto"
try store.saveASRConfig(asrConfig)
try store.updateCloudValidationState(for: .xiaomiMiMoTokenPlanASR, status: .verified)

var changedConfig = store.asrConfig
changedConfig.xiaomiMiMoTokenPlan.language = "zh"
try store.saveASRConfig(changedConfig)

XCTAssertEqual(store.asrConfig.xiaomiMiMoTokenPlan.validationStatus, .unvalidated)
XCTAssertNil(store.asrConfig.xiaomiMiMoTokenPlan.lastValidationError)
}

@MainActor
func testSaveAndReloadGeneralConfigPersistsInteractionSoundEnabled() throws {
let firstStore = ConfigStore(configDirectory: tempDirectory)
Expand Down
17 changes: 0 additions & 17 deletions app/TypolessTests/XiaomiMiMoASRProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,23 +83,6 @@ final class XiaomiMiMoASRProviderTests: XCTestCase {
XCTAssertEqual(asrOptions?["language"] as? String, "auto")
}

func testRecognizeUsesConfiguredLanguage() async throws {
let client = StubXiaomiMiMoHTTPClient(
responseData: Data(#"{"choices":[{"message":{"content":"text"}}]}"#.utf8),
statusCode: 200
)
let provider = XiaomiMiMoASRProvider(apiKey: "mimo-key", language: "zh", httpClient: client)

_ = try await provider.recognize(audioData: Data([1]), timeout: nil)

let lastRequest = await client.lastRequest
let request = try XCTUnwrap(lastRequest)
let body = try XCTUnwrap(request.httpBody)
let json = try JSONSerialization.jsonObject(with: body) as? [String: Any]
let asrOptions = json?["asr_options"] as? [String: Any]
XCTAssertEqual(asrOptions?["language"] as? String, "zh")
}

func testRecognizeThrowsConfigurationIncompleteWhenAPIKeyMissing() async {
let client = StubXiaomiMiMoHTTPClient(
responseData: Data(#"{"choices":[{"message":{"content":"text"}}]}"#.utf8),
Expand Down
6 changes: 2 additions & 4 deletions docs/TDD.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,11 +486,9 @@ Segment 级诊断字段(每段独立记录):
- `asr.xunfei.validationStatus`:科大讯飞配置验证状态(unvalidated / validating / verified / failed)
- `asr.xunfei.lastValidationError`:科大讯飞最近一次验证失败摘要
- `asr.xiaomiMiMo.apiKey`:小米 MiMo API Key
- `asr.xiaomiMiMo.language`:小米 MiMo 识别语言(auto / zh / en)
- `asr.xiaomiMiMo.validationStatus`:小米 MiMo 配置验证状态(unvalidated / validating / verified / failed)
- `asr.xiaomiMiMo.lastValidationError`:小米 MiMo 最近一次验证失败摘要
- `asr.xiaomiMiMoTokenPlan.apiKey`:小米 MiMo Token Plan API Key
- `asr.xiaomiMiMoTokenPlan.language`:小米 MiMo Token Plan 识别语言(auto / zh / en)
- `asr.xiaomiMiMoTokenPlan.validationStatus`:小米 MiMo Token Plan 配置验证状态(unvalidated / validating / verified / failed)
- `asr.xiaomiMiMoTokenPlan.lastValidationError`:小米 MiMo Token Plan 最近一次验证失败摘要

Expand Down Expand Up @@ -563,8 +561,8 @@ Segment 级诊断字段(每段独立记录):
- `XunfeiSentenceASRProvider` 使用语音听写 WebSocket 接口,并从 `wav` 中提取 PCM 数据按帧发送。
- 配置:AppID、API Key、API Secret,存于 `asr.xunfei`。
- `XiaomiMiMoASRProvider` 调用 OpenAI Chat Completions 兼容接口;普通 MiMo Base URL 为 `https://api.xiaomimimo.com/v1`,Token Plan Base URL 为 `https://token-plan-cn.xiaomimimo.com/v1`,最终请求路径均为 `/chat/completions`。
- 模型固定为 `mimo-v2.5-asr`,请求体通过 `messages[].content[].input_audio.data` 传入 `data:audio/wav;base64,<audio>`,`asr_options.language` 支持 `auto` / `zh` / `en`。
- 鉴权使用 `Authorization: Bearer <apiKey>`,配置仅包含 API Key 和识别语言;Token Plan 作为 ASR 选择列表中的独立入口,不开放任意 Base URL 输入。
- 模型固定为 `mimo-v2.5-asr`,请求体通过 `messages[].content[].input_audio.data` 传入 `data:audio/wav;base64,<audio>`,`asr_options.language` 固定发送 `auto`。
- 鉴权使用 `Authorization: Bearer <apiKey>`,配置仅包含 API Key;Token Plan 作为 ASR 选择列表中的独立入口,不开放任意 Base URL 输入。
- 所有云 Provider 超时按分段时长动态计算:`min(90s, max(15s, segmentDurationSeconds * 1.3 + 10s))`。
- 云端 ASR Provider 均需提供 `validateCredentials()` 能力,供设置页真实验证调用。
- 验证请求以最小真实请求验证鉴权与接口可达性;若鉴权成功但测试音频返回空结果,仍视为验证通过。
Expand Down
Loading