[P0-③] API Key data flow fix
- AppGroup.defaults: in DEBUG, missing App Group is a hard fatalError
with a precise remediation message (was a soft print + .standard
fallback, which desynced the keyboard extension from the main App).
Release keeps the fallback + NSLog so end-users still get a usable app.
- KeyboardViewController.loadPersistedLocale now prints a masked DEBUG
view of the live App Group config (provider, baseURL, masked key,
model, mode, locale) so the extension's view is visible in the
device console.
- KeyboardViewController.handleFinalTranscript now routes by typed error:
noAPIKey → red error '未配置 API Key · 请在主 App 设置中填写'
http 401 → red error 'API Key 无效 (401) · 请检查主 App 设置'
http 429 → red error 'API 限流 (429) · 请稍后再试'
other → insert raw transcript + generic error badge
- APISettingsCard gains a 'Test connection' button that runs a single
client.polish('ping') round-trip and surfaces the typed result inline.
- PolishingService.timeout raised 12s → 15s to match LLMClient.request
timeout (was racing and discarding successful responses in 12–15s).
- Tests: 4 new cases (HTTP 429, transport timeout, App Group cross-process,
AppGroupStore→LLMClient noAPIKey). All 8 tests pass on iPhone 16e sim.
xcodebuild iOS Simulator: SUCCEEDED
xcodebuild test: 8/8 passed
This commit is contained in:
@@ -10,6 +10,14 @@ import OSGKeyboardShared
|
||||
struct APISettingsCard: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@State private var showKey: Bool = false
|
||||
@State private var testStatus: TestStatus = .idle
|
||||
|
||||
private enum TestStatus: Equatable {
|
||||
case idle
|
||||
case running
|
||||
case success(String)
|
||||
case failure(String)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -54,6 +62,8 @@ struct APISettingsCard: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Divider().background(Palette.divider)
|
||||
testConnectionRow
|
||||
}
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
@@ -121,4 +131,104 @@ struct APISettingsCard: View {
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
// MARK: - Test connection
|
||||
|
||||
private var testConnectionRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Connection")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
Spacer()
|
||||
Button(action: runTest) {
|
||||
HStack(spacing: 6) {
|
||||
if testStatus == .running {
|
||||
ProgressView().controlSize(.mini)
|
||||
} else {
|
||||
Image(systemName: testIcon)
|
||||
.foregroundStyle(testTint)
|
||||
}
|
||||
Text(testButtonLabel)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(testTint)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(testStatus == .running)
|
||||
}
|
||||
if let detail = testDetail {
|
||||
Text(detail)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(testTint)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
private var testButtonLabel: String {
|
||||
switch testStatus {
|
||||
case .idle: return "Test connection"
|
||||
case .running: return "Testing…"
|
||||
case .success: return "OK · retry"
|
||||
case .failure: return "Failed · retry"
|
||||
}
|
||||
}
|
||||
|
||||
private var testIcon: String {
|
||||
switch testStatus {
|
||||
case .idle, .running: return "bolt.horizontal.circle"
|
||||
case .success: return "checkmark.circle.fill"
|
||||
case .failure: return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var testTint: Color {
|
||||
switch testStatus {
|
||||
case .idle, .running: return Palette.accent
|
||||
case .success: return Palette.success
|
||||
case .failure: return Palette.danger
|
||||
}
|
||||
}
|
||||
|
||||
private var testDetail: String? {
|
||||
switch testStatus {
|
||||
case .idle, .running: return nil
|
||||
case .success(let s): return s
|
||||
case .failure(let s): return s
|
||||
}
|
||||
}
|
||||
|
||||
private func runTest() {
|
||||
testStatus = .running
|
||||
let store = AppGroupStore()
|
||||
let client = OpenAICompatibleClient(
|
||||
baseURL: store.baseURL,
|
||||
apiKey: store.apiKey,
|
||||
model: store.model
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
let reply = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
|
||||
testStatus = .success("连接成功 · “\(reply.prefix(60))”")
|
||||
} catch LLMError.noAPIKey {
|
||||
testStatus = .failure("未填写 API Key")
|
||||
} catch let error as LLMError {
|
||||
switch error {
|
||||
case .http(let status):
|
||||
testStatus = .failure("HTTP \(status)")
|
||||
case .rateLimited:
|
||||
testStatus = .failure("API 限流 (429)")
|
||||
case .transport(let msg):
|
||||
testStatus = .failure("网络错误: \(msg)")
|
||||
default:
|
||||
testStatus = .failure(error.errorDescription ?? "\(error)")
|
||||
}
|
||||
} catch {
|
||||
testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user