feat: polish scenarios and stabilize keyboard layout height
Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip) with ScenarioPrompt and style directives; drive keyboard height from content (240pt) and use viewIsAppearing encapsulated-height offset for smoother keyboard switches; remove redundant StatusBadge and hide system dictation via hasDictationKey.
This commit is contained in:
@@ -8,14 +8,12 @@
|
||||
// Engine matrix:
|
||||
// - `engineMode == "cloud"` → always polish (cloud engine's whole point).
|
||||
// - `engineMode == "local"`,
|
||||
// `localModeCloudPolishEnabled == false` → ASR-only, return raw.
|
||||
// cloud polish disabled → ASR-only, return raw.
|
||||
// - `engineMode == "local"`,
|
||||
// `localModeCloudPolishEnabled == true` → polish via the user's LLM
|
||||
// (DeepSeek by default). The local engine gains stronger accuracy on
|
||||
// noisy / dialectal Chinese at the cost of one cloud round-trip.
|
||||
// If the user hasn't entered an API key the call falls back to the
|
||||
// raw transcript and surfaces a warning so the keyboard can show
|
||||
// the "fill in your key" hint.
|
||||
// cloud polish enabled → DeepSeek LLM step (polish or translate).
|
||||
// Translation uses `.translate` + `TranslationPrompt`; polish uses
|
||||
// the default system prompt. Missing preconfigured DeepSeek key
|
||||
// throws `missingAPIKey` and callers deliver raw + warning.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -24,10 +22,8 @@ public actor PolishingService {
|
||||
public enum PolishError: Error, Equatable {
|
||||
case noTranscript
|
||||
case timeout
|
||||
/// v0.2.0: local engine + cloud-polish-on, but the user hasn't
|
||||
/// saved an API key in the Keychain. Caller surfaces an Alert
|
||||
/// telling them to fill it in; we deliver the raw transcript
|
||||
/// so no data is lost.
|
||||
/// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is
|
||||
/// still the repo placeholder, or cloud engine Keychain is empty.
|
||||
case missingAPIKey
|
||||
}
|
||||
|
||||
@@ -74,16 +70,10 @@ public actor PolishingService {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||
|
||||
// Local engine: ASR-only unless the user opted into cloud
|
||||
// polish via `localModeCloudPolishEnabled`. The cloud polish
|
||||
// path still requires an API key; if the Keychain is empty we
|
||||
// fall back to the raw transcript and throw `missingAPIKey`
|
||||
// so the UI can surface the "fill in your key" hint.
|
||||
// Local engine: ASR-only unless cloud polish is enabled
|
||||
// (translation is a sub-option of that LLM step).
|
||||
if store.engineMode == "local" {
|
||||
guard store.localModeCloudPolishEnabled else { return trimmed }
|
||||
guard !store.apiKey.isEmpty else {
|
||||
throw PolishError.missingAPIKey
|
||||
}
|
||||
guard store.shouldRunCloudLLMStep else { return trimmed }
|
||||
return try await polishRemote(
|
||||
trimmed,
|
||||
mode: mode,
|
||||
@@ -117,12 +107,11 @@ public actor PolishingService {
|
||||
client = injectedClient
|
||||
} else {
|
||||
let preset = LLMProvider.provider(id: effectiveProviderId)
|
||||
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
||||
// Pre-existing typo fix: the user-overridden `store.model`
|
||||
// path was returning `preset.defaultModel` on both branches,
|
||||
// silently ignoring the user's custom model field. Restore
|
||||
// the asymmetry so the user override actually wins.
|
||||
let model = store.model.isEmpty ? preset.defaultModel : store.model
|
||||
let (baseURL, model) = Self.resolveLLMEndpoint(
|
||||
store: store,
|
||||
preset: preset,
|
||||
providerIdOverride: providerIdOverride
|
||||
)
|
||||
let apiKey: String
|
||||
if effectiveProviderId == "deepseek" {
|
||||
let preconfigured = PreconfiguredKeys.deepseek
|
||||
@@ -138,7 +127,11 @@ public actor PolishingService {
|
||||
}
|
||||
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
|
||||
}
|
||||
let prompt = resolvedSystemPrompt(for: mode, override: systemPrompt)
|
||||
let prompt = resolvedSystemPrompt(
|
||||
for: mode,
|
||||
override: systemPrompt,
|
||||
providerId: effectiveProviderId
|
||||
)
|
||||
let budget = effectiveTimeout(for: trimmed)
|
||||
|
||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
||||
@@ -161,17 +154,26 @@ public actor PolishingService {
|
||||
/// existing `store.systemPrompt` behaviour so every other call site
|
||||
/// is byte-identical to before. An explicit `override` wins over
|
||||
/// both paths so callers (and tests) can pin a specific prompt.
|
||||
private func resolvedSystemPrompt(for mode: PolishMode, override: String? = nil) -> String {
|
||||
private func resolvedSystemPrompt(
|
||||
for mode: PolishMode,
|
||||
override: String? = nil,
|
||||
providerId: String? = nil
|
||||
) -> String {
|
||||
if let override, !override.isEmpty {
|
||||
return override
|
||||
}
|
||||
switch mode {
|
||||
case .polish:
|
||||
return store.systemPrompt
|
||||
return store.resolvedPolishSystemPrompt(providerId: providerId)
|
||||
case .translate(let targetLocaleId):
|
||||
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||
let pid = store.providerId
|
||||
return TranslationPrompt.make(target: target, providerId: pid)
|
||||
let pid = providerId ?? store.providerId
|
||||
return TranslationPrompt.make(
|
||||
target: target,
|
||||
providerId: pid,
|
||||
scenarioId: store.polishScenarioId,
|
||||
uiLanguage: store.uiLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +182,41 @@ public actor PolishingService {
|
||||
let scaled = timeout + (Double(text.count) / 200.0) * 2.0
|
||||
return min(max(scaled, timeout), 120)
|
||||
}
|
||||
|
||||
/// Picks base URL + model for one remote polish call.
|
||||
///
|
||||
/// When `providerIdOverride` is set (local engine pins DeepSeek),
|
||||
/// always use that preset's defaults so cloud-engine settings
|
||||
/// (e.g. Qwen base URL saved while testing cloud mode) are not
|
||||
/// mixed with the pinned provider's API key. Cloud engine passes
|
||||
/// `nil` and keeps honoring `store.baseURL` / `store.model`.
|
||||
internal static func resolveLLMEndpoint(
|
||||
store: AppGroupStore,
|
||||
preset: LLMProvider,
|
||||
providerIdOverride: String?
|
||||
) -> (baseURL: String, model: String) {
|
||||
if providerIdOverride != nil {
|
||||
return (preset.defaultBaseURL, preset.defaultModel)
|
||||
}
|
||||
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
||||
// Pre-existing typo fix: the user-overridden `store.model`
|
||||
// path was returning `preset.defaultModel` on both branches,
|
||||
// silently ignoring the user's custom model field. Restore
|
||||
// the asymmetry so the user override actually wins.
|
||||
let model = store.model.isEmpty ? preset.defaultModel : store.model
|
||||
return (baseURL, model)
|
||||
}
|
||||
}
|
||||
|
||||
extension PolishingService.PolishError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noTranscript:
|
||||
return "No transcript to polish."
|
||||
case .timeout:
|
||||
return "LLM polish timed out."
|
||||
case .missingAPIKey:
|
||||
return "Missing API key (local: set PreconfiguredKeys.deepseek; cloud: Settings API key)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user