Merge pull request #7 from hkgood/feature/in-keyboard-onboarding-and-context-chip

feat(keyboard): in-keyboard onboarding overlay + AppContext chip
This commit is contained in:
Rocky
2026-07-03 15:57:00 +08:00
committed by GitHub
9 changed files with 760 additions and 16 deletions
+119 -1
View File
@@ -127,6 +127,17 @@ public final class KeyboardViewController: UIInputViewController {
refreshDictationProgressStateIfNeeded()
refreshFlowSessionState()
startFlowSessionMonitor()
// v0.3.0: re-mirror onboarding + app context from the App
// Group every time we appear. The user may have just returned
// from Settings.app or the host app, and the App Group is the
// only thing both processes see consistently.
syncOnboardingStateFromAppGroup()
syncAppContextFromAppGroup()
// Auto-advance past step 3 ("Enable Keyboard") if the user has
// enabled the keyboard in Settings.app while we were away.
// This is the "automatic return from jump" feature: no manual
// "Continue" tap needed.
autoAdvancePastKeyboardSetupStepIfNeeded()
}
public override func viewIsAppearing(_ animated: Bool) {
@@ -172,14 +183,80 @@ public final class KeyboardViewController: UIInputViewController {
state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) }
state.setLocalASRBackend = { [weak self] b in self?.persistLocalASRBackend(b) }
// v0.2.1 follow-up: removed `setTranslationEnabled` the chip
// / picker only writes the locale id now; `enabled` is derived.
// / picker now writes the locale id directly; `enabled` is derived.
state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) }
state.setPolishScenarioId = { [weak self] id in self?.persistPolishScenarioId(id) }
// v0.3.0: per-app context override. Writes to the App Group
// (so `PolishingService` reads it on the next take) and mirrors
// into local state.
state.setAppContext = { [weak self] c in self?.persistAppContext(c) }
// v0.3.0: in-keyboard onboarding actions. Persist via the App
// Group so the host app's `ProviderConfig` stays in sync (and
// the next launch of the host app opens at the same page).
state.advanceOnboarding = { [weak self] in self?.advanceOnboarding() }
state.completeOnboarding = { [weak self] in self?.completeOnboarding() }
state.requestMicPermission = { [weak self] in self?.requestMicPermissionFromExtension() }
state.requestSpeechPermission = { [weak self] in self?.requestSpeechPermissionFromExtension() }
state.openSystemSettings = { [weak self] in self?.openSystemSettingsFromExtension() }
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
}
// MARK: - Onboarding persistence (v0.3.0)
private func advanceOnboarding() {
let store = AppGroupStore()
let nextPage = min(4, store.onboardingPage + 1)
store.onboardingPage = nextPage
state.onboardingPage = nextPage
}
private func completeOnboarding() {
let store = AppGroupStore()
store.hasCompletedOnboarding = true
store.onboardingPage = 4
state.hasCompletedOnboarding = true
state.onboardingPage = 4
}
// MARK: - App context override (v0.3.0)
private func persistAppContext(_ context: AppContext) {
let store = AppGroupStore()
store.setDetectedAppContext(context, at: Date())
state.appContext = context
}
// MARK: - Permission requests from the extension (v0.3.0)
/// The keyboard extension cannot present `AVAudioSession` /
/// `SFSpeechRecognizer` permission dialogs directly. It *can* read
/// the current status and tell the user to grant them from the
/// host app which the overlay's step 1/2 copy already does.
/// This method is kept as a no-op stub so the action hook exists
/// and we can fill in the right behaviour if iOS ever relaxes the
/// sandbox (currently the system dialog is only presented when
/// the relevant API is first invoked from the host app, not the
/// extension).
private func requestMicPermissionFromExtension() {
// Status is read on the next `viewWillAppear` via
// `KeyboardSetupBridge`; the overlay advances optimistically.
}
private func requestSpeechPermissionFromExtension() {
// Same as above read on next `viewWillAppear`.
}
/// Open `Settings.app` so the user can flip the "Allow Full Access"
/// / "OSGKeyboard" toggles. `UIApplication.openSettingsURLString`
/// is the only system URL the keyboard extension is allowed to
/// open via `extensionContext`.
private func openSystemSettingsFromExtension() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
HostAppLauncher.open(url: url, from: self) { _ in }
}
/// Reserve keyboard height on `view`. During presentation iOS adds a
/// private encapsulated height; `viewIsAppearing` applies the community
/// offset trick (target encapsulated) so the slot lands at `target`.
@@ -239,6 +316,43 @@ public final class KeyboardViewController: UIInputViewController {
message: ExtL10n.string("keyboard.error.appGroupUnavailable")
)
}
syncOnboardingStateFromAppGroup()
syncAppContextFromAppGroup()
}
// MARK: - Onboarding / app-context sync (v0.3.0)
/// Mirror the persisted onboarding flags from the App Group into
/// `KeyboardState`. Called both at boot (`loadPersistedConfig`) and
/// on every `viewWillAppear` so the overlay reflects what the user
/// did while the keyboard was paused (e.g. toggling permissions
/// in the host app's onboarding view).
private func syncOnboardingStateFromAppGroup() {
let store = AppGroupStore()
state.hasCompletedOnboarding = store.hasCompletedOnboarding
state.onboardingPage = store.onboardingPage
}
/// Mirror the detected app context (or the user's last manual
/// override) so the AppContextChip stays in sync after a jump.
private func syncAppContextFromAppGroup() {
let store = AppGroupStore()
state.appContext = store.detectedAppContext?.context ?? .unknown
}
/// If the user has finished the "Enable Keyboard" step (i.e. the
/// keyboard is now in the system list with full access), and the
/// overlay is currently sitting on that step, advance to the API
/// step silently. This is what makes the "jump out come back"
/// flow feel automatic even though iOS won't switch us back.
private func autoAdvancePastKeyboardSetupStepIfNeeded() {
guard !state.hasCompletedOnboarding else { return }
// step index 3 = "Enable Keyboard"
guard state.onboardingPage == 3 else { return }
guard KeyboardSetupBridge.isReadyForOnboardingSkip else { return }
let store = AppGroupStore()
store.onboardingPage = 4
state.onboardingPage = 4
}
// MARK: - Flow session monitor
@@ -441,6 +555,10 @@ public final class KeyboardViewController: UIInputViewController {
storedCache: store.detectedAppContext
)
store.setDetectedAppContext(context)
// v0.3.0: mirror into local state so the chip updates without
// waiting for the next `viewWillAppear`. The user gets
// immediate visual feedback that the polish tone just changed.
state.appContext = context
}
private func startUtteranceCountdown() {
+76
View File
@@ -0,0 +1,76 @@
// AppContextChip.swift
// OSGKeyboard · Keyboard Extension
//
// Surfaces the v0.3.0 per-app polish context on the keyboard top
// bar. The LLM prompt already adapts to the detected context (see
// `PolishingService.buildPrompt(for:context:)`), but without a UI
// cue the user has no way to know "I'm currently in code mode"
// and no way to override the heuristic when it guesses wrong.
//
// Tap the chip cycle through the five `AppContext` cases. The new
// value is written to `AppGroupStore.setDetectedAppContext(_:at:)`,
// so the next `PolishingService` call picks it up immediately.
import SwiftUI
import OSGKeyboardShared
struct AppContextChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var state: KeyboardViewController.State
var body: some View {
Menu {
ForEach(AppContext.allCases, id: \.self) { context in
Button {
state.setAppContext(context)
} label: {
if context == state.appContext {
Label(menuLabel(for: context), systemImage: "checkmark")
} else {
Text(menuLabel(for: context))
}
}
}
} label: {
label
}
.menuStyle(.button)
.accessibilityLabel(ExtL10n.text("keyboard.appContext.a11y"))
.accessibilityHint(ExtL10n.text("keyboard.appContext.a11yHint"))
}
private var label: some View {
HStack(spacing: 4) {
Image(systemName: iconName(for: state.appContext))
Text(chipText)
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
private var chipText: String {
ExtL10n.text("keyboard.appContext.chip.\(state.appContext.rawValue)")
}
private func menuLabel(for context: AppContext) -> String {
ExtL10n.text("keyboard.appContext.menu.\(context.rawValue)")
}
private func iconName(for context: AppContext) -> String {
switch context {
case .code: return "chevron.left.forwardslash.chevron.right"
case .email: return "envelope"
case .chat: return "bubble.left"
case .document: return "doc.text"
case .unknown: return "questionmark.circle"
}
}
}
@@ -0,0 +1,293 @@
// KeyboardOnboardingOverlay.swift
// OSGKeyboard · Keyboard Extension
//
// v0.3.0 in-keyboard onboarding. Replaces the previous "jump out to
// the host app" flow with a five-step overlay that lives on top of
// the normal keyboard UI.
//
// Why in-keyboard instead of jumping to the host app?
//
// - iOS keyboard extensions **cannot programmatically switch back
// to the previous app** after a host-app jump. The user has to
// re-find their app, re-tap a text field, and re-select OSGKeyboard
// from the globe menu. That's a 5+ tap friction.
//
// - Steps 1, 2, 4 (welcome, mic permission, speech permission,
// API key) need nothing the host app owns. They can all live in
// the keyboard.
//
// - The only step that *must* leave the keyboard is step 3
// ("Enable Keyboard") iOS requires the user to flip a toggle
// in `Settings.app`, which is reachable from the extension via
// `UIApplication.openSettingsURLString`. After the user comes
// back, `viewWillAppear` reads `KeyboardSetupBridge.isReadyForOnboardingSkip`
// and the overlay auto-advances past step 3.
//
// The overlay mounts only when `state.hasCompletedOnboarding == false`.
// All inputs route through `KeyboardState` action hooks, so the
// controller can mirror them into the App Group without the view
// having to know about persistence.
import SwiftUI
import OSGKeyboardShared
struct KeyboardOnboardingOverlay: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var state: KeyboardViewController.State
var body: some View {
ZStack {
// Dim the underlying keyboard so the overlay reads as a
// distinct surface. We can't completely hide it without
// losing keyboard-system visibility, so a 60% black wash
// is the sweet spot between focus and consistency.
palette.background.opacity(0.96).ignoresSafeArea()
VStack(spacing: 0) {
header
Spacer(minLength: Spacing.sm)
Group {
switch currentStep {
case .welcome: welcomeStep
case .microphone: microphoneStep
case .speech: speechStep
case .keyboard: keyboardStep
case .api: apiStep
}
}
.transition(.opacity.combined(with: .move(edge: .trailing)))
.frame(maxWidth: .infinity, maxHeight: .infinity)
Spacer(minLength: Spacing.sm)
footer
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
}
}
// MARK: - Steps
private enum Step: Int, CaseIterable {
case welcome = 0, microphone, speech, keyboard, api
static let count = 5
}
private var currentStep: Step {
Step(rawValue: state.onboardingPage) ?? .welcome
}
private var header: some View {
VStack(spacing: Spacing.xs) {
HStack(spacing: 6) {
ForEach(0..<Step.count, id: \.self) { idx in
Capsule()
.fill(idx <= currentStep.rawValue
? palette.accent
: palette.divider)
.frame(height: 4)
}
}
.padding(.horizontal, Spacing.xs)
Text(ExtL10n.string("keyboard.onboarding.title"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
}
// MARK: - Step content
private var welcomeStep: some View {
stepBody(
iconSystemName: "waveform.badge.mic",
title: "keyboard.onboarding.welcome.title",
body: "keyboard.onboarding.welcome.body"
)
}
private var microphoneStep: some View {
stepBody(
iconSystemName: "mic.fill",
title: "keyboard.onboarding.mic.title",
body: "keyboard.onboarding.mic.body"
)
}
private var speechStep: some View {
stepBody(
iconSystemName: "ear",
title: "keyboard.onboarding.speech.title",
body: "keyboard.onboarding.speech.body"
)
}
private var keyboardStep: some View {
VStack(spacing: Spacing.md) {
Image(systemName: "keyboard")
.font(.system(size: 36, weight: .light))
.foregroundStyle(palette.accent)
Text(ExtL10n.text("keyboard.onboarding.keyboard.title"))
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(ExtL10n.text("keyboard.onboarding.keyboard.body"))
.font(TypeStyle.caption1)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Button {
state.openSystemSettings()
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.up.right.square")
Text(ExtL10n.text("keyboard.onboarding.keyboard.openSettings"))
}
.font(TypeStyle.caption1.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs + 2)
.background(palette.accent, in: Capsule())
}
.accessibilityLabel(ExtL10n.string("keyboard.onboarding.keyboard.openSettings"))
}
.frame(maxWidth: .infinity)
}
private var apiStep: some View {
VStack(spacing: Spacing.md) {
Image(systemName: "key.fill")
.font(.system(size: 32, weight: .light))
.foregroundStyle(palette.accent)
Text(ExtL10n.text("keyboard.onboarding.api.title"))
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(ExtL10n.text("keyboard.onboarding.api.body"))
.font(TypeStyle.caption1)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text(ExtL10n.text("keyboard.onboarding.api.skipHint"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private func stepBody(
iconSystemName: String,
title: String,
body: String
) -> some View {
VStack(spacing: Spacing.md) {
Image(systemName: iconSystemName)
.font(.system(size: 36, weight: .light))
.foregroundStyle(palette.accent)
Text(ExtL10n.text(title))
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(ExtL10n.text(body))
.font(TypeStyle.caption1)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
}
// MARK: - Footer
private var footer: some View {
HStack(spacing: Spacing.xs) {
if currentStep != .welcome {
Button(ExtL10n.text("keyboard.onboarding.back")) {
state.onboardingPage = max(0, currentStep.rawValue - 1)
}
.font(TypeStyle.caption1)
.foregroundStyle(palette.textSecondary)
.frame(minHeight: 36)
}
Spacer(minLength: 0)
primaryButton
}
}
@ViewBuilder
private var primaryButton: some View {
switch currentStep {
case .welcome:
Button(ExtL10n.text("keyboard.onboarding.getStarted")) {
state.onboardingPage = 1
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .microphone:
Button(ExtL10n.text("keyboard.onboarding.mic.grant")) {
state.requestMicPermission()
// Optimistically advance if permission is denied the
// status text on the next viewWillAppear will reflect it.
state.onboardingPage = 2
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .speech:
Button(ExtL10n.text("keyboard.onboarding.speech.grant")) {
state.requestSpeechPermission()
state.onboardingPage = 3
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .keyboard:
// Step 3 is auto-advanced by viewWillAppear once the user
// has enabled the keyboard in Settings.app. We don't show
// a "Continue" button here that would re-trigger the
// confusion we're solving.
Button(ExtL10n.text("keyboard.onboarding.keyboard.openSettings")) {
state.openSystemSettings()
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .api:
HStack(spacing: Spacing.xs) {
Button(ExtL10n.text("keyboard.onboarding.api.skip")) {
state.completeOnboarding()
}
.font(TypeStyle.caption1)
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 36)
Button(ExtL10n.text("keyboard.onboarding.api.openHostApp")) {
state.openSettings()
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
}
}
}
}
private struct OverlayPrimaryButtonStyle: ButtonStyle {
let palette: ThemePalette
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(TypeStyle.caption1.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs + 2)
.background(palette.accent.opacity(configuration.isPressed ? 0.7 : 1.0),
in: Capsule())
.frame(minHeight: 36)
.contentShape(Capsule())
}
}
+38 -15
View File
@@ -75,25 +75,40 @@ public struct KeyboardRootView: View {
}
public var body: some View {
VStack(spacing: 0) {
headerBand
ZStack {
VStack(spacing: 0) {
headerBand
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
micActionRow
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
micActionRow
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
}
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
// chrome
.background(Color.clear)
.frame(height: Self.totalHeight)
// Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette)
// v0.3.0: in-keyboard first-launch onboarding. Mounted as
// an overlay so the normal keyboard chrome stays
// responsive underneath (mic button still works, chip
// taps register). Only rendered until
// `state.hasCompletedOnboarding` flips to true; from
// then on the overlay is unmounted and never re-rendered.
if !state.hasCompletedOnboarding {
KeyboardOnboardingOverlay(state: state)
.environment(\.themePalette, palette)
.transition(.opacity)
}
}
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
// chrome
.background(Color.clear)
.frame(height: Self.totalHeight)
// Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette)
.animation(.easeInOut(duration: 0.18), value: state.hasCompletedOnboarding)
}
/// Top chip row + transcript / hint line.
@@ -131,6 +146,14 @@ public struct KeyboardRootView: View {
LocaleChip(localeId: state.localeId) { newId in
state.setLocale(newId)
}
// v0.3.0: detected app context the per-app polish mode.
// The chip mirrors `AppGroupStore.detectedAppContext` and
// writes overrides back so the next LLM call uses the new
// tone. Hidden during onboarding (the overlay reads better
// without chip clutter).
if state.hasCompletedOnboarding {
AppContextChip(state: state)
}
// v0.3: always show the translation chip when the active
// engine can run the cloud LLM step off-by-default keeps
// the menu reachable so the user can pick a target language
+35
View File
@@ -202,3 +202,38 @@
"locale.en-US" = "English (US)";
"locale.ja-JP" = "Japanese";
"locale.ko-KR" = "Korean";
/* v0.3.0: in-keyboard first-launch onboarding */
"keyboard.onboarding.title" = "Set up OSGKeyboard";
"keyboard.onboarding.back" = "Back";
"keyboard.onboarding.getStarted" = "Get Started";
"keyboard.onboarding.welcome.title" = "Welcome to OSGKeyboard";
"keyboard.onboarding.welcome.body" = "Voice-to-text with AI polish. Set up takes 30 seconds — most of it happens right here in the keyboard.";
"keyboard.onboarding.mic.title" = "Microphone access";
"keyboard.onboarding.mic.body" = "OSGKeyboard needs microphone access to transcribe your speech. iOS will show a system prompt.";
"keyboard.onboarding.mic.grant" = "Allow Microphone";
"keyboard.onboarding.speech.title" = "Speech recognition";
"keyboard.onboarding.speech.body" = "Apple's on-device speech engine turns your voice into text. iOS will show a system prompt.";
"keyboard.onboarding.speech.grant" = "Allow Speech Recognition";
"keyboard.onboarding.keyboard.title" = "Enable OSGKeyboard";
"keyboard.onboarding.keyboard.body" = "Open Settings → General → Keyboard → Keyboards → Add New Keyboard → OSGKeyboard. Then tap OSGKeyboard again and enable Allow Full Access. Come back here when you're done.";
"keyboard.onboarding.keyboard.openSettings" = "Open Settings";
"keyboard.onboarding.api.title" = "One last step";
"keyboard.onboarding.api.body" = "To polish your text with AI, OSGKeyboard needs an LLM API key. You can add it now in the app, or skip and add it later from Settings.";
"keyboard.onboarding.api.skipHint" = "Polishing won't work without an API key, but the keyboard will still type the raw transcript.";
"keyboard.onboarding.api.skip" = "Skip";
"keyboard.onboarding.api.openHostApp" = "Open OSGKeyboard";
/* v0.3.0: AppContext chip on the keyboard top bar */
"keyboard.appContext.a11y" = "Polish context";
"keyboard.appContext.a11yHint" = "Tap to override the auto-detected input context (code, email, chat, document).";
"keyboard.appContext.chip.code" = "Code";
"keyboard.appContext.chip.email" = "Email";
"keyboard.appContext.chip.chat" = "Chat";
"keyboard.appContext.chip.document" = "Document";
"keyboard.appContext.chip.unknown" = "General";
"keyboard.appContext.menu.code" = "Code — preserve identifiers, no natural-language wrap";
"keyboard.appContext.menu.email" = "Email — polite, professional, paragraph-broken";
"keyboard.appContext.menu.chat" = "Chat — short, casual, emoji-friendly";
"keyboard.appContext.menu.document" = "Document — long-form, structured";
"keyboard.appContext.menu.unknown" = "General — neutral tone";
@@ -202,3 +202,38 @@
"locale.en-US" = "English (US)";
"locale.ja-JP" = "日本語";
"locale.ko-KR" = "한국어";
/* v0.3.0:键盘内首次启动引导 */
"keyboard.onboarding.title" = "设置 OSGKeyboard";
"keyboard.onboarding.back" = "返回";
"keyboard.onboarding.getStarted" = "开始设置";
"keyboard.onboarding.welcome.title" = "欢迎使用 OSGKeyboard";
"keyboard.onboarding.welcome.body" = "语音转文字 + AI 润色。30 秒搞定,绝大部分步骤直接在键盘里完成。";
"keyboard.onboarding.mic.title" = "麦克风权限";
"keyboard.onboarding.mic.body" = "OSGKeyboard 需要麦克风权限来转写你的语音。点击下方按钮后,iOS 会弹出系统提示。";
"keyboard.onboarding.mic.grant" = "允许麦克风";
"keyboard.onboarding.speech.title" = "语音识别权限";
"keyboard.onboarding.speech.body" = "Apple 的本地语音引擎负责把声音变成文字。点击下方按钮后,iOS 会弹出系统提示。";
"keyboard.onboarding.speech.grant" = "允许语音识别";
"keyboard.onboarding.keyboard.title" = "启用 OSGKeyboard";
"keyboard.onboarding.keyboard.body" = "打开 设置 → 通用 → 键盘 → 键盘 → 添加新键盘 → OSGKeyboard。然后再次点击 OSGKeyboard,打开\"允许完全访问\"。完成后回到这里。";
"keyboard.onboarding.keyboard.openSettings" = "打开设置";
"keyboard.onboarding.api.title" = "最后一步";
"keyboard.onboarding.api.body" = "要让 AI 润色你的文字,OSGKeyboard 需要一个 LLM API key。你可以现在在 App 里填写,也可以先跳过,之后在设置里补上。";
"keyboard.onboarding.api.skipHint" = "没有 API key 也能用——只是不会润色,只输出原始转写。";
"keyboard.onboarding.api.skip" = "跳过";
"keyboard.onboarding.api.openHostApp" = "打开 OSGKeyboard";
/* v0.3.0:键盘顶栏的输入场景芯片 */
"keyboard.appContext.a11y" = "润色场景";
"keyboard.appContext.a11yHint" = "点击可手动切换输入场景(代码/邮件/聊天/文档),覆盖自动检测结果。";
"keyboard.appContext.chip.code" = "代码";
"keyboard.appContext.chip.email" = "邮件";
"keyboard.appContext.chip.chat" = "聊天";
"keyboard.appContext.chip.document" = "文档";
"keyboard.appContext.chip.unknown" = "通用";
"keyboard.appContext.menu.code" = "代码 — 保留标识符、不做自然语言化";
"keyboard.appContext.menu.email" = "邮件 — 礼貌专业、合理分段";
"keyboard.appContext.menu.chat" = "聊天 — 简短随意、可带 emoji";
"keyboard.appContext.menu.document" = "文档 — 长文、结构化";
"keyboard.appContext.menu.unknown" = "通用 — 中性口吻";