feat(keyboard): expand contextual skills and managed flows

Add local clipboard intent recommendations, webpage and phone actions, and safer host handoffs. Refine managed gateway, catalog refresh, onboarding, and adaptive polish behavior.
This commit is contained in:
Rocky
2026-08-23 14:02:33 +08:00
parent 7df5bbdaa0
commit 2cc81c4628
68 changed files with 10368 additions and 7765 deletions
+6 -4
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.osgkeyboard.ios.analytics-sync</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
@@ -98,6 +102,8 @@
<key>NSAllowsArbitraryLoads</key>
<false/>
</dict>
<key>NSContactsUsageDescription</key>
<string>OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.</string>
<key>NSMicrophoneUsageDescription</key>
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
<key>NSSpeechRecognitionUsageDescription</key>
@@ -115,10 +121,6 @@
<array>
<string>audio</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.osgkeyboard.ios.analytics-sync</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
+35 -4
View File
@@ -97,7 +97,9 @@ final class FlowSessionManager: ObservableObject {
private struct PrefilledAIRequest {
let question: String
let conversationID: UUID?
let webPageURL: URL?
let taskKind: ManagedGatewayTaskKind
let requestSource: ManagedGatewayRequestSource?
let requestPurpose: ManagedGatewayRequestPurpose?
let oobeFeature: ManagedGatewayOOBEFeature?
let thinkingEnabled: Bool
@@ -118,6 +120,7 @@ final class FlowSessionManager: ObservableObject {
private let polisher: PolishingService
/// AI-mode turns are intentionally process-local and never persisted.
private let aiConversations = AIConversationStore()
private let webPageContentService: WebPageContentService
/// 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
@@ -221,11 +224,13 @@ final class FlowSessionManager: ObservableObject {
init(
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
pipController: any FlowPictureInPictureControlling = FlowPictureInPictureController()
pipController: any FlowPictureInPictureControlling = FlowPictureInPictureController(),
webPageContentService: WebPageContentService = .shared
) {
self.analyticsClient = analyticsClient
self.polisher = PolishingService(analyticsClient: analyticsClient)
self.pipController = pipController
self.webPageContentService = webPageContentService
// Sessions are (re)started explicitly on app foreground via
// `activateOnForeground()`. We deliberately do NOT silently reattach a
// stored session here — after a force-quit that would resurrect capture
@@ -1643,7 +1648,9 @@ final class FlowSessionManager: ObservableObject {
request: PrefilledAIRequest(
question: question,
conversationID: conversationID,
webPageURL: command.aiWebPageURL,
taskKind: taskKind,
requestSource: command.managedRequestSource,
requestPurpose: command.managedRequestPurpose,
oobeFeature: command.managedOOBEFeature,
thinkingEnabled: thinkingEnabled
@@ -1698,10 +1705,11 @@ final class FlowSessionManager: ObservableObject {
// send an XML envelope that must never appear above the mic.
let pipelineStore = AppGroupStore()
do {
let resolvedQuestion = try await resolvedQuestion(for: request)
let taskKind = if pipelineStore.credentialSource == .managed,
request.requestPurpose == nil {
ManagedGatewayQuestionRouter.taskKind(
for: request.question,
for: resolvedQuestion,
requestedTaskKind: request.taskKind
)
} else {
@@ -1711,6 +1719,7 @@ final class FlowSessionManager: ObservableObject {
store: pipelineStore,
conversations: aiConversations,
taskKind: taskKind,
requestSource: request.requestSource,
requestPurpose: request.requestPurpose,
oobeFeature: request.oobeFeature,
thinkingEnabled: request.thinkingEnabled,
@@ -1719,7 +1728,7 @@ final class FlowSessionManager: ObservableObject {
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let answer = try await service.answer(
question: request.question,
question: resolvedQuestion,
conversationID: conversationID,
targetLocaleID: pipelineStore.translationTargetLocaleId
) { [weak self] partial in
@@ -1745,7 +1754,7 @@ final class FlowSessionManager: ObservableObject {
force: true
)
await service.commitSuccessfulTurn(
question: request.question,
question: resolvedQuestion,
answer: answer,
conversationID: conversationID
)
@@ -1773,6 +1782,17 @@ final class FlowSessionManager: ObservableObject {
}
}
private func resolvedQuestion(
for request: PrefilledAIRequest
) async throws -> String {
guard let url = request.webPageURL else { return request.question }
let webpage = try await webPageContentService.fetchSummarySource(from: url)
return AIClipboardPrompt.compose(
instruction: request.question,
material: webpage.summaryMaterial
)
}
private func beginAudioPrime(_ command: FlowCommand) {
guard startingUtteranceId == nil,
!isUtteranceRecording,
@@ -3525,6 +3545,17 @@ final class FlowSessionManager: ObservableObject {
}
private static func aiQuestionFailureMessage(for error: Error) -> String {
if let webPageError = error as? WebPageContentError {
switch webPageError {
case .invalidURL:
return AppL10n.string("flow.error.webPageInvalid")
case .unsupportedContentType, .undecodableText, .emptyContent:
return AppL10n.string("flow.error.webPageEmpty")
case .tooManyRedirects, .invalidResponse, .httpStatus,
.responseTooLarge, .transport:
return AppL10n.string("flow.error.webPageUnavailable")
}
}
if let managedError = error as? ManagedGatewayError {
return managedError.localizedDescription
}
@@ -128,7 +128,10 @@ actor OfficialSkillCatalogRefreshService {
received: received.revision
)
}
try store.setOfficialSkillCatalog(received)
try store.setOfficialSkillCatalog(
received,
installingNewDefaultSkills: true
)
return .updated(revision: received.revision)
default:
throw PublicContentHTTPError.unexpectedStatus(response.statusCode)
@@ -0,0 +1,475 @@
// WebPageContentService.swift
// OSGKeyboard · Main App
//
// Fetches a bounded public HTTPS page in the host process, then turns static
// HTML into untrusted source text for the existing clipboard AI pipeline.
import Foundation
struct WebPageContent: Equatable, Sendable {
let finalURL: URL
let title: String?
let body: String
var summaryMaterial: String {
var sections = ["Source URL: \(finalURL.absoluteString)"]
if let title, !title.isEmpty {
sections.append("Title: \(title)")
}
sections.append("Webpage body:\n\(body)")
return sections.joined(separator: "\n")
}
}
enum WebPageContentError: Error, Equatable, Sendable {
case invalidURL
case tooManyRedirects
case invalidResponse
case httpStatus(Int)
case responseTooLarge
case unsupportedContentType
case undecodableText
case emptyContent
case transport
}
enum WebPageURLPolicy {
static func validatedSummaryURL(_ url: URL) -> URL? {
guard var components = URLComponents(
url: url,
resolvingAgainstBaseURL: false
) else {
return nil
}
guard components.scheme?.lowercased() == "https",
let host = components.host?.lowercased(),
!host.isEmpty,
components.user == nil,
components.password == nil,
!isPrivateHost(host) else {
return nil
}
components.scheme = "https"
return components.url
}
private static func isPrivateHost(_ rawHost: String) -> Bool {
let host = rawHost.trimmingCharacters(in: CharacterSet(charactersIn: "."))
if host == "localhost"
|| host.hasSuffix(".localhost")
|| host.hasSuffix(".local")
|| host.hasSuffix(".internal")
|| host.hasSuffix(".lan") {
return true
}
if host.contains(":") {
// IPv6 literals are uncommon webpage targets and include several
// private/link-local encodings that are easy to disguise.
return true
}
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
let numericHost = host.allSatisfy { $0.isNumber || $0 == "." }
guard numericHost else { return false }
guard parts.count == 4,
parts.allSatisfy({ part in
guard let value = Int(part) else { return false }
return (0...255).contains(value)
}) else {
return true
}
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return true }
let first = octets[0]
let second = octets[1]
return first == 0
|| first == 10
|| first == 127
|| (first == 100 && (64...127).contains(second))
|| (first == 169 && second == 254)
|| (first == 172 && (16...31).contains(second))
|| (first == 192 && second == 168)
|| first >= 224
}
}
final class WebPageContentService: @unchecked Sendable {
static let shared = WebPageContentService()
private static let maximumResponseBytes = 2 * 1_024 * 1_024
private static let maximumBodyCharacters = 40_000
private let configurationTemplate: URLSessionConfiguration
init(configuration: URLSessionConfiguration = .ephemeral) {
let copy = configuration.copy() as? URLSessionConfiguration ?? .ephemeral
copy.timeoutIntervalForRequest = 12
copy.timeoutIntervalForResource = 18
copy.requestCachePolicy = .reloadIgnoringLocalCacheData
copy.urlCache = nil
copy.httpCookieStorage = nil
copy.httpShouldSetCookies = false
copy.httpAdditionalHeaders = [
"Accept": "text/html, application/xhtml+xml, text/plain;q=0.8",
"User-Agent": "OSGKeyboard/2 WebpageSummary"
]
configurationTemplate = copy
}
func fetchSummarySource(from sourceURL: URL) async throws -> WebPageContent {
guard let url = WebPageURLPolicy.validatedSummaryURL(sourceURL) else {
throw WebPageContentError.invalidURL
}
let configuration = configurationTemplate.copy() as? URLSessionConfiguration
?? .ephemeral
let response = try await BoundedWebPageLoader(
configuration: configuration,
maximumBytes: Self.maximumResponseBytes
).load(url)
guard let finalURL = WebPageURLPolicy.validatedSummaryURL(response.finalURL) else {
throw WebPageContentError.invalidURL
}
let html = String(data: response.data, encoding: .utf8)
?? String(data: response.data, encoding: .isoLatin1)
guard let html else {
throw WebPageContentError.undecodableText
}
return try WebPageHTMLExtractor.extract(
html: html,
finalURL: finalURL,
maximumBodyCharacters: Self.maximumBodyCharacters
)
}
}
private struct WebPageHTTPResponse: Sendable {
let data: Data
let finalURL: URL
}
private final class BoundedWebPageLoader:
NSObject,
URLSessionDataDelegate,
URLSessionTaskDelegate,
@unchecked Sendable {
private let maximumBytes: Int
private let queue: OperationQueue
private let lock = NSLock()
private var session: URLSession!
private var continuation: CheckedContinuation<WebPageHTTPResponse, Error>?
private var receivedData = Data()
private var response: HTTPURLResponse?
private var redirectCount = 0
private var terminalError: WebPageContentError?
private var finished = false
init(configuration: URLSessionConfiguration, maximumBytes: Int) {
self.maximumBytes = maximumBytes
queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
queue.qualityOfService = .userInitiated
super.init()
session = URLSession(
configuration: configuration,
delegate: self,
delegateQueue: queue
)
}
func load(_ url: URL) async throws -> WebPageHTTPResponse {
try Task.checkCancellation()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
lock.lock()
guard !finished else {
lock.unlock()
continuation.resume(throwing: CancellationError())
return
}
self.continuation = continuation
lock.unlock()
var request = URLRequest(url: url)
request.httpMethod = "GET"
session.dataTask(with: request).resume()
}
} onCancel: {
self.finish(.failure(CancellationError()))
}
}
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
guard let http = response as? HTTPURLResponse else {
completionHandler(.cancel)
finish(.failure(WebPageContentError.invalidResponse))
return
}
guard (200..<300).contains(http.statusCode) else {
completionHandler(.cancel)
finish(.failure(WebPageContentError.httpStatus(http.statusCode)))
return
}
if let mimeType = http.mimeType?.lowercased(),
!Self.allowedMIMETypes.contains(mimeType) {
completionHandler(.cancel)
finish(.failure(WebPageContentError.unsupportedContentType))
return
}
if http.expectedContentLength > Int64(maximumBytes) {
completionHandler(.cancel)
finish(.failure(WebPageContentError.responseTooLarge))
return
}
lock.lock()
self.response = http
lock.unlock()
completionHandler(.allow)
}
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive data: Data
) {
lock.lock()
guard !finished else {
lock.unlock()
return
}
guard receivedData.count + data.count <= maximumBytes else {
terminalError = .responseTooLarge
lock.unlock()
dataTask.cancel()
return
}
receivedData.append(data)
lock.unlock()
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
lock.lock()
redirectCount += 1
let count = redirectCount
lock.unlock()
guard count <= 5 else {
completionHandler(nil)
finish(.failure(WebPageContentError.tooManyRedirects))
return
}
guard let redirectedURL = request.url,
let safeURL = WebPageURLPolicy.validatedSummaryURL(redirectedURL) else {
completionHandler(nil)
finish(.failure(WebPageContentError.invalidURL))
return
}
var redirectedRequest = request
redirectedRequest.url = safeURL
redirectedRequest.httpMethod = "GET"
completionHandler(redirectedRequest)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
lock.lock()
let storedError = terminalError
let storedResponse = response
let data = receivedData
lock.unlock()
if let storedError {
finish(.failure(storedError))
} else if error != nil {
finish(.failure(WebPageContentError.transport))
} else if let finalURL = storedResponse?.url {
finish(.success(WebPageHTTPResponse(data: data, finalURL: finalURL)))
} else {
finish(.failure(WebPageContentError.invalidResponse))
}
}
private func finish(_ result: Result<WebPageHTTPResponse, Error>) {
lock.lock()
guard !finished else {
lock.unlock()
return
}
finished = true
let continuation = continuation
self.continuation = nil
lock.unlock()
session.invalidateAndCancel()
continuation?.resume(with: result)
}
private static let allowedMIMETypes: Set<String> = [
"text/html",
"application/xhtml+xml",
"text/plain"
]
}
enum WebPageHTMLExtractor {
static func extract(
html: String,
finalURL: URL,
maximumBodyCharacters: Int
) throws -> WebPageContent {
let title = firstCapture(
pattern: #"(?is)<title\b[^>]*>(.*?)</title>"#,
in: html
).map(cleanInlineText)
let selected = firstCapture(
pattern: #"(?is)<article\b[^>]*>(.*?)</article>"#,
in: html
) ?? firstCapture(
pattern: #"(?is)<main\b[^>]*>(.*?)</main>"#,
in: html
) ?? firstCapture(
pattern: #"(?is)<body\b[^>]*>(.*?)</body>"#,
in: html
) ?? html
var text = replacing(
pattern: #"(?is)<!--.*?-->"#,
in: selected,
with: " "
)
text = replacing(
pattern: #"(?is)<(script|style|noscript|template|svg|canvas|nav|footer|aside|form|dialog)\b[^>]*>.*?</\1\s*>"#,
in: text,
with: " "
)
text = replacing(
pattern: #"(?is)<br\s*/?>|</?(p|div|section|article|main|h[1-6]|li|ul|ol|tr|blockquote|pre)\b[^>]*>"#,
in: text,
with: "\n"
)
text = replacing(pattern: #"(?is)<[^>]+>"#, in: text, with: " ")
text = decodeHTMLEntities(text)
text = normalizedBody(text)
guard text.count >= 40 else {
throw WebPageContentError.emptyContent
}
if text.count > maximumBodyCharacters {
text = String(text.prefix(maximumBodyCharacters))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
let resolvedTitle = title.flatMap { $0.isEmpty ? nil : $0 }
return WebPageContent(
finalURL: finalURL,
title: resolvedTitle,
body: text
)
}
private static func firstCapture(pattern: String, in text: String) -> String? {
guard let expression = try? NSRegularExpression(pattern: pattern),
let match = expression.firstMatch(
in: text,
range: NSRange(text.startIndex..., in: text)
),
match.numberOfRanges > 1,
let range = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[range])
}
private static func replacing(
pattern: String,
in text: String,
with replacement: String
) -> String {
guard let expression = try? NSRegularExpression(pattern: pattern) else {
return text
}
return expression.stringByReplacingMatches(
in: text,
range: NSRange(text.startIndex..., in: text),
withTemplate: replacement
)
}
private static func cleanInlineText(_ text: String) -> String {
normalizedBody(
decodeHTMLEntities(
replacing(pattern: #"(?is)<[^>]+>"#, in: text, with: " ")
)
)
.replacingOccurrences(of: "\n", with: " ")
}
private static func normalizedBody(_ text: String) -> String {
let normalizedNewlines = text
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
return normalizedNewlines
.split(separator: "\n")
.map {
replacing(
pattern: #"[^\S\n]+"#,
in: String($0),
with: " "
).trimmingCharacters(in: .whitespacesAndNewlines)
}
.filter { !$0.isEmpty }
.joined(separator: "\n")
}
private static func decodeHTMLEntities(_ source: String) -> String {
var text = source
let namedEntities = [
"&nbsp;": " ",
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&#39;": "'",
"&apos;": "'",
"&ndash;": "–",
"&mdash;": "—",
"&hellip;": "…",
"&lsquo;": "‘",
"&rsquo;": "’",
"&ldquo;": "“",
"&rdquo;": "”"
]
for (entity, value) in namedEntities {
text = text.replacingOccurrences(of: entity, with: value)
}
guard let expression = try? NSRegularExpression(
pattern: #"&#(?:([0-9]+)|x([0-9A-Fa-f]+));"#
) else {
return text
}
let matches = expression.matches(
in: text,
range: NSRange(text.startIndex..., in: text)
)
for match in matches.reversed() {
guard let fullRange = Range(match.range, in: text) else { continue }
let decimal = Range(match.range(at: 1), in: text).flatMap {
UInt32(text[$0], radix: 10)
}
let hexadecimal = Range(match.range(at: 2), in: text).flatMap {
UInt32(text[$0], radix: 16)
}
guard let scalarValue = decimal ?? hexadecimal,
let scalar = UnicodeScalar(scalarValue) else {
continue
}
text.replaceSubrange(fullRange, with: String(scalar))
}
return text
}
}
-4
View File
@@ -397,10 +397,6 @@ struct HomeView: View {
}
.buttonStyle(.plain)
Text("home.card.dictionary.smart.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
if dictionarySuggestions.isEmpty {
libraryEmptyLine("home.card.dictionary.smart.empty")
} else {
+25 -3
View File
@@ -31,6 +31,7 @@ struct MainAppRoot: View {
@State private var rimeStartupTask: Task<Void, Never>?
@State private var firstOpenAcquisitionChannel =
AnalyticsFirstOpenAttribution.ordinaryLaunch
@State private var newContactDraft: NewContactDraft?
init(accountDependencies: AccountDependencies? = nil) {
let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make()
@@ -88,6 +89,11 @@ struct MainAppRoot: View {
ReleaseNotesSheet(language: config.uiLanguage)
.environment(\.locale, config.uiLanguage.swiftUILocale)
}
.sheet(item: $newContactDraft) { draft in
NewContactSheet(phoneNumber: draft.phoneNumber) {
newContactDraft = nil
}
}
.onAppear {
flowManager.setAppForeground(scenePhase == .active)
// Register the URL handler BEFORE the foreground auto-start.
@@ -126,7 +132,9 @@ struct MainAppRoot: View {
if scenePhase == .active {
activateForegroundServices(reason: "MainAppRoot.onAppear")
AIHintRefreshService.refreshIfNeeded(reason: "MainAppRoot.onAppear")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
if newContactDraft == nil {
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
} else {
OSGDiag.log(
"MainAppRoot.onAppear defer foreground services scene="
@@ -157,7 +165,9 @@ struct MainAppRoot: View {
if scenePhase == .active {
activateForegroundServices(reason: "onboardingCompleted")
AIHintRefreshService.refreshIfNeeded(reason: "onboardingCompleted")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
if newContactDraft == nil {
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
}
if accountSession.shouldPresentAccountCenter {
Task { @MainActor in
@@ -194,7 +204,9 @@ struct MainAppRoot: View {
if config.hasCompletedOnboarding {
activateForegroundServices(reason: "scenePhase.active")
AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
if newContactDraft == nil {
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
}
Task {
await AppCloudSync.shared.pullAllIfEnabled()
@@ -336,6 +348,16 @@ struct MainAppRoot: View {
SettingsDeepLink.setPending(.clipboard)
}
NotificationCenter.default.post(name: .osgOpenSettingsDeepLink, object: nil)
case "contact":
guard url.path.contains("new"),
let payload = AppGroupStore().consumePendingContactCreation() else {
return
}
releaseNotes.isPresented = false
Task { @MainActor in
await Task.yield()
newContactDraft = NewContactDraft(phoneNumber: payload.phoneNumber)
}
#if DEBUG
case "seed-demo":
DemoDataSeeder.seedRichPlaceholderData()
+56
View File
@@ -0,0 +1,56 @@
// NewContactSheet.swift
// OSGKeyboard · Main App
//
// System-owned contact editor used after an explicit keyboard skill handoff.
import Contacts
import ContactsUI
import SwiftUI
struct NewContactDraft: Identifiable, Equatable {
let id = UUID()
let phoneNumber: String
}
struct NewContactSheet: UIViewControllerRepresentable {
let phoneNumber: String
let onComplete: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onComplete: onComplete)
}
func makeUIViewController(context: Context) -> UINavigationController {
let contact = CNMutableContact()
contact.phoneNumbers = [
CNLabeledValue(
label: CNLabelPhoneNumberMobile,
value: CNPhoneNumber(stringValue: phoneNumber)
)
]
let editor = CNContactViewController(forNewContact: contact)
editor.contactStore = CNContactStore()
editor.delegate = context.coordinator
return UINavigationController(rootViewController: editor)
}
func updateUIViewController(
_ uiViewController: UINavigationController,
context: Context
) {}
final class Coordinator: NSObject, CNContactViewControllerDelegate {
private let onComplete: () -> Void
init(onComplete: @escaping () -> Void) {
self.onComplete = onComplete
}
func contactViewController(
_ viewController: CNContactViewController,
didCompleteWith contact: CNContact?
) {
onComplete()
}
}
}
@@ -1148,6 +1148,7 @@ struct OnboardingExperienceView: View {
practiceStartedAt = nil
completedPracticeFeatures = loadCompletedPracticeFeatures()
practiceFeature = firstIncompletePracticeFeature ?? .askAI
practiceText = ""
practiceSessionID = nil
didCopyPracticeSample = false
managedPracticeReady = false
@@ -1285,6 +1286,7 @@ struct OnboardingExperienceView: View {
}
withAnimation(Motion.soft) {
practiceFeature = feature
practiceText = ""
didCopyPracticeSample = false
}
focusPracticeField()
+1
View File
@@ -2,3 +2,4 @@
"NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard transcribes your voice with on-device speech recognition by default. If you explicitly switch to a cloud engine in Settings, recordings are sent to the ASR provider you configure.";
"NSContactsUsageDescription" = "OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.";
+22 -9
View File
@@ -99,7 +99,7 @@
"onboarding.experience.practice.replyTitle" = "Try Reply";
"onboarding.experience.practice.replySubtitle" = "Use the same sample, tap Reply in the keyboard, then insert the generated response.";
"onboarding.experience.practice.askAITitle" = "Ask AI";
"onboarding.experience.practice.askAISubtitle" = "Hold the keyboard microphone, ask a question, then insert the answer when it is ready.";
"onboarding.experience.practice.askAISubtitle" = "Hold the keyboard microphone and ask a question. Tap it again to stop recording and wait for the answer, then insert the result.";
"onboarding.experience.practice.copyAction" = "Copy Sample";
"onboarding.experience.practice.copied" = "Copied. Switch to OSGKeyboard.";
"onboarding.experience.practice.waiting" = "Waiting for you to complete this step in the keyboard…";
@@ -515,7 +515,7 @@
"home.card.history.empty" = "No voice transcripts yet";
"home.card.dictionary.empty" = "No words yet";
"home.card.dictionary.smart.subtitle" = "Frequently used new terms discovered from your Rime typing";
"home.card.dictionary.smart.empty" = "Keep using the Chinese keyboard. Repeated new terms will appear here.";
"home.card.dictionary.smart.empty" = "No suggestions yet";
"home.card.dictionary.smart.accessibilityHint" = "Used %lld times. Double-tap to add it to your personal dictionary.";
"home.card.dictionary.confirm.title" = "Add “%@” to your personal dictionary?";
"home.card.dictionary.confirm.message" = "Rime has recorded this term %lld times. Once added, it will also improve speech recognition and AI polishing.";
@@ -562,24 +562,34 @@
"skills.action.install" = "Install";
"skills.action.uninstall" = "Uninstall";
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
"skills.reply.name" = "Reply";
"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
"skills.reply.name" = "Reply in source language";
"skills.reply.description" = "Understand the copied text and reply naturally in its primary language.";
"skills.playfulReply.name" = "Playful reply";
"skills.playfulReply.description" = "Reply with a short, witty joke that stays kind and appropriate.";
"skills.replyInSourceLanguage.name" = "Reply in source language";
"skills.replyInSourceLanguage.description" = "Understand the copied text and reply naturally in the same language.";
"skills.summarize.name" = "Summarize";
"skills.summarize.description" = "Keep the key facts and conclusions, without rewriting it as a chat message.";
"skills.summarize.name" = "Summarize key points";
"skills.summarize.description" = "Summarize the content and extract facts, decisions, conclusions, and next steps when present.";
"skills.openLink.name" = "Open Link";
"skills.openLink.description" = "Open the single copied web link in your default browser.";
"skills.summarizeWebPage.name" = "Summarize Webpage";
"skills.summarizeWebPage.description" = "Securely extract the public webpage text, then summarize its key facts and conclusions.";
"skills.callPhone.name" = "Call Number";
"skills.callPhone.description" = "Call the single phone number detected in the copied text.";
"skills.createContact.name" = "Create Contact";
"skills.createContact.description" = "Open the system contact editor with the detected phone number prefilled.";
"skills.extractConclusions.name" = "Extract conclusions";
"skills.extractConclusions.description" = "Pull out decisions, conclusions, and next steps from long text.";
"skills.translate.name" = "Translate";
"skills.translate.description" = "Translate the copied text using your keyboard language setting.";
"skills.translate.description" = "Translate copied text in another language into the device's current primary system language.";
"skills.acceptInvitation.name" = "Accept invitation";
"skills.acceptInvitation.description" = "Draft a concise acceptance and confirm the important details.";
"skills.declineInvitation.name" = "Decline politely";
"skills.declineInvitation.description" = "Draft a sincere decline without inventing an excuse.";
"skills.acceptTask.name" = "Acknowledge task";
"skills.acceptTask.description" = "Confirm the requested work and any stated deadline.";
"skills.clarifyRequest.name" = "Clarify request";
"skills.clarifyRequest.description" = "Ask only the key questions needed before replying or acting.";
"skills.clarifyRequest.name" = "Clarify and follow up";
"skills.clarifyRequest.description" = "Ask only the key questions needed for a question, task, or problem.";
"skills.empathyReply.name" = "Empathetic reply";
"skills.empathyReply.description" = "Acknowledge a complaint and offer a safe next step.";
"skills.askForDetails.name" = "Ask for details";
@@ -804,6 +814,9 @@
"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.";
"flow.error.webPageInvalid" = "This link cannot be summarized.";
"flow.error.webPageUnavailable" = "The webpage could not be loaded. It may require a login or block automated reading.";
"flow.error.webPageEmpty" = "No readable webpage content was found.";
/* Optional account center */
"account.title" = "Account";
@@ -2,3 +2,4 @@
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,包括你在其他 App 中输入时保持启用的 Flow 会话。";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。";
"NSContactsUsageDescription" = "仅当你为复制的电话号码选择“新建联系人”时,OSGKeyboard 才会使用通讯录。";
+22 -9
View File
@@ -99,7 +99,7 @@
"onboarding.experience.practice.replyTitle" = "试试回复";
"onboarding.experience.practice.replySubtitle" = "使用同一段示例文字,点击键盘中的「回复」,然后插入生成的回复。";
"onboarding.experience.practice.askAITitle" = "问问 AI";
"onboarding.experience.practice.askAISubtitle" = "长按键盘麦克风,说出一个问题;松开后等待回答,并将结果插入文本框。";
"onboarding.experience.practice.askAISubtitle" = "长按键盘麦克风,说出一个问题;再次点击结束录音并等待回答,然后将结果插入文本框。";
"onboarding.experience.practice.copyAction" = "复制示例文字";
"onboarding.experience.practice.copied" = "已复制,请切换到 OSGKeyboard";
"onboarding.experience.practice.waiting" = "等待你在键盘中完成这一步…";
@@ -514,7 +514,7 @@
"home.card.history.empty" = "还没有语音识别记录";
"home.card.dictionary.empty" = "还没有词条";
"home.card.dictionary.smart.subtitle" = "从 Rime 输入习惯中发现的高频新词";
"home.card.dictionary.smart.empty" = "继续使用中文键盘,重复输入的新词会出现在这里。";
"home.card.dictionary.smart.empty" = "暂无推荐词";
"home.card.dictionary.smart.accessibilityHint" = "已使用 %lld 次,轻点两次加入个性词库。";
"home.card.dictionary.confirm.title" = "将“%@”加入个性词库?";
"home.card.dictionary.confirm.message" = "Rime 已记录这个词 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。";
@@ -561,24 +561,34 @@
"skills.action.install" = "安装";
"skills.action.uninstall" = "卸载";
"skills.action.reinstallShortcut" = "重新安装捷径";
"skills.reply.name" = "回复";
"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
"skills.reply.name" = "原语言回复";
"skills.reply.description" = "理解复制内容,并使用原文的主要语言自然回复。";
"skills.playfulReply.name" = "俏皮回复";
"skills.playfulReply.description" = "用简短、有梗但不冒犯的方式接住这句话。";
"skills.replyInSourceLanguage.name" = "用原语言回复";
"skills.replyInSourceLanguage.description" = "理解复制内容,并使用相同语言自然回复。";
"skills.summarize.name" = "总结";
"skills.summarize.description" = "保留关键事实与结论,不会改写成可发送的短消息。";
"skills.summarize.name" = "总结要点";
"skills.summarize.description" = "总结核心内容,并按需提炼事实、决定、结论和下一步。";
"skills.openLink.name" = "打开链接";
"skills.openLink.description" = "使用系统默认浏览器打开复制内容中的唯一网页链接。";
"skills.summarizeWebPage.name" = "总结网页";
"skills.summarizeWebPage.description" = "安全提取公开网页正文,再总结其中的关键事实与结论。";
"skills.callPhone.name" = "拨打电话";
"skills.callPhone.description" = "拨打复制内容中识别出的唯一电话号码。";
"skills.createContact.name" = "新建联系人";
"skills.createContact.description" = "打开系统联系人编辑页,并预填识别出的电话号码。";
"skills.extractConclusions.name" = "提取结论";
"skills.extractConclusions.description" = "从长文提取决定、结论和下一步。";
"skills.translate.name" = "翻译";
"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
"skills.translate.description" = "将不同语言的复制文本翻译为设备当前的首选系统语言。";
"skills.acceptInvitation.name" = "接受邀约";
"skills.acceptInvitation.description" = "起草简洁的接受回复,并确认重要信息。";
"skills.declineInvitation.name" = "委婉拒绝";
"skills.declineInvitation.description" = "真诚婉拒邀约,不虚构理由。";
"skills.acceptTask.name" = "确认任务";
"skills.acceptTask.description" = "确认收到的事项和原文写明的截止时间。";
"skills.clarifyRequest.name" = "澄清要求";
"skills.clarifyRequest.description" = "只追问回答或执行前最关键的信息。";
"skills.clarifyRequest.name" = "澄清与追问";
"skills.clarifyRequest.description" = "针对问题、任务或故障描述,只追问最关键的信息。";
"skills.empathyReply.name" = "共情回复";
"skills.empathyReply.description" = "理解投诉或负面反馈,并给出稳妥下一步。";
"skills.askForDetails.name" = "询问细节";
@@ -803,6 +813,9 @@
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
"flow.error.clipboardUnavailable" = "没有可用的剪贴板内容。请开启剪贴板历史并先复制文本。";
"flow.error.webPageInvalid" = "该链接无法用于网页总结。";
"flow.error.webPageUnavailable" = "无法读取该网页,页面可能要求登录或禁止自动读取。";
"flow.error.webPageEmpty" = "网页中没有可读取的正文内容。";
/* 可选账号中心 */
"account.title" = "账号";