feat(keyboard): ship AI mode surface with streaming search answers
Add the AI keyboard tab, Agent settings, and user-owned LLM key path for 1.7.0, including streaming answers and web-search transports without the built-in DeepSeek fallback.
This commit is contained in:
@@ -22,13 +22,14 @@
|
||||
<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> July 23, 2026</p>
|
||||
<p><strong>Last updated:</strong> August 10, 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 Apple’s 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 provider’s privacy policy applies. OSGKeyboard does not store or proxy the audio on its own servers.</li>
|
||||
<li><strong>Transcribed text</strong> — in Cloud polish mode, the final text (not audio) may be sent to the LLM provider you configure (e.g. OpenAI) for punctuation and formatting.</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>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>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>
|
||||
@@ -49,7 +50,7 @@
|
||||
</ul>
|
||||
|
||||
<h2>Third parties</h2>
|
||||
<p>When you enable cloud recognition, recordings are sent directly to the speech provider you configure. When you choose Cloud polish mode, transcribed text is sent to the API endpoint you configure. Those providers’ privacy policies apply to the requests.</p>
|
||||
<p>When you enable cloud recognition, recordings are sent directly to the speech provider you configure. When you configure an LLM API key, transcribed text may be sent for polish, and AI-mode questions may be sent for answering (including optional provider-side web search). Those providers’ privacy policies apply to the requests.</p>
|
||||
|
||||
<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>
|
||||
@@ -61,13 +62,14 @@
|
||||
|
||||
<hr id="zh">
|
||||
<h1>OSGKeyboard 隐私政策</h1>
|
||||
<p><strong>更新日期:</strong>2026 年 7 月 23 日</p>
|
||||
<p><strong>更新日期:</strong>2026 年 8 月 10 日</p>
|
||||
<p>OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。本政策说明应用处理哪些数据及用途。</p>
|
||||
|
||||
<h2>我们处理的数据</h2>
|
||||
<ul>
|
||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。默认本地模式通过 Apple 语音能力在设备端转写,不会上传原始录音。若你主动启用云端识别,录音会发送到你配置的语音服务商完成转写,并适用该服务商的隐私政策。OSGKeyboard 自身不会存储或中转音频。</li>
|
||||
<li><strong>转写文字</strong> — 云端润色模式下,最终文字(非音频)可能发送到你配置的 LLM 服务商以整理标点和格式。</li>
|
||||
<li><strong>转写文字</strong> — 当你配置了 LLM API Key 并启用润色时,最终文字(非音频)会发送到该服务商以整理标点和格式。未填写 API Key 时直接插入原始识别结果,不会发起润色请求。</li>
|
||||
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字会发送到同一套已配置的 LLM 服务商以生成回答。若该服务商支持服务端联网搜索,可能为回答时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
|
||||
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
|
||||
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像偏好、统计与语音历史。</li>
|
||||
</ul>
|
||||
@@ -87,7 +89,7 @@
|
||||
</ul>
|
||||
|
||||
<h2>第三方</h2>
|
||||
<p>启用云端识别时,录音会直接发送到你配置的语音服务商;选择云端润色时,转写文字会发送到你配置的 API。相关请求适用对应服务商的隐私政策。</p>
|
||||
<p>启用云端识别时,录音会直接发送到你配置的语音服务商;配置 LLM API Key 后,转写文字可能用于润色,AI 模式问题可能用于生成回答(含服务商侧可选联网搜索)。相关请求适用对应服务商的隐私政策。</p>
|
||||
|
||||
<h2>数据保留</h2>
|
||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;偏好、统计与历史可能经私有 iCloud 账户同步。</p>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// FlowASRPostProcessor.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Applies deterministic on-device transcript corrections before Flow branches
|
||||
// into dictation polish or AI question handling.
|
||||
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum FlowASRPostProcessor {
|
||||
struct Output: Equatable {
|
||||
let text: String
|
||||
let textForPolish: String
|
||||
}
|
||||
|
||||
static func process(
|
||||
text: String,
|
||||
textForPolish: String,
|
||||
engineMode: String,
|
||||
dictionary: PersonalDictionary
|
||||
) -> Output {
|
||||
guard engineMode == "local" else {
|
||||
return Output(text: text, textForPolish: textForPolish)
|
||||
}
|
||||
|
||||
let correctionPairs = dictionary.localCorrectionPairs()
|
||||
return Output(
|
||||
text: LocalASRTranscriptCorrector.apply(text, pairs: correctionPairs),
|
||||
textForPolish: LocalASRTranscriptCorrector.apply(
|
||||
textForPolish,
|
||||
pairs: correctionPairs
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
private let capture = FlowContinuousCapture()
|
||||
private let pipController = FlowPictureInPictureController()
|
||||
private let store = AppGroupStore()
|
||||
/// Cloud-engine polish; local engine runs through built-in DeepSeek polish.
|
||||
/// Cloud / local polish via the user-configured LLM provider.
|
||||
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
|
||||
/// `SpeechAnalyzer`, which has no warm-up step — we can hand the
|
||||
/// factory-built service straight back without going through the
|
||||
@@ -88,10 +90,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var pendingEditSourceText: String?
|
||||
private var pendingSourceHistoryEntryID: UUID?
|
||||
private var pendingSourceHistoryEntryRevision: Int64?
|
||||
private var pendingAIConversationID: UUID?
|
||||
private var pendingProcessingDeadlineAt: TimeInterval?
|
||||
private var pendingStopUtteranceId: UUID?
|
||||
private var currentCommandSeq: Int64 = 0
|
||||
private var lastHandledCommandSeq: Int64 = 0
|
||||
/// Throttles AI-mode LLM draft writes into App Group.
|
||||
private var aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||
/// Published so Home / debug UI can show "recording" instead of a false "ready".
|
||||
@Published private(set) var isUtteranceRecording = false
|
||||
/// True from `stopped` until the result/error is written back to App Group.
|
||||
@@ -476,6 +481,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
sessionASR = nil
|
||||
sessionASREngineMode = nil
|
||||
sessionASRWarmedLocaleID = nil
|
||||
Task { await aiConversations.removeAll() }
|
||||
FlowSessionBridge.markSessionInactive()
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
isActive = false
|
||||
@@ -1032,6 +1038,12 @@ final class FlowSessionManager: ObservableObject {
|
||||
private func consumeHistoryMutationOutbox() {
|
||||
for mutation in HistoryMutationOutbox.pending() {
|
||||
let entry = SpeechHistoryStore.shared.applyHistoryMutation(mutation)
|
||||
if mutation.usageCategory == .ai, let text = mutation.text {
|
||||
UsageStatisticsStore.shared.recordAIInsertion(
|
||||
text: text,
|
||||
commitID: mutation.id
|
||||
)
|
||||
}
|
||||
HistoryMutationReceiptStore.save(
|
||||
HistoryMutationReceipt(
|
||||
mutationID: mutation.id,
|
||||
@@ -1103,7 +1115,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
errorKind: .audioUnavailable,
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration(),
|
||||
revision: Self.resultRevision(),
|
||||
utteranceMode: command.utteranceMode
|
||||
utteranceMode: command.utteranceMode,
|
||||
aiConversationID: command.aiConversationID
|
||||
)
|
||||
)
|
||||
traceState(
|
||||
@@ -1207,6 +1220,9 @@ final class FlowSessionManager: ObservableObject {
|
||||
)
|
||||
)
|
||||
currentUtteranceMode = command.resolvedUtteranceMode
|
||||
pendingAIConversationID = currentUtteranceMode == .aiQuestion
|
||||
? command.aiConversationID
|
||||
: nil
|
||||
if currentUtteranceMode == .editLastInput {
|
||||
let source = command.editSourceText?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -1263,6 +1279,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
beginAudioPrime(command)
|
||||
case .cancelPrimeAudio:
|
||||
cancelAudioPrime(command)
|
||||
case .endAIConversation:
|
||||
if let conversationID = command.aiConversationID {
|
||||
Task { await aiConversations.removeConversation(conversationID) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1372,7 +1392,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
rawText: trimmed,
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration(),
|
||||
revision: Self.resultRevision(),
|
||||
utteranceMode: currentUtteranceMode
|
||||
utteranceMode: currentUtteranceMode,
|
||||
aiConversationID: pendingAIConversationID
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1395,7 +1416,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
rawText: trimmed,
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration(),
|
||||
revision: Self.resultRevision(),
|
||||
utteranceMode: currentUtteranceMode
|
||||
utteranceMode: currentUtteranceMode,
|
||||
aiConversationID: pendingAIConversationID
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1416,7 +1438,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
errorKind: kind,
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration(),
|
||||
revision: Self.resultRevision(),
|
||||
utteranceMode: currentUtteranceMode
|
||||
utteranceMode: currentUtteranceMode,
|
||||
aiConversationID: pendingAIConversationID
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1998,6 +2021,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
pendingEditSourceText = nil
|
||||
pendingSourceHistoryEntryID = nil
|
||||
pendingSourceHistoryEntryRevision = nil
|
||||
pendingAIConversationID = nil
|
||||
pendingProcessingDeadlineAt = nil
|
||||
currentUtteranceMode = .dictation
|
||||
}
|
||||
@@ -2014,7 +2038,9 @@ final class FlowSessionManager: ObservableObject {
|
||||
let editSourceText = pendingEditSourceText
|
||||
let sourceHistoryEntryID = pendingSourceHistoryEntryID
|
||||
let sourceHistoryEntryRevision = pendingSourceHistoryEntryRevision
|
||||
let aiConversationID = pendingAIConversationID
|
||||
let processingDeadlineAt = pendingProcessingDeadlineAt
|
||||
let asrEngineMode = sessionASREngineMode ?? store.engineMode
|
||||
// ALWAYS clear the processing gate for this utterance. The previous
|
||||
// guard required currentUtteranceId to still match; a racing
|
||||
// fail/abort/cancel path could nil the id (or leave processing stuck)
|
||||
@@ -2025,6 +2051,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
pendingEditSourceText = nil
|
||||
pendingSourceHistoryEntryID = nil
|
||||
pendingSourceHistoryEntryRevision = nil
|
||||
pendingAIConversationID = nil
|
||||
pendingProcessingDeadlineAt = nil
|
||||
if currentUtteranceMode == utteranceMode {
|
||||
currentUtteranceMode = .dictation
|
||||
@@ -2063,13 +2090,24 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
let asrElapsed = Date().timeIntervalSince(pipelineStarted)
|
||||
FlowDiagnostics.log("ASR phase done in \(String(format: "%.1f", asrElapsed))s finalLen=\(lastFinal.count)")
|
||||
FlowTrace.transcript(
|
||||
"asr.beforeGuard",
|
||||
lastFinal,
|
||||
"stage=stitchedFinal engine=\(store.engineMode) "
|
||||
+ "elapsed=\(String(format: "%.2f", asrElapsed))s"
|
||||
)
|
||||
FlowTrace.transcript("asr.bestPartial", bestPartialSnapshot, "stage=partialSnapshot")
|
||||
if utteranceMode == .aiQuestion {
|
||||
FlowTrace.pipeline(
|
||||
"aiQuestion.asrReady",
|
||||
"finalLen=\(lastFinal.count) partialLen=\(bestPartialSnapshot.count)"
|
||||
)
|
||||
} else {
|
||||
FlowTrace.transcript(
|
||||
"asr.beforeGuard",
|
||||
lastFinal,
|
||||
"stage=stitchedFinal engine=\(store.engineMode) "
|
||||
+ "elapsed=\(String(format: "%.2f", asrElapsed))s"
|
||||
)
|
||||
FlowTrace.transcript(
|
||||
"asr.bestPartial",
|
||||
bestPartialSnapshot,
|
||||
"stage=partialSnapshot"
|
||||
)
|
||||
}
|
||||
|
||||
var text = UtteranceTranscriptGuard.resolve(
|
||||
stitchedFinal: lastFinal,
|
||||
@@ -2098,7 +2136,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
!utterancePCMSamples.isEmpty {
|
||||
text = await runBatchASRFallback(currentText: text)
|
||||
}
|
||||
let textForPolish = text == lastFinal && !lastFinalWithPauseMarks.isEmpty
|
||||
let rawTextForPolish = text == lastFinal && !lastFinalWithPauseMarks.isEmpty
|
||||
? lastFinalWithPauseMarks
|
||||
: text
|
||||
utterancePCMSamples = []
|
||||
@@ -2130,6 +2168,19 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-read App Group at finalize so dictionary and translation changes
|
||||
// from the keyboard extension are visible before local correction,
|
||||
// polish, or translation.
|
||||
let pipelineStore = AppGroupStore()
|
||||
let postASR = FlowASRPostProcessor.process(
|
||||
text: text,
|
||||
textForPolish: rawTextForPolish,
|
||||
engineMode: asrEngineMode,
|
||||
dictionary: pipelineStore.personalDictionary
|
||||
)
|
||||
text = postASR.text
|
||||
let textForPolish = postASR.textForPolish
|
||||
|
||||
storeRawCandidate(
|
||||
text,
|
||||
sessionId: finalizeSessionId,
|
||||
@@ -2141,11 +2192,84 @@ final class FlowSessionManager: ObservableObject {
|
||||
EditUsageMetricsStore.recordInstructionDuration(recordingDuration)
|
||||
}
|
||||
|
||||
let engineMode = store.engineMode
|
||||
let engineMode = asrEngineMode
|
||||
let chunkNote = Self.chunkWarningMessage(chunkWarnings)
|
||||
// Re-read App Group at finalize so chip-side translation changes
|
||||
// from the keyboard extension are visible before polish/translate.
|
||||
let pipelineStore = AppGroupStore()
|
||||
if utteranceMode == .aiQuestion {
|
||||
guard let aiConversationID else {
|
||||
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
|
||||
storeFinalizedError(
|
||||
AppL10n.string("flow.error.aiQuestionFailed"),
|
||||
kind: .generic,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
commandSeq: finalizeCommandSeq
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let service = try AIQuestionService.configured(
|
||||
store: pipelineStore,
|
||||
conversations: aiConversations
|
||||
)
|
||||
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||
let publishUtteranceId = finalizeUtteranceId
|
||||
let publishSessionId = finalizeSessionId
|
||||
let publishCommandSeq = finalizeCommandSeq
|
||||
let publishConversationID = aiConversationID
|
||||
let answer = try await service.answer(
|
||||
question: text,
|
||||
conversationID: aiConversationID,
|
||||
targetLocaleID: pipelineStore.translationTargetLocaleId
|
||||
) { [weak self] partial in
|
||||
Task { @MainActor in
|
||||
self?.publishStreamingAIAnswerIfNeeded(
|
||||
partial,
|
||||
sessionId: publishSessionId,
|
||||
utteranceId: publishUtteranceId,
|
||||
commandSeq: publishCommandSeq,
|
||||
aiConversationID: publishConversationID,
|
||||
force: partial.isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
// Flush the final draft when throttle skipped the last characters.
|
||||
publishStreamingAIAnswerIfNeeded(
|
||||
answer,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
commandSeq: finalizeCommandSeq,
|
||||
aiConversationID: aiConversationID,
|
||||
force: true
|
||||
)
|
||||
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
|
||||
await service.commitSuccessfulTurn(
|
||||
question: text,
|
||||
answer: answer,
|
||||
conversationID: aiConversationID
|
||||
)
|
||||
storeFinalizedResult(
|
||||
answer,
|
||||
warning: chunkNote,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
commandSeq: finalizeCommandSeq,
|
||||
aiConversationID: aiConversationID
|
||||
)
|
||||
} catch {
|
||||
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
|
||||
storeFinalizedError(
|
||||
AppL10n.string("flow.error.aiQuestionFailed"),
|
||||
kind: .generic,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
commandSeq: finalizeCommandSeq,
|
||||
aiConversationID: aiConversationID
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let polishContext = PolishContext(
|
||||
appContext: pipelineStore.detectedAppContext?.context ?? .unknown,
|
||||
precedingText: fieldContext?.precedingText,
|
||||
@@ -2383,6 +2507,53 @@ final class FlowSessionManager: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
private func publishStreamingAIAnswerIfNeeded(
|
||||
_ text: String,
|
||||
sessionId: UUID?,
|
||||
utteranceId: UUID?,
|
||||
commandSeq: Int64,
|
||||
aiConversationID: UUID?,
|
||||
force: Bool
|
||||
) {
|
||||
guard aiAnswerStreamThrottle.shouldPublish(
|
||||
accumulatedCount: text.count,
|
||||
force: force
|
||||
) else { return }
|
||||
storeStreamingAIAnswer(
|
||||
text,
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: commandSeq,
|
||||
aiConversationID: aiConversationID
|
||||
)
|
||||
}
|
||||
|
||||
private func storeStreamingAIAnswer(
|
||||
_ text: String,
|
||||
sessionId: UUID?,
|
||||
utteranceId: UUID?,
|
||||
commandSeq: Int64,
|
||||
aiConversationID: UUID?
|
||||
) {
|
||||
guard let sessionId, let utteranceId else { return }
|
||||
guard currentUtteranceId == utteranceId,
|
||||
!terminalUtteranceIds.contains(utteranceId) else { return }
|
||||
FlowSessionBridge.writeResult(
|
||||
FlowResult(
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: commandSeq,
|
||||
status: .streaming,
|
||||
text: text,
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration(),
|
||||
revision: Self.resultRevision(),
|
||||
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
|
||||
utteranceMode: .aiQuestion,
|
||||
aiConversationID: aiConversationID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func storeFinalizedResult(
|
||||
_ text: String,
|
||||
rawText: String? = nil,
|
||||
@@ -2391,7 +2562,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
utteranceId: UUID?,
|
||||
commandSeq: Int64,
|
||||
historyEntryID: UUID? = nil,
|
||||
historyEntryRevision: Int64? = nil
|
||||
historyEntryRevision: Int64? = nil,
|
||||
aiConversationID: UUID? = nil
|
||||
) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
@@ -2405,12 +2577,21 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
guard let sessionId, let utteranceId else { return }
|
||||
FlowTrace.transcript(
|
||||
"host.delivered",
|
||||
trimmed,
|
||||
"utterance=\(utteranceId.uuidString.prefix(8)) commandSeq=\(commandSeq) "
|
||||
+ "warning=\(warning == nil ? 0 : 1)"
|
||||
)
|
||||
if currentUtteranceMode == .aiQuestion {
|
||||
FlowTrace.pipeline(
|
||||
"aiQuestion.delivered",
|
||||
"answerLen=\(trimmed.count) "
|
||||
+ "utterance=\(utteranceId.uuidString.prefix(8)) "
|
||||
+ "commandSeq=\(commandSeq)"
|
||||
)
|
||||
} else {
|
||||
FlowTrace.transcript(
|
||||
"host.delivered",
|
||||
trimmed,
|
||||
"utterance=\(utteranceId.uuidString.prefix(8)) commandSeq=\(commandSeq) "
|
||||
+ "warning=\(warning == nil ? 0 : 1)"
|
||||
)
|
||||
}
|
||||
FlowSessionBridge.writeResult(
|
||||
FlowResult(
|
||||
sessionId: sessionId,
|
||||
@@ -2425,7 +2606,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
|
||||
utteranceMode: currentUtteranceMode,
|
||||
historyEntryID: historyEntryID,
|
||||
historyEntryRevision: historyEntryRevision
|
||||
historyEntryRevision: historyEntryRevision,
|
||||
aiConversationID: aiConversationID
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -2449,7 +2631,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration(),
|
||||
revision: Self.resultRevision(),
|
||||
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
|
||||
utteranceMode: currentUtteranceMode
|
||||
utteranceMode: currentUtteranceMode,
|
||||
aiConversationID: pendingAIConversationID
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -2460,7 +2643,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
sessionId: UUID?,
|
||||
utteranceId: UUID?,
|
||||
commandSeq: Int64,
|
||||
status: FlowResult.Status = .error
|
||||
status: FlowResult.Status = .error,
|
||||
aiConversationID: UUID? = nil
|
||||
) {
|
||||
guard let sessionId, let utteranceId else { return }
|
||||
FlowTrace.warn(
|
||||
@@ -2480,7 +2664,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration(),
|
||||
revision: Self.resultRevision(),
|
||||
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
|
||||
utteranceMode: currentUtteranceMode
|
||||
utteranceMode: currentUtteranceMode,
|
||||
aiConversationID: aiConversationID ?? pendingAIConversationID
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -2525,8 +2710,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
return max(0, Date().timeIntervalSince(start))
|
||||
}
|
||||
|
||||
/// v0.2.0: surface the local-mode cloud-polish error path with a
|
||||
/// localised hint ("please fill in your DeepSeek key in Settings")
|
||||
/// Surface polish failures with a localised hint (e.g. missing API key)
|
||||
/// rather than letting the keyboard show a generic network error.
|
||||
static func makeFallbackDelivery(
|
||||
rawText: String,
|
||||
|
||||
@@ -158,10 +158,20 @@ struct HistoryView: View {
|
||||
|
||||
private func historyRow(_ entry: SpeechHistoryEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(Self.timeFormatter.string(from: entry.createdAt))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.monospacedDigit()
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text(Self.timeFormatter.string(from: entry.createdAt))
|
||||
.monospacedDigit()
|
||||
if entry.source == .ai {
|
||||
Text("AI")
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(palette.accent)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(palette.accent.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(entry.text)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
@@ -33,8 +33,12 @@ struct HomeView: View {
|
||||
flowManager.isActive || flowManager.isStarting
|
||||
}
|
||||
|
||||
private var needsCloudSetup: Bool {
|
||||
!config.isLocalEngine && !config.isConfigured
|
||||
/// Cloud: ASR + polish keys. Local: polish LLM key (ASR is on-device).
|
||||
private var needsAPIKeySetup: Bool {
|
||||
if config.isLocalEngine {
|
||||
return !config.isPolishConfigured
|
||||
}
|
||||
return !config.isConfigured
|
||||
}
|
||||
|
||||
private var needsPermissionSetup: Bool {
|
||||
@@ -53,7 +57,7 @@ struct HomeView: View {
|
||||
&& !KeyboardSetupBridge.isReadyForOnboardingSkip
|
||||
&& !needsPermissionSetup
|
||||
&& flowManager.sessionWarning == nil
|
||||
&& !needsCloudSetup
|
||||
&& !needsAPIKeySetup
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -249,7 +253,7 @@ struct HomeView: View {
|
||||
|
||||
private var headerGradientColors: [Color] {
|
||||
// 云端引擎未配置(缺 API Key)时不算就绪,保持中性灰渐变。
|
||||
if sessionIsLive, !needsCloudSetup {
|
||||
if sessionIsLive, !needsAPIKeySetup {
|
||||
return [
|
||||
palette.accent.opacity(0.28),
|
||||
palette.accent.opacity(0.10),
|
||||
@@ -287,7 +291,7 @@ struct HomeView: View {
|
||||
.fill(flowStatusColor)
|
||||
.frame(width: 6, height: 6)
|
||||
|
||||
if needsCloudSetup {
|
||||
if needsAPIKeySetup {
|
||||
// 云端引擎缺 API Key:不显示就绪 / 计时 / 结束按钮。
|
||||
Text("home.flow.notReady")
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -325,7 +329,7 @@ struct HomeView: View {
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
|
||||
if needsCloudSetup {
|
||||
if needsAPIKeySetup {
|
||||
// 无按钮:引导卡片已提示去设置填 API Key。
|
||||
EmptyView()
|
||||
} else if flowManager.isActive {
|
||||
@@ -364,7 +368,7 @@ struct HomeView: View {
|
||||
private var showsFlowSessionExtras: Bool {
|
||||
needsPermissionSetup
|
||||
|| flowManager.sessionWarning != nil
|
||||
|| needsCloudSetup
|
||||
|| needsAPIKeySetup
|
||||
|| shouldShowKeyboardHint
|
||||
|| !flowManager.isActive
|
||||
}
|
||||
@@ -395,9 +399,11 @@ struct HomeView: View {
|
||||
.foregroundStyle(palette.warning)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} else if needsCloudSetup {
|
||||
} else if needsAPIKeySetup {
|
||||
setupGuidanceCard {
|
||||
Text("home.setup.cloudIncomplete")
|
||||
Text(config.isLocalEngine
|
||||
? "home.setup.polishKeyMissing"
|
||||
: "home.setup.cloudIncomplete")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.warning)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -442,7 +448,7 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
if needsCloudSetup { return palette.warning }
|
||||
if needsAPIKeySetup { return palette.warning }
|
||||
if flowManager.isUtteranceRecording { return palette.accent }
|
||||
if flowManager.isUtteranceProcessing { return palette.accent }
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
//
|
||||
// "Local engine" block for the settings card.
|
||||
//
|
||||
// v0.2.0:
|
||||
// - On-device ASR is fixed at iOS 26 `SpeechAnalyzer` +
|
||||
// `DictationTranscriber` (nothing to download).
|
||||
// - Post-ASR polish is always on via the built-in DeepSeek path
|
||||
// (`PreconfiguredKeys.local.swift`, gitignored). The user never
|
||||
// pastes a key for local mode.
|
||||
// - Post-ASR polish uses the LLM provider / API key configured in Settings.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
@@ -48,15 +45,22 @@ struct LocalModelsGroup: View {
|
||||
|
||||
// MARK: Polish row
|
||||
|
||||
/// Built-in post-ASR polish for the local engine. No API key UI —
|
||||
/// the vendor key is supplied at build time only.
|
||||
/// Local ASR still works without a key; polish requires the Settings LLM key.
|
||||
private var polishRow: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text("settings.localModels.polishRole")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
engineBadge("settings.localModels.polishEngine")
|
||||
if config.isPolishConfigured {
|
||||
engineBadge(
|
||||
Text(LLMProvider.provider(id: config.providerId).name)
|
||||
)
|
||||
} else {
|
||||
Text("settings.localModels.polishNeedsKey")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
}
|
||||
}
|
||||
.settingsListRow()
|
||||
}
|
||||
@@ -83,12 +87,16 @@ struct LocalModelsGroup: View {
|
||||
// MARK: Helpers
|
||||
|
||||
/// Accent badge naming the engine that backs each local-mode row
|
||||
/// (e.g. "Apple iOS Speech" for ASR, "OSGKeyboard 内置" for polish).
|
||||
/// (e.g. "Apple iOS Speech" for ASR).
|
||||
private func engineBadge(_ labelKey: LocalizedStringKey) -> some View {
|
||||
engineBadge(Text(labelKey))
|
||||
}
|
||||
|
||||
private func engineBadge(_ label: Text) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text(labelKey)
|
||||
label
|
||||
.font(TypeStyle.caption)
|
||||
}
|
||||
.foregroundStyle(palette.accent)
|
||||
|
||||
@@ -167,8 +167,11 @@ private struct WideStatusFooter: View {
|
||||
@State private var micStatus = AppPermissions.micStatus
|
||||
@State private var speechStatus = AppPermissions.speechStatus
|
||||
|
||||
private var needsCloudSetup: Bool {
|
||||
!config.isLocalEngine && !config.isConfigured
|
||||
private var needsAPIKeySetup: Bool {
|
||||
if config.isLocalEngine {
|
||||
return !config.isPolishConfigured
|
||||
}
|
||||
return !config.isConfigured
|
||||
}
|
||||
|
||||
private var needsPermissionSetup: Bool {
|
||||
@@ -223,7 +226,7 @@ private struct WideStatusFooter: View {
|
||||
.fill(flowStatusColor)
|
||||
.frame(width: 6, height: 6)
|
||||
|
||||
if needsCloudSetup {
|
||||
if needsAPIKeySetup {
|
||||
Text("home.flow.notReady")
|
||||
.foregroundStyle(palette.warning)
|
||||
} else if flowManager.isUtteranceRecording {
|
||||
@@ -249,7 +252,7 @@ private struct WideStatusFooter: View {
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if canManuallyStartSession && !needsCloudSetup {
|
||||
} else if canManuallyStartSession && !needsAPIKeySetup {
|
||||
Button {
|
||||
flowManager.activateOnForeground(
|
||||
reason: "MainSplitView.startButton",
|
||||
@@ -278,7 +281,7 @@ private struct WideStatusFooter: View {
|
||||
}
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
if !config.isLocalEngine && !config.isConfigured { return palette.warning }
|
||||
if needsAPIKeySetup { return palette.warning }
|
||||
if flowManager.isUtteranceRecording || flowManager.isUtteranceProcessing {
|
||||
return palette.accent
|
||||
}
|
||||
@@ -289,7 +292,7 @@ private struct WideStatusFooter: View {
|
||||
}
|
||||
|
||||
private var flowStatusLabel: LocalizedStringKey {
|
||||
if !config.isLocalEngine && !config.isConfigured {
|
||||
if needsAPIKeySetup {
|
||||
return "home.flow.notReady"
|
||||
}
|
||||
if flowManager.isStarting {
|
||||
|
||||
@@ -260,7 +260,8 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
private var onboardingCompleteReady: Bool {
|
||||
// Local engine: built-in polish satisfies `isPolishConfigured`.
|
||||
// Local ASR needs no cloud key; polish key is guided on Home / keyboard
|
||||
// rather than blocking onboarding completion.
|
||||
// Cloud engine: ASR (step 5) + polish LLM (step 6) must both be ready.
|
||||
if config.isLocalEngine { return true }
|
||||
return config.isConfigured
|
||||
|
||||
@@ -128,6 +128,30 @@ struct PolishIntensityPickerRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AI response length picker row
|
||||
|
||||
struct AIResponseLengthPickerRow: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
|
||||
var body: some View {
|
||||
SettingsMenuPickerRow(
|
||||
title: AppL10n.string("settings.aiAgent.responseLength.title", language: config.uiLanguage),
|
||||
options: AIResponseLength.allCases.map { length in
|
||||
(
|
||||
length.rawValue,
|
||||
SharedL10n.string(length.labelKey, language: config.uiLanguage)
|
||||
)
|
||||
},
|
||||
selection: Binding(
|
||||
get: { config.aiResponseLength.rawValue },
|
||||
set: { rawValue in
|
||||
config.aiResponseLength = AIResponseLength.resolve(storedRawValue: rawValue)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Default input surface toggle
|
||||
|
||||
struct DefaultTypingInputToggleRow: View {
|
||||
|
||||
@@ -282,6 +282,30 @@ struct GeneralSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AI Agent
|
||||
|
||||
struct AIAgentSettingsView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject var config: ProviderConfig
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("settings.aiAgent.responseLength.section") {
|
||||
VStack(spacing: 0) {
|
||||
AIResponseLengthPickerRow(config: config)
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(AppL10n.string("settings.aiAgent.title", language: config.uiLanguage))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - About
|
||||
|
||||
struct AboutSettingsView: View {
|
||||
|
||||
@@ -21,6 +21,7 @@ private enum SettingsRoute: Hashable {
|
||||
case speechRecognition
|
||||
case textPolish
|
||||
case general
|
||||
case aiAgent
|
||||
case about
|
||||
}
|
||||
|
||||
@@ -107,6 +108,8 @@ struct SettingsView: View {
|
||||
TextPolishSettingsView(config: config)
|
||||
case .general:
|
||||
GeneralSettingsView(config: config)
|
||||
case .aiAgent:
|
||||
AIAgentSettingsView(config: config)
|
||||
case .about:
|
||||
AboutSettingsView(config: config)
|
||||
}
|
||||
@@ -121,6 +124,17 @@ struct SettingsView: View {
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
settingsRouteButton(
|
||||
.aiAgent,
|
||||
title: "settings.aiAgent.title",
|
||||
subtitle: SharedL10n.string(
|
||||
config.aiResponseLength.labelKey,
|
||||
language: config.uiLanguage
|
||||
)
|
||||
)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
LocalePickerRow(
|
||||
locales: effectiveLocales,
|
||||
selection: Binding(
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
"onboarding.enable.resources.failed" = "Could not prepare Chinese input resources";
|
||||
"onboarding.enable.resources.retry" = "Retry";
|
||||
"onboarding.api.title" = "Choose Engine";
|
||||
"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition — no API key needed.";
|
||||
"onboarding.api.localModels.hint" = "Local engine uses on-device speech recognition. Add an API key below to enable AI polish.";
|
||||
"onboarding.polish.title" = "Text polish (LLM)";
|
||||
"onboarding.polish.subtitle" = "Cleans up the transcript after recognition. Can differ from the ASR provider.";
|
||||
"onboarding.polish.localHint" = "Built-in polish is included. Add your own API key here to override.";
|
||||
"onboarding.polish.localHint" = "Fill in an API key to enable AI polish. Without a key, raw ASR text is inserted.";
|
||||
"settings.onboarding.replay" = "Restart permission setup";
|
||||
|
||||
/* Common navigation */
|
||||
@@ -169,7 +169,8 @@
|
||||
"settings.localModels.polishRole" = "Polish";
|
||||
"settings.localModels.builtIn" = "Built-in";
|
||||
"settings.localModels.speechEngine" = "Apple iOS Speech";
|
||||
"settings.localModels.polishEngine" = "OSGKeyboard Built-in";
|
||||
"settings.localModels.polishEngine" = "Settings LLM";
|
||||
"settings.localModels.polishNeedsKey" = "Add API key";
|
||||
"settings.localModels.allReady" = "Ready";
|
||||
"settings.localModels.readiness %lld %lld" = "%lld/%lld ready";
|
||||
"settings.localModels.cloudPolish.title" = "Cloud polish after ASR";
|
||||
@@ -196,6 +197,9 @@
|
||||
"settings.general.appearanceLanguage.title" = "Appearance & Language";
|
||||
"settings.general.keyboard.title" = "Keyboard & Gestures";
|
||||
"settings.general.sync.title" = "Sync";
|
||||
"settings.aiAgent.title" = "AI Agent";
|
||||
"settings.aiAgent.responseLength.section" = "Answers";
|
||||
"settings.aiAgent.responseLength.title" = "Response length";
|
||||
"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";
|
||||
@@ -373,6 +377,7 @@
|
||||
"home.setup.permission.both" = "Microphone and speech recognition are off — voice input won't work.";
|
||||
"home.setup.permission.request" = "Next";
|
||||
"home.setup.cloudIncomplete" = "Cloud engine needs an API key. Open the Settings tab.";
|
||||
"home.setup.polishKeyMissing" = "Add an API key in Settings to enable AI polish. Without it, raw ASR text is inserted.";
|
||||
"home.setup.keyboardHint" = "Don't see OSGKeyboard? Add it in iOS Settings.";
|
||||
"home.setup.keyboardHint.dismiss" = "Got it";
|
||||
"home.flow.starting" = "Starting voice session…";
|
||||
@@ -561,3 +566,4 @@
|
||||
"hostApp.tiktok" = "TikTok";
|
||||
"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.";
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
"onboarding.enable.resources.failed" = "中文输入资源准备失败";
|
||||
"onboarding.enable.resources.retry" = "重试";
|
||||
"onboarding.api.title" = "选择语音转文字 AI 引擎";
|
||||
"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别,无需填写 API Key。";
|
||||
"onboarding.api.localModels.hint" = "本地引擎使用系统语音识别。填写下方 API Key 即可开启 AI 润色。";
|
||||
"onboarding.polish.title" = "文本润色(LLM)";
|
||||
"onboarding.polish.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
|
||||
"onboarding.polish.localHint" = "已包含内置润色。在此填入 API Key 可替换为自定义模型。";
|
||||
"onboarding.polish.localHint" = "填写 API Key 才能开启 AI 润色;未填写时将直接插入原始识别结果。";
|
||||
"settings.onboarding.replay" = "重新开始权限引导";
|
||||
|
||||
/* Common navigation */
|
||||
@@ -169,7 +169,8 @@
|
||||
"settings.localModels.polishRole" = "润色";
|
||||
"settings.localModels.builtIn" = "内置";
|
||||
"settings.localModels.speechEngine" = "Apple iOS Speech";
|
||||
"settings.localModels.polishEngine" = "OSGKeyboard 内置";
|
||||
"settings.localModels.polishEngine" = "设置中的模型";
|
||||
"settings.localModels.polishNeedsKey" = "请填写 API Key";
|
||||
"settings.localModels.allReady" = "已就绪";
|
||||
"settings.localModels.readiness %lld %lld" = "%lld/%lld 已就绪";
|
||||
"settings.localModels.cloudPolish.title" = "识别后云端润色";
|
||||
@@ -196,6 +197,9 @@
|
||||
"settings.general.appearanceLanguage.title" = "外观与语言";
|
||||
"settings.general.keyboard.title" = "键盘与操作";
|
||||
"settings.general.sync.title" = "同步";
|
||||
"settings.aiAgent.title" = "AI Agent";
|
||||
"settings.aiAgent.responseLength.section" = "回答";
|
||||
"settings.aiAgent.responseLength.title" = "回复篇幅";
|
||||
"settings.typingInput.title" = "文本输入";
|
||||
"settings.typingInput.default.title" = "默认进行文字输入";
|
||||
"settings.typingInput.default.description" = "打开键盘时,默认使用文字输入键盘而非语音输入";
|
||||
@@ -372,6 +376,7 @@
|
||||
"home.setup.permission.both" = "麦克风和语音识别还没授权,语音输入用不了。";
|
||||
"home.setup.permission.request" = "下一步";
|
||||
"home.setup.cloudIncomplete" = "选了云端引擎,先去「设置」填 API Key。";
|
||||
"home.setup.polishKeyMissing" = "请先在「设置」填写 API Key 才能润色;未填写时会直接插入原始识别结果。";
|
||||
"home.setup.keyboardHint" = "列表里没有?去系统设置里添加键盘。";
|
||||
"home.setup.keyboardHint.dismiss" = "知道了";
|
||||
"home.flow.starting" = "正在启动语音会话…";
|
||||
@@ -560,3 +565,4 @@
|
||||
"hostApp.tiktok" = "TikTok";
|
||||
"flow.warning.polishDegradedQuality" = "本次润色结果未通过校验,已插入保守处理版本。";
|
||||
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
|
||||
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
|
||||
|
||||
Reference in New Issue
Block a user