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