feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
+90 -1
View File
@@ -29,8 +29,15 @@ struct OSGKeyboardApp: App {
var body: some Scene {
WindowGroup {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--edit-demo") {
if ProcessInfo.processInfo.arguments.contains("--whats-new-host") {
// Approach A: Notes-like host only; real keyboard extension overlays it.
Self.makeWhatsNewHostView()
} else if ProcessInfo.processInfo.arguments.contains("--edit-demo") {
EditDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--ai-demo") {
AIKeyboardDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
ClipboardHistoryDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
ThemedRoot {
EditPagerUITestHarness()
@@ -62,4 +69,86 @@ struct OSGKeyboardApp: App {
#endif
}
}
#if DEBUG
@MainActor
private static func makeWhatsNewHostView() -> some View {
let args = ProcessInfo.processInfo.arguments
let scenario = whatsNewScenario(from: args) ?? .edit
let language = whatsNewLanguage(from: args)
let seed = whatsNewSeedText(for: scenario, language: language)
WhatsNewDemoScenario.clear()
WhatsNewDemoScenario.arm(scenario, seedText: seed, language: language)
if let defaults = AppGroup.defaultsIfAvailable {
defaults.set(true, forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding)
// Force extension ExtL10n / SharedL10n into the demo language.
defaults.set(
(language == .en ? AppUILanguage.english : AppUILanguage.chinese).rawValue,
forKey: AppGroupConfiguration.Keys.uiLanguage
)
// Clipboard demo needs history + suggestion strip flags on.
if scenario == .clipboard {
defaults.set(true, forKey: AppGroupConfiguration.Keys.clipboardHistoryEnabled)
defaults.set(
true,
forKey: AppGroupConfiguration.Keys.clipboardCandidateBarEnabled
)
}
defaults.synchronize()
}
return NotesHostDemoView(
scenario: scenario,
seedText: seed,
language: language
)
}
private static func whatsNewScenario(from args: [String]) -> WhatsNewDemoScenario? {
if let paired = args.first(where: { $0.hasPrefix("--whats-new-scenario=") }) {
let raw = String(paired.dropFirst("--whats-new-scenario=".count))
return WhatsNewDemoScenario(rawValue: raw)
}
if let idx = args.firstIndex(of: "--whats-new-scenario"),
args.index(after: idx) < args.endIndex
{
return WhatsNewDemoScenario(rawValue: args[args.index(after: idx)])
}
return nil
}
private static func whatsNewLanguage(from args: [String]) -> WhatsNewDemoScenario.Language {
if let paired = args.first(where: { $0.hasPrefix("--whats-new-lang=") }) {
let raw = String(paired.dropFirst("--whats-new-lang=".count))
return WhatsNewDemoScenario.Language(rawValue: raw) ?? .zh
}
if let idx = args.firstIndex(of: "--whats-new-lang"),
args.index(after: idx) < args.endIndex
{
return WhatsNewDemoScenario.Language(
rawValue: args[args.index(after: idx)]
) ?? .zh
}
return .zh
}
private static func whatsNewSeedText(
for scenario: WhatsNewDemoScenario,
language: WhatsNewDemoScenario.Language
) -> String {
switch (scenario, language) {
case (.edit, .zh):
return "明天下午三点开会讨论方案"
case (.edit, .en):
return "Meeting at 3pm tomorrow to discuss the plan"
case (.ai, .zh):
return "周末想找个地方放松一下"
case (.ai, .en):
return "Looking for a place to relax this weekend"
case (.clipboard, .zh):
return "待办:"
case (.clipboard, .en):
return "Todo: "
}
}
#endif
}
+18 -8
View File
@@ -22,18 +22,22 @@
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
<div id="top">
<h1>OSGKeyboard Privacy Policy</h1>
<p><strong>Last updated:</strong> August 10, 2026</p>
<p><strong>Last updated:</strong> August 12, 2026</p>
<p>OSGKeyboard is a custom iOS keyboard that turns your voice into text. This policy explains what data the app processes and how it is used.</p>
<h2>What we collect</h2>
<ul>
<li><strong>Voice audio</strong> — captured only while you actively record. The default on-device mode transcribes locally with Apples speech APIs and does not upload raw audio. If you explicitly enable cloud recognition, recordings are sent to the speech provider you configure for transcription; that providers privacy policy applies. OSGKeyboard does not store or proxy the audio on its own servers.</li>
<li><strong>Transcribed text</strong> — when AI polish is enabled, the final text (not audio) is sent to the LLM provider whose API key you configured (e.g. OpenAI, DeepSeek) for punctuation and formatting. Without an API key, raw ASR text is inserted and no polish request is sent.</li>
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text is sent to the same configured LLM provider to generate an answer. When that provider supports server-side web search, the provider may retrieve public web results to answer time-sensitive questions. Search queries and retrieved snippets are processed by that provider under its own privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the same configured LLM provider to generate an answer. When that provider supports server-side web search, the provider may retrieve public web results to answer time-sensitive questions. Search queries and retrieved snippets are processed by that provider under its own privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
<li><strong>AI idle suggestions</strong> — the main app may periodically download public hint titles (e.g. hot topics) from OSGKeyboards hint feed and, using your configured polish LLM, compress them into short on-device suggestion labels. Suggestion packs are cached in the App Group for the keyboard; the keyboard extension does not fetch the feed itself.</li>
<li><strong>Clipboard history (opt-in)</strong> — when you enable Clipboard History, the keyboard may read plain-text pasteboard content while it is visible and keep recent copies on device for the history panel and optional suggestion strip. Within about 30 seconds after a copy, AI mode may also offer clipboard-related idle suggestions; tapping one sends the clipboard text with that prompt to your configured LLM. Saying “clipboard” in an AI question does the same, because naming it is how you choose that text; every other AI question is sent without it. In both cases the clipboard body travels as separate quoted data, never as instructions. Clipboard history is local-only and not synced via iCloud.</li>
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors preferences, usage statistics, and voice history through your private iCloud account.</li>
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this devices host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
<li><strong>On-device typing learning</strong> — the Chinese keyboard stores selected words and candidate frequencies in the App Group on your device. OSGKeyboard does not upload this user dictionary.</li>
</ul>
<p>Clipboard sensitive-content filtering is applied to newly captured items. Existing history is retained until you use the confirmed clear action.</p>
<h2>What we do not collect</h2>
<ul>
@@ -46,7 +50,7 @@
<ul>
<li><strong>Microphone</strong> — required for voice input and background voice sessions.</li>
<li><strong>Speech recognition</strong> — required for on-device transcription.</li>
<li><strong>Full Access</strong> — required so the keyboard can reach the microphone, read your API key, and communicate with the main app. Full Access does not grant us access to everything you type; we do not exfiltrate keystrokes.</li>
<li><strong>Full Access</strong> — required so the keyboard can reach the microphone, read your API key, communicate with the main app, and—only when clipboard history is enabled—read clipboard text. Full Access does not grant us access to everything you type; we do not exfiltrate keystrokes.</li>
</ul>
<h2>Third parties</h2>
@@ -55,6 +59,7 @@
<h2>Data retention</h2>
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; preferences, statistics, and history may sync via your private iCloud account.</p>
<p><strong>Voice history</strong> — successful transcripts may be saved in the main apps History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
<p><strong>Clipboard history</strong> — stays in this devices App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
<h2>Contact</h2>
<p>Questions: open an issue at <a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a>.</p>
@@ -62,17 +67,21 @@
<hr id="zh">
<h1>OSGKeyboard 隐私政策</h1>
<p><strong>更新日期:</strong>2026 年 8 月 10</p>
<p><strong>更新日期:</strong>2026 年 8 月 12</p>
<p>OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。本政策说明应用处理哪些数据及用途。</p>
<h2>我们处理的数据</h2>
<ul>
<li><strong>语音音频</strong> — 仅在你主动录音时采集。默认本地模式通过 Apple 语音能力在设备端转写,不会上传原始录音。若你主动启用云端识别,录音会发送到你配置的语音服务商完成转写,并适用该服务商的隐私政策。OSGKeyboard 自身不会存储或中转音频。</li>
<li><strong>转写文字</strong> — 当你配置了 LLM API Key 并启用润色时,最终文字(非音频)会发送到该服务商以整理标点和格式。未填写 API Key 时直接插入原始识别结果,不会发起润色请求。</li>
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字会发送到同一套已配置的 LLM 服务商以生成回答。若该服务商支持服务端联网搜索,可能为回答时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字,或你点选空闲建议后生成的提问,会发送到同一套已配置的 LLM 服务商以生成回答。若该服务商支持服务端联网搜索,可能为回答时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
<li><strong>AI 空闲建议</strong> — 主 App 可能定期从 OSGKeyboard 热点建议源下载公开标题,并使用你配置的润色 LLM 压缩为短标签,缓存在 App Group 供键盘读取;键盘扩展本身不会直接请求该源。</li>
<li><strong>剪贴板历史(可选)</strong> — 当你开启「剪贴板历史」后,键盘在可见期间可能读取纯文本粘贴板内容并在本机保存,供历史面板与可选建议条使用。复制后约 30 秒内,AI 模式也可能展示剪贴板相关空闲建议;点选后会将剪贴板正文与提示一并发送到你配置的 LLM。在 AI 提问中明确说出「剪贴板」同样如此——说出即代表你选择了这段材料;其余 AI 提问不会附带剪贴板。两种情况下剪贴板正文都作为单独引用的数据发送,绝不作为指令。剪贴板历史仅存本机,不经 iCloud 同步。</li>
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像偏好、统计与语音历史。</li>
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
</ul>
<p>剪贴板敏感内容过滤仅在新内容采集时执行;已有历史会继续保留,直到你使用带确认的清空操作。</p>
<h2>我们不收集的内容</h2>
<ul>
@@ -85,7 +94,7 @@
<ul>
<li><strong>麦克风</strong> — 语音输入与后台语音会话所需。</li>
<li><strong>语音识别</strong> — 端侧转写所需。</li>
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key与主 App 通信。完全访问不代表我们会收集全部击键内容。</li>
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key与主 App 通信,并仅在你开启剪贴板历史后读取剪贴板文字。完全访问不代表我们会收集全部击键内容。</li>
</ul>
<h2>第三方</h2>
@@ -94,6 +103,7 @@
<h2>数据保留</h2>
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;偏好、统计与历史可能经私有 iCloud 账户同步。</p>
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
<h2>联系</h2>
<p>问题反馈:<a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a></p>
@@ -0,0 +1,126 @@
// AIHintRefreshService.swift
// OSGKeyboard · Main App
//
// Silent 12h refresh: fetch remote packs, compress titles with polish LLM,
// merge local evergreen cards, write App Group ready packs for the keyboard.
import Foundation
import OSGKeyboardShared
@MainActor
enum AIHintRefreshService {
private static var inFlight: Task<Void, Never>?
static func refreshIfNeeded(reason: String) {
guard AIHintStore.shouldRefresh() else {
OSGDiag.log("AIHintRefresh skip (fresh) reason=\(reason)", category: "hints")
return
}
guard inFlight == nil else {
OSGDiag.log("AIHintRefresh skip (inFlight) reason=\(reason)", category: "hints")
return
}
inFlight = Task {
defer { inFlight = nil }
await runRefresh(reason: reason)
}
}
private static func runRefresh(reason: String) async {
AIHintStore.markAttempt()
OSGDiag.log("AIHintRefresh start reason=\(reason)", category: "hints")
// The manifest only supplies fallback dates, so a manifest failure must
// not stop the packs, and one locale's failure must not stop the other.
let manifest = try? await fetchManifest()
for locale in AIHintFeedEndpoints.supportedLocales {
if Task.isCancelled { return }
guard AIHintStore.shouldRefresh(locale: locale) else { continue }
do {
let pack = try await readyPack(locale: locale, manifest: manifest)
AIHintStore.saveReadyPack(pack)
OSGDiag.log(
"AIHintRefresh wrote locale=\(locale) cards=\(pack.cards.count)",
category: "hints"
)
} catch {
// Strategy B: keep this locale's previous successful ready pack.
OSGDiag.log(
"AIHintRefresh failed locale=\(locale) reason=\(reason) "
+ "error=\(error.localizedDescription)",
category: "hints"
)
}
}
}
private static func readyPack(
locale: String,
manifest: AIHintManifest?
) async throws -> AIHintPack {
let remote = try await fetchPack(locale: locale)
let filtered = remote.cards.filter { card in
let hay = card.displayText + card.prompt
return !hay.contains("历史上的今天")
&& !hay.localizedCaseInsensitiveContains("on this day")
}
// Prefer remote clipboard cards when present; always keep local evergreen.
let merged = merge(remote: filtered, locale: locale)
let compressed = await AIHintKeywordCompressor().compress(
cards: merged,
locale: locale
)
return AIHintPack(
locale: locale,
generatedAt: remote.generatedAt ?? manifest?.generatedAt,
expiresAt: remote.expiresAt ?? manifest?.expiresAt,
version: max(remote.version, 1),
cards: compressed,
refreshedAt: Date()
)
}
private static func merge(remote: [AIHintCard], locale: String) -> [AIHintCard] {
var byID: [String: AIHintCard] = [:]
for card in AIHintLocalCatalog.cards(locale: locale) {
byID[card.id] = card
}
for card in remote {
// Local clipboard display/prompt stays authoritative when ids collide
// with baseline remote cards; otherwise remote wins for hot topics.
if card.requiresClipboard30s, byID[card.id] != nil {
continue
}
if card.source == "local", byID.keys.contains(where: { $0.hasPrefix("local-\(locale)-") }) {
// Drop remote baseline duplicates when we already have local evergreen.
if ["clipboard", "capability", "economy"].contains(card.category) {
continue
}
}
byID[card.id] = card
}
return Array(byID.values).sorted { $0.priority > $1.priority }
}
private static func fetchManifest() async throws -> AIHintManifest {
let (data, response) = try await URLSession.shared.data(from: AIHintFeedEndpoints.manifestURL)
try validateHTTP(response)
return try JSONDecoder().decode(AIHintManifest.self, from: data)
}
private static func fetchPack(locale: String) async throws -> AIHintPack {
let url = AIHintFeedEndpoints.packURL(locale: locale)
let (data, response) = try await URLSession.shared.data(from: url)
try validateHTTP(response)
return try JSONDecoder().decode(AIHintPack.self, from: data)
}
private static func validateHTTP(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
}
}
+11 -1
View File
@@ -1,7 +1,7 @@
// AppPermissions.swift
// OSGKeyboard · Main App
//
// Central permission status for onboarding and Flow session startup.
// Central permission handling for onboarding, Flow, and clipboard access.
import AVFoundation
import Speech
@@ -78,6 +78,16 @@ enum AppPermissions {
UIApplication.shared.open(url)
}
/// Performs an explicit direct read so iOS can present paste authorization
/// and create the app's "Paste from Other Apps" settings entry.
@MainActor
@discardableResult
static func requestPasteAccess() -> Bool {
let pasteboard = UIPasteboard.general
guard pasteboard.hasStrings else { return false }
return pasteboard.string != nil
}
/// Home-screen guidance when Flow permissions are missing after onboarding.
static var homePermissionGuidanceMessage: String {
let micMissing = micStatus != .granted
+204 -94
View File
@@ -46,7 +46,7 @@ final class FlowSessionManager: ObservableObject {
private let polisher = PolishingService()
/// AI-mode turns are intentionally process-local and never persisted.
private let aiConversations = AIConversationStore()
/// Cached ASR instance. v0.2.0: the only on-device backend is iOS
/// Cached ASR instance. The only on-device backend is iOS
/// `SpeechAnalyzer`, which has no warm-up step we can hand the
/// factory-built service straight back without going through the
/// old `OnDeviceModelWarmup` registry.
@@ -550,7 +550,7 @@ final class FlowSessionManager: ObservableObject {
Task { @MainActor [weak self] in
await self?.reactivateCaptureIfNeeded()
// v0.2.0: iOS `SpeechAnalyzer` is bundled with the OS; no
// iOS `SpeechAnalyzer` is bundled with the OS; no
// on-device weights to reload after a background trip.
self?.bindSessionASRIfNeeded()
// ASR warmup stays on first mic press never on foreground bounce.
@@ -1283,6 +1283,168 @@ final class FlowSessionManager: ObservableObject {
if let conversationID = command.aiConversationID {
Task { await aiConversations.removeConversation(conversationID) }
}
case .submitAIQuestion:
guard command.resolvedUtteranceMode == .aiQuestion else {
storeRejectedStart(
command,
message: AppL10n.string("flow.error.aiQuestionFailed"),
status: .error
)
return
}
let question = command.aiQuestionText?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !question.isEmpty else {
storeRejectedStart(
command,
message: AppL10n.string("flow.error.aiQuestionFailed"),
status: .error
)
return
}
let startDecision = FlowStartTransactionPolicy.decide(
incomingUtteranceID: command.utteranceId,
deadlineAt: command.startDeadlineAt,
hostState: hostUtteranceState
)
switch startDecision {
case .idempotent:
refreshHostReady()
return
case .rejectBusy, .rejectExpired:
let status: FlowResult.Status =
startDecision == .rejectExpired ? .timeout : .error
storeRejectedStart(
command,
message: AppL10n.string("flow.error.recognitionInterrupted"),
status: status
)
return
case .accept:
break
}
guard prepareUtteranceIdentity(
utteranceId: command.utteranceId,
commandSeq: command.commandSeq
) else { return }
currentUtteranceMode = .aiQuestion
pendingAIConversationID = command.aiConversationID
pendingEditSourceText = nil
pendingSourceHistoryEntryID = nil
pendingSourceHistoryEntryRevision = nil
startingUtteranceId = nil
startTransactionDeadlineAt = nil
FlowSessionBridge.clearStartTransaction()
isUtteranceRecording = false
isUtteranceProcessing = true
refreshHostReady()
let conversationID = command.aiConversationID
let utteranceId = command.utteranceId
let commandSeq = command.commandSeq
let sessionId = command.sessionId
Task { @MainActor [weak self] in
await self?.answerPrefilledAIQuestion(
question: question,
conversationID: conversationID,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
)
}
}
}
/// Clipboard body for an explicitly spoken clipboard request. Opt-in
/// history is the authorization gate, and while the keyboard is visible its
/// newest stored item is the live pasteboard. Unlike the idle hint cards,
/// naming the clipboard out loud is not bound to the 30s hint window.
private func clipboardMaterialForAIQuestion(store: AppGroupStore) -> String? {
guard store.clipboardHistoryEnabled else { return nil }
return ClipboardHistoryStore().newestEntry?.text
}
/// Skip ASR and answer a prefilled AI hint / typed question.
private func answerPrefilledAIQuestion(
question: String,
conversationID: UUID?,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64
) async {
guard let conversationID else {
guard claimTerminal(utteranceId: utteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.aiQuestionFailed"),
kind: .generic,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
)
return
}
storeRawCandidate(
question,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
)
let pipelineStore = AppGroupStore()
do {
let service = try AIQuestionService.configured(
store: pipelineStore,
conversations: aiConversations
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let answer = try await service.answer(
question: question,
conversationID: conversationID,
targetLocaleID: pipelineStore.translationTargetLocaleId
) { [weak self] partial in
Task { @MainActor in
self?.publishStreamingAIAnswerIfNeeded(
partial,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID,
force: partial.isEmpty
)
}
}
publishStreamingAIAnswerIfNeeded(
answer,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID,
force: true
)
guard claimTerminal(utteranceId: utteranceId) else { return }
await service.commitSuccessfulTurn(
question: question,
answer: answer,
conversationID: conversationID
)
storeFinalizedResult(
answer,
warning: nil,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID
)
} catch {
guard claimTerminal(utteranceId: utteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.aiQuestionFailed"),
kind: .generic,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID
)
}
}
@@ -1398,30 +1560,6 @@ final class FlowSessionManager: ObservableObject {
)
}
private func storeCurrentFinal(_ text: String, warning: String? = nil) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
storeCurrentError(AppL10n.string("flow.error.noSpeech"), kind: .noSpeech)
return
}
guard let activeSessionId, let currentUtteranceId else { return }
FlowSessionBridge.writeResult(
FlowResult(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
commandSeq: currentCommandSeq,
status: .final,
text: trimmed,
warning: warning,
rawText: trimmed,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
utteranceMode: currentUtteranceMode,
aiConversationID: pendingAIConversationID
)
)
}
private func storeCurrentError(
_ message: String,
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
@@ -1777,16 +1915,19 @@ final class FlowSessionManager: ObservableObject {
manager.currentPartial = ""
case .failure(let message):
manager.asrFailureMessage = message
manager.debug("asr error: \(message)")
manager.debug(
"asr error category=asrFailure errorBytes=\(message.utf8.count)"
)
FlowTrace.warn(
"asr.outcome.failed",
"engine=\(manager.store.engineMode) streaming=\(useStreaming ? 1 : 0) "
+ "partialLen=\(manager.currentPartial.count) "
+ "bestPartialLen=\(manager.bestPartialSnapshot.count) error=\(message)"
+ "bestPartialLen=\(manager.bestPartialSnapshot.count) "
+ "errorCategory=asrFailure errorBytes=\(message.utf8.count)"
)
// Prefer any non-empty partial over a hard no-speech failure.
// finishProcessing used to clear bestPartialSnapshot and race
// finalize into an empty transcript even when ASR had text.
// Clearing `bestPartialSnapshot` here would race finalize
// into an empty transcript even when ASR had usable text.
let recovery = [
manager.currentPartial,
manager.bestPartialSnapshot
@@ -1976,39 +2117,6 @@ final class FlowSessionManager: ObservableObject {
debug("utterance failed: \(message)")
}
private func finishProcessing(
withError message: String,
kind: FlowSessionKeys.TranscriptionErrorKind = .asrFailed
) {
guard claimTerminal(utteranceId: currentUtteranceId) else { return }
isUtteranceProcessing = false
startingUtteranceId = nil
startTransactionDeadlineAt = nil
FlowSessionBridge.clearStartTransaction()
utteranceRecordingStartedAt = nil
utteranceSafetyTask?.cancel()
utteranceSafetyTask = nil
finalizeTask?.cancel()
finalizeTask = nil
chunkedPipeline = nil
capture.cancelUtterance()
releaseCaptureAfterPiPUtteranceIfNeeded()
currentPartial = ""
lastFinal = ""
lastFinalWithPauseMarks = ""
bestPartialSnapshot = ""
utterancePCMSamples = []
chunkWarnings = []
storeCurrentError(message, kind: kind)
pendingFieldContext = nil
clearPendingInstructionState()
utteranceGeneration &+= 1
currentUtteranceId = nil
currentCommandSeq = 0
refreshHostReady()
debug("utterance processing failed: \(message)")
}
private func claimTerminal(utteranceId: UUID?) -> Bool {
guard let utteranceId, !terminalUtteranceIds.contains(utteranceId) else {
return false
@@ -2207,6 +2315,23 @@ final class FlowSessionManager: ObservableObject {
return
}
let spoken = AIClipboardPrompt.resolveSpoken(
question: text,
material: clipboardMaterialForAIQuestion(store: pipelineStore)
)
guard case .ready(let question) = spoken else {
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.clipboardUnavailable"),
kind: .generic,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq,
aiConversationID: aiConversationID
)
return
}
do {
let service = try AIQuestionService.configured(
store: pipelineStore,
@@ -2218,7 +2343,7 @@ final class FlowSessionManager: ObservableObject {
let publishCommandSeq = finalizeCommandSeq
let publishConversationID = aiConversationID
let answer = try await service.answer(
question: text,
question: question,
conversationID: aiConversationID,
targetLocaleID: pipelineStore.translationTargetLocaleId
) { [weak self] partial in
@@ -2244,7 +2369,7 @@ final class FlowSessionManager: ObservableObject {
)
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
await service.commitSuccessfulTurn(
question: text,
question: question,
answer: answer,
conversationID: aiConversationID
)
@@ -2404,13 +2529,13 @@ final class FlowSessionManager: ObservableObject {
if isInstructionMode {
FlowDiagnostics.log(
"instruction edit failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
"\(Self.safeErrorLogMetadata(error))"
)
FlowTrace.warn(
"editLastInput.failed",
"elapsed=\(FlowTrace.seconds(since: polishStarted))s "
+ "cancelled=\(error is CancellationError ? 1 : 0) "
+ "error=\(error.localizedDescription)"
+ "\(Self.safeErrorLogMetadata(error))"
)
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
@@ -2432,14 +2557,14 @@ final class FlowSessionManager: ObservableObject {
)
FlowDiagnostics.log(
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
"\(Self.safeErrorLogMetadata(error))"
)
FlowTrace.warn(
"polish.failed",
"mode=\(Self.polishModeLogLabel(polishMode)) engine=\(engineMode) "
+ "elapsed=\(FlowTrace.seconds(since: polishStarted))s "
+ "cancelled=\(error is CancellationError ? 1 : 0) "
+ "error=\(error.localizedDescription)"
+ "\(Self.safeErrorLogMetadata(error))"
)
FlowTrace.transcript(
"polish.fallback",
@@ -2651,7 +2776,7 @@ final class FlowSessionManager: ObservableObject {
"host.deliveredError",
"kind=\(kind.rawValue) status=\(status.rawValue) "
+ "utterance=\(utteranceId.uuidString.prefix(8)) commandSeq=\(commandSeq) "
+ "message=\(message)"
+ "messageBytes=\(message.utf8.count)"
)
FlowSessionBridge.writeResult(
FlowResult(
@@ -2775,10 +2900,13 @@ final class FlowSessionManager: ObservableObject {
)
return resolved
case .failure(let message):
FlowDiagnostics.log("batch fallback failed: \(message)")
FlowDiagnostics.log(
"batch fallback failed category=asrFailure errorBytes=\(message.utf8.count)"
)
FlowTrace.warn(
"asr.batchFallback.failed",
"samples=\(samples.count) rms=\(FlowTrace.rms(samples)) error=\(message)"
"samples=\(samples.count) rms=\(FlowTrace.rms(samples)) "
+ "errorCategory=asrFailure errorBytes=\(message.utf8.count)"
)
return currentText
case .cancelled:
@@ -2788,7 +2916,7 @@ final class FlowSessionManager: ObservableObject {
}
private func asrWaitTimeout() -> TimeInterval {
// v0.2.0: local engine is iOS `SpeechAnalyzer` only, so the
// Local engine is iOS `SpeechAnalyzer` only, so the
// previous Qwen3-specific timeout collapses into the shared
// local path.
if store.engineMode == "local" {
@@ -2866,27 +2994,9 @@ final class FlowSessionManager: ObservableObject {
FlowDiagnostics.log(message)
}
// MARK: - Temporary Flow debug panel (remove after orange-mic investigation)
/// Snapshot for the on-screen debug panel. Safe to call from the main actor.
func makeDebugRows() -> [FlowDebugRow] {
let snapshot = FlowSessionBridge.readySnapshot()
let hostRows = FlowDebugAppGroupSnapshot.rows()
let memRows: [FlowDebugRow] = [
FlowDebugRow("isActive", isActive ? "1" : "0"),
FlowDebugRow("isStarting", isStarting ? "1" : "0"),
FlowDebugRow("coldStart", isColdStartHandoff ? "1" : "0"),
FlowDebugRow("engineLive", capture.engineIsLive ? "1" : "0"),
FlowDebugRow("audioFresh", capture.engineHasRecentAudio(maxAge: 2) ? "1" : "0"),
FlowDebugRow("mem.reason", snapshot?.reason.rawValue ?? "nil"),
FlowDebugRow("utt.rec", isUtteranceRecording ? "1" : "0"),
FlowDebugRow("utt.proc", isUtteranceProcessing ? "1" : "0"),
FlowDebugRow("sessionId", activeSessionId.map { String($0.uuidString.prefix(8)) } ?? "nil"),
FlowDebugRow("warning", sessionWarning == nil ? "0" : "1"),
FlowDebugRow("bridgeReady", FlowSessionBridge.isHostReady() ? "1" : "0")
]
// Prefer App Group snap.reason near the top of the shared block.
return memRows + hostRows
private static func safeErrorLogMetadata(_ error: Error) -> String {
"errorCategory=\(String(reflecting: type(of: error))) "
+ "errorBytes=\(error.localizedDescription.utf8.count)"
}
private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) {
+133
View File
@@ -0,0 +1,133 @@
// AIKeyboardDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// What's New 1.7.0 recording host. Uses the **real** AI Agent settings page
// and the **real** `AIKeyboardView` (compiled into the app target) driven by a
// scripted `KeyboardState` no ASR / LLM. Launch with `--ai-demo`.
#if DEBUG
import SwiftUI
import OSGKeyboardShared
struct AIKeyboardDemoView: View {
private enum Scene: Equatable {
case settings
case keyboard
}
private static let question = "周末去哪儿玩比较合适?"
private static let answer =
"可以去近郊走走:上午逛古镇或公园,下午找一家口碑好的咖啡馆休息,傍晚再吃顿当地特色菜。若想轻松一点,选人少的湖边步道也很合适。"
@StateObject private var config = ProviderConfig.shared
@StateObject private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@State private var scene: Scene = .settings
@State private var levelTick = 0.35
var body: some View {
ZStack {
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
// Both scenes sit on the bottom band so what's-new crop matches Ext chrome.
VStack(spacing: 0) {
Spacer(minLength: 0)
switch scene {
case .settings:
ThemedRoot {
NavigationStack {
AIAgentSettingsView(config: config)
}
}
.preferredColorScheme(.light)
.frame(maxWidth: .infinity)
// Keep under what's-new crop (~327 pt visible at 3x).
.frame(height: 300)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 8)
.padding(.bottom, 24)
.transition(.opacity)
case .keyboard:
AIKeyboardView(
state: state,
typing: typing,
onInsert: { _ in }
)
.background(Palette.light.background.ignoresSafeArea(edges: .bottom))
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
}
.environment(\.locale, Locale(identifier: "zh-Hans"))
.task { await runTimeline() }
}
// MARK: - Scripted timeline (real view models)
private func runTimeline() async {
prepareKeyboardState()
config.uiLanguage = .chinese
config.aiResponseLength = .medium
try? await sleep(2.4)
withAnimation(.easeInOut(duration: 0.35)) {
scene = .keyboard
}
try? await sleep(1.2)
let utteranceID = UUID()
state.aiSession.enter()
state.aiSession.beginPreparing(utteranceID: utteranceID)
try? await sleep(0.35)
state.aiSession.beginListening(utteranceID: utteranceID)
state.level = 0.4
for _ in 0..<8 {
try? await sleep(0.2)
levelTick = Double.random(in: 0.25...0.9)
state.level = levelTick
state.aiSession.updateTranscript(Self.question, utteranceID: utteranceID)
}
state.aiSession.beginRecognizing(utteranceID: utteranceID)
try? await sleep(0.55)
state.aiSession.beginGenerating(question: Self.question, utteranceID: utteranceID)
try? await sleep(0.7)
// Progressive draft so the real answer area updates like production.
let chars = Array(Self.answer)
var index = 0
let step = 4
while index < chars.count {
index = min(index + step, chars.count)
state.aiSession.receivePartialAnswer(
String(chars[..<index]),
utteranceID: utteranceID
)
try? await sleep(0.05)
}
state.aiSession.receiveAnswer(Self.answer, utteranceID: utteranceID)
try? await sleep(1.0)
state.aiSession.markAnswerInserted(offersSend: true)
try? await sleep(1.1)
state.aiSession.markAnswerSent()
try? await sleep(1.6)
}
private func prepareKeyboardState() {
state.surface = .ai
state.aiServiceAvailable = true
state.micDisabled = false
state.layoutWidth = 390
state.usesIPadLayoutMetrics = false
state.micVoiceAvailability = .ready
state.aiSession.enter()
}
/// Slow-mo for screenshot-sequence recording.
private func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
}
}
#endif
+42 -18
View File
@@ -35,13 +35,22 @@ struct APISettingsCard: View {
title: AppL10n.string("api.model"),
placeholder: LLMProvider.provider(id: config.providerId).defaultModel,
model: $config.model,
fetchModels: fetchModels
providerIdentity: config.providerId,
endpointIdentity: config.baseURL,
credentialIdentity: config.apiKey,
makeFetchModelsRequest: makeFetchModelsRequest
)
.id(config.providerId)
rowDivider
thinkingRow
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
SettingsProviderToolsRow(
providerIdentity: config.providerId,
endpointIdentity: config.baseURL,
credentialIdentity: config.apiKey,
modelIdentity: config.model,
makeValidateRequest: makeValidateRequest
)
}
.surfaceCard(enabled: showsSurface)
}
@@ -72,25 +81,40 @@ struct APISettingsCard: View {
.settingsListRow()
}
private func validateConnection() async throws {
@MainActor
private func makeValidateRequest() -> ProviderToolRequest<Void> {
// Use on-screen config not a fresh AppGroupStore so a just-typed
// key is visible even if Keychain write is still settling.
let client = LLMClientFactory.make(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model,
thinkingEnabled: config.llmThinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
let providerID = config.providerId
let baseURL = config.baseURL
let apiKey = config.apiKey
let model = config.model
let thinkingEnabled = config.llmThinkingEnabled
return ProviderToolRequest(providerIdentity: providerID) {
let client = LLMClientFactory.make(
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
model: model,
thinkingEnabled: thinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
}
}
private func fetchModels() async throws -> [String] {
try await ProviderModelService.listLLMModels(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
currentModel: config.model
)
@MainActor
private func makeFetchModelsRequest() -> ProviderToolRequest<[String]> {
let providerID = config.providerId
let baseURL = config.baseURL
let apiKey = config.apiKey
let currentModel = config.model
return ProviderToolRequest(providerIdentity: providerID) {
try await ProviderModelService.listLLMModels(
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
currentModel: currentModel
)
}
}
}
+32 -22
View File
@@ -22,7 +22,13 @@ struct ASRSettingsCard: View {
genericRows
}
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
SettingsProviderToolsRow(
providerIdentity: config.asrProviderId,
endpointIdentity: config.asrBaseURL,
credentialIdentity: config.asrApiKey,
modelIdentity: config.asrModel,
makeValidateRequest: makeValidateRequest
)
}
.surfaceCard(enabled: showsSurface)
}
@@ -51,7 +57,10 @@ struct ASRSettingsCard: View {
title: AppL10n.string("settings.asr.model"),
placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId),
model: $config.asrModel,
fetchModels: fetchModels
providerIdentity: config.asrProviderId,
endpointIdentity: config.asrBaseURL,
credentialIdentity: config.asrApiKey,
makeFetchModelsRequest: makeFetchModelsRequest
)
.id(config.asrProviderId)
}
@@ -108,16 +117,6 @@ struct ASRSettingsCard: View {
isMonospaced: true
)
}
rowDivider
SettingsProviderRow(title: AppL10n.string("settings.provider.note")) {
Text(volcengineFields.usesAPIKeyAuth
? "settings.asr.volcengine.note.apiKey"
: "settings.asr.volcengine.note.appToken")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
@@ -152,18 +151,29 @@ struct ASRSettingsCard: View {
config.asrApiKey = fields.encodedAPIKey
}
private func validateConnection() async throws {
@MainActor
private func makeValidateRequest() -> ProviderToolRequest<Void> {
let persisted = AppGroupStore()
let live = LiveConfigurationStore(config: config, fallback: persisted)
try await CloudASRConnectionCheck.validate(store: live)
let store = LiveConfigurationStore(config: config, fallback: persisted)
let providerID = config.asrProviderId
return ProviderToolRequest(providerIdentity: providerID) {
try await CloudASRConnectionCheck.validate(store: store)
}
}
private func fetchModels() async throws -> [String] {
try await ProviderModelService.listASRModels(
providerId: config.asrProviderId,
baseURL: config.asrBaseURL,
apiKey: config.asrApiKey,
currentModel: config.asrModel
)
@MainActor
private func makeFetchModelsRequest() -> ProviderToolRequest<[String]> {
let providerID = config.asrProviderId
let baseURL = config.asrBaseURL
let apiKey = config.asrApiKey
let currentModel = config.asrModel
return ProviderToolRequest(providerIdentity: providerID) {
try await ProviderModelService.listASRModels(
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
currentModel: currentModel
)
}
}
}
@@ -0,0 +1,265 @@
// ClipboardHistoryDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// What's New 1.7.0 recording host. Voice chrome + clipboard panel are the
// **real** extension views (`KeyboardTopControls`, `RecordButton`,
// `KeyboardTranslationMenuButton`, `ClipboardHistoryPanelView`, ) driven by
// scripted `KeyboardState`. Launch with `--clipboard-demo`.
#if DEBUG
import SwiftUI
import OSGKeyboardShared
struct ClipboardHistoryDemoView: View {
private enum Layout {
static let micSize: CGFloat = 121
static let undoSize: CGFloat = 44
static let micToButtonGap: CGFloat = 8
static let actionClusterTopGap: CGFloat = Spacing.xl
static let micUpwardAdjustment: CGFloat =
(actionClusterTopGap - micToButtonGap) / 2
}
@StateObject private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@StateObject private var history = ClipboardHistoryStore(
defaults: UserDefaults(suiteName: "osg.whatsnew.clipboard.demo")
)
@Environment(\.colorScheme) private var colorScheme
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
var body: some View {
ZStack {
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
VStack(spacing: 0) {
Spacer(minLength: 0)
keyboardChrome
.background(palette.background.ignoresSafeArea(edges: .bottom))
.overlay(alignment: .top) {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
}
}
}
.environment(\.themePalette, palette)
.environment(\.locale, Locale(identifier: "zh-Hans"))
.preferredColorScheme(.light)
.task { await runTimeline() }
}
// MARK: - Real keyboard chrome (voice + clipboard overlay)
private var keyboardChrome: some View {
ZStack {
voiceSurface
.opacity(state.clipboardOverlay == .none ? 1 : 0)
.allowsHitTesting(state.clipboardOverlay == .none)
if state.clipboardOverlay == .historyPanel {
ClipboardHistoryPanelView(
history: history,
onClose: { state.clipboardOverlay = .none },
onClear: { history.clearAll() },
onInsert: { text in
state.clipboardSuggestionText = text
state.clipboardOverlay = .none
},
onDelete: { history.remove(id: $0) },
pastePermissionHint: nil
)
}
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: .infinity)
.frame(height: KeyboardChromeLayout.totalHeight)
.padding(.bottom, 24)
.environment(\.themePalette, palette)
}
private var voiceSurface: some View {
VStack(spacing: 0) {
topBar.frame(height: KeyboardTopBarMetrics.height)
Color.clear.frame(height: Layout.actionClusterTopGap)
Spacer(minLength: 0)
micActionRow
}
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
.frame(maxWidth: .infinity)
}
private var topBar: some View {
HStack(spacing: Spacing.xs) {
if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
ClipboardSuggestionBar(
text: suggestion,
onInsert: {},
onDismiss: { state.clipboardSuggestionText = nil }
)
} else {
KeyboardBrandLogo(action: {})
Spacer(minLength: 0)
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: { _ in }
)
}
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
private var micActionRow: some View {
VStack(spacing: Layout.micToButtonGap) {
HStack(spacing: 0) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .leading) {
demoKey(
systemName: "arrow.uturn.backward",
width: Layout.undoSize,
height: Layout.undoSize
)
.offset(y: -Layout.micUpwardAdjustment)
}
RecordButton(
phase: .idleReady,
level: 0,
isEnabled: true,
onToggle: {},
onPressingChanged: { _ in },
onEditLongPressBegan: nil
)
.frame(width: Layout.micSize, height: Layout.micSize)
.offset(y: -Layout.micUpwardAdjustment)
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .trailing) {
KeyboardTranslationMenuButton(
palette: palette,
targetLocaleId: TranslationLanguageCatalog.offLocaleId,
onSelect: { _ in }
)
.equatable()
.frame(width: Layout.undoSize, height: Layout.undoSize)
.offset(y: -Layout.micUpwardAdjustment)
}
}
.frame(height: Layout.micSize)
GeometryReader { proxy in
let widths = KeyboardChromeLayout.actionKeyWidthsWithoutGlobe(
availableWidth: proxy.size.width
)
HStack(spacing: KeyboardChromeLayout.actionKeySpacing) {
demoKey(systemName: "delete.backward", width: widths.side)
demoKey(
title: ExtL10n.string("common.newline"),
width: widths.center
)
demoKey(spaceStyle: true, width: widths.side2)
}
}
.frame(height: KeyboardChromeLayout.actionKeyHeight)
}
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
}
/// Same chrome as Ext `RectangularToolbarButton` / native key surface.
private func demoKey(
systemName: String? = nil,
title: String? = nil,
spaceStyle: Bool = false,
width: CGFloat,
height: CGFloat = KeyboardChromeLayout.actionKeyHeight
) -> some View {
NativeKeyboardKeySurface(
isPressed: false,
fill: NativeKeyboardKeyColors.fill(for: colorScheme),
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
border: palette.divider,
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
) {
Group {
if spaceStyle {
Capsule()
.fill(NativeKeyboardKeyColors.text(for: colorScheme).opacity(0.22))
.frame(width: 31, height: 4)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
} else if let title {
Text(title)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
}
}
}
.frame(width: width, height: height)
}
// MARK: - Timeline
private func runTimeline() async {
prepareState()
seedHistory()
// Hold on voice idle so translation + clipboard chip are readable.
try? await sleep(2.2)
withAnimation(.easeInOut(duration: 0.2)) {
state.clipboardOverlay = .historyPanel
}
try? await sleep(2.0)
if let head = history.newestEntry {
withAnimation(.easeInOut(duration: 0.25)) {
state.clipboardSuggestionText = head.text
state.clipboardOverlay = .none
}
}
try? await sleep(2.4)
}
private func prepareState() {
state.surface = .voice
state.micVoiceAvailability = .ready
state.layoutWidth = 390
state.usesIPadLayoutMetrics = false
state.showsSystemGlobeKey = false
state.clipboardOverlay = .none
state.clipboardSuggestionText = nil
state.openClipboardPanel = {
state.clipboardOverlay = .historyPanel
}
state.dismissClipboardOverlay = {
state.clipboardOverlay = .none
}
state.insertClipboardText = { text in
state.clipboardSuggestionText = text
state.clipboardOverlay = .none
}
}
private func seedHistory() {
history.clearAll()
_ = history.ingest(rawText: "订单号 OSG-20260811-8842", changeCount: 3)
_ = history.ingest(rawText: "https://osglab.com", changeCount: 2)
_ = history.ingest(rawText: "明天下午三点会议室见", changeCount: 1)
history.reload()
}
private func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
}
}
#endif
@@ -2,15 +2,17 @@
// OSGKeyboard · Main App
//
// Observes usage + dictionary counts and feeds the shared
// `UsageStatsCluster` (phone stacked / iPad split).
// `UsageStatsCluster` (phone stacked / iPad split). Optional `header`
// (e.g. glass preview field) sits on the 7-day chart card.
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardHostSupport
struct HomeUsageStatsSection: View {
let layout: UsageStatsCluster.Layout
struct HomeUsageStatsSection<Header: View>: View {
let layout: UsageStatsClusterLayout
var compact: Bool = false
@ViewBuilder var header: () -> Header
@ObservedObject private var stats = UsageStatisticsStore.shared
@ObservedObject private var config = ProviderConfig.shared
@@ -26,7 +28,8 @@ struct HomeUsageStatsSection: View {
dictationDurationSeconds: stats.dictationDurationSeconds,
translationCharacterCount: stats.translationCharacterCount,
dictionaryTermCount: dictionaryCount,
compact: compact
compact: compact,
header: header
)
.onAppear(perform: refreshDictionaryCount)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
@@ -45,6 +48,12 @@ struct HomeUsageStatsSection: View {
}
}
extension HomeUsageStatsSection where Header == EmptyView {
init(layout: UsageStatsClusterLayout, compact: Bool = false) {
self.init(layout: layout, compact: compact, header: { EmptyView() })
}
}
#if DEBUG
#Preview("Phone stacked") {
ThemedRoot {
@@ -1,25 +1,22 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
// Bottom tab bar five icons, no labels.
// Bottom tab bar three icons, no labels.
// Capsule uses iOS 26 Liquid Glass (.regular.interactive) so content
// behind the dock refracts through on scroll.
// History + dictionary live as Home cards (not dock tabs).
import SwiftUI
import OSGKeyboardShared
enum AppTab: Int, CaseIterable {
case keyboard
case history
case dictionary
case styles
case settings
var icon: MaterialIconName {
switch self {
case .keyboard: return .keyboard
case .history: return .menuBook // unused history uses SF Symbol
case .dictionary: return .menuBook // unused dictionary uses SF Symbol
case .styles: return .menuBook // unused styles uses SF Symbol
case .settings: return .settings
}
@@ -28,8 +25,6 @@ enum AppTab: Int, CaseIterable {
/// SF Symbol overrides shared with the Mac and iPad sidebars.
var sfSymbol: String? {
switch self {
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .styles: return "text.badge.star"
default: return nil
}
@@ -38,8 +33,6 @@ enum AppTab: Int, CaseIterable {
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
case .history: return "tab.history"
case .dictionary: return "tab.dictionary"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
@@ -51,8 +44,6 @@ enum AppTab: Int, CaseIterable {
var sidebarSystemImage: String {
switch self {
case .keyboard: return "house"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .styles: return "text.badge.star"
case .settings: return "gearshape"
}
@@ -62,36 +53,64 @@ enum AppTab: Int, CaseIterable {
struct MinimalTabBar: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.colorScheme) private var colorScheme
@Namespace private var selectionGlassNamespace
@Binding var selection: AppTab
var body: some View {
HStack(spacing: 0) {
ForEach(AppTab.allCases, id: \.rawValue) { tab in
Button {
withAnimation(Motion.quick) { selection = tab }
} label: {
Group {
if let sfSymbol = tab.sfSymbol {
Image(systemName: sfSymbol)
.font(.system(size: 20, weight: .regular))
} else {
MaterialIcon(name: tab.icon, size: 24)
GlassEffectContainer(spacing: 0) {
HStack(spacing: 0) {
ForEach(AppTab.allCases, id: \.rawValue) { tab in
Button {
withAnimation(Motion.soft) {
selection = tab
}
} label: {
Group {
if let sfSymbol = tab.sfSymbol {
Image(systemName: sfSymbol)
.font(.system(size: 20, weight: .regular))
} else {
MaterialIcon(name: tab.icon, size: 24)
}
}
.foregroundStyle(tabIconColor(for: tab))
.frame(maxWidth: .infinity)
.frame(height: 48)
.background {
if selection == tab {
// Capsule, not circle: a circle would inscribe to the
// smaller edge and leave the tab slot looking empty.
Color.clear
.frame(width: 52, height: 44)
.glassEffect(
.regular
.tint(palette.accent.opacity(0.18))
.interactive(),
in: .capsule
)
.glassEffectID(
"main-tab-selection",
in: selectionGlassNamespace
)
.glassEffectTransition(.matchedGeometry)
.matchedGeometryEffect(
id: "main-tab-selection",
in: selectionGlassNamespace
)
}
}
.contentShape(Rectangle())
}
.foregroundStyle(tabIconColor(for: tab))
.frame(maxWidth: .infinity)
.frame(height: 48)
.contentShape(Rectangle())
.buttonStyle(.plain)
.accessibilityLabel(tab.accessibilityKey)
.accessibilityAddTraits(selection == tab ? .isSelected : [])
}
.buttonStyle(.plain)
.accessibilityLabel(tab.accessibilityKey)
.accessibilityAddTraits(selection == tab ? .isSelected : [])
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.glassEffect(.regular.interactive(), in: .capsule)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.glassEffect(.regular.interactive(), in: .capsule)
.frame(maxWidth: 336)
.frame(maxWidth: 280)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, Spacing.xs)
}
+98 -199
View File
@@ -1,28 +1,26 @@
// EditDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// Scripted, keyboard-sized recreation of the extension's `LastInputEditView`
// used ONLY to record the "Edit last input" What's New clip in the simulator.
// It reuses the real shared `EditTextPager`, design tokens, and the real
// `EditSessionState` machine, and steps a fixed timeline (idle hint listening
// processing review swipe apply) with canned text no ASR, no LLM.
// Launched via `--edit-demo` (see OSGKeyboardApp). Not shipped in Release.
// What's New "Edit last input" recording host. Uses the **real**
// `LastInputEditView` (compiled into the app target) driven by a scripted
// `KeyboardState` no ASR / LLM. Opening beat shows the voice mic + hint.
// Launch with `--edit-demo`. Not shipped in Release.
#if DEBUG
import SwiftUI
import OSGKeyboardShared
struct EditDemoView: View {
// Canned material for the clip.
private static let originalText = "明天下午三点开会讨论方案"
private static let editedText = "各位好,明天下午三点在 A 会议室召开方案讨论会,请准时参加。"
private let palette = Palette.light
private enum Scene: Equatable {
case hint
case editing
}
@State private var editSession: EditSessionState = .inactive
@State private var showHint = true
@State private var selectedPage: Int? = 0
@State private var remainingSeconds = 59
@StateObject private var state = KeyboardState()
@State private var scene: Scene = .hint
private var source: EditSessionSource {
let reference = EditableInputReference(
@@ -34,41 +32,60 @@ struct EditDemoView: View {
return EditSessionSource(reference: reference)
}
private var palette: ThemePalette { Palette.light }
var body: some View {
ZStack {
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
VStack(spacing: 0) {
Spacer(minLength: 0)
keyboardPanel
.background(panelBackground)
.overlay(alignment: .top) {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
Group {
switch scene {
case .hint:
hintPanel
case .editing:
LastInputEditView(state: state)
}
}
.background(palette.background.ignoresSafeArea(edges: .bottom))
.overlay(alignment: .top) {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
}
}
}
.environment(\.themePalette, palette)
.environment(\.locale, Locale(identifier: "zh-Hans"))
.preferredColorScheme(.light)
.task { await runTimeline() }
}
private var panelBackground: some View {
palette.background.ignoresSafeArea(edges: .bottom)
}
// MARK: - Opening hint (voice mic + real copy)
// MARK: - Panel (mirrors LastInputEditView layout)
private var keyboardPanel: some View {
VStack(spacing: 0) {
topBar.frame(height: 44)
if showHint {
hintBody
} else {
pages.frame(height: 144)
statusLine.frame(height: 18)
pageIndicator.frame(height: 12)
primaryRow.frame(height: 55)
private var hintPanel: some View {
VStack(spacing: Spacing.sm) {
HStack {
KeyboardBrandLogo(action: {})
Spacer(minLength: 0)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
.frame(height: KeyboardTopBarMetrics.height)
Spacer(minLength: 0)
Text(ExtL10n.string("keyboard.edit.hint.available"))
.font(TypeStyle.footnote)
.foregroundStyle(palette.accent)
RecordButton(
phase: .idleReady,
level: 0,
isEnabled: true,
onToggle: {},
onPressingChanged: { _ in },
onEditLongPressBegan: nil
)
.frame(width: 121, height: 121)
Spacer(minLength: 0)
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
@@ -77,183 +94,65 @@ struct EditDemoView: View {
.padding(.bottom, 24)
}
private var topBar: some View {
HStack {
Text("OSG")
.font(.system(size: 17, weight: .heavy, design: .rounded))
.foregroundStyle(palette.accent)
Spacer(minLength: 0)
Image(systemName: "xmark")
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.frame(width: 44, height: 44)
.background(palette.surfaceElevated.opacity(0.72), in: Circle())
}
// keyboardPanel already contributes 8pt; add the nested 4pt so the
// effective top-bar inset matches the normal voice surface's 12pt.
.padding(.horizontal, Spacing.xs)
}
// Opening frame: idle mic + "" hint.
private var hintBody: some View {
VStack(spacing: Spacing.sm) {
Spacer(minLength: 0)
Text("长按可编辑上一条")
.font(TypeStyle.footnote)
.foregroundStyle(palette.accent)
ZStack {
Circle().fill(palette.accent)
Image(systemName: "mic.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(.white)
}
.frame(width: 64, height: 64)
.shadow(color: palette.accentGlow, radius: 12)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var pages: some View {
EditTextPager(
originalTitle: "原文",
originalText: Self.originalText,
editedTitle: "编辑后",
editedText: editSession.review?.resultText,
selectedPage: $selectedPage
)
}
private var statusLine: some View {
Text(statusText)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.frame(maxWidth: .infinity)
}
private var pageIndicator: some View {
HStack(spacing: 5) {
Circle()
.fill(selectedPage != 1 ? palette.textPrimary : palette.textTertiary.opacity(0.45))
.frame(width: 5, height: 5)
Circle()
.fill(selectedPage == 1 ? palette.textPrimary : palette.textTertiary.opacity(0.45))
.frame(width: 5, height: 5)
}
.opacity(editSession.review == nil ? 0 : 1)
}
private var primaryRow: some View {
HStack(spacing: Spacing.sm) {
helperText(leftHelper)
ZStack {
Capsule().fill(palette.accent)
primaryIcon
}
.frame(width: 150, height: 50)
helperText(rightHelper)
}
}
private func helperText(_ value: String) -> some View {
Text(value)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary.opacity(0.55))
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
}
@ViewBuilder
private var primaryIcon: some View {
switch editSession {
case .processing, .applying, .appending:
ProgressView().tint(.white)
case .review:
Image(systemName: "checkmark")
.font(.system(size: 21, weight: .bold))
.foregroundStyle(.white)
case .listening:
VStack(spacing: 0) {
Text(formatRemaining(remainingSeconds))
.font(.system(size: 10, weight: .semibold, design: .rounded))
.monospacedDigit()
Image(systemName: "mic.fill")
.font(.system(size: 16, weight: .semibold))
}
.foregroundStyle(.white)
default:
Image(systemName: "mic.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(.white)
}
}
// MARK: - Copy (mirrors ExtL10n zh keyboard.edit.*)
private var statusText: String {
switch editSession {
case .listening: return "正在聆听编辑指令"
case .processing: return "正在编辑…"
case .review: return "左右滑动对比原文和结果"
case .applying, .appending: return "正在应用编辑…"
default: return ""
}
}
private var leftHelper: String {
editSession.review == nil ? "说话编辑文字" : "左右滑动对比"
}
private var rightHelper: String {
editSession.review != nil ? "点击应用编辑" : "点击完成编辑"
}
private func formatRemaining(_ seconds: Int) -> String {
"\(seconds / 60):\(String(format: "%02d", seconds % 60))"
}
// MARK: - Scripted timeline
// MARK: - Timeline
private func runTimeline() async {
let src = source
let review = EditReview(source: src, resultText: Self.editedText, utteranceID: UUID())
let review = EditReview(
source: src,
resultText: Self.editedText,
utteranceID: UUID()
)
try? await sleep(1.3) // idle hint
state.editCanReplaceOriginal = true
state.layoutWidth = 390
state.micVoiceAvailability = .ready
state.closeEditMode = {}
state.confirmEditResult = {}
state.stopEditListening = {}
state.beginEditLastInput = {}
state.openSettings = {}
try? await sleep(1.4) // hint hold
withAnimation(.easeInOut(duration: 0.28)) {
scene = .editing
state.editSession = .listening(src)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.listening")
state.level = 0.45
state.utteranceRemainingSeconds = 59
}
for _ in 0..<4 {
try? await sleep(0.4)
state.level = Double.random(in: 0.25...0.85)
state.utteranceRemainingSeconds = max(0, state.utteranceRemainingSeconds - 1)
}
withAnimation(.easeInOut(duration: 0.2)) {
state.editSession = .processing(src)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.processing")
}
try? await sleep(1.5)
withAnimation(.easeInOut(duration: 0.25)) {
showHint = false
editSession = .listening(src)
state.editSession = .review(review)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.review")
}
// Tick the utterance countdown while listening.
for _ in 0..<3 {
try? await sleep(0.5)
remainingSeconds -= 1
// Hold long enough for auto-scroll to+ a beat of reading.
try? await sleep(3.2)
withAnimation(.easeInOut(duration: 0.2)) {
state.editSession = .applying(review)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.applying")
}
withAnimation(.easeInOut(duration: 0.2)) { editSession = .processing(src) }
try? await sleep(1.3)
withAnimation(.easeInOut(duration: 0.25)) {
editSession = .review(review)
selectedPage = 0
}
try? await sleep(1.1)
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.86)) {
selectedPage = 1
}
try? await sleep(1.6)
withAnimation(.easeInOut(duration: 0.2)) { editSession = .applying(review) }
try? await sleep(0.9)
// Stay on applying so the last captured frames are still the real UI.
try? await sleep(2.0)
try? await Task.sleep(nanoseconds: 60_000_000_000)
}
/// Slow-mo for screenshot-sequence recording (~2× wall clock).
private func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
try await Task.sleep(nanoseconds: UInt64(seconds * 2.0 * 1_000_000_000))
}
}
#endif
+45 -46
View File
@@ -27,60 +27,59 @@ struct HistoryView: View {
}()
var body: some View {
NavigationStack {
ZStack {
palette.background.ignoresSafeArea()
ZStack {
palette.background.ignoresSafeArea()
if store.entries.isEmpty {
emptyState
} else {
list
}
if store.entries.isEmpty {
emptyState
} else {
list
}
.background(palette.background)
.navigationTitle("history.title")
.navigationBarTitleDisplayMode(.large)
.toolbar {
if !store.entries.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button {
showClearConfirmation = true
} label: {
Image(systemName: "trash")
}
.accessibilityLabel("history.clear.button")
}
.background(palette.background)
.navigationTitle("history.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.toolbar {
if !store.entries.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button {
showClearConfirmation = true
} label: {
Image(systemName: "trash")
}
.accessibilityLabel("history.clear.button")
}
}
.confirmationDialog(
"history.clear.title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("history.clear.confirm", role: .destructive) {
store.clearAll()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text("history.clear.message")
}
.confirmationDialog(
"history.clear.title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("history.clear.confirm", role: .destructive) {
store.clearAll()
}
.confirmationDialog(
"history.clearDay.title",
isPresented: $showDeleteDayConfirmation,
titleVisibility: .visible
) {
Button("history.clearDay.confirm", role: .destructive) {
if let day = dayPendingDelete {
store.deleteEntries(on: day)
}
dayPendingDelete = nil
Button("common.cancel", role: .cancel) {}
} message: {
Text("history.clear.message")
}
.confirmationDialog(
"history.clearDay.title",
isPresented: $showDeleteDayConfirmation,
titleVisibility: .visible
) {
Button("history.clearDay.confirm", role: .destructive) {
if let day = dayPendingDelete {
store.deleteEntries(on: day)
}
Button("common.cancel", role: .cancel) {
dayPendingDelete = nil
}
} message: {
Text("history.clearDay.message")
dayPendingDelete = nil
}
Button("common.cancel", role: .cancel) {
dayPendingDelete = nil
}
} message: {
Text("history.clearDay.message")
}
}
+234 -117
View File
@@ -1,29 +1,32 @@
// HomeView.swift
// OSGKeyboard · Main App
//
// Minimal home: logo, status capsule, flow hints, inline preview field.
//
// v0.2.0: removed the on-device model warm-up / download state machine
// (Qwen3 CoreML is gone). The local engine uses iOS 26 `SpeechAnalyzer`
// which is always ready, so the previous "model warming / download"
// capsule states collapse into a single "ready" line.
// Home: logo, flow hints, usage stats, history + dictionary entry card,
// then engine / session status at the scroll bottom. History/dictionary
// open via push (system back) rather than bottom-tab destinations.
import SwiftUI
import OSGKeyboardShared
import UIKit
private enum HomeRoute: Hashable {
case history
case dictionary
}
struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var speechHistory = SpeechHistoryStore.shared
@EnvironmentObject private var flowManager: FlowSessionManager
@FocusState private var previewFocused: Bool
@State private var previewText = ""
@State private var keyboardHintDismissed = HomeGuideState.isKeyboardHintDismissed
@State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
@State private var path = NavigationPath()
@State private var dictionaryPreviewEntries: [PersonalDictionary.Entry] = []
private var usesWideLayout: Bool {
horizontalSizeClass == .regular
@@ -61,26 +64,43 @@ struct HomeView: View {
}
var body: some View {
Group {
if usesWideLayout {
wideBody
} else {
phoneBody
NavigationStack(path: $path) {
Group {
if usesWideLayout {
wideBody
} else {
phoneBody
}
}
.toolbar(path.isEmpty ? .hidden : .automatic, for: .navigationBar)
.navigationDestination(for: HomeRoute.self) { route in
switch route {
case .history:
HistoryView()
case .dictionary:
PersonalDictionaryView()
}
}
}
.onAppear {
refreshPermissionStatuses()
refreshDictionaryPreview()
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
refreshPermissionStatuses()
refreshDictionaryPreview()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshPermissionStatuses()
refreshDictionaryPreview()
}
.onChange(of: previewFocused) { _, focused in
guard focused else { return }
Task { await flowManager.refreshForInlineKeyboardFocus() }
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionaryPreview()
}
.onChange(of: path.count) { _, count in
guard count == 0 else { return }
refreshDictionaryPreview()
}
}
@@ -89,109 +109,83 @@ struct HomeView: View {
private var phoneBody: some View {
GeometryReader { geo in
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
// iPhone SE
// tab
let isCompact = geo.size.height < 700
// logo
let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl
let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl
let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg
// /
let previewMinHeight: CGFloat = {
if showsFlowSessionExtras {
return isCompact ? 44 : 88
}
return isCompact ? 72 : 160
}()
ZStack(alignment: .top) {
sessionHeaderGradient(height: gradientHeight)
.ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
VStack(spacing: 0) {
logoHeader(compact: isCompact)
.padding(.top, logoTopPadding)
.padding(.bottom, logoBottomPadding)
ScrollView {
VStack(spacing: 0) {
logoHeader(compact: isCompact)
.padding(.top, logoTopPadding)
.padding(.bottom, logoBottomPadding)
if showsFlowSessionExtras {
flowSessionExtras
if showsFlowSessionExtras {
flowSessionExtras
.padding(.horizontal, Spacing.lg)
.padding(.bottom, extrasBottomPadding)
}
HomeUsageStatsSection(layout: .stacked, compact: isCompact)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.md)
homeLibrarySection
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.xl)
// Scrolls with the page (not pinned); clearance comes from
// `tabBarScrollBottomPadding` so the dock never covers it.
scrollStatusFooter
.padding(.horizontal, Spacing.lg)
.padding(.bottom, extrasBottomPadding)
}
HomeUsageStatsSection(layout: .stacked, compact: isCompact)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.md)
// safeAreaInset
// tab dock
previewField(minHeight: previewMinHeight)
.padding(.horizontal, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.layoutPriority(-1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.safeAreaInset(edge: .bottom, spacing: 0) {
phoneStatusFooter
.frame(maxWidth: .infinity)
.tabBarScrollBottomPadding()
}
}
.background(palette.background)
.contentShape(Rectangle())
.onTapGesture {
if previewFocused {
previewFocused = false
}
}
}
}
/// Engine + Flow bottom inset tab
private var phoneStatusFooter: some View {
HStack(spacing: Spacing.sm) {
/// Engine + Flow status last content in the scroll stack.
private var scrollStatusFooter: some View {
VStack(spacing: Spacing.xs) {
engineStatusLine
flowStatusFooter
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.sm)
.background(palette.background.opacity(0.96))
.multilineTextAlignment(.center)
}
// MARK: - Wide layout (iPad / regular width)
private var wideBody: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.lg) {
wideHeroHeader
// On iPad / regular width the keyboard-setup hint (and any
// other flow-session extras) used to render below
// `HomeUsageStatsSection`, burying the most actionable guidance
// beneath the stats cards. Match the phone layout's ordering:
// hero header hint stats preview, so the hint sits at
// the top of the page and is the first thing a user notices.
if showsFlowSessionExtras {
flowSessionExtras
}
HomeUsageStatsSection(layout: .split)
widePreviewStage
homeLibrarySection
scrollStatusFooter
.frame(maxWidth: .infinity)
}
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.md)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .topLeading)
.tabBarScrollBottomPadding()
}
.background(palette.background)
.contentShape(Rectangle())
.onTapGesture {
if previewFocused {
previewFocused = false
}
}
}
private var wideHeroHeader: some View {
@@ -209,17 +203,145 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
private var widePreviewStage: some View {
WideCard(padding: Spacing.md, cornerRadius: Radius.large) {
previewFieldContent
.frame(
maxWidth: .infinity,
minHeight: WideLayoutMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
// MARK: - History / dictionary cards
/// Two independent cards; each header mirrors the stats tiles (accent icon
/// + small uppercase label) and the body grows with its rows.
private var homeLibrarySection: some View {
VStack(spacing: Spacing.md) {
historyCard
dictionaryCard
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private var historyCard: some View {
let entries = Array(speechHistory.entries.prefix(Self.libraryPreviewLimit))
return homeLibraryCard(
titleKey: "history.title",
systemImage: "clock.arrow.circlepath",
route: .history
) {
if entries.isEmpty {
libraryEmptyLine("home.card.history.empty")
} else {
VStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { libraryRowDivider }
HStack(alignment: .firstTextBaseline, spacing: Spacing.sm) {
Text(entry.text)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer(minLength: Spacing.xs)
Text(Self.previewTimeFormatter.string(from: entry.createdAt))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, Spacing.sm)
}
}
}
}
}
private var dictionaryCard: some View {
homeLibraryCard(
titleKey: "settings.personalDictionary.title",
systemImage: "character.book.closed",
route: .dictionary
) {
if dictionaryPreviewEntries.isEmpty {
libraryEmptyLine("home.card.dictionary.empty")
} else {
VStack(spacing: 0) {
ForEach(Array(dictionaryPreviewEntries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { libraryRowDivider }
VStack(alignment: .leading, spacing: 2) {
Text(entry.term)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text(dictionaryDetailLine(for: entry))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, Spacing.sm)
}
}
}
}
}
/// Card shell: accent icon + label top-left, chevron trailing, custom body.
private func homeLibraryCard<Content: View>(
titleKey: LocalizedStringKey,
systemImage: String,
route: HomeRoute,
@ViewBuilder content: () -> Content
) -> some View {
Button {
path.append(route)
} label: {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(spacing: Spacing.xs) {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
Text(titleKey)
.font(TypeStyle.caption2)
.tracking(0.6)
.textCase(.uppercase)
.foregroundStyle(palette.textTertiary)
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
content()
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.surfaceCard()
.accessibilityElement(children: .combine)
}
private var libraryRowDivider: some View {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
}
private func libraryEmptyLine(_ key: LocalizedStringKey) -> some View {
Text(key)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, Spacing.xs)
}
/// Source · uses · aliases same secondary line as the dictionary page rows.
private func dictionaryDetailLine(for entry: PersonalDictionary.Entry) -> String {
var parts = [SharedL10n.string(entry.source.labelKey, language: config.uiLanguage)]
if entry.usageCount > 1 {
let format = AppL10n.string(
"settings.personalDictionary.usageCount",
language: config.uiLanguage
)
parts.append(String(format: format, entry.usageCount))
}
if !entry.aliases.isEmpty {
parts.append(entry.aliases.joined(separator: " / "))
}
return parts.joined(separator: " · ")
}
private func refreshPermissionStatuses() {
@@ -227,6 +349,18 @@ struct HomeView: View {
speechStatus = AppPermissions.speechStatus
}
private func refreshDictionaryPreview() {
dictionaryPreviewEntries = AppGroupStore().personalDictionary.entries
.sorted { lhs, rhs in
if lhs.updatedAt != rhs.updatedAt {
return lhs.updatedAt > rhs.updatedAt
}
return lhs.usageCount > rhs.usageCount
}
.prefix(Self.libraryPreviewLimit)
.map { $0 }
}
private func handlePermissionGuidanceAction() {
if AppPermissions.canRequestPermissionsInApp {
Task {
@@ -292,7 +426,7 @@ struct HomeView: View {
.frame(width: 6, height: 6)
if needsAPIKeySetup {
// API Key / /
// API Key
Text("home.flow.notReady")
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
@@ -359,7 +493,6 @@ struct HomeView: View {
.padding(.leading, Spacing.xs)
}
}
.fixedSize(horizontal: true, vertical: false)
.animation(Motion.soft, value: flowManager.isActive)
}
@@ -461,7 +594,7 @@ struct HomeView: View {
}
/// Single source of truth for the logo status capsule. The local
/// engine is always "ready" in v0.2.0 (iOS `SpeechAnalyzer` ships
/// engine is always "ready" because iOS `SpeechAnalyzer` ships
/// with the OS), so the previous downloading / warming / failed
/// states collapse into the cloud-engine branch.
private var flowCapsuleStatusMessage: String {
@@ -484,32 +617,6 @@ struct HomeView: View {
return AppL10n.string("home.flow.inactive")
}
// MARK: - Preview field
private func previewField(minHeight: CGFloat) -> some View {
previewFieldContent
.frame(maxWidth: .infinity, minHeight: minHeight, maxHeight: .infinity, alignment: .topLeading)
.padding(Spacing.md)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(previewFocused ? palette.dividerStrong : palette.dividerStrong.opacity(0.75), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.onTapGesture {
previewFocused = true
}
}
private var previewFieldContent: some View {
TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.tint(palette.accent)
.focused($previewFocused)
.lineLimit(1...100)
}
private var engineStatusLine: some View {
Text(
EngineServiceLabel.summary(
@@ -525,6 +632,16 @@ struct HomeView: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
/// Rows shown inside the history / dictionary preview cards.
private static let libraryPreviewLimit = 3
private static let previewTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
}
// MARK: - Home guidance persistence
@@ -541,4 +658,4 @@ private enum HomeGuideState {
guard AppGroup.isAvailable else { return }
AppGroup.defaults.set(true, forKey: keyboardHintDismissedKey)
}
}
}
@@ -10,7 +10,7 @@
import SwiftUI
import OSGKeyboardShared
// MARK: - Local models group (v0.2.0)
// MARK: - Local models group
struct LocalModelsGroup: View {
@Environment(\.themePalette) private var palette: ThemePalette
+57 -15
View File
@@ -18,6 +18,7 @@ struct MainAppRoot: View {
@ObservedObject private var releaseNotes = ReleaseNotesController.shared
@StateObject private var flowManager = FlowSessionManager()
@State private var clmWarmupTask: Task<Void, Never>?
@State private var rimeStartupTask: Task<Void, Never>?
var body: some View {
Group {
@@ -67,13 +68,19 @@ struct MainAppRoot: View {
if config.hasCompletedOnboarding {
// Rime deployment is host-only and idempotent. Run it
// immediately when missing so returning users never have to
// wait for an opportunistic background warmup.
RimeDeploymentController.shared.deployNow(reason: "MainAppRoot.onAppear")
// Automatically arm the low-profile PiP on every host open.
// Capture/ASR remain lazy and start only on an actual mic press.
flowManager.activateOnForeground(reason: "MainAppRoot.onAppear")
scheduleCLMWarmup(reason: "MainAppRoot.onAppear")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
// wait for an opportunistic background warmup. The startup
// scheduler yields the first frame before beginning deployment.
if scenePhase == .active {
activateForegroundServices(reason: "MainAppRoot.onAppear")
AIHintRefreshService.refreshIfNeeded(reason: "MainAppRoot.onAppear")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
} else {
OSGDiag.log(
"MainAppRoot.onAppear defer foreground services scene="
+ "\(String(describing: scenePhase))",
category: "flow"
)
}
} else {
OSGDiag.log(
"MainAppRoot.onAppear skip Flow/CLM/Rime (onboarding incomplete)",
@@ -86,14 +93,15 @@ struct MainAppRoot: View {
}
.onChange(of: config.hasCompletedOnboarding) { _, done in
if done {
flowManager.activateOnForeground(reason: "onboardingCompleted")
// Deploy now rather than via warmup: the user just finished
// setup, is still in the app, and has not started using the
// keyboard yet so there is nothing to race for memory. This
// also covers users who skipped the keyboard page entirely.
RimeDeploymentController.shared.deployNow(reason: "onboardingCompleted")
scheduleCLMWarmup(reason: "onboardingCompleted")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
if scenePhase == .active {
activateForegroundServices(reason: "onboardingCompleted")
AIHintRefreshService.refreshIfNeeded(reason: "onboardingCompleted")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
}
}
.onChange(of: scenePhase) { _, phase in
@@ -101,14 +109,14 @@ struct MainAppRoot: View {
guard phase == .active else {
clmWarmupTask?.cancel()
clmWarmupTask = nil
rimeStartupTask?.cancel()
rimeStartupTask = nil
FlowSessionBridge.setHostHeavy(false)
return
}
if config.hasCompletedOnboarding {
RimeDeploymentController.shared.deployNow(reason: "scenePhase.active")
flowManager.activateOnForeground(reason: "scenePhase.active")
// Retry deferred CLM after a jetsam-prone launch.
scheduleCLMWarmup(reason: "scenePhase.active.retry")
activateForegroundServices(reason: "scenePhase.active")
AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
Task {
@@ -117,6 +125,40 @@ struct MainAppRoot: View {
}
}
/// Starts foreground-only services once per active transition. Rime yields
/// the first frame; Flow and CLM keep their existing lazy-heavy-work rules.
private func activateForegroundServices(reason: String) {
scheduleRimeDeployment(reason: reason)
// Automatically arm the low-profile PiP on every host open.
// Capture/ASR remain lazy and start only on an actual mic press.
flowManager.activateOnForeground(reason: reason)
// Retry deferred CLM after a jetsam-prone launch.
scheduleCLMWarmup(reason: reason)
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
/// Rime remains startup-owned, but a short delay keeps its CPU and file I/O
/// away from SwiftUI's first-frame layout on installs and version updates.
private func scheduleRimeDeployment(reason: String) {
rimeStartupTask?.cancel()
guard !RimeResourceInstaller.isReady else {
RimeDeploymentController.shared.refreshStatus()
rimeStartupTask = nil
return
}
OSGDiag.log(
"rime startup scheduled reason=\(reason) delay=500ms \(OSGDiag.memoryTag())",
category: "flow"
)
rimeStartupTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 500_000_000)
guard !Task.isCancelled, scenePhase == .active else { return }
RimeDeploymentController.shared.deployNow(reason: reason)
rimeStartupTask = nil
}
}
@ViewBuilder
private var mainContent: some View {
if config.hasCompletedOnboarding {
-4
View File
@@ -14,10 +14,6 @@ struct MainTabContent: View {
switch tab {
case .keyboard:
HomeView()
case .history:
HistoryView()
case .dictionary:
PersonalDictionaryView()
case .styles:
PolishStylesView()
case .settings:
+166
View File
@@ -0,0 +1,166 @@
// NotesHostDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// Minimal Notes / Messages-like host for What's New recording. Only presents a
// text field the **real** keyboard extension paints over it (Approach A).
// Launch with `--whats-new-host` (+ optional `--whats-new-scenario=` / `--whats-new-lang=`).
#if DEBUG
import SwiftUI
import UIKit
import OSGKeyboardShared
struct NotesHostDemoView: View {
let scenario: WhatsNewDemoScenario
let seedText: String
let language: WhatsNewDemoScenario.Language
private var title: String {
switch (scenario, language) {
case (.ai, .en): return "Messages"
case (.ai, .zh): return "信息"
case (.edit, .en), (.clipboard, .en): return "Notes"
case (.edit, .zh), (.clipboard, .zh): return "备忘录"
}
}
var body: some View {
ZStack {
Color(uiColor: .systemGroupedBackground)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(Color(uiColor: .secondaryLabel))
.padding(.horizontal, 4)
if scenario == .ai {
ChatHostTextView(text: seedText)
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
} else {
NotesHostTextView(text: seedText)
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
}
}
.padding(.horizontal, 18)
.padding(.top, 56)
.padding(.bottom, 12)
}
.preferredColorScheme(.light)
.environment(
\.locale,
language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans")
)
.task {
// Refresh TTL while armed; stop once the extension consumes / plays.
WhatsNewDemoScenario.arm(scenario, seedText: seedText, language: language)
for _ in 0..<25 {
try? await Task.sleep(nanoseconds: 700_000_000)
if WhatsNewDemoScenario.isPlaying() { break }
guard WhatsNewDemoScenario.peek() != nil else { break }
WhatsNewDemoScenario.arm(scenario, seedText: seedText, language: language)
}
}
}
}
/// UITextView wrapper that becomes first responder so the system presents
/// the real custom keyboard extension.
private struct NotesHostTextView: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.backgroundColor = .clear
view.font = .systemFont(ofSize: 20)
view.textColor = .label
view.text = text
view.isEditable = true
view.isScrollEnabled = true
view.textContainerInset = .zero
view.textContainer.lineFragmentPadding = 0
view.returnKeyType = .default
view.delegate = context.coordinator
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
view.becomeFirstResponder()
}
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
if uiView.text != text, !context.coordinator.userEdited {
uiView.text = text
}
if !uiView.isFirstResponder {
DispatchQueue.main.async {
_ = uiView.becomeFirstResponder()
}
}
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject, UITextViewDelegate {
var userEdited = false
func textViewDidChange(_ textView: UITextView) {
userEdited = true
}
}
}
/// Messaging-style composer: Return key is **Send** so AI insert send is valid.
private struct ChatHostTextView: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.backgroundColor = .clear
view.font = .systemFont(ofSize: 17)
view.textColor = .label
view.text = text
view.isEditable = true
view.isScrollEnabled = true
view.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
view.textContainer.lineFragmentPadding = 0
view.returnKeyType = .send
view.enablesReturnKeyAutomatically = true
view.delegate = context.coordinator
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
view.becomeFirstResponder()
}
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
if uiView.text != text, !context.coordinator.userEdited {
uiView.text = text
}
if !uiView.isFirstResponder {
DispatchQueue.main.async {
_ = uiView.becomeFirstResponder()
}
}
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject, UITextViewDelegate {
var userEdited = false
func textViewDidChange(_ textView: UITextView) {
userEdited = true
}
}
}
#endif
+4 -4
View File
@@ -32,7 +32,7 @@ struct OnboardingView: View {
@State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
@State private var keyboardReady = KeyboardSetupBridge.isReadyForOnboardingSkip
// v0.2.0: no on-device model downloads remain, so the API setup
// No on-device model downloads remain, so the API setup
// page no longer needs a ModelManager / pendingDownload binding.
private var currentPage: OnboardingPage {
@@ -90,7 +90,7 @@ struct OnboardingView: View {
applyOnboardingDefaultsIfNeeded()
refreshPermissionStatuses()
snapToVisiblePageIfNeeded()
// v0.2.0: no on-device ASR weights to warm up iOS
// No on-device ASR weights need warming iOS
// `SpeechAnalyzer` ships with iOS 26 and is always ready.
}
.onChange(of: scenePhase) { _, phase in
@@ -111,13 +111,13 @@ struct OnboardingView: View {
private func applyOnboardingDefaultsIfNeeded() {
guard !config.hasCompletedOnboarding, config.onboardingPage == 0 else { return }
// First-time users with no API key: default to local for a faster path.
// v0.2.0: iOS SpeechAnalyzer is the only on-device ASR path.
// iOS SpeechAnalyzer is the only on-device ASR path.
if config.apiKey.isEmpty, config.engineMode == "cloud" {
config.engineMode = "local"
}
}
/// v0.2.0: with iOS `SpeechAnalyzer` as the only local backend,
/// With iOS `SpeechAnalyzer` as the only local backend,
/// the "local engine ready" check is always true there is nothing
/// for the user to download. Kept as a derived property so the
/// existing call sites (which feed the Done button state) compile
+37 -38
View File
@@ -25,52 +25,51 @@ struct PersonalDictionaryView: View {
private let aliasGenerator = DictionaryAliasGenerator()
var body: some View {
NavigationStack {
ZStack {
palette.background.ignoresSafeArea()
ZStack {
palette.background.ignoresSafeArea()
if dictionary.entries.isEmpty {
emptyState
} else {
list
}
if dictionary.entries.isEmpty {
emptyState
} else {
list
}
.background(palette.background)
.navigationTitle("settings.personalDictionary.title")
.navigationBarTitleDisplayMode(.large)
.toolbar {
if !dictionary.entries.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button {
showClearAllConfirmation = true
} label: {
Image(systemName: "trash")
}
.accessibilityLabel(AppL10n.string("settings.personalDictionary.clearAll"))
.confirmationDialog(
AppL10n.string("settings.personalDictionary.clearAll.confirmTitle"),
isPresented: $showClearAllConfirmation,
titleVisibility: .visible
) {
Button(AppL10n.string("settings.personalDictionary.clearAll.confirm"), role: .destructive) {
clearAll()
}
Button(AppL10n.string("common.cancel"), role: .cancel) {}
} message: {
Text("settings.personalDictionary.clearAll.message")
}
}
}
}
.background(palette.background)
.navigationTitle("settings.personalDictionary.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.toolbar {
if !dictionary.entries.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button {
editingEntry = nil
showEntrySheet = true
showClearAllConfirmation = true
} label: {
Image(systemName: "plus")
Image(systemName: "trash")
}
.accessibilityLabel(AppL10n.string("settings.personalDictionary.clearAll"))
.confirmationDialog(
AppL10n.string("settings.personalDictionary.clearAll.confirmTitle"),
isPresented: $showClearAllConfirmation,
titleVisibility: .visible
) {
Button(AppL10n.string("settings.personalDictionary.clearAll.confirm"), role: .destructive) {
clearAll()
}
Button(AppL10n.string("common.cancel"), role: .cancel) {}
} message: {
Text("settings.personalDictionary.clearAll.message")
}
.accessibilityLabel(AppL10n.string("settings.personalDictionary.add.title"))
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
editingEntry = nil
showEntrySheet = true
} label: {
Image(systemName: "plus")
}
.accessibilityLabel(AppL10n.string("settings.personalDictionary.add.title"))
}
}
.sheet(isPresented: $showEntrySheet) {
PersonalDictionaryEntrySheet(
@@ -1,10 +0,0 @@
// PreviewASRController.swift
// OSGKeyboard · Main App
//
// Legacy name kept for existing call sites and tests. The implementation
// lives in `OSGKeyboardShared` as `LiveDictationController`.
import OSGKeyboardShared
import OSGKeyboardHostSupport
typealias PreviewASRController = LiveDictationController
+27 -7
View File
@@ -122,18 +122,26 @@ struct SettingsICloudSyncRow: View {
do {
try await CloudSyncContext.shared.dictionarySyncService.enableSync()
} catch let error as PersonalDictionaryCloudSyncError {
CloudSyncContext.shared.settingsSyncService.disableSync()
isEnabled = false
syncErrorMessage = localizedDictionarySyncError(error)
do {
try CloudSyncContext.shared.settingsSyncService.disableSync()
isEnabled = false
syncErrorMessage = localizedDictionarySyncError(error)
} catch let rollbackError as SettingsCloudSyncError {
reloadFromStore()
syncErrorMessage = localizedSyncError(rollbackError)
} catch {
reloadFromStore()
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
return
}
reloadFromStore()
} catch let error as SettingsCloudSyncError {
isEnabled = false
reloadFromStore()
syncErrorMessage = localizedSyncError(error)
} catch {
isEnabled = false
reloadFromStore()
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
@@ -141,9 +149,19 @@ struct SettingsICloudSyncRow: View {
}
private func disableSync() {
CloudSyncContext.shared.settingsSyncService.disableSync()
isEnabled = false
syncErrorMessage = nil
isApplyingToggle = true
do {
try CloudSyncContext.shared.settingsSyncService.disableSync()
reloadFromStore()
} catch let error as SettingsCloudSyncError {
reloadFromStore()
syncErrorMessage = localizedSyncError(error)
} catch {
reloadFromStore()
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
}
private func syncNow() {
@@ -171,6 +189,8 @@ struct SettingsICloudSyncRow: View {
switch error {
case .encodeFailed, .decodeFailed:
return AppL10n.string("settings.appSettings.iCloudSync.error.generic")
case .credentialMigrationFailed:
return AppL10n.string("settings.appSettings.iCloudSync.error.generic")
}
}
+39 -5
View File
@@ -152,15 +152,21 @@ struct AIResponseLengthPickerRow: View {
}
}
// MARK: - Default input surface toggle
// MARK: - Default input mode picker
struct DefaultTypingInputToggleRow: View {
struct DefaultInputModePickerRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var config = ProviderConfig.shared
@Binding var isOn: Bool
@Binding var selection: DefaultInputMode
private var options: [(id: String, label: String)] {
DefaultInputMode.allCases.map { mode in
(mode.rawValue, AppL10n.string(mode.labelKey, language: config.uiLanguage))
}
}
var body: some View {
Toggle(isOn: $isOn) {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(AppL10n.string("settings.typingInput.default.title", language: config.uiLanguage))
.font(TypeStyle.body)
@@ -170,10 +176,38 @@ struct DefaultTypingInputToggleRow: View {
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Menu {
ForEach(options, id: \.id) { option in
Button {
selection = DefaultInputMode(rawValue: option.id) ?? .voice
} label: {
if option.id == selection.rawValue {
Label(option.label, systemImage: "checkmark")
} else {
Text(option.label)
}
}
}
} label: {
HStack(spacing: 4) {
Text(currentLabel)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(palette.textTertiary)
}
}
}
.tint(palette.accent)
.settingsListRow()
}
private var currentLabel: String {
options.first(where: { $0.id == selection.rawValue })?.label ?? ""
}
}
struct RememberLastSurfaceToggleRow: View {
+116 -27
View File
@@ -125,12 +125,16 @@ struct SettingsModelPickerRow: View {
let title: String
let placeholder: String
@Binding var model: String
let fetchModels: () async throws -> [String]
let providerIdentity: String
let endpointIdentity: String
let credentialIdentity: String
let makeFetchModelsRequest: @MainActor () -> ProviderToolRequest<[String]>
@State private var models: [String] = []
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
@State private var requestCoordinator = ProviderToolRequestCoordinator()
private let controlHeight: CGFloat = 38
@@ -150,6 +154,11 @@ struct SettingsModelPickerRow: View {
}
}
}
.onChange(of: providerIdentity) { _, _ in invalidateRequest() }
.onChange(of: endpointIdentity) { _, _ in invalidateRequest() }
.onChange(of: credentialIdentity) { _, _ in invalidateRequest() }
.onChange(of: model) { _, _ in invalidateRequestIfRunning() }
.onDisappear { invalidateRequest() }
}
/// Editable model id + trailing menu chevron in one well (same chrome as
@@ -158,7 +167,7 @@ struct SettingsModelPickerRow: View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
let chevronWidth: CGFloat = 28
return ZStack(alignment: .trailing) {
TextField(placeholder, text: $model)
TextField(placeholder, text: editableModelBinding)
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
@@ -177,6 +186,7 @@ struct SettingsModelPickerRow: View {
} else {
ForEach(models, id: \.self) { modelId in
Button {
invalidateRequest()
model = modelId
message = AppL10n.format("settings.provider.modelSelected", modelId)
failed = false
@@ -203,7 +213,7 @@ struct SettingsModelPickerRow: View {
private var refreshButton: some View {
Button {
Task { await runFetchModels() }
runFetchModels()
} label: {
Group {
if isRunning {
@@ -227,25 +237,68 @@ struct SettingsModelPickerRow: View {
.accessibilityLabel(AppL10n.string("settings.provider.fetchModels"))
}
private var editableModelBinding: Binding<String> {
Binding(
get: { model },
set: { newValue in
invalidateRequest()
model = newValue
}
)
}
@MainActor
private func runFetchModels() async {
private func runFetchModels() {
let request = makeFetchModelsRequest()
let currentModel = model
let runningMessage = AppL10n.string("settings.provider.loadingModels")
let emptyMessage = SharedL10n.string("providerTools.error.empty")
isRunning = true
failed = false
defer { isRunning = false }
message = runningMessage
let outcome = await ProviderToolRunner.runFetchModels(
runningMessage: AppL10n.string("settings.provider.loadingModels"),
loadedMessage: { AppL10n.format("settings.provider.modelsLoaded", $0) },
emptyMessage: SharedL10n.string("providerTools.error.empty"),
currentModel: model,
fetchModels: fetchModels
requestCoordinator.start(
providerIdentity: request.providerIdentity,
operation: {
await ProviderToolRunner.runFetchModels(
runningMessage: runningMessage,
loadedMessage: { AppL10n.format("settings.provider.modelsLoaded", $0) },
emptyMessage: emptyMessage,
currentModel: currentModel,
fetchModels: request.operation
)
},
commit: { outcome in
isRunning = false
switch outcome {
case .cancelled:
message = nil
failed = false
case .completed(let state, let selectedModel):
models = state.models
message = state.message
failed = state.failed
if let selectedModel {
model = selectedModel
}
}
}
)
models = outcome.state.models
message = outcome.state.message
failed = outcome.state.failed
if let selected = outcome.selectedModel {
model = selected
}
}
@MainActor
private func invalidateRequest() {
requestCoordinator.invalidate()
isRunning = false
message = nil
failed = false
}
@MainActor
private func invalidateRequestIfRunning() {
guard requestCoordinator.isRunning else { return }
invalidateRequest()
}
}
@@ -254,11 +307,16 @@ struct SettingsModelPickerRow: View {
struct SettingsProviderToolsRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let validate: () async throws -> Void
let providerIdentity: String
let endpointIdentity: String
let credentialIdentity: String
let modelIdentity: String
let makeValidateRequest: @MainActor () -> ProviderToolRequest<Void>
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
@State private var requestCoordinator = ProviderToolRequestCoordinator()
var body: some View {
HStack(alignment: .center, spacing: Spacing.sm) {
@@ -280,7 +338,7 @@ struct SettingsProviderToolsRow: View {
Spacer(minLength: 0)
Button {
Task { await runValidate() }
runValidate()
} label: {
Text(AppL10n.string("settings.provider.validate"))
.font(TypeStyle.body)
@@ -297,20 +355,51 @@ struct SettingsProviderToolsRow: View {
.disabled(isRunning)
}
.settingsListRow()
.onChange(of: providerIdentity) { _, _ in invalidateRequest() }
.onChange(of: endpointIdentity) { _, _ in invalidateRequest() }
.onChange(of: credentialIdentity) { _, _ in invalidateRequest() }
.onChange(of: modelIdentity) { _, _ in invalidateRequest() }
.onDisappear { invalidateRequest() }
}
@MainActor
private func runValidate() async {
private func runValidate() {
let request = makeValidateRequest()
let runningMessage = AppL10n.string("api.test.running")
let successMessage = AppL10n.string("api.test.success")
isRunning = true
failed = false
defer { isRunning = false }
message = runningMessage
let outcome = await ProviderToolRunner.runValidate(
runningMessage: AppL10n.string("api.test.running"),
successMessage: AppL10n.string("api.test.success"),
validate: validate
requestCoordinator.start(
providerIdentity: request.providerIdentity,
operation: {
await ProviderToolRunner.runValidate(
runningMessage: runningMessage,
successMessage: successMessage,
validate: request.operation
)
},
commit: { outcome in
isRunning = false
switch outcome {
case .cancelled:
message = nil
failed = false
case .completed(let state):
message = state.message
failed = state.failed
}
}
)
message = outcome.message
failed = outcome.failed
}
@MainActor
private func invalidateRequest() {
requestCoordinator.invalidate()
isRunning = false
message = nil
failed = false
}
}
+63 -2
View File
@@ -220,8 +220,8 @@ struct GeneralSettingsView: View {
CardSection("settings.general.keyboard.title") {
VStack(spacing: 0) {
DefaultTypingInputToggleRow(
isOn: $typingConfiguration.defaultToTyping
DefaultInputModePickerRow(
selection: $typingConfiguration.defaultInputMode
)
Divider().background(palette.divider)
@@ -311,6 +311,8 @@ struct AIAgentSettingsView: View {
struct ClipboardSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@ObservedObject private var history = ClipboardHistoryStore.shared
@State private var showClearConfirmation = false
var body: some View {
ScrollView {
@@ -363,6 +365,20 @@ struct ClipboardSettingsView: View {
Divider().background(palette.divider)
Button {
AppPermissions.requestPasteAccess()
} label: {
SettingsNavigationRow(
titleText: AppL10n.string(
"settings.clipboard.paste.request",
language: config.uiLanguage
)
)
}
.buttonStyle(.plain)
Divider().background(palette.divider)
Button {
AppPermissions.openSystemSettings()
} label: {
@@ -377,12 +393,57 @@ struct ClipboardSettingsView: View {
}
.surfaceCard()
}
CardSection("settings.clipboard.storage.section") {
VStack(spacing: 0) {
Text("settings.clipboard.storage.body")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider().background(palette.divider)
Button(role: .destructive) {
showClearConfirmation = true
} label: {
HStack {
Text("settings.clipboard.clear.button")
Spacer()
Image(systemName: "trash")
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(history.entries.isEmpty)
.opacity(history.entries.isEmpty ? 0.45 : 1)
}
.surfaceCard()
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("settings.clipboard.title", language: config.uiLanguage))
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.confirmationDialog(
"settings.clipboard.clear.title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("settings.clipboard.clear.confirm", role: .destructive) {
history.clearAll()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text("settings.clipboard.clear.message")
}
.onAppear {
history.reload()
}
.onChange(of: config.clipboardHistoryEnabled) { _, enabled in
if !enabled {
config.clipboardCandidateBarEnabled = false
+3 -12
View File
@@ -6,18 +6,9 @@
// default) or one of the 10 target languages, all from a single
// `Menu`.
//
// v0.2.1 follow-up: row is rendered through an `isVisible` parameter
// so callers (`SettingsView`, `OnboardingView`) can drop the row
// entirely when the engine can't run the cloud translate-and-polish
// step (`ProviderConfig.isTranslationRowVisible`). The "needs cloud"
// inline hint was deleted along with the previous Bool toggle the
// user only sees the row when the engine can act on the choice.
//
// v0.2.1 final review: both engines now run the translate-and-polish
// step (the local engine routes through DeepSeek via
// `ProviderConfig.localModeProviderId`), so the row title changed
// from "Translation" to "Polish then translate" to match the new
// always-on translation contract.
// `isVisible` keeps conditional rendering under caller control. Current
// settings expose the row for both local and cloud ASR modes, and both
// use the user's selected polish provider.
//
// Mapping to persisted state:
// "" translationTargetLocaleId = "off"
+22 -11
View File
@@ -101,7 +101,7 @@
"settings.appearance.light" = "Light";
"settings.appearance.dark" = "Dark";
"settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared. Clipboard history is kept.";
"settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Speech Transcription & Polish";
"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
@@ -142,8 +142,8 @@
"settings.asr.volcengine.appId" = "APP ID";
"settings.asr.volcengine.accessToken" = "Access Token";
"settings.asr.volcengine.apiKey" = "API Key";
"settings.asr.volcengine.apiKeyMode.title" = "Use new API Key auth";
"settings.asr.volcengine.apiKeyMode.subtitle" = "Turn on for the new Volcengine console. Keep off if you already use APP ID + Access Token.";
"settings.asr.volcengine.apiKeyMode.title" = "Use new API Key";
"settings.asr.volcengine.apiKeyMode.subtitle" = "API Key for the new console; turn off for APP ID + Token.";
"settings.asr.volcengine.note.appToken" = "Legacy console: enter APP ID and Access Token. Secret Key is not required. Resource is fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
"settings.asr.volcengine.note.apiKey" = "New console: enter only the API Key. Resource is fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
"provider.openai" = "OpenAI";
@@ -205,17 +205,27 @@
"settings.clipboard.subtitle.on" = "On";
"settings.clipboard.subtitle.off" = "Off";
"settings.clipboard.history.title" = "History";
"settings.clipboard.history.footer" = "Keeps the latest 15 plain-text copies on this device.";
"settings.clipboard.history.footer" = "Off by default. Captures text copied on this device or through Universal Clipboard and keeps up to 15 items in this devices App Group. AI mode can suggest clipboard-related prompts for about 30 seconds after a copy.";
"settings.clipboard.candidate.title" = "Suggestion strip";
"settings.clipboard.candidate.footer" = "Shows the newest copy above the keys for one-tap insert.";
"settings.clipboard.paste.section" = "System access";
"settings.clipboard.paste.body" = "Set Paste from Other Apps to Allow to stop paste prompts. If the option is missing, copy text and tap the keyboard suggestion once first.";
"settings.clipboard.paste.body" = "Copy some text, tap Request Paste Access, and allow access. iOS will then create the Paste from Other Apps setting, where you can select Allow.";
"settings.clipboard.paste.request" = "Request Paste Access";
"settings.clipboard.paste.open" = "Open iOS Settings";
"settings.clipboard.storage.section" = "Local history";
"settings.clipboard.storage.body" = "Turning History off stops capture and the suggestion strip but keeps saved items. Clipboard history does not sync through iCloud and is never sent to AI automatically. After you insert it, active polish may include it as context for your configured provider. Sensitive filtering is conservative and cannot detect every password.";
"settings.clipboard.clear.button" = "Clear clipboard history";
"settings.clipboard.clear.title" = "Clear clipboard history?";
"settings.clipboard.clear.message" = "This permanently removes all saved clipboard items from this device. This cannot be undone.";
"settings.clipboard.clear.confirm" = "Clear history";
"settings.typingInput.title" = "Text Input";
"settings.typingInput.default.title" = "Default to Text Input";
"settings.typingInput.default.description" = "Open the text input keyboard instead of voice input by default";
"settings.typingInput.default.title" = "Default Input";
"settings.typingInput.default.description" = "Used when the keyboard opens";
"settings.typingInput.default.mode.voice" = "Voice";
"settings.typingInput.default.mode.pinyin" = "Pinyin";
"settings.typingInput.default.mode.english" = "English";
"settings.typingInput.rememberLast.title" = "Remember Last Choice";
"settings.typingInput.rememberLast.description" = "Reopen on the voice or text surface you left last time";
"settings.typingInput.rememberLast.description" = "Restore last surface on reopen";
"settings.typingInput.schema.section" = "Input Method";
"settings.typingInput.schema.picker" = "Pinyin Scheme";
"typing.schema.fullPinyin" = "Full Pinyin";
@@ -240,7 +250,7 @@
"settings.keyboardHaptic.off" = "Off";
"settings.keyboardHaptic.light" = "Light";
"settings.keyboardHaptic.strong" = "Strong";
"settings.cursorDragNavigation.title" = "Drag beside mic to move cursor";
"settings.cursorDragNavigation.title" = "Drag empty area to move cursor";
"settings.systemPrompt.reset" = "Reset";
"settings.asrLocale" = "ASR locale";
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
@@ -343,8 +353,6 @@
"keyboard.placeholder.preparing" = "Preparing";
"keyboard.placeholder.processing" = "Processing";
"keyboard.placeholder.error" = "Polishing failed";
"keyboard.placeholder.localBadge" = "On-device";
"keyboard.placeholder.cloudBadge" = "Cloud";
"keyboard.rec" = "REC";
"keyboard.space" = "Space";
"keyboard.denied.mic" = "Mic denied";
@@ -399,6 +407,8 @@
"home.flow.startShort" = "Start";
"home.preview.label" = "Try typing";
"home.preview.placeholder" = "Tap to type and test…";
"home.card.history.empty" = "No voice transcripts yet";
"home.card.dictionary.empty" = "No words yet";
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
"home.wide.mode.cloud" = "Cloud";
"home.wide.mode.local" = "On-device";
@@ -578,3 +588,4 @@
"flow.warning.polishDegradedQuality" = "Polish output failed validation; inserted a conservative version.";
"flow.error.editLastInputFailed" = "Could not complete the edit. Please try again.";
"flow.error.aiQuestionFailed" = "AI response failed. Please try again.";
"flow.error.clipboardUnavailable" = "No clipboard text available. Turn on Clipboard History and copy the text first.";
+25 -14
View File
@@ -101,7 +101,7 @@
"settings.appearance.light" = "浅色";
"settings.appearance.dark" = "深色";
"settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态;剪贴板历史会保留。";
"settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "语音转写与润色";
"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
@@ -142,8 +142,8 @@
"settings.asr.volcengine.appId" = "APP ID";
"settings.asr.volcengine.accessToken" = "Access Token";
"settings.asr.volcengine.apiKey" = "API Key";
"settings.asr.volcengine.apiKeyMode.title" = "使用新版 API Key 鉴权";
"settings.asr.volcengine.apiKeyMode.subtitle" = "新控制台请打开此开关;已有 AppID + Token 可保持关闭。";
"settings.asr.volcengine.apiKeyMode.title" = "使用新版 API Key";
"settings.asr.volcengine.apiKeyMode.subtitle" = "新控制台用 API Key;旧版请关闭并用 APP ID + Token。";
"settings.asr.volcengine.note.appToken" = "旧版控制台:填写 APP ID 与 Access Token。Secret Key 无需填写。识别资源固定为豆包流式 2.0volc.seedasr.sauc.duration)。";
"settings.asr.volcengine.note.apiKey" = "新版控制台:只需填写 API Key。识别资源固定为豆包流式 2.0volc.seedasr.sauc.duration)。";
"provider.openai" = "OpenAI";
@@ -205,17 +205,27 @@
"settings.clipboard.subtitle.on" = "已开启";
"settings.clipboard.subtitle.off" = "已关闭";
"settings.clipboard.history.title" = "历史记录";
"settings.clipboard.history.footer" = "本机保存最近 15 条纯文本。";
"settings.clipboard.history.footer" = "默认关闭。开启后采集本机或通用剪贴板复制的文本,并在本机 App Group 中保存最近 15 条;复制约 30 秒内,AI 模式也可展示剪贴板相关建议。";
"settings.clipboard.candidate.title" = "建议条";
"settings.clipboard.candidate.footer" = "最新复制显示在键盘上方,点一下即可插入。";
"settings.clipboard.paste.section" = "系统授权";
"settings.clipboard.paste.body" = "将「从其他 App 粘贴」设为「允许」,即可不再弹窗。若没有此项,先复制文字并点一次键盘建议条。";
"settings.clipboard.paste.body" = "先复制一段文字,再点「请求粘贴权限」并允许访问;iOS 随后会生成「从其他 App 粘贴」设置项,可将其设为「允许」。";
"settings.clipboard.paste.request" = "请求粘贴权限";
"settings.clipboard.paste.open" = "打开系统设置";
"settings.clipboard.storage.section" = "本机历史";
"settings.clipboard.storage.body" = "关闭「历史记录」只会停止采集并关闭建议条,已有记录仍会保留。剪贴板历史不经 iCloud 同步,也不会自动发送给 AI;插入后若主动使用润色,内容可能作为上下文发送给你配置的服务商。敏感内容过滤采用保守规则,无法识别所有密码。";
"settings.clipboard.clear.button" = "清空剪贴板历史";
"settings.clipboard.clear.title" = "清空剪贴板历史?";
"settings.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
"settings.clipboard.clear.confirm" = "清空历史";
"settings.typingInput.title" = "文本输入";
"settings.typingInput.default.title" = "默认进行文字输入";
"settings.typingInput.default.description" = "打开键盘时,默认使用文字输入键盘而非语音输入";
"settings.typingInput.default.title" = "默认输入方式";
"settings.typingInput.default.description" = "打开键盘时使用此方式";
"settings.typingInput.default.mode.voice" = "语音";
"settings.typingInput.default.mode.pinyin" = "拼音";
"settings.typingInput.default.mode.english" = "英文";
"settings.typingInput.rememberLast.title" = "记住上次选择";
"settings.typingInput.rememberLast.description" = "下次打开键盘时,保持你上次离开时的语音或文字输入界面";
"settings.typingInput.rememberLast.description" = "下次打开时恢复上次界面";
"settings.typingInput.schema.section" = "输入方案";
"settings.typingInput.schema.picker" = "拼音方案";
"typing.schema.fullPinyin" = "全拼";
@@ -228,8 +238,8 @@
"settings.typingInput.resources.ready" = "已就绪";
"settings.typingInput.resources.pending" = "待初始化";
"settings.typingInput.resources.redeploy" = "重新部署输入法资源";
"settings.speechRecognition.title" = "语音识别配置";
"settings.textPolish.title" = "文本润色配置";
"settings.speechRecognition.title" = "语音识别";
"settings.textPolish.title" = "文本润色";
"settings.preferences.title" = "偏好设置";
"settings.dictionaryAndPolish.title" = "词库与润色";
"settings.polishPreferences.title" = "润色偏好";
@@ -240,7 +250,7 @@
"settings.keyboardHaptic.off" = "关";
"settings.keyboardHaptic.light" = "轻";
"settings.keyboardHaptic.strong" = "强";
"settings.cursorDragNavigation.title" = "麦克风旁拖动移动光标";
"settings.cursorDragNavigation.title" = "触摸空白区域移动光标";
"settings.systemPrompt.reset" = "重置";
"settings.asrLocale" = "识别语言";
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
@@ -342,15 +352,13 @@
"keyboard.placeholder.preparing" = "准备中…";
"keyboard.placeholder.processing" = "处理中…";
"keyboard.placeholder.error" = "润色失败";
"keyboard.placeholder.localBadge" = "本地";
"keyboard.placeholder.cloudBadge" = "云端";
"keyboard.rec" = "REC";
"keyboard.space" = "空格";
"keyboard.denied.mic" = "麦克风被拒绝";
"keyboard.denied.speech" = "语音识别被拒绝";
"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置";
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
"keyboard.pressToTalkA11y" = "按说话";
"keyboard.tapToTalkA11y" = "按说话";
/* Mode chip labels */
"mode.off" = "关闭";
@@ -398,6 +406,8 @@
"home.flow.startShort" = "开启";
"home.preview.label" = "输入测试";
"home.preview.placeholder" = "点这里试试键盘";
"home.card.history.empty" = "还没有语音识别记录";
"home.card.dictionary.empty" = "还没有词条";
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
"home.wide.mode.cloud" = "云端";
"home.wide.mode.local" = "本机";
@@ -577,3 +587,4 @@
"flow.warning.polishDegradedQuality" = "本次润色结果未通过校验,已插入保守处理版本。";
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
"flow.error.clipboardUnavailable" = "没有可用的剪贴板内容。请开启剪贴板历史并先复制文本。";