chore: snapshot uncommitted local work before rebasing onto cloud-agent

Safety commit so the 94 uncommitted files in the working tree (in-flight
Account/Mac/Tip work plus the feature-preview recording tooling) survive the
move onto cursor/cloud-agent-1787824905651-vxuwc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 11:28:07 -07:00
co-authored by Claude Opus 5
parent 39002336c0
commit 5d51c5bf77
101 changed files with 3629 additions and 1354 deletions
@@ -0,0 +1,80 @@
// AccountAnalyticsBearerBridge.swift
// OSGKeyboard · Main App / Mac
//
// Bridges account credentials into the analytics uploader. Extracted from
// `AnalyticsHostService.swift` (UIKit-only) so the macOS target — which
// compiles the account stack at source level — can reuse it unchanged.
#if canImport(OSGKeyboardHostSupport)
import OSGKeyboardHostSupport
#endif
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
import Foundation
final class HostAnalyticsBearerBridge: AnalyticsBearerProviding, @unchecked Sendable {
static let shared = HostAnalyticsBearerBridge()
private let lock = NSLock()
private var provider: (any AnalyticsBearerProviding)?
func install(_ provider: any AnalyticsBearerProviding) {
lock.lock()
self.provider = provider
lock.unlock()
}
func bearerToken() async throws -> String? {
try await currentProvider()?.bearerToken()
}
func refreshBearerToken(
afterUnauthorizedAccessToken failedToken: String?
) async throws -> String? {
try await currentProvider()?.refreshBearerToken(
afterUnauthorizedAccessToken: failedToken
)
}
private func currentProvider() -> (any AnalyticsBearerProviding)? {
lock.lock()
defer { lock.unlock() }
return provider
}
}
struct AccountAnalyticsBearerProvider: AnalyticsBearerProviding {
let apiClient: AccountAPIClient
func bearerToken() async throws -> String? {
guard try await apiClient.currentSession() != nil else { return nil }
do {
return try await apiClient.accessTokenForAuthorizedRequest()
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
return nil
}
}
func refreshBearerToken(
afterUnauthorizedAccessToken failedToken: String?
) async throws -> String? {
guard let failedToken, !failedToken.isEmpty else { return nil }
do {
return try await apiClient.refreshAccessToken(
afterUnauthorizedAccessToken: failedToken
)
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
return nil
}
}
private static func isInvalidSession(_ error: AccountAPIError) -> Bool {
switch error {
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
return true
default:
return false
}
}
}
@@ -12,72 +12,6 @@ import OSGKeyboardShared
import OSLog
import UIKit
final class HostAnalyticsBearerBridge: AnalyticsBearerProviding, @unchecked Sendable {
static let shared = HostAnalyticsBearerBridge()
private let lock = NSLock()
private var provider: (any AnalyticsBearerProviding)?
func install(_ provider: any AnalyticsBearerProviding) {
lock.lock()
self.provider = provider
lock.unlock()
}
func bearerToken() async throws -> String? {
try await currentProvider()?.bearerToken()
}
func refreshBearerToken(
afterUnauthorizedAccessToken failedToken: String?
) async throws -> String? {
try await currentProvider()?.refreshBearerToken(
afterUnauthorizedAccessToken: failedToken
)
}
private func currentProvider() -> (any AnalyticsBearerProviding)? {
lock.lock()
defer { lock.unlock() }
return provider
}
}
struct AccountAnalyticsBearerProvider: AnalyticsBearerProviding {
let apiClient: AccountAPIClient
func bearerToken() async throws -> String? {
guard try await apiClient.currentSession() != nil else { return nil }
do {
return try await apiClient.accessTokenForAuthorizedRequest()
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
return nil
}
}
func refreshBearerToken(
afterUnauthorizedAccessToken failedToken: String?
) async throws -> String? {
guard let failedToken, !failedToken.isEmpty else { return nil }
do {
return try await apiClient.refreshAccessToken(
afterUnauthorizedAccessToken: failedToken
)
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
return nil
}
}
private static func isInvalidSession(_ error: AccountAPIError) -> Bool {
switch error {
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
return true
default:
return false
}
}
}
struct HostAnalyticsLogger: AnalyticsLogging {
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.osgkeyboard.ios",
+28 -2
View File
@@ -22,6 +22,9 @@ struct AIKeyboardDemoView: View {
@State private var scene: Scene = .settings
@State private var levelTick = 0.35
/// Mirrors the chat host for `--preview-fullscreen` recordings.
@State private var hostDraft: String = ""
@State private var hostSent = false
init() {
AIKeyboardView.debugSkipsLongPressCoach = true
@@ -29,10 +32,24 @@ struct AIKeyboardDemoView: View {
var body: some View {
ZStack {
OSGColor.demoBackground.ignoresSafeArea()
if FeaturePreviewFlags.isFullscreen {
Color(uiColor: .systemGroupedBackground).ignoresSafeArea()
} else {
OSGColor.demoBackground.ignoresSafeArea()
}
// Both scenes sit on the bottom band so what's-new crop matches Ext chrome.
VStack(spacing: 0) {
Spacer(minLength: 0)
if FeaturePreviewFlags.isFullscreen {
FeaturePreviewHostDocument(
kind: .messages,
title: language == .en ? "Messages" : "信息",
text: hostDraft,
incoming: incoming,
isSent: hostSent
)
} else {
Spacer(minLength: 0)
}
switch scene {
case .settings:
ThemedRoot {
@@ -116,8 +133,10 @@ struct AIKeyboardDemoView: View {
try? await sleep(3.0)
state.aiSession.markAnswerInserted(offersSend: true)
hostDraft = answer
try? await sleep(1.1)
state.aiSession.markAnswerSent()
hostSent = true
try? await sleep(1.6)
}
@@ -149,6 +168,13 @@ struct AIKeyboardDemoView: View {
: "周末去哪儿玩比较合适?"
}
/// The message being replied to — same seed the extension host uses.
private var incoming: String {
language == .en
? "Looking for a place to relax this weekend"
: "周末想找个地方放松一下"
}
private var answer: String {
language == .en
? "Try a nearby town day trip: walk through a park or old street, stop at a café, then have a local dinner."
@@ -9,11 +9,6 @@ import OSGKeyboardHostSupport
import OSGKeyboardShared
import SwiftUI
enum AccountAppleAuthorizationPurpose {
case signIn
case deleteAccount
}
struct AccountAppleAuthorizationButton: View {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject private var coordinator: AccountSessionCoordinator
@@ -140,50 +135,3 @@ struct AccountAppleAuthorizationButton: View {
}
}
}
private enum AccountAppleAuthorizationPayload {
static func make(
from authorization: ASAuthorization,
rawNonce: String
) -> AppleAuthorizationPayload? {
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let identityTokenData = credential.identityToken,
let authorizationCodeData = credential.authorizationCode,
let identityToken = String(data: identityTokenData, encoding: .utf8),
let authorizationCode = String(data: authorizationCodeData, encoding: .utf8),
!identityToken.isEmpty,
!authorizationCode.isEmpty,
!credential.user.isEmpty
else {
return nil
}
return AppleAuthorizationPayload(
identityToken: identityToken,
authorizationCode: authorizationCode,
nonce: rawNonce,
displayName: displayName(from: credential.fullName),
userIdentifier: credential.user
)
}
private static func displayName(from components: PersonNameComponents?) -> String? {
guard let components else { return nil }
let value = PersonNameComponentsFormatter.localizedString(
from: components,
style: .default,
options: []
)
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
private enum AccountAppleNonce {
static func make() -> AppleSignInNonce? {
// Do not fall back to a merely unique identifier if the system RNG is
// unavailable. The completion path rejects authorization without a
// cryptographically random raw nonce.
return try? AppleSignInNonceGenerator().makeNonce()
}
}
@@ -0,0 +1,67 @@
// AccountAppleAuthorizationFactories.swift
// OSGKeyboard · Main App / Mac
//
// Nonce generation and ASAuthorization → payload mapping, shared by the iOS
// account center and the macOS account page. Extracted from
// `AccountAppleAuthorizationButton.swift` (iOS-only view) so both platforms
// run the identical credential validation instead of a second copy.
import AuthenticationServices
import Foundation
#if canImport(OSGKeyboardHostSupport)
import OSGKeyboardHostSupport
#endif
/// Whether an Apple authorization pass is establishing a session or
/// re-authenticating for account deletion.
enum AccountAppleAuthorizationPurpose {
case signIn
case deleteAccount
}
enum AccountAppleAuthorizationPayload {
static func make(
from authorization: ASAuthorization,
rawNonce: String
) -> AppleAuthorizationPayload? {
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let identityTokenData = credential.identityToken,
let authorizationCodeData = credential.authorizationCode,
let identityToken = String(data: identityTokenData, encoding: .utf8),
let authorizationCode = String(data: authorizationCodeData, encoding: .utf8),
!identityToken.isEmpty,
!authorizationCode.isEmpty,
!credential.user.isEmpty
else {
return nil
}
return AppleAuthorizationPayload(
identityToken: identityToken,
authorizationCode: authorizationCode,
nonce: rawNonce,
displayName: displayName(from: credential.fullName),
userIdentifier: credential.user
)
}
private static func displayName(from components: PersonNameComponents?) -> String? {
guard let components else { return nil }
let value = PersonNameComponentsFormatter.localizedString(
from: components,
style: .default,
options: []
)
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
enum AccountAppleNonce {
static func make() -> AppleSignInNonce? {
// Do not fall back to a merely unique identifier if the system RNG is
// unavailable. The completion path rejects authorization without a
// cryptographically random raw nonce.
return try? AppleSignInNonceGenerator().makeNonce()
}
}
@@ -1,4 +1,4 @@
// AccountModels.swift
// AccountCenterModels.swift
// OSGKeyboard · Main App
//
// Account-center domain models and narrow service boundaries. Transport DTOs
@@ -1,13 +1,17 @@
// AccountCreditPurchaseManager.swift
// OSGKeyboard · Main App
//
// StoreKit credit purchases are isolated from the voluntary tip product.
// StoreKit credit purchases for the optional managed account.
// Transactions finish only after the account server verifies and records them.
import Combine
import Foundation
#if canImport(OSGKeyboardHostSupport)
import OSGKeyboardHostSupport
#endif
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
struct AccountCreditPurchaseOption: Identifiable, Equatable {
var id: String { productID }
@@ -7,7 +7,9 @@
import AuthenticationServices
import Combine
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
@MainActor
final class AccountSessionCoordinator: ObservableObject {
@@ -37,6 +39,10 @@ final class AccountSessionCoordinator: ObservableObject {
@Published private(set) var snapshotPhase: SnapshotPhase = .idle
@Published private(set) var operation: Operation?
@Published private(set) var operationErrorKey: String?
/// Machine-readable code for `operationErrorKey` (see `AccountDiagnostic`).
/// The localized key alone says "sign-in failed"; this says *why*, which is
/// the difference between a bug report we can act on and one we cannot.
@Published private(set) var operationErrorDetail: String?
@Published private(set) var pendingReferralCode: String?
@Published private(set) var shouldPresentAccountCenter = false
@Published private(set) var lastAccountRefreshAt: Date?
@@ -48,6 +54,11 @@ final class AccountSessionCoordinator: ObservableObject {
private let sessionService: any AccountSessionServicing
private let centerService: any AccountCenterServicing
/// False on macOS: a Developer ID build cannot use StoreKit, so starting a
/// purchase session there would fetch a product catalog and open a
/// `Transaction` listener for a store that can never complete a purchase.
/// Credits are still spent normally — only buying is unavailable.
private let arePurchasesAvailable: Bool
private let pendingReferralStore: any PendingReferralCodeStoring
private let analyticsClient: any AnalyticsClient
private let onAccountAuthenticated: (UUID) async -> Void
@@ -71,6 +82,7 @@ final class AccountSessionCoordinator: ObservableObject {
referralProfileStore: any ReferralProfileStoring =
UserDefaultsReferralProfileStore(),
accountRefreshInterval: TimeInterval = 10 * 60,
arePurchasesAvailable: Bool = true,
now: @escaping () -> Date = Date.init,
notificationCenter: NotificationCenter = .default,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
@@ -95,6 +107,7 @@ final class AccountSessionCoordinator: ObservableObject {
self.onAccountSignedOut = onAccountSignedOut
self.onAccountDeleted = onAccountDeleted
self.accountRefreshInterval = accountRefreshInterval
self.arePurchasesAvailable = arePurchasesAvailable
self.now = now
pendingReferralCode = pendingReferralStore.code
let sessionEventSource = dependencies.sessionEventSource
@@ -138,6 +151,7 @@ final class AccountSessionCoordinator: ObservableObject {
didAttemptRestore = true
sessionPhase = .restoring
operationErrorKey = nil
operationErrorDetail = nil
do {
guard let session = try await sessionService.restoreSession() else {
@@ -149,7 +163,9 @@ final class AccountSessionCoordinator: ObservableObject {
await onAccountAuthenticated(session.accountID)
advanceSessionRevision()
sessionPhase = .signedIn(session)
creditPurchases.startSession(accountID: session.accountID)
if arePurchasesAvailable {
creditPurchases.startSession(accountID: session.accountID)
}
referralProfile.startSession(accountID: session.accountID)
await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true)
@@ -196,6 +212,7 @@ final class AccountSessionCoordinator: ObservableObject {
pendingReferralCode = code
shouldPresentAccountCenter = true
operationErrorKey = nil
operationErrorDetail = nil
analyticsClient.recordInviteOpened()
if isSignedIn {
@@ -217,6 +234,7 @@ final class AccountSessionCoordinator: ObservableObject {
guard operation == nil else { return }
operation = .signingIn
operationErrorKey = nil
operationErrorDetail = nil
defer { operation = nil }
do {
@@ -224,7 +242,9 @@ final class AccountSessionCoordinator: ObservableObject {
await onAccountAuthenticated(session.accountID)
advanceSessionRevision()
sessionPhase = .signedIn(session)
creditPurchases.startSession(accountID: session.accountID)
if arePurchasesAvailable {
creditPurchases.startSession(accountID: session.accountID)
}
referralProfile.startSession(accountID: session.accountID)
await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true)
@@ -233,6 +253,7 @@ final class AccountSessionCoordinator: ObservableObject {
for: error,
fallback: "account.error.signIn"
)
operationErrorDetail = AccountDiagnostic.code(for: error)
}
}
@@ -287,6 +308,7 @@ final class AccountSessionCoordinator: ObservableObject {
let expectedRevision = sessionRevision
operation = .updatingProfile
operationErrorKey = nil
operationErrorDetail = nil
defer { operation = nil }
do {
@@ -325,6 +347,7 @@ final class AccountSessionCoordinator: ObservableObject {
let expectedRevision = sessionRevision
operation = .preparingManagedGateway
operationErrorKey = nil
operationErrorDetail = nil
defer { operation = nil }
do {
try await sessionService.prepareManagedGateway()
@@ -355,6 +378,7 @@ final class AccountSessionCoordinator: ObservableObject {
guard operation == nil, isSignedIn else { return }
operation = .signingOut
operationErrorKey = nil
operationErrorDetail = nil
defer { operation = nil }
referralProfile.cancelRefresh()
@@ -379,6 +403,7 @@ final class AccountSessionCoordinator: ObservableObject {
guard operation == nil, isSignedIn else { return }
operation = .deletingAccount
operationErrorKey = nil
operationErrorDetail = nil
defer { operation = nil }
referralProfile.cancelRefresh()
@@ -402,12 +427,14 @@ final class AccountSessionCoordinator: ObservableObject {
}
}
func recordAppleAuthorizationFailure() {
func recordAppleAuthorizationFailure(detail: String = "apple-authorization") {
operationErrorKey = "account.error.appleAuthorization"
operationErrorDetail = detail
}
func dismissOperationError() {
operationErrorKey = nil
operationErrorDetail = nil
}
private func performAccountRefresh(for accountID: UUID, requestID: UUID) async {
@@ -7,8 +7,12 @@
import AuthenticationServices
import Foundation
#if canImport(OSGKeyboardHostSupport)
import OSGKeyboardHostSupport
#endif
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
@MainActor
enum LiveAccountDependencyFactory {
@@ -333,7 +337,7 @@ private actor LiveAccountService:
)
throw AccountAPIError.secureStorage
}
let session: OSGKeyboardHostSupport.AccountSession
let session: AccountTokenSession
do {
session = try await apiClient.signInWithApple(
AppleSignInRequest(
@@ -603,7 +607,7 @@ private actor LiveAccountService:
}
private func uiSession(
_ session: OSGKeyboardHostSupport.AccountSession,
_ session: AccountTokenSession,
createdAtEpochSeconds: Int64,
displayName: String?
) -> AccountSession {
@@ -616,7 +620,11 @@ private actor LiveAccountService:
}
private enum AccountDiagnostic {
/// Stable, non-identifying failure codes for account traffic. Logged on both
/// platforms and — on macOS, where sign-in has no App Store receipt trail to
/// fall back on — also shown under the error alert so a failure can be
/// reported without a console capture.
enum AccountDiagnostic {
static func code(for error: Error) -> String {
guard let error = error as? AccountAPIError else {
return String(describing: type(of: error))
@@ -5,7 +5,9 @@
// the local cache keeps the permanent invitation link visible when offline.
import Foundation
#if canImport(OSGKeyboardHostSupport)
import OSGKeyboardHostSupport
#endif
@MainActor
final class ReferralProfileViewModel: ObservableObject {
@@ -22,11 +22,18 @@ struct ClipboardHistoryDemoView: View {
@StateObject private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@StateObject private var history = ClipboardHistoryStore(
defaults: UserDefaults(suiteName: "osg.whatsnew.clipboard.demo")
)
// `AIKeyboardView` ranks its skill row off `ClipboardHistoryStore.shared`
// and `ClipboardSemanticRankingStore.shared`. Driving a private store here
// would leave the row to be faked, which is exactly what made earlier takes
// diverge from the shipping UI.
@ObservedObject private var history = ClipboardHistoryStore.shared
@ObservedObject private var ranking = ClipboardSemanticRankingStore.shared
@Environment(\.colorScheme) private var colorScheme
/// Mirrors the chat host for `--preview-fullscreen` recordings.
@State private var hostDraft: String = ""
/// Closing beat: hand the copied text to the real AI skill row.
@State private var showsSkills = false
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
@@ -34,9 +41,22 @@ struct ClipboardHistoryDemoView: View {
var body: some View {
ZStack {
OSGColor.demoBackground.ignoresSafeArea()
if FeaturePreviewFlags.isFullscreen {
Color(uiColor: .systemGroupedBackground).ignoresSafeArea()
} else {
OSGColor.demoBackground.ignoresSafeArea()
}
VStack(spacing: 0) {
Spacer(minLength: 0)
if FeaturePreviewFlags.isFullscreen {
FeaturePreviewHostDocument(
kind: .messages,
title: "信息",
text: hostDraft,
incoming: Self.incomingMessage
)
} else {
Spacer(minLength: 0)
}
keyboardChrome
.background(palette.background.ignoresSafeArea(edges: .bottom))
.overlay(alignment: .top) {
@@ -56,9 +76,15 @@ struct ClipboardHistoryDemoView: View {
private var keyboardChrome: some View {
ZStack {
voiceSurface
.opacity(state.clipboardOverlay == .none ? 1 : 0)
.allowsHitTesting(state.clipboardOverlay == .none)
if showsSkills {
// Real unified AI surface — the skill row owns「一键回复」.
AIKeyboardView(state: state, typing: typing, onInsert: { _ in })
.transition(.opacity)
} else {
voiceSurface
.opacity(state.clipboardOverlay == .none ? 1 : 0)
.allowsHitTesting(state.clipboardOverlay == .none)
}
if state.clipboardOverlay == .historyPanel {
ClipboardHistoryPanelView(
@@ -228,6 +254,134 @@ struct ClipboardHistoryDemoView: View {
}
}
try? await sleep(2.4)
guard FeaturePreviewFlags.isFullscreen else { return }
await runReplyBeat()
}
/// Reply styles are separate skills in the row rather than variants offered
/// after one tap, so the beat runs the same copied message through two of
/// them back to back — that is what "多个风格的回复" looks like in the real UI.
///
/// Which chips appear is decided by `ClipboardSkillSemanticRanker`, not by
/// this file: production caps specialised reply skills at two alongside the
/// generic 回复, so hand-picking a longer row produced a keyboard that could
/// never exist. The beat therefore taps whatever the ranker surfaced.
private func runReplyBeat() async {
AIKeyboardView.debugSkipsLongPressCoach = true
AIKeyboardView.debugPreviewSkills = nil
state.aiServiceAvailable = true
state.surface = .ai
state.aiSession.enter()
state.pendingClipboardSkillID = nil
withAnimation(.easeInOut(duration: 0.3)) {
showsSkills = true
}
let skills = await rankedReplySkills()
try? await sleep(0.85)
guard let generic = skills.first(where: {
$0.id == AIClipboardSkillCatalog.replyID
}) else { return }
await runReplySkill(
skill: generic,
answer: "好的,预算版我整理一下,明天上午发你。"
)
state.pendingClipboardSkillID = nil
try? await sleep(0.6)
// Second style: whatever specialised reply the ranker actually offered.
if let specialised = skills.first(where: {
$0.supportsReplyStyle && $0.id != AIClipboardSkillCatalog.replyID
}) {
await runReplySkill(
skill: specialised,
answer: Self.answer(for: specialised.id)
)
}
// End back on the skill row so the card loops without a jump.
state.pendingClipboardSkillID = nil
try? await sleep(0.25)
}
/// Runs the real semantic analysis the keyboard uses, then reports the row
/// it produced so the beat can tap chips that are genuinely on screen.
private func rankedReplySkills() async -> [AIClipboardSkill] {
guard let newest = history.newestEntry else { return [] }
ranking.analyze(newest)
// The analyzer runs off the main actor; wait for its snapshot rather
// than racing it, or the row falls back to a lone 回复 chip.
for _ in 0..<40 {
if ranking.snapshot?.entryID == newest.id { break }
try? await Task.sleep(nanoseconds: 50_000_000)
}
guard let snapshot = ranking.snapshot, snapshot.entryID == newest.id else {
return state.clipboardSkillCatalog.filter {
$0.id == AIClipboardSkillCatalog.replyID
}
}
return ClipboardSkillSemanticRanker.recommended(
skills: state.clipboardSkillCatalog,
sourceText: newest.text,
analysis: snapshot.analysis,
uiLanguage: state.uiLanguage,
limit: 5
)
}
/// Demo copy per reply style. Keyed by skill so the answer matches whatever
/// chip the ranker put in the row.
private static func answer(for skillID: String) -> String {
switch skillID {
case AIClipboardSkillCatalog.playfulReplyID:
return "行嘞,预算表这就去梳妆打扮,明早准时上桌。"
case AIClipboardSkillCatalog.businessReplyID:
return "收到,预算版本我今晚整理完,明日上午发送给您。"
case AIClipboardSkillCatalog.empathyReplyID:
return "理解,这版确实等着用。我加紧整理,明早一定发你。"
case AIClipboardSkillCatalog.acceptTaskID:
return "好的,这件事我接了,明天上午把预算版发你。"
case AIClipboardSkillCatalog.clarifyRequestID:
return "没问题,你要的是含人力成本的那版,还是只要采购部分?"
default:
return "好的,我整理一下,明天上午发你。"
}
}
/// One tap on a reply-style chip: the row gives way to the thinking state,
/// the answer streams in, then it is offered for insertion.
private func runReplySkill(skill: AIClipboardSkill, answer: String) async {
// Drives the real generating copy (falls through to「AI 正在思考…」,
// same as a production skill run).
state.pendingClipboardSkillID = skill.id
// `beginGenerating` (and every later step) is guarded on
// `activeUtteranceID`, which only `beginPreparing` sets — skipping it
// makes the whole sequence a silent no-op and the keyboard never
// shows an answer.
let utteranceID = UUID()
state.aiSession.enter()
state.aiSession.beginPreparing(utteranceID: utteranceID)
state.aiSession.beginGenerating(question: "", utteranceID: utteranceID)
try? await sleep(0.2)
let chars = Array(answer)
var index = 0
while index < chars.count {
index = min(chars.count, index + 3)
state.aiSession.receivePartialAnswer(
String(chars[..<index]),
utteranceID: utteranceID
)
try? await sleep(0.04)
}
state.aiSession.receiveAnswer(answer, utteranceID: utteranceID)
try? await sleep(0.45)
state.aiSession.markAnswerInserted(offersSend: true)
hostDraft = answer
try? await sleep(0.2)
}
private func prepareState() {
@@ -236,6 +390,10 @@ struct ClipboardHistoryDemoView: View {
state.layoutWidth = 390
state.usesIPadLayoutMetrics = false
state.showsSystemGlobeKey = false
// `AIKeyboardView.showsClipboardSkills` gates on this — without it the
// real skill row never appears no matter what the ranker returns.
state.clipboardHistoryEnabled = true
state.clipboardCandidateBarEnabled = true
state.clipboardOverlay = .none
state.clipboardSuggestionText = nil
state.openClipboardPanel = {
@@ -254,10 +412,14 @@ struct ClipboardHistoryDemoView: View {
history.clearAll()
_ = history.ingest(rawText: "订单号 OSG-20260811-8842", changeCount: 3)
_ = history.ingest(rawText: "https://osglab.com", changeCount: 2)
_ = history.ingest(rawText: "明天下午三点会议室见", changeCount: 1)
_ = history.ingest(rawText: Self.incomingMessage, changeCount: 1)
history.reload()
}
/// The message the clip replies to. Copying it is what puts the reply
/// skills in the row, so it has to be the newest clipboard entry.
static let incomingMessage = "方案我看了,明天能不能把预算那一版也发我?"
private func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
}
+18 -2
View File
@@ -21,6 +21,8 @@ struct EditDemoView: View {
@StateObject private var state = KeyboardState()
@State private var scene: Scene = .hint
/// Mirrors the host document for `--preview-fullscreen` recordings.
@State private var hostText: String = EditDemoView.originalText
private var source: EditSessionSource {
let reference = EditableInputReference(
@@ -36,9 +38,21 @@ struct EditDemoView: View {
var body: some View {
ZStack {
OSGColor.demoBackground.ignoresSafeArea()
if FeaturePreviewFlags.isFullscreen {
Color(uiColor: .systemGroupedBackground).ignoresSafeArea()
} else {
OSGColor.demoBackground.ignoresSafeArea()
}
VStack(spacing: 0) {
Spacer(minLength: 0)
if FeaturePreviewFlags.isFullscreen {
FeaturePreviewHostDocument(
kind: .notes,
title: "备忘录",
text: hostText
)
} else {
Spacer(minLength: 0)
}
Group {
switch scene {
case .hint:
@@ -144,6 +158,8 @@ struct EditDemoView: View {
withAnimation(.easeInOut(duration: 0.2)) {
state.editSession = .applying(review)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.applying")
// Close the loop: the host document shows the polished result.
hostText = Self.editedText
}
// Stay on applying so the last captured frames are still the real UI.
try? await sleep(2.0)
@@ -0,0 +1,147 @@
// FeaturePreviewHostDocument.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// Fills the empty band above the scripted keyboard demos so a full-screen
// App Store preview can be recorded from them. The What's New workflow crops
// to the keyboard chrome and must keep its blank backdrop, so every demo view
// only shows this when `--preview-fullscreen` is passed.
//
// Visual language deliberately matches `NotesHostDemoView` — the same
// Notes / Messages stand-in the extension-based recordings sit on top of.
#if DEBUG
import OSGKeyboardShared
import SwiftUI
enum FeaturePreviewFlags {
/// Opt into the full-screen host document above the keyboard chrome.
static var isFullscreen: Bool {
ProcessInfo.processInfo.arguments.contains("--preview-fullscreen")
}
}
/// A Notes- or Messages-like document that reacts to the demo timeline.
struct FeaturePreviewHostDocument: View {
enum Kind {
case notes
case messages
}
let kind: Kind
let title: String
/// Text the keyboard has produced so far. Empty renders the placeholder.
let text: String
/// Messages only — the message being replied to.
var incoming: String?
/// Draws the send-confirmed state for the AI clip's closing beat.
var isSent: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.system(size: 13))
.foregroundStyle(Color(uiColor: .secondaryLabel))
.padding(.horizontal, 4)
switch kind {
case .notes:
notesBody
case .messages:
messagesBody
}
}
.padding(.horizontal, 18)
.padding(.top, 56)
.padding(.bottom, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var notesBody: some View {
VStack(alignment: .leading, spacing: 0) {
if text.isEmpty {
Text(placeholder)
.font(.system(size: 20))
.foregroundStyle(Color(uiColor: .tertiaryLabel))
} else {
HStack(alignment: .top, spacing: 0) {
Text(text)
.font(.system(size: 20))
.foregroundStyle(Color(uiColor: .label))
.multilineTextAlignment(.leading)
caret
}
}
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
// Text lands in a stable place instead of jumping as it grows.
.animation(.easeOut(duration: 0.2), value: text)
}
private var messagesBody: some View {
VStack(alignment: .leading, spacing: 12) {
Spacer(minLength: 0)
if let incoming, !incoming.isEmpty {
bubble(incoming, outgoing: false)
}
if !text.isEmpty {
bubble(text, outgoing: true)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
if isSent {
Text("已发送")
.font(.system(size: 12))
.foregroundStyle(Color(uiColor: .tertiaryLabel))
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
.animation(.easeOut(duration: 0.24), value: text)
.animation(.easeOut(duration: 0.24), value: isSent)
}
private func bubble(_ content: String, outgoing: Bool) -> some View {
HStack {
if outgoing { Spacer(minLength: 40) }
Text(content)
.font(.system(size: 17))
.foregroundStyle(outgoing ? Color.white : Color(uiColor: .label))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(
outgoing
? Color(red: 0.20, green: 0.66, blue: 0.38)
: Color(uiColor: .tertiarySystemFill)
)
)
if !outgoing { Spacer(minLength: 40) }
}
}
private var caret: some View {
RoundedRectangle(cornerRadius: 1)
.fill(Color.accentColor)
.frame(width: 2, height: 24)
.padding(.leading, 1)
}
private var placeholder: String {
switch kind {
case .notes: return "开始记录…"
case .messages: return ""
}
}
}
#endif