import AgentPetCompanionCore import AppKit import Combine import Foundation import UniformTypeIdentifiers enum OverlayBubbleDisclosureDirection: Equatable { case expanding case collapsing } enum OverlayBubbleDisclosureAction: Equatable { case revealBubble case revealCollapsedStandaloneStack case expandStandaloneStack case collapseStandaloneStack case dismissBubble static func resolve( groupSessionsByAgent: Bool, sessionCount: Int, bubbleDismissed: Bool, standaloneStackExpanded: Bool, standaloneStackDirection: OverlayBubbleDisclosureDirection ) -> Self? { guard sessionCount > 0 else { return nil } guard !groupSessionsByAgent, sessionCount > 1 else { return bubbleDismissed ? .revealBubble : .dismissBubble } if bubbleDismissed { return .revealCollapsedStandaloneStack } if standaloneStackExpanded { return .collapseStandaloneStack } return standaloneStackDirection == .expanding ? .expandStandaloneStack : .dismissBubble } var revealsMoreContent: Bool { switch self { case .revealBubble, .revealCollapsedStandaloneStack, .expandStandaloneStack: true case .collapseStandaloneStack, .dismissBubble: false } } } struct AppStoreBootstrapHooks { typealias EnsureRunning = @Sendable () async -> ServiceStartResult typealias Recover = @Sendable () async -> ServiceStartResult typealias FetchInitialBehavior = @MainActor (AppStore) async throws -> Any typealias RefreshSnapshot = @MainActor (AppStore) async throws -> Void typealias OnReady = @MainActor (AppStore) async -> Void let ensureRunning: EnsureRunning let recover: Recover let fetchInitialBehavior: FetchInitialBehavior? let refreshSnapshot: RefreshSnapshot let onReady: OnReady let requiresAuthoritativeSnapshotOnReady: Bool init( ensureRunning: @escaping EnsureRunning, recover: @escaping Recover, fetchInitialBehavior: FetchInitialBehavior? = nil, refreshSnapshot: @escaping RefreshSnapshot, onReady: @escaping OnReady, requiresAuthoritativeSnapshotOnReady: Bool = false ) { self.ensureRunning = ensureRunning self.recover = recover self.fetchInitialBehavior = fetchInitialBehavior self.refreshSnapshot = refreshSnapshot self.onReady = onReady self.requiresAuthoritativeSnapshotOnReady = requiresAuthoritativeSnapshotOnReady } } private struct OverlayPetFrameHitTestProjection { var hitTest: OverlayPetFrameHitTest? var petID: String var semanticOwnerEntryID: String } enum PetCoreRuntimePhase: Equatable { case checking case running case failed } enum PetStudioCodexAvailability: Equatable, Sendable { case checking case available case missing case unavailable var permitsGeneration: Bool { self == .available } } struct PetCoreRuntimeInfo: Equatable { var phase: PetCoreRuntimePhase var version: String? var appBuild: String? var buildID: String? var rpcProtocol: String? var releaseChannel: String? var databaseSchemaRange: String? var instanceID: String? var errorMessage: String? static func initial(manifest: RuntimeReleaseManifest?) -> Self { Self( phase: .checking, version: manifest?.appVersion, appBuild: manifest?.appBuild, buildID: manifest?.buildID, rpcProtocol: manifest?.petCoreRPCProtocol, releaseChannel: manifest?.releaseChannel, databaseSchemaRange: manifest.map { $0.minimumDatabaseSchemaVersion == $0.maximumDatabaseSchemaVersion ? String($0.minimumDatabaseSchemaVersion) : "\($0.minimumDatabaseSchemaVersion)–\($0.maximumDatabaseSchemaVersion)" }, instanceID: nil, errorMessage: nil ) } static func running( healthValue: Any, expectedManifest: RuntimeReleaseManifest? = PetCoreRuntimeContract.requiredManifest ) -> Self? { guard PetCoreRuntimeContract.acceptsHealth( healthValue, expectedBuildID: expectedManifest?.buildID ?? PetCoreRuntimeContract.requiredBuildID, expectedManifest: expectedManifest ), let health = healthValue as? [String: Any] else { return nil } let manifest = RuntimeReleaseManifest.decodeHealthValue(health["runtime_manifest"]) ?? expectedManifest var info = initial(manifest: manifest) info.phase = .running info.version = health["version"] as? String ?? info.version info.buildID = health["build_id"] as? String ?? info.buildID info.rpcProtocol = health["rpc_protocol"] as? String ?? info.rpcProtocol info.instanceID = health["instance_id"] as? String return info } mutating func markChecking() { phase = .checking } mutating func markRunning() { phase = .running errorMessage = nil } mutating func markFailed(_ reason: String) { phase = .failed errorMessage = reason } } enum AgentSessionDeepLink { static func url(source: AgentSource?, sessionID: String?) -> URL? { guard let source else { return nil } guard let sessionID = sessionID?.trimmingCharacters(in: .whitespacesAndNewlines), sessionID.count == 36, let uuid = UUID(uuidString: sessionID) else { return nil } let canonical = uuid.uuidString.lowercased() guard canonical.caseInsensitiveCompare(sessionID) == .orderedSame else { return nil } switch source { case .codex: return URL(string: "codex://threads/\(canonical)") // Claude Desktop's `claude://resume` imports a CLI transcript as a new // Desktop session rather than locating the existing one, so there is no // truthful exact-session link to build for Claude Code. case .claudeCode, .pi, .opencode: return nil } } } enum AgentSessionOpenRoute: Equatable { case url(URL) case application(bundleIdentifiers: [String], paths: [String]) } enum AgentSessionOpenFailure: Equatable, Sendable { case urlOpenRejected case applicationUnavailable case applicationLaunchFailed } enum AgentSessionOpenOutcome: Equatable, Sendable { case openedExactSession case openedAgentHost case failed(AgentSessionOpenFailure) var didOpen: Bool { switch self { case .openedExactSession, .openedAgentHost: true case .failed: false } } } private struct OverlaySessionNavigationNoticeRecord: Equatable { let identity: OverlaySessionProjectionIdentity let notice: OverlaySessionNavigationNotice } private struct OverlaySessionProjectionIdentity: Equatable, Sendable { let eventID: String let acknowledgementID: String? } enum AgentSessionRouter { private static let chatGPTBundleIdentifiers = ["com.openai.codex"] private static let chatGPTPaths = ["/Applications/ChatGPT.app", "/Applications/Codex.app"] private static let claudeBundleIdentifiers = ["com.anthropic.claudefordesktop"] private static let claudePaths = ["/Applications/Claude.app"] private static let openCodeBundleIdentifiers = [ "ai.opencode.desktop", "ai.opencode.desktop.beta", "ai.opencode.desktop.dev" ] private static let openCodePaths = [ "/Applications/OpenCode.app", "/Applications/OpenCode Beta.app", "/Applications/OpenCode Dev.app" ] private static let terminalTargets: [String: ([String], [String])] = [ "warp": (["dev.warp.Warp-Stable", "dev.warp.Warp-Preview"], ["/Applications/Warp.app", "/Applications/WarpPreview.app"]), "terminal": (["com.apple.Terminal"], ["/System/Applications/Utilities/Terminal.app"]), "iterm2": (["com.googlecode.iterm2"], ["/Applications/iTerm.app"]), "ghostty": (["com.mitchellh.ghostty"], ["/Applications/Ghostty.app"]) ] static func route( source: AgentSource?, sessionID: String?, navigation: AgentSessionNavigation ) -> AgentSessionOpenRoute? { guard !navigation.explicitlyClosed else { return nil } switch navigation.capability { case .exactSession: return exactSessionRoute(source: source, navigation: navigation) case .agentHost: return agentHostRoute(source: source, navigation: navigation) case .unavailable: return nil } } static func validatedCapability( source: AgentSource?, sessionID: String?, navigation: AgentSessionNavigation ) -> NavigationCapability { route(source: source, sessionID: sessionID, navigation: navigation) == nil ? .unavailable : navigation.capability } static func hostFallbackRoute( source: AgentSource?, navigation: AgentSessionNavigation ) -> AgentSessionOpenRoute? { guard navigation.capability == .exactSession, !navigation.explicitlyClosed else { return nil } return agentHostRoute(source: source, navigation: navigation) } private static func exactSessionRoute( source: AgentSource?, navigation: AgentSessionNavigation ) -> AgentSessionOpenRoute? { if navigation.surface == "cli_terminal", navigation.terminalApp == "warp", let openURL = validatedSessionOpenURL(navigation.openURL) { return .url(openURL) } if source == .codex, navigation.surface == "chatgpt_app", navigation.sessionOpen == true, let deepLink = AgentSessionDeepLink.url( source: source, // Only PetCore's dedicated, strictly validated routing field // may cross back into a Codex task URL. Never reinterpret the // generic projected session identity as a routable raw ID. sessionID: navigation.routableSessionID ) { return .url(deepLink) } return nil } private static func agentHostRoute( source: AgentSource?, navigation: AgentSessionNavigation ) -> AgentSessionOpenRoute? { guard let source else { return nil } if navigation.surface == "cli_terminal" { guard let terminalApp = navigation.terminalApp, let target = terminalTargets[terminalApp] else { return nil } return .application(bundleIdentifiers: target.0, paths: target.1) } if source == .codex, navigation.surface == "chatgpt_app" { return .application( bundleIdentifiers: chatGPTBundleIdentifiers, paths: chatGPTPaths ) } if source == .claudeCode, navigation.surface == "claude_app" { return .application( bundleIdentifiers: claudeBundleIdentifiers, paths: claudePaths ) } if source == .opencode, navigation.surface == "opencode_app" { return .application( bundleIdentifiers: openCodeBundleIdentifiers, paths: openCodePaths ) } return nil } static func validatedSessionOpenURL(_ value: String?) -> URL? { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), let url = URL(string: value), ["warp", "warppreview"].contains(url.scheme?.lowercased() ?? ""), url.host?.lowercased() == "session", url.user == nil, url.password == nil, url.port == nil, url.query == nil, url.fragment == nil else { return nil } let identifier = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard identifier.range( of: "^[0-9A-Fa-f]{32}$", options: .regularExpression ) != nil else { return nil } return url } } extension AgentEvent { /// App Server lease renewal advances `created_at` even when every visible /// field is unchanged. Treat that timestamp as transport metadata so the /// overlay does not redraw once per poll while still publishing real /// state, message, navigation, or activity changes. func hasSamePresentation(as other: AgentEvent) -> Bool { id == other.id && source == other.source && sessionID == other.sessionID && eventType == other.eventType && title == other.title && detail == other.detail && payloadJSON == other.payloadJSON } } extension ActiveAgentState { func hasSamePresentation(as other: ActiveAgentState) -> Bool { let current = OverlaySessionContent(state: self) let next = OverlaySessionContent(state: other) return state == other.state && officialStatus == other.officialStatus && sessionActive == other.sessionActive && leaseSeconds == other.leaseSeconds // `expiresAt`, persisted sequence, event identity, and connector // lifecycle aliases are transport/audit metadata. Compare the // values the bubble actually renders plus the pet's semantic // state-entry identity so equivalent terminal edges cannot cause // a second redraw or animation restart. && current.id == next.id && current.source == next.source && current.sessionID == next.sessionID && current.eventType == next.eventType && current.sessionTitle == next.sessionTitle && current.activityText == next.activityText && current.messageText == next.messageText && current.statusText == next.statusText && current.actionLabel == next.actionLabel && current.navigation == next.navigation && OverlayPetAnimationIdentity.stateEntryID(for: self) == OverlayPetAnimationIdentity.stateEntryID(for: other) } } private func optionalEventHasSamePresentation(_ lhs: AgentEvent?, _ rhs: AgentEvent?) -> Bool { switch (lhs, rhs) { case (nil, nil): true case let (.some(lhs), .some(rhs)): lhs.hasSamePresentation(as: rhs) default: false } } private func activeStatesHaveSamePresentation( _ lhs: [ActiveAgentState], _ rhs: [ActiveAgentState] ) -> Bool { lhs.count == rhs.count && zip(lhs, rhs).allSatisfy { $0.hasSamePresentation(as: $1) } } private func eventsHaveSamePresentation(_ lhs: [AgentEvent], _ rhs: [AgentEvent]) -> Bool { lhs.count == rhs.count && zip(lhs, rhs).allSatisfy { $0.hasSamePresentation(as: $1) } } struct OverlayBubbleLayoutSignature: Equatable { struct Group: Equatable { let id: String let visibleSessionCount: Int let isStacked: Bool } let bubbleDismissed: Bool let groups: [Group] init(contents: [OverlayBubbleContent], bubbleDismissed: Bool) { self.bubbleDismissed = bubbleDismissed groups = contents.map { content in Group( id: content.id, visibleSessionCount: content.visibleSessions.count, isStacked: content.isStacked ) } } } enum PetCoreServiceFailureCode: String, Codable, Equatable, Sendable { case none case petCoreBinaryMissing = "petcore_binary_missing" case cliMissing = "cli_missing" case launchAgentDisabled = "launch_agent_disabled" case runtimePathsFailed = "runtime_paths_failed" case launchctlFailed = "launchctl_failed" case candidateHealthFailed = "candidate_health_failed" case directLaunchFailed = "direct_launch_failed" case updateRollbackFailed = "update_rollback_failed" case unknown } enum PetCoreServiceFailureClassifier { static func classify(_ reason: String) -> PetCoreServiceFailureCode { if reason.contains("PetCore 更新失败且回滚未完成") || reason.contains("PetCore 更新失败,已恢复") { return .updateRollbackFailed } if reason.contains("未找到 petcore-cli 可执行文件") { return .cliMissing } if reason.contains("未找到 petcore 可执行文件") { return .petCoreBinaryMissing } if reason.contains("LaunchAgent 已由 APC_DISABLE_LAUNCH_AGENT 禁用") { return .launchAgentDisabled } if reason.contains("准备 petcore 运行目录失败") { return .runtimePathsFailed } if reason.contains("PetCore LaunchAgent 命令失败") { return .launchctlFailed } if reason.contains("候选 PetCore 启动后未通过版本与健康检查") || reason.contains("候选 PetCore 预检失败") || reason.contains("LaunchAgent 已启动,但 PetCore 未在限定时间内就绪") { return .candidateHealthFailed } if reason.contains("启动 petcore 失败") || reason.contains("PetCore 直接启动后未在限定时间内就绪") { return .directLaunchFailed } return .unknown } } private enum PetAssetDiagnosticCategory: String { case assetsInvalid = "assets_invalid" case unknown static func classify(_ warning: PetAssetWarning) -> Self { warning.code == "pet_assets_invalid" ? .assetsInvalid : .unknown } } enum OverlayKeyboardFocusAction: CaseIterable, Hashable { case bubbleSessions func isAvailable(overlayEnabled: Bool, bubbleSessionCount: Int) -> Bool { guard overlayEnabled else { return false } return bubbleSessionCount > 0 } static func availableActions( overlayEnabled: Bool, bubbleSessionCount: Int ) -> Set { Set(allCases.filter { $0.isAvailable( overlayEnabled: overlayEnabled, bubbleSessionCount: bubbleSessionCount ) }) } } private enum LatestGenerationRestoreAttemptState: Equatable { case notAttempted case inFlight case resolved } private struct ActiveGenerationRecoveryProjectionIdentity: Equatable { var jobID: String var form: GenerationForm var referenceReselectionCount: Int } private struct SanitizedGenerationRecoveryProjection { var identity: ActiveGenerationRecoveryProjectionIdentity var form: GenerationForm? var referenceReselectionCount: Int } enum OnboardingOperationFailure: Equatable { case serviceUnavailable case petActivation case revisionConflict case requestRejected } enum IncludedCompanionRestoreState: Equatable { case idle case restoring case restored case failed } enum PetAssetRepairState: Equatable { case idle case repairing case repaired case failed } enum AppUpdateConvergenceAttention: Equatable { case bundledPets case connectors([ProductConnectorConvergenceIssue]) case service var sources: [AgentSource] { if case let .connectors(issues) = self { issues.map(\.source) } else { [] } } } enum AppUpdateConvergenceState: Equatable { case idle case waitingForActiveWork case updating case completed(version: String) case needsAttention(AppUpdateConvergenceAttention) } @MainActor final class AppStore: ObservableObject { typealias BundledPetSeeder = @MainActor () async -> Bool typealias BundledPetSeedSleeper = @Sendable (Duration) async throws -> Void typealias InitialAppearanceFallbackSleeper = @Sendable (Duration) async throws -> Void typealias OverlayPlacementRetrySleeper = @Sendable (Duration) async throws -> Void typealias AgentSessionRouteOpener = @MainActor ( AgentSessionOpenRoute ) async -> AgentSessionOpenOutcome typealias ProductConvergenceSleeper = @Sendable (Duration) async throws -> Void typealias ProductConvergenceUpgradeEvidence = @MainActor ( RuntimeReleaseManifest ) -> Bool typealias RuntimeHandoffCheck = @MainActor () -> Bool typealias ApplicationAppearanceApplier = @MainActor (AppearanceTheme) -> Void typealias ApplicationLanguageApplier = @MainActor (InterfaceLanguage) -> Void typealias OverlayPresenter = @MainActor (PetOverlayController, AppStore) -> Void typealias OverlayKeyboardFocusHandler = @MainActor ( _ controller: PetOverlayController, _ action: OverlayKeyboardFocusAction ) -> Void typealias PetCoreRequestOverride = @MainActor ( _ method: String, _ params: Any, _ timeout: Duration? ) async throws -> Any @Published var selection: NavigationSection = .library @Published private(set) var descriptionText = AIPetMakerDefaults.descriptionText @Published private(set) var selectedStyle = AIPetMakerDefaults.style @Published private(set) var selectedQuality = AIPetMakerDefaults.quality @Published private(set) var referenceImages: [String] = [] @Published private(set) var referenceImageIssue: MakerReferenceImageIssue? @Published private(set) var referenceReselectionCount = 0 @Published private(set) var petStudioCodexAvailability = PetStudioCodexAvailability.checking @Published var behavior = BehaviorSettings() @Published private(set) var activeAgentState: ActiveAgentState? @Published private(set) var activeAgentSessions: [ActiveAgentState] = [] @Published private(set) var activeAgentSessionsOmittedCount = 0 @Published private(set) var overlayVisibility = OverlayVisibility() @Published var pets: [PetSummary] = [] @Published private(set) var hasLoadedStateSnapshot = false @Published private(set) var initialAppearanceReadiness = InitialAppearanceReadiness.pending @Published private(set) var petAssetWarningIndex = PetAssetWarningIndex() @Published var events: [AgentEvent] = [] @Published var recentEvents: [AgentEvent] = [] @Published var connections: [AgentConnectionStatus] = [] @Published private(set) var onboarding: VersionedOnboardingProgress? @Published private(set) var onboardingMutationInFlight = false @Published private(set) var onboardingDismissedForCurrentLaunch = false @Published private(set) var onboardingOperationFailure: OnboardingOperationFailure? @Published private(set) var includedCompanionRestoreState = IncludedCompanionRestoreState.idle @Published private(set) var petAssetRepairStates: [String: PetAssetRepairState] = [:] @Published private(set) var generationSession = GenerationSession() @Published private(set) var generationHistorySnapshot = GenerationStudioHistorySnapshot() @Published private(set) var selectedGenerationHistoryJobID: String? @Published private(set) var generationHistoryDetail: GenerationStudioHistoryDetail? @Published private(set) var generationHistoryMessages: [GenerationMessage] = [] @Published private(set) var generationHistoryMessagesHasMore = false @Published private(set) var generationHistoryMessagesIsLoading = false @Published private(set) var makerDraftIsActive = false @Published private(set) var generationHistoryIsLoading = false @Published private(set) var generationHistoryHasLoaded = false @Published private(set) var generationHistoryDetailIsLoading = false @Published private(set) var generationHistoryLoadFailed = false @Published private(set) var generationHistoryDeleteInFlightJobID: String? @Published private(set) var generationHistoryMutationError: String? @Published var generationReplyText = "" @Published var statusText = "正在初始化" @Published var serviceStatusText = "正在初始化" @Published private(set) var petCoreOperationalState = PetCoreOperationalState.checking @Published private(set) var petCoreRuntimeInfo = PetCoreRuntimeInfo.initial( manifest: PetCoreRuntimeContract.requiredManifest ) @Published private(set) var lastServiceFailureCode = PetCoreServiceFailureCode.none @Published private(set) var overlayDisplayWidthPt = OverlayGeometry.defaultDisplayWidthPt @Published var overlayVisible = true @Published var overlayScreenFrame = CGRect(x: 780, y: 140, width: 704, height: 640) @Published var overlayScreenVisibleFrame = NSScreen.main?.visibleFrame ?? .zero @Published var overlayPetScreenCenter = CGPoint.zero var overlayPresentedPetScreenCenter: CGPoint { overlayPetDragPresentationCenter ?? overlayPetScreenCenter } private(set) var overlayPetVisualEnvelope: OverlayPetVisualEnvelope? private var overlayPetFrameHitTestProjection: OverlayPetFrameHitTestProjection? var overlayPetFrameHitTest: OverlayPetFrameHitTest? { guard let projection = overlayPetFrameHitTestProjection, activePet?.id == projection.petID, OverlayPetAnimationIdentity.stateEntryID(for: presentedActiveAgentState) == projection.semanticOwnerEntryID else { return nil } return projection.hitTest } var overlayPetPointerMaskState: OverlayPointerMaskState { guard let projection = overlayPetFrameHitTestProjection else { return .missing } guard activePet?.id == projection.petID, OverlayPetAnimationIdentity.stateEntryID( for: presentedActiveAgentState ) == projection.semanticOwnerEntryID else { return .stale } return projection.hitTest == nil ? .missing : .valid } var overlayActivePointerInteractionID: UUID? { overlayDragInteractionID } @Published var overlayBubbleDismissed = false /// Presentation-only side of the pet used to keep flat session cards and /// the disclosure chevron ordered toward the pet. It changes only when a /// placement edge actually flips, never for ordinary drag samples. @Published private(set) var overlayBubbleAnchorDirection: OverlayBubbleAnchorDirection = .above @Published var overlayDismissedBubbleEventIDs: Set = [] @Published private var overlaySessionNavigationNotices: [String: OverlaySessionNavigationNoticeRecord] = [:] private var overlaySessionProjectionIdentities: [String: OverlaySessionProjectionIdentity] = [:] @Published private(set) var overlayAgentGroupExpansionOverrides: [AgentSource: Bool] = [:] /// Oldest-to-newest stable slots for the non-grouped tray. Status churn /// within one activation epoch never mutates this order. private var overlayStandaloneSessionOrder: [String] = [] private var overlayStandaloneSessionActivationIDs: [String: String] = [:] @Published private(set) var overlayStandaloneStackExpansionOverride: Bool? private var overlayStandaloneStackDisclosureDirection: OverlayBubbleDisclosureDirection = .expanding @Published var overlayPointerNearPet = false @Published var overlayPetDragInProgress = false @Published var petOperationIDs: Set = [] @Published var isImportingPetpack = false @Published private(set) var petpackImportProgress: PetLibraryImportProgress? @Published private(set) var petLibraryNotice: PetLibraryNotice? @Published private(set) var diagnosticsExportState = DiagnosticsExportState.idle @Published private(set) var connectionOperationState = AgentConnectionOperationState.idle @Published private(set) var portableMakerSkillStatus: PortableMakerSkillStatus? @Published private(set) var portableMakerSkillOperation = PortableMakerSkillOperation.idle @Published private(set) var portableMakerSkillFailure: PortableMakerSkillFailure? @Published private(set) var manualAppInstallationRequest: AppManualInstallationRequest? @Published private(set) var appUpdateConvergenceState = AppUpdateConvergenceState.idle private let client: PetCoreClient private let overlayController: PetOverlayController private let bootstrapHooks: AppStoreBootstrapHooks private let diagnostics: AppDiagnostics private let bundledPetSeederOverride: BundledPetSeeder? private let bundledPetSeedSleeper: BundledPetSeedSleeper private let initialAppearanceFallbackSleeper: InitialAppearanceFallbackSleeper private let overlayPlacementRetrySleeper: OverlayPlacementRetrySleeper private let overlayPlacementJournalStore: OverlayPlacementJournalStore private let agentSessionRouteOpener: AgentSessionRouteOpener private let runtimeHandoffIfNeeded: RuntimeHandoffCheck private let applicationAppearanceApplier: ApplicationAppearanceApplier private let applicationLanguageApplier: ApplicationLanguageApplier private let overlayPresenter: OverlayPresenter private let overlayKeyboardFocusHandler: OverlayKeyboardFocusHandler private let petCoreRequestOverride: PetCoreRequestOverride? let appUpdater: AppUpdateController private let productConvergenceSleeper: ProductConvergenceSleeper private let productConvergenceNoticePreferences: ProductConvergenceNoticePreferences private let productConvergenceManifest: RuntimeReleaseManifest? private let productConvergenceUpgradeEvidence: ProductConvergenceUpgradeEvidence private var refreshTask: Task? private var petpackImportTask: Task? private var overlayPetPositionInitialized = false private var overlayPlacementAuthority = OverlayPlacementAuthority() private var overlayPlacementSaveTask: Task? private var overlayPlacementExhaustedGeneration: UInt64? private var overlayPlacementJournalDidLoad = false private let overlayPlacementPreviewDriver = OverlayDisplayLinkCoalescer() private var overlayDisplayWidthCommitTask: Task? private var overlayDisplayWidthCommitDeadline: TimeInterval? private var overlayPetDragPresentationCenter: CGPoint? private var overlayBubbleProjectionCache: ( inputs: OverlayBubbleProjectionInputs, contents: [OverlayBubbleContent] )? private var overlayDragInteractionID: UUID? private var overlayLostMouseUpFallbackTask: Task? private var pendingDisplayWidthPt: CGFloat? private var stateRevision = "" private(set) var behaviorRevision = "0" private var authoritativeBehavior = BehaviorSettings() private var overlayLastReopenIDBySession: [String: String] = [:] private var behaviorMutationTask: Task? private var behaviorMutationSequence: UInt64 = 0 private var pendingBehaviorMutationCount = 0 private var mainWindowPresenter: (() -> Void)? private weak var controlCenterWindow: NSWindow? private var controlCenterCloseObserver: AnyCancellable? private(set) var controlCenterIsOpen = false private var pendingMainWindowPresentation = false private var pendingMainWindowPresentationChecksRuntimeHandoff = true private var generationMessagesTask: Task? private var latestGenerationRestoreAttemptState = LatestGenerationRestoreAttemptState.notAttempted private var latestGenerationRestoreAttemptSequence: UInt64 = 0 private var latestGenerationRestoreInFlight: (id: UInt64, task: Task)? private var makerUserMutationRevision: UInt64 = 0 private var automaticLatestGenerationRestoreInvalidated = false private var generationHistoryListSequence: UInt64 = 0 private var generationHistoryDetailSequence: UInt64 = 0 private var generationHistoryMessagesSequence: UInt64 = 0 /// A job ID returned by a successful start/edit/retry RPC is authoritative /// before it appears in an eventually refreshed history list. Keep that /// selection stable until a list snapshot catches up. private var generationHistorySelectionIntentJobID: String? private var reselectedReferenceImagePaths: Set = [] private var activeGenerationRecoveryProjection: SanitizedGenerationRecoveryProjection? private var runtimeBootstrapCompleted = false private var runtimeBootstrapRequiresFullRecovery = false private var runtimeBootstrapSequence: UInt64 = 0 private var runtimeBootstrap: (id: UInt64, task: Task)? private var runtimeBootstrapRetryTask: Task? private var runtimeBootstrapRetryDelaySeconds: UInt64 = 2 private var bundledPetSeedRetryTask: Task? private var initialAppearanceFallbackTask: Task? private var recoverySequence: UInt64 = 0 private var serviceRecovery: (id: UInt64, task: Task)? private var connectionOperationGate = AgentConnectionOperationGate() private var automaticConnectionCheckRequested = false private var automaticConnectionCheckTask: Task? private var hasPresentedOverlay = false private var productConvergenceTask: Task? private var productConvergenceVisibilityTask: Task? private var inFlightProtectedMutationCount = 0 static let bundledPetSeedRetryDelays: [Duration] = [ .seconds(2), .seconds(4), .seconds(8) ] static let initialAppearanceFallbackDelay: Duration = .milliseconds(500) static func stateWaitTimeoutMilliseconds( generationIsActive: Bool, hasActiveAgentState: Bool ) -> Int { if generationIsActive || hasActiveAgentState { return 1_000 } return 30_000 } static let controlCenterWindowIdentifier = NSUserInterfaceItemIdentifier( "dev.agentpet.companion.control-center" ) private static let defaultOverlayKeyboardFocusHandler: OverlayKeyboardFocusHandler = { controller, action in switch action { case .bubbleSessions: controller.focusBubbleForKeyboardNavigation() } } init(diagnostics: AppDiagnostics = .shared) { let processManager = PetCoreProcessManager() let bootstrapCoordinator = PetCoreAppBootstrapCoordinator( ensureRunning: { await processManager.ensureRunning() } ) client = PetCoreClient() overlayController = PetOverlayController() self.diagnostics = diagnostics bundledPetSeederOverride = nil bundledPetSeedSleeper = { duration in try await Task.sleep(for: duration) } initialAppearanceFallbackSleeper = { duration in try await Task.sleep(for: duration) } overlayPlacementRetrySleeper = { duration in try await Task.sleep(for: duration) } overlayPlacementJournalStore = .fileBacked() agentSessionRouteOpener = Self.openAgentSessionRoute runtimeHandoffIfNeeded = { AppUpdateHandoffCoordinator.shared.restartIfInstalledBuildChanged() } applicationAppearanceApplier = { theme in APCApplicationAppearance.apply(theme) } applicationLanguageApplier = { language in APCLocalization.applyInterfaceLanguage(language) } overlayPresenter = { controller, store in controller.show(store: store) } overlayKeyboardFocusHandler = Self.defaultOverlayKeyboardFocusHandler petCoreRequestOverride = nil appUpdater = AppUpdateController(diagnostics: diagnostics) productConvergenceSleeper = { duration in try await Task.sleep(for: duration) } productConvergenceNoticePreferences = ProductConvergenceNoticePreferences() productConvergenceManifest = PetCoreRuntimeContract.requiredManifest productConvergenceUpgradeEvidence = { manifest in PetCoreRuntimeUpgradeEvidence.hasManagedUpdateContext( currentBuildID: manifest.buildID ) } bootstrapHooks = AppStoreBootstrapHooks( ensureRunning: { await bootstrapCoordinator.ensureRunning() }, recover: { await bootstrapCoordinator.recover() }, fetchInitialBehavior: { store in try await store.requestPetCore(method: "behavior.get") }, refreshSnapshot: { store in try await store.refreshSnapshot() }, onReady: { store in await store.completeRuntimeBootstrap() }, requiresAuthoritativeSnapshotOnReady: true ) } init( client: PetCoreClient = PetCoreClient(), bootstrapHooks: AppStoreBootstrapHooks, diagnostics: AppDiagnostics = .disabled, bundledPetSeeder: BundledPetSeeder? = nil, bundledPetSeedSleeper: @escaping BundledPetSeedSleeper = { duration in try await Task.sleep(for: duration) }, initialAppearanceFallbackSleeper: @escaping InitialAppearanceFallbackSleeper = { duration in try await Task.sleep(for: duration) }, overlayPlacementRetrySleeper: @escaping OverlayPlacementRetrySleeper = { duration in try await Task.sleep(for: duration) }, overlayPlacementJournalStore: OverlayPlacementJournalStore = .disabled(), agentSessionRouteOpener: @escaping AgentSessionRouteOpener = { _ in .failed(.applicationUnavailable) }, runtimeHandoffIfNeeded: @escaping RuntimeHandoffCheck = { false }, applicationAppearanceApplier: @escaping ApplicationAppearanceApplier = { theme in APCApplicationAppearance.apply(theme) }, applicationLanguageApplier: @escaping ApplicationLanguageApplier = { language in APCLocalization.applyInterfaceLanguage(language) }, overlayPresenter: @escaping OverlayPresenter = { controller, store in controller.show(store: store) }, overlayKeyboardFocusHandler: OverlayKeyboardFocusHandler? = nil, petCoreRequestOverride: PetCoreRequestOverride? = nil, initialPetStudioCodexAvailability: PetStudioCodexAvailability = .checking, appUpdater: AppUpdateController? = nil, productConvergenceSleeper: @escaping ProductConvergenceSleeper = { duration in try await Task.sleep(for: duration) }, productConvergenceNoticePreferences: ProductConvergenceNoticePreferences = ProductConvergenceNoticePreferences(), productConvergenceManifest: RuntimeReleaseManifest? = PetCoreRuntimeContract.requiredManifest, productConvergenceUpgradeEvidence: @escaping ProductConvergenceUpgradeEvidence = { manifest in PetCoreRuntimeUpgradeEvidence.hasPriorManagedBuild( currentBuildID: manifest.buildID ) } ) { self.client = client overlayController = PetOverlayController() self.bootstrapHooks = bootstrapHooks self.diagnostics = diagnostics bundledPetSeederOverride = bundledPetSeeder self.bundledPetSeedSleeper = bundledPetSeedSleeper self.initialAppearanceFallbackSleeper = initialAppearanceFallbackSleeper self.overlayPlacementRetrySleeper = overlayPlacementRetrySleeper self.overlayPlacementJournalStore = overlayPlacementJournalStore self.agentSessionRouteOpener = agentSessionRouteOpener self.runtimeHandoffIfNeeded = runtimeHandoffIfNeeded self.applicationAppearanceApplier = applicationAppearanceApplier self.applicationLanguageApplier = applicationLanguageApplier self.overlayPresenter = overlayPresenter self.overlayKeyboardFocusHandler = overlayKeyboardFocusHandler ?? Self.defaultOverlayKeyboardFocusHandler self.petCoreRequestOverride = petCoreRequestOverride petStudioCodexAvailability = initialPetStudioCodexAvailability self.appUpdater = appUpdater ?? AppUpdateController( automaticChecksEnabled: false, diagnostics: diagnostics ) self.productConvergenceSleeper = productConvergenceSleeper self.productConvergenceNoticePreferences = productConvergenceNoticePreferences self.productConvergenceManifest = productConvergenceManifest self.productConvergenceUpgradeEvidence = productConvergenceUpgradeEvidence } var activePet: PetSummary? { pets.first(where: \.active) } func petSummary(id: String?) -> PetSummary? { guard let id, !id.isEmpty else { return nil } return pets.first { $0.id == id } } var interfaceLocaleIdentifier: String { APCLocalization.resolvedInterfaceLocaleIdentifier( interfaceLanguage: behavior.interfaceLanguage ) } private var hasProtectedUserWork: Bool { let diagnosticsBusy = switch diagnosticsExportState { case .exporting, .saving: true case .idle, .ready, .succeeded, .failed, .saveFailed: false } return generationSession.isActive || connectionOperationState.isRunning || isImportingPetpack || !petOperationIDs.isEmpty || diagnosticsBusy || inFlightProtectedMutationCount > 0 || pendingBehaviorMutationCount > 0 || overlayPlacementSaveTask != nil || overlayPlacementAuthority.pending != nil || overlayPlacementPreviewDriver.hasPending || overlayDisplayWidthCommitTask != nil || overlayPetDragInProgress || runtimeBootstrap != nil || serviceRecovery != nil } private var isSafeForProductConvergence: Bool { !hasProtectedUserWork } var isSafeForAppUpdateHandoff: Bool { isSafeForProductConvergence && productConvergenceTask == nil } var shouldBlockForAppUpdateConvergence: Bool { guard !hasLoadedStateSnapshot else { return false } return switch appUpdateConvergenceState { case .waitingForActiveWork, .updating: true case .idle, .completed, .needsAttention: false } } var onboardingCompanionCandidates: [PetSummary] { let candidates = Dictionary( pets .filter(\.isIncludedCompanionCandidate) .map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } ) return PetSummary.includedCompanionIDs.compactMap { candidates[$0] } } var shouldPresentOnboarding: Bool { guard !onboardingDismissedForCurrentLaunch, let onboarding else { return false } return !onboarding.progress.stage.isTerminal } var onboardingAvailability: OnboardingFlowAvailability { petCoreOperationalState == .online ? .ready : .serviceUnavailable } /// Compatibility projection for older callers. The typed operation state /// remains the single source of truth for serialization and failure UI. var connectionOperationSources: Set { Set(connectionOperationState.runningOperation?.sources ?? []) } var canStartConnectionOperation: Bool { !connectionOperationState.isRunning && productConvergenceTask == nil } var activeOverlayEvent: AgentEvent? { presentedActiveAgentState?.event } var presentedActiveAgentState: ActiveAgentState? { OverlayPresentedAgentState.resolve( canonicalState: activeAgentState, activeSessions: activeAgentSessions, dismissedSessionIDs: overlayDismissedBubbleEventIDs ) } var activeAgentEventText: String { activeOverlayEvent.map { "\($0.source.title) · \($0.title)" } ?? "暂无活跃 Agent 事件" } var overlayBubbleEvents: [AgentEvent] { activeAgentSessions .filter { !overlayDismissedBubbleEventIDs.contains(OverlaySessionContent.stableID( source: $0.source, sessionID: $0.sessionID ?? $0.event.sessionID, anonymousSessionAlias: $0.anonymousSessionAlias, fallbackEventID: $0.event.id )) } .map(\.event) } /// Everything the bubble projection reads. Comparing this is far cheaper /// than rebuilding the projection, which localizes and normalizes text for /// every session. private struct OverlayBubbleProjectionInputs: Equatable { let statusBubbleVisible: Bool let sessions: [ActiveAgentState] let omittedCount: Int let dismissedSessionIDs: Set let groupExpansionOverrides: [AgentSource: Bool] let groupSessionsByAgent: Bool let standaloneSessionOrder: [String] let standaloneStackExpanded: Bool let sessionGroupDisplay: SessionGroupDisplay let navigationNotices: [String: OverlaySessionNavigationNoticeRecord] let projectionIdentities: [String: OverlaySessionProjectionIdentity] } private var currentOverlayBubbleProjectionInputs: OverlayBubbleProjectionInputs { OverlayBubbleProjectionInputs( statusBubbleVisible: overlayVisibility.statusBubbleVisible, sessions: activeAgentSessions, omittedCount: activeAgentSessionsOmittedCount, dismissedSessionIDs: overlayDismissedBubbleEventIDs, groupExpansionOverrides: overlayAgentGroupExpansionOverrides, groupSessionsByAgent: behavior.groupSessionsByAgent, standaloneSessionOrder: overlayStandaloneSessionOrder, standaloneStackExpanded: overlayStandaloneStackIsExpanded, sessionGroupDisplay: behavior.sessionGroupDisplay, navigationNotices: overlaySessionNavigationNotices, projectionIdentities: overlaySessionProjectionIdentities ) } /// Memoizes the projection so the several reads that happen inside a single /// SwiftUI pass — bubble contents, availability, session count, keyboard /// focus — share one build instead of repeating it. Purely derived state, /// so nothing is published when the cache is refreshed. var overlayAvailableBubbleContents: [OverlayBubbleContent] { let inputs = currentOverlayBubbleProjectionInputs if let cached = overlayBubbleProjectionCache, cached.inputs == inputs { return cached.contents } let contents = buildOverlayAvailableBubbleContents(inputs) overlayBubbleProjectionCache = (inputs, contents) return contents } private func buildOverlayAvailableBubbleContents( _ inputs: OverlayBubbleProjectionInputs ) -> [OverlayBubbleContent] { guard inputs.statusBubbleVisible else { return [] } var contents = OverlayBubbleProjection.contents( states: inputs.sessions, omittedCount: inputs.omittedCount, dismissedSessionIDs: inputs.dismissedSessionIDs, groupSessionsByAgent: inputs.groupSessionsByAgent, standaloneSessionOrder: inputs.standaloneSessionOrder, standaloneStackExpanded: inputs.standaloneStackExpanded, isExpanded: { source in inputs.groupExpansionOverrides[source] ?? (inputs.sessionGroupDisplay == .expanded) } ) for contentIndex in contents.indices { for sessionIndex in contents[contentIndex].sessions.indices { let session = contents[contentIndex].sessions[sessionIndex] guard let record = inputs.navigationNotices[session.id], record.identity == inputs.projectionIdentities[session.id] else { continue } contents[contentIndex].sessions[sessionIndex].navigationNotice = record.notice } } return contents } var overlayBubbleContents: [OverlayBubbleContent] { guard !overlayBubbleDismissed else { return [] } return overlayAvailableBubbleContents } var hasAvailableOverlayBubbleContent: Bool { !overlayAvailableBubbleContents.isEmpty } var overlayBubbleSessionCount: Int { overlayAvailableBubbleContents .reduce(0) { $0 + $1.representedSessionCount } } var canFocusOverlayBubbleForKeyboardNavigation: Bool { overlayKeyboardFocusActions.contains(.bubbleSessions) } private var overlayKeyboardFocusActions: Set { OverlayKeyboardFocusAction.availableActions( overlayEnabled: behavior.enabled, bubbleSessionCount: overlayBubbleSessionCount ) } var overlayBubbleStatusTone: OverlaySessionGroupTone { OverlaySessionGroupTone.aggregate( overlayAvailableBubbleContents.flatMap(\.sessions) ) } var overlayBubbleIsCollapsed: Bool { overlayBubbleDismissed || overlayBubbleContents.isEmpty } func overlayAgentGroupIsExpanded(_ source: AgentSource) -> Bool { overlayAgentGroupExpansionOverrides[source] ?? (behavior.sessionGroupDisplay == .expanded) } var overlayStandaloneStackIsExpanded: Bool { overlayStandaloneStackExpansionOverride ?? (behavior.sessionGroupDisplay == .expanded) } var canStartGeneration: Bool { canStartNewGenerationWork && petStudioCodexAvailability.permitsGeneration && !generationSession.isActive && GenerationPromptPolicy.isValid(descriptionText) } var generationStartBlockingDetail: String? { guard !canStartGeneration else { return nil } guard canStartNewGenerationWork else { return APCLocalization.text(.appUpdateConvergenceMakerBlocked) } if generationSession.isActive { return generationStateTitle } switch petStudioCodexAvailability { case .checking: return APCLocalization.text(.studioCodexCheckingDetail) case .missing: return APCLocalization.text(.studioCodexMissingDetail) case .unavailable: return APCLocalization.text(.studioCodexUnavailableDetail) case .available: return GenerationPromptPolicy.isValid(descriptionText) ? nil : APCLocalization.text(.studioDescriptionRequired) } } var generationStartPresentationDetail: String { generationStartBlockingDetail ?? APCLocalization.text(.studioWelcomeDetail) } var canStartNewGenerationWork: Bool { guard productConvergenceTask == nil else { return false } return switch appUpdateConvergenceState { case .idle, .completed: true case let .needsAttention(.connectors(issues)): !issues.contains { $0.source == .codex } case .needsAttention(.bundledPets), .needsAttention(.service): false case .waitingForActiveWork, .updating: false } } /// Continuing the one preserved Studio job is different from starting new /// protected work. An update waiting for that job to finish must not make /// the job impossible to resume; the convergence task will keep waiting /// for the resumed generation to reach a terminal state. private var canResumeExistingGenerationWork: Bool { return switch appUpdateConvergenceState { case .idle, .completed, .waitingForActiveWork: true case let .needsAttention(.connectors(issues)): !issues.contains { $0.source == .codex } case .needsAttention(.bundledPets), .needsAttention(.service), .updating: false } } var canClearStudioForm: Bool { !generationSession.isActive && ( descriptionText != AIPetMakerDefaults.descriptionText || selectedStyle != AIPetMakerDefaults.style || selectedQuality != AIPetMakerDefaults.quality || !referenceImages.isEmpty || referenceImageIssue != nil || referenceReselectionCount != 0 || !generationReplyText.isEmpty ) } var isWaitingForGenerationInput: Bool { generationSession.state == .waitingForInput } var canSendGenerationReply: Bool { generationSession.canSendReply } var canRetryGeneration: Bool { generationSession.canRetry && petStudioCodexAvailability.permitsGeneration && referenceReselectionCount == 0 && (generationSession.operation == .modify || !descriptionText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } var canResumeGeneration: Bool { generationSession.canResume && canResumeExistingGenerationWork && petStudioCodexAvailability.permitsGeneration && referenceReselectionCount == 0 } var canResumeSelectedGenerationHistory: Bool { guard let detail = generationHistoryDetail, detail.found, let jobID = detail.jobID, jobID == selectedGenerationHistoryJobID, detail.capabilities?.canResume == true else { return false } return canResumeExistingGenerationWork && petStudioCodexAvailability.permitsGeneration } var canCopySelectedGenerationHistoryBrief: Bool { guard !generationSession.isActive, let detail = selectedGenerationHistoryDetail, let description = detail.description, !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, detail.quality?.isStudioSupported == true else { return false } return detail.style != nil } var canRetrySelectedGenerationHistory: Bool { guard canCopySelectedGenerationHistoryBrief, canStartNewGenerationWork, petStudioCodexAvailability.permitsGeneration, let status = selectedGenerationHistoryDetail?.status else { return false } return switch status { case .failed: selectedGenerationHistoryDetail?.recoverable != true case .completed, .canceled: false case .pending, .running, .waitingForUser: false } } var selectedGenerationHistoryResultPet: PetSummary? { petSummary(id: selectedGenerationHistoryDetail?.resultPetID) } var selectedGenerationHistoryResultPetIsActive: Bool { selectedGenerationHistoryResultPet?.active == true } var selectedGenerationHistoryDetail: GenerationStudioHistoryDetail? { guard let generationHistoryDetail, generationHistoryDetail.found, generationHistoryDetail.jobID == selectedGenerationHistoryJobID else { return nil } return generationHistoryDetail } var generationStateTitle: String { if generationSession.operation == .modify { return switch generationSession.state { case .idle: "尚未开始" case .starting: "正在启动修改" case .running: "正在修改" case .waitingForInput: "修改等待补充信息" case .paused: "修改已中断,等待继续" case .recoverableFailed: "修改失败,可继续" case .cancelling: "正在取消修改" case .cancelCleanup: "正在清理修改会话" case .succeeded: "修改完成" case .failed: "修改失败" case .cancelled: "修改已取消" } } return switch generationSession.state { case .idle: "尚未开始" case .starting: "正在启动" case .running: "正在生成" case .waitingForInput: "等待补充信息" case .paused: "已中断,等待继续" case .recoverableFailed: "生成失败,可继续" case .cancelling: "正在取消" case .cancelCleanup: "正在清理会话" case .succeeded: "生成完成" case .failed: "生成失败" case .cancelled: "已取消" } } func setMainWindowPresenter(_ presenter: @escaping () -> Void) { mainWindowPresenter = presenter guard pendingMainWindowPresentation else { return } let checksRuntimeHandoff = pendingMainWindowPresentationChecksRuntimeHandoff pendingMainWindowPresentation = false pendingMainWindowPresentationChecksRuntimeHandoff = true guard !checksRuntimeHandoff || !runtimeHandoffIfNeeded() else { return } presenter() NSApp?.activate(ignoringOtherApps: true) } func registerControlCenterWindow(_ window: NSWindow) { guard controlCenterWindow !== window else { return } controlCenterCloseObserver?.cancel() window.identifier = Self.controlCenterWindowIdentifier controlCenterWindow = window controlCenterIsOpen = true overlayController.controlCenterDidOpen() controlCenterCloseObserver = NotificationCenter.default.publisher( for: NSWindow.willCloseNotification, object: window ) .sink { [weak self, weak window] _ in Task { @MainActor in guard let self, self.controlCenterWindow === window else { return } self.controlCenterIsOpen = false self.overlayController.controlCenterDidClose() } } } func presentManualAppInstallation(_ request: AppManualInstallationRequest) { manualAppInstallationRequest = request appUpdater.dismissSheet() presentMainWindow(checkRuntimeHandoff: false) } func dismissManualAppInstallation() { manualAppInstallationRequest = nil } func presentDeferredAppUpdateHandoff() { appUpdateConvergenceState = .waitingForActiveWork presentMainWindow(checkRuntimeHandoff: false) } func presentFailedAppUpdateHandoff(_ request: AppManualInstallationRequest) { if appUpdateConvergenceState == .waitingForActiveWork { appUpdateConvergenceState = .idle } presentManualAppInstallation(request) } func dismissAppUpdateConvergenceNotice() { if let buildID = productConvergenceManifest?.buildID { productConvergenceNoticePreferences.acknowledge(buildID: buildID) } appUpdateConvergenceState = .idle } func retryProductConvergence() { guard productConvergenceTask == nil, let manifest = productConvergenceManifest else { return } appUpdateConvergenceState = .updating let task = Task { @MainActor [weak self] in guard let self else { return } let bundledPetsReady = await self.performBundledPetSeed() if bundledPetsReady { _ = await self.refresh() } await self.performProductConvergence( manifest: manifest, force: true, bundledPetsReady: bundledPetsReady ) self.productConvergenceTask = nil } productConvergenceTask = task } func beginManualAppUpdateDownload(_ release: AppReleaseUpdate) { guard NSWorkspace.shared.open(release.asset.downloadURL) else { diagnostics.log( .error, category: "update", event: "release_download_open_failed", metadata: ["version": .string(release.version.description)] ) appUpdater.reportDownloadOpenFailure() return } let installationRequest = AppManualInstallationRequest( origin: .updateDownload, version: release.version.description, candidateBundlePath: nil ) manualAppInstallationRequest = installationRequest appUpdater.dismissSheet() } func checkForAppUpdatesManually() { appUpdater.checkManually() presentMainWindow() } func presentAvailableAppUpdate() { appUpdater.presentAvailableUpdate() presentMainWindow() } func presentMainWindow() { presentMainWindow(checkRuntimeHandoff: true) } private func presentMainWindow(checkRuntimeHandoff: Bool) { guard !checkRuntimeHandoff || !runtimeHandoffIfNeeded() else { return } if frontExistingMainWindow() { pendingMainWindowPresentation = false pendingMainWindowPresentationChecksRuntimeHandoff = true NSApp?.activate(ignoringOtherApps: true) return } if let mainWindowPresenter { pendingMainWindowPresentation = false pendingMainWindowPresentationChecksRuntimeHandoff = true mainWindowPresenter() } else { // A secondary launch or Dock reopen can arrive before either scene // has installed its openWindow presenter. Replay exactly once when // the first presenter becomes available. pendingMainWindowPresentation = true pendingMainWindowPresentationChecksRuntimeHandoff = checkRuntimeHandoff } NSApp?.activate(ignoringOtherApps: true) } func presentAgentSession( source: AgentSource?, sessionID: String? = nil, navigation: AgentSessionNavigation = AgentSessionNavigation() ) async -> AgentSessionOpenOutcome { guard source != nil else { presentMainWindow() return .openedAgentHost } guard let route = AgentSessionRouter.route( source: source, sessionID: sessionID, navigation: navigation ) else { return .failed(.applicationUnavailable) } let outcome = await agentSessionRouteOpener(route) if !outcome.didOpen, let fallback = AgentSessionRouter.hostFallbackRoute( source: source, navigation: navigation ), fallback != route { return await agentSessionRouteOpener(fallback) } return outcome } func activateOverlaySession(_ session: OverlaySessionContent) { let capturedIdentity = overlaySessionProjectionIdentities[session.id] ?? OverlaySessionProjectionIdentity( eventID: session.eventID, acknowledgementID: session.acknowledgementID ) let actionSession = currentOverlayActionSession( stableID: session.id, identity: capturedIdentity ) ?? session guard actionSession.canOpen else { setOverlaySessionNavigationNotice( .unavailable, sessionID: actionSession.id, identity: capturedIdentity ) return } Task { @MainActor [weak self] in guard let self else { return } let outcome = await presentAgentSession( source: actionSession.source, sessionID: actionSession.sessionID, navigation: actionSession.navigation ) guard overlaySessionProjectionIsCurrent( sessionID: actionSession.id, identity: capturedIdentity ) else { return } let reachedRequestedDestination: Bool switch outcome { case .openedExactSession: reachedRequestedDestination = true case .openedAgentHost: reachedRequestedDestination = actionSession.navigationCapability != .exactSession if !reachedRequestedDestination { setOverlaySessionNavigationNotice( .degradedToHost, sessionID: actionSession.id, identity: capturedIdentity ) } case .failed: reachedRequestedDestination = false setOverlaySessionNavigationNotice( .failed, sessionID: actionSession.id, identity: capturedIdentity ) } guard reachedRequestedDestination else { return } clearOverlaySessionNavigationNotice( sessionID: actionSession.id, identity: capturedIdentity ) if actionSession.dismissesAfterActivation { guard await acknowledgeOverlaySession( acknowledgementID: capturedIdentity.acknowledgementID ), overlaySessionProjectionIsCurrent( sessionID: actionSession.id, identity: capturedIdentity ) else { return } dismissOverlayBubble(eventID: actionSession.id) } } } private func currentOverlayActionSession( stableID: String, identity: OverlaySessionProjectionIdentity ) -> OverlaySessionContent? { activeAgentSessions.lazy .map(OverlaySessionContent.init(state:)) .first { candidate in candidate.id == stableID && candidate.eventID == identity.eventID && candidate.acknowledgementID == identity.acknowledgementID } } private func overlaySessionProjectionIsCurrent( sessionID: String, identity: OverlaySessionProjectionIdentity ) -> Bool { overlaySessionProjectionIdentities[sessionID] == identity } private func setOverlaySessionNavigationNotice( _ notice: OverlaySessionNavigationNotice, sessionID: String, identity: OverlaySessionProjectionIdentity ) { overlaySessionNavigationNotices[sessionID] = OverlaySessionNavigationNoticeRecord( identity: identity, notice: notice ) overlayController.updateLayout(animateBubble: true) } private func clearOverlaySessionNavigationNotice( sessionID: String, identity: OverlaySessionProjectionIdentity ) { guard overlaySessionNavigationNotices[sessionID]?.identity == identity else { return } guard overlaySessionNavigationNotices.removeValue( forKey: sessionID ) != nil else { return } overlayController.updateLayout(animateBubble: true) } private func reconcileOverlaySessionNavigationNotices() { guard !overlaySessionNavigationNotices.isEmpty else { return } overlaySessionNavigationNotices = overlaySessionNavigationNotices.filter { sessionID, record in overlaySessionProjectionIdentities[sessionID] == record.identity } } private func acknowledgeOverlaySession( acknowledgementID: String? ) async -> Bool { guard let acknowledgementID else { return true } do { let result = try await requestPetCore( method: "agent.session.acknowledge", params: ["acknowledgement_id": acknowledgementID] ) guard let result = result as? [String: Any], result["acknowledged"] as? Bool == true, result["acknowledgement_id"] as? String == acknowledgementID else { throw PetCoreClientError.invalidResponse } return true } catch { return false } } private static func openAgentSessionRoute( _ route: AgentSessionOpenRoute ) async -> AgentSessionOpenOutcome { let workspace = NSWorkspace.shared switch route { case let .url(url): return workspace.open(url) ? .openedExactSession : .failed(.urlOpenRejected) case let .application(bundleIdentifiers, paths): return await openAgentApplication( bundleIdentifiers: bundleIdentifiers, paths: paths, workspace: workspace ) } } private static func openAgentApplication( bundleIdentifiers: [String], paths: [String], workspace: NSWorkspace ) async -> AgentSessionOpenOutcome { if let running = workspace.runningApplications .filter({ application in application.bundleIdentifier.map(bundleIdentifiers.contains) == true && !application.isTerminated }) .sorted(by: { ($0.isActive ? 1 : 0) > ($1.isActive ? 1 : 0) }) .first, running.activate(options: []) { return .openedAgentHost } let applicationURL = bundleIdentifiers.lazy .compactMap { workspace.urlForApplication(withBundleIdentifier: $0) } .first ?? paths .map { URL(fileURLWithPath: $0, isDirectory: true) } .first(where: { FileManager.default.fileExists(atPath: $0.path) }) guard let applicationURL else { return .failed(.applicationUnavailable) } let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true return await withCheckedContinuation { continuation in workspace.openApplication( at: applicationURL, configuration: configuration ) { @Sendable application, error in continuation.resume(returning: application != nil && error == nil ? .openedAgentHost : .failed(.applicationLaunchFailed) ) } } } private func frontExistingMainWindow() -> Bool { guard let application = NSApp else { return false } let window = if let controlCenterWindow, Self.isMainWindowCandidate(controlCenterWindow) { controlCenterWindow } else { application.windows.first(where: Self.isMainWindowCandidate) } guard let window else { return false } if window.isMiniaturized { window.deminiaturize(nil) } window.makeKeyAndOrderFront(nil) controlCenterIsOpen = true overlayController.controlCenterDidOpen() return true } static func isMainWindowCandidate(_ window: NSWindow) -> Bool { window.identifier == controlCenterWindowIdentifier && !(window is NSPanel) && window.level == .normal && window.styleMask.contains(.titled) } private enum RuntimeBootstrapStartMode { case ensureRunning case recover } func bootstrapIfNeeded() async { _ = await runRuntimeBootstrapIfNeeded(startMode: .ensureRunning) } private func runRuntimeBootstrapIfNeeded( startMode: RuntimeBootstrapStartMode ) async -> Bool { if runtimeBootstrapCompleted { return true } if let runtimeBootstrap { return await runtimeBootstrap.task.value } runtimeBootstrapSequence &+= 1 let id = runtimeBootstrapSequence let task = Task { @MainActor [weak self] in await self?.performRuntimeBootstrap(startMode: startMode) ?? false } runtimeBootstrap = (id, task) let succeeded = await task.value if runtimeBootstrap?.id == id { runtimeBootstrap = nil } return succeeded } private func performRuntimeBootstrap( startMode: RuntimeBootstrapStartMode ) async -> Bool { diagnostics.log(.info, category: "service", event: "petcore_bootstrap_started") if let manifest = productConvergenceManifest, manifest.releaseChannel == "release", productConvergenceUpgradeEvidence(manifest), !productConvergenceNoticePreferences.hasAcknowledged( buildID: manifest.buildID ) { appUpdateConvergenceState = .updating } setServiceChecking() // Bound the invisible window gate from bootstrap start. Runtime // replacement and rollback can take much longer than appearance // hydration, and must not make the App look dead while it is checking. scheduleInitialAppearanceFallback() let startResult = switch startMode { case .ensureRunning: await bootstrapHooks.ensureRunning() case .recover: await bootstrapHooks.recover() } switch startResult { case .alreadyHealthy, .started: diagnostics.log(.notice, category: "service", event: "petcore_bootstrap_ready") if appUpdateConvergenceState == .waitingForActiveWork { appUpdateConvergenceState = .updating } setServiceOnline() runtimeBootstrapRetryTask?.cancel() runtimeBootstrapRetryTask = nil runtimeBootstrapRetryDelaySeconds = 2 await prepareInitialAppearance() await bootstrapHooks.onReady(self) if bootstrapHooks.requiresAuthoritativeSnapshotOnReady, (!hasLoadedStateSnapshot || petCoreOperationalState != .online) { diagnostics.log( .error, category: "service", event: "petcore_bootstrap_snapshot_not_ready", throttleKey: "petcore_bootstrap_snapshot_not_ready", minimumInterval: 30 ) runtimeBootstrapCompleted = false runtimeBootstrapRequiresFullRecovery = true setServiceFailure( "本地服务尚未返回完整状态", status: "PetCore 状态同步失败", operationalState: .offline ) resolveInitialAppearanceAsUnavailable() scheduleRuntimeBootstrapRetry() return false } // Prefer the first authoritative snapshot when behavior.get could // not provide a theme, but never hold the window beyond the // bounded appearance fallback scheduled above. if initialAppearanceReadiness == .pending { resolveInitialAppearanceAsUnavailable() } runtimeBootstrapCompleted = true runtimeBootstrapRequiresFullRecovery = false presentOverlayAfterFirstSnapshotIfNeeded() return true case let .deferred(reason): diagnostics.log( .notice, category: "update", event: "runtime_update_deferred_for_active_work" ) appUpdateConvergenceState = .waitingForActiveWork setServiceChecking() setServiceStatusText(reason) runtimeBootstrapRequiresFullRecovery = false runtimeBootstrapRetryDelaySeconds = 2 resolveInitialAppearanceAsUnavailable() scheduleRuntimeBootstrapRetry() return false case let .failed(reason): let failureCode = PetCoreServiceFailureClassifier.classify(reason) diagnostics.log( .error, category: "service", event: "petcore_bootstrap_failed", metadata: ["failure_code": .string(failureCode.rawValue)], throttleKey: "petcore_bootstrap_failed", minimumInterval: 30 ) setServiceFailure(reason, failureCode: failureCode) runtimeBootstrapRequiresFullRecovery = true resolveInitialAppearanceAsUnavailable() scheduleRuntimeBootstrapRetry() return false } } private func scheduleInitialAppearanceFallback() { guard initialAppearanceReadiness == .pending, initialAppearanceFallbackTask == nil else { return } let sleeper = initialAppearanceFallbackSleeper initialAppearanceFallbackTask = Task { @MainActor [weak self] in do { try await sleeper(Self.initialAppearanceFallbackDelay) } catch { return } guard !Task.isCancelled, let self, self.initialAppearanceReadiness == .pending else { return } self.initialAppearanceFallbackTask = nil self.initialAppearanceReadiness = .unavailable } } private func resolveInitialAppearanceAsUnavailable() { initialAppearanceFallbackTask?.cancel() initialAppearanceFallbackTask = nil if initialAppearanceReadiness == .pending { initialAppearanceReadiness = .unavailable } } private func resolveInitialAppearanceAsAuthoritative() { initialAppearanceFallbackTask?.cancel() initialAppearanceFallbackTask = nil initialAppearanceReadiness = .authoritative } private func prepareInitialAppearance() async { guard initialAppearanceReadiness != .authoritative else { return } guard let fetchInitialBehavior = bootstrapHooks.fetchInitialBehavior else { return } do { let result = try await fetchInitialBehavior(self) let data = try JSONSerialization.data(withJSONObject: result) let versioned = try JSONDecoder().decode( VersionedBehaviorSettings.self, from: data ) // A full snapshot may arrive through an explicit refresh while the // focused behavior request is suspended. Once that snapshot is // authoritative, never publish this older focused result. guard initialAppearanceReadiness != .authoritative else { return } // The publication order is deliberate: every window gate observing // readiness must see behavior, revision, and AppKit appearance as // one fully prepared initial presentation state. authoritativeBehavior = versioned.behavior behavior = versioned.behavior behaviorRevision = versioned.revision applyCurrentPresentation() resolveInitialAppearanceAsAuthoritative() } catch { diagnostics.logFailure( error, category: "service", event: "initial_appearance_unavailable", throttleKey: "initial_appearance_unavailable", minimumInterval: 30 ) } } func retryPetCoreStartup() { guard petCoreRuntimeInfo.phase != .running else { return } diagnostics.log(.notice, category: "service", event: "petcore_retry_requested") runtimeBootstrapRetryTask?.cancel() runtimeBootstrapRetryTask = nil setServiceChecking() Task { @MainActor [weak self] in guard let self else { return } _ = await self.recoverServiceConnection() } } private func scheduleRuntimeBootstrapRetry() { guard !runtimeBootstrapCompleted, runtimeBootstrapRetryTask == nil else { return } let delay = runtimeBootstrapRetryDelaySeconds diagnostics.log( .info, category: "service", event: "petcore_retry_scheduled", metadata: ["delay_seconds": .integer(Int64(delay))], throttleKey: "petcore_retry_scheduled", minimumInterval: 5 ) runtimeBootstrapRetryDelaySeconds = min(delay * 2, 30) runtimeBootstrapRetryTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(delay)) guard !Task.isCancelled, let self else { return } self.runtimeBootstrapRetryTask = nil await self.bootstrapIfNeeded() } } private func completeRuntimeBootstrap() async { await refreshPetCoreRuntimeInfo() diagnostics.log( .notice, category: "service", event: "petcore_runtime_connected", metadata: [ "version": .string(petCoreRuntimeInfo.version ?? "unknown"), "build_id": .string(petCoreRuntimeInfo.buildID ?? "unknown"), "rpc_protocol": .string(petCoreRuntimeInfo.rpcProtocol ?? "unknown") ] ) let bundledPetsReady = await performBundledPetSeed() let bundledPetFailureStatus = bundledPetsReady ? nil : statusText let snapshotReady = await refreshDuringRuntimeBootstrap() if let bundledPetFailureStatus, snapshotReady { // A healthy state snapshot must not hide a failed inventory seed. // Keep the actionable library error visible while bounded retries // continue independently of PetCore runtime rollback. statusText = bundledPetFailureStatus } if !bundledPetsReady { scheduleBundledPetSeedRetry() } guard snapshotReady else { return } refreshTask?.cancel() refreshTask = Task { [weak self] in while !Task.isCancelled { await self?.waitForStateChange() } } scheduleProductConvergence(bundledPetsReady: bundledPetsReady) appUpdater.checkAutomaticallyIfDue() } @discardableResult func scheduleProductConvergence( force: Bool = false, bundledPetsReady: Bool = true ) -> Task? { guard productConvergenceTask == nil, let manifest = productConvergenceManifest else { return productConvergenceTask } let task = Task { @MainActor [weak self] in guard let self else { return } await self.performProductConvergence( manifest: manifest, force: force, bundledPetsReady: bundledPetsReady ) self.productConvergenceTask = nil } productConvergenceTask = task return task } private func performProductConvergence( manifest: RuntimeReleaseManifest, force: Bool, bundledPetsReady: Bool ) async { guard bundledPetsReady else { finishProductConvergenceAttention( attention: .bundledPets, failure: "bundled_pet_verification_incomplete" ) return } scheduleProductConvergenceVisibility() do { let priorReceipt = try await productConvergenceReceipt() if !force, priorReceipt?.exactlyMatches(manifest: manifest) == true { finishProductConvergenceSuccess( manifest: manifest, presentsCompletion: productConvergenceUpgradeEvidence(manifest) ) return } let presentsCompletion = ( priorReceipt.map { $0.buildID != manifest.buildID } ?? false ) || productConvergenceUpgradeEvidence(manifest) try await waitForProductConvergenceSafePoint() if appUpdateConvergenceState == .waitingForActiveWork { appUpdateConvergenceState = .updating } let refreshValue = try await requestPetCore( method: "connections.refresh_installed", timeout: .seconds(180) ) let report: ProductConnectorRefreshReport = try decodeProductConvergenceValue( refreshValue ) guard report.isExactlyConverged else { let issues = report.attentionIssues finishProductConvergenceAttention( attention: issues.isEmpty ? .service : .connectors(issues), failure: issues.isEmpty ? "managed_connector_report_inconsistent" : "managed_connector_verification_incomplete" ) _ = await refreshSnapshotAfterProductConvergence() return } let reportValue = try JSONSerialization.jsonObject( with: JSONEncoder().encode(report) ) let receiptValue = try await requestPetCore( method: "product.convergence.update", params: [ "schema_version": ProductConvergenceReceipt.schemaVersion, "build_id": manifest.buildID, "app_version": manifest.appVersion, "connector_report": reportValue, ] ) let receipt: ProductConvergenceReceipt = try decodeProductConvergenceValue( receiptValue ) guard receipt.exactlyMatches(manifest: manifest, report: report) else { throw PetCoreClientError.invalidResponse } guard await refreshSnapshotAfterProductConvergence() else { finishProductConvergenceAttention( attention: .service, failure: "authoritative_snapshot_refresh_failed" ) return } finishProductConvergenceSuccess( manifest: manifest, presentsCompletion: presentsCompletion ) } catch is CancellationError { productConvergenceVisibilityTask?.cancel() productConvergenceVisibilityTask = nil } catch { finishProductConvergenceAttention( attention: .service, failure: "convergence_rpc_failed", error: error ) } } private func productConvergenceReceipt() async throws -> ProductConvergenceReceipt? { let value = try await requestPetCore(method: "product.convergence.get") guard !(value is NSNull) else { return nil } return try decodeProductConvergenceValue(value) } private func waitForProductConvergenceSafePoint() async throws { while !Task.isCancelled { if !isSafeForProductConvergence { publishProductConvergenceWaiting() try await productConvergenceSleeper(.seconds(1)) continue } let value = try await requestPetCore(method: "product.convergence.preflight") let preflight: ProductConvergencePreflight = try decodeProductConvergenceValue(value) guard !preflight.safe else { return } publishProductConvergenceWaiting() try await productConvergenceSleeper(.seconds(1)) } throw CancellationError() } private func scheduleProductConvergenceVisibility() { productConvergenceVisibilityTask?.cancel() let sleeper = productConvergenceSleeper productConvergenceVisibilityTask = Task { @MainActor [weak self] in do { try await sleeper(.seconds(1)) } catch { return } guard !Task.isCancelled, let self, self.productConvergenceTask != nil, self.appUpdateConvergenceState != .waitingForActiveWork else { return } self.appUpdateConvergenceState = .updating } } private func publishProductConvergenceWaiting() { productConvergenceVisibilityTask?.cancel() productConvergenceVisibilityTask = nil appUpdateConvergenceState = .waitingForActiveWork } private func finishProductConvergenceSuccess( manifest: RuntimeReleaseManifest, presentsCompletion: Bool ) { productConvergenceVisibilityTask?.cancel() productConvergenceVisibilityTask = nil if presentsCompletion, manifest.releaseChannel == "release", !productConvergenceNoticePreferences.hasAcknowledged( buildID: manifest.buildID ) { appUpdateConvergenceState = .completed(version: manifest.appVersion) } else { appUpdateConvergenceState = .idle } diagnostics.log( .notice, category: "update", event: "product_convergence_completed", metadata: [ "build_id": .string(manifest.buildID), "version": .string(manifest.appVersion) ] ) } private func finishProductConvergenceAttention( attention: AppUpdateConvergenceAttention, failure: String, error: Error? = nil ) { productConvergenceVisibilityTask?.cancel() productConvergenceVisibilityTask = nil appUpdateConvergenceState = .needsAttention(attention) var metadata: [String: AppDiagnosticMetadataValue] = [ "failure": .string(failure), "sources": .string(attention.sources.map(\.rawValue).joined(separator: ",")) ] if let error { metadata["error_type"] = .string(String(describing: type(of: error))) } diagnostics.log( .error, category: "update", event: "product_convergence_needs_attention", metadata: metadata, throttleKey: "product_convergence_needs_attention", minimumInterval: 30 ) } private func refreshSnapshotAfterProductConvergence() async -> Bool { do { try await bootstrapHooks.refreshSnapshot(self) return true } catch { diagnostics.logFailure( error, category: "update", event: "product_convergence_snapshot_refresh_failed", throttleKey: "product_convergence_snapshot_refresh_failed", minimumInterval: 30 ) return false } } private func decodeProductConvergenceValue( _ value: Any ) throws -> Value { guard JSONSerialization.isValidJSONObject(value) else { throw PetCoreClientError.invalidResponse } return try JSONDecoder().decode( Value.self, from: JSONSerialization.data(withJSONObject: value) ) } /// Converges the closed, content-pinned App inventory before the first /// state snapshot is presented. PetCore preserves ordinary same-ID pets /// and appends a revision only for an identity it previously marked as /// bundled when the release digest changes. private func seedBundledPets() async -> Bool { guard BundledPetInventory.hasCompleteResources() else { diagnostics.log( .error, category: "library", event: "bundled_pet_seed_failed", metadata: ["failure_code": .string("resource_missing")] ) statusText = "App 内置宠物资源不完整" return false } do { let result = try await requestPetCore( method: "petpack.seed_bundled", params: BundledPetInventory.rpcParameters ) let response = try BundledPetInventory.validatedSeedResponse(result) let installedCount = response.outcomes.lazy.filter { $0.status == .installed }.count let revisedCount = response.outcomes.lazy.filter { $0.status == .installedNewRevision }.count diagnostics.log( .info, category: "library", event: "bundled_pet_seed_completed", metadata: [ "inventory_count": .integer(Int64(BundledPetInventory.fileNames.count)), "installed_count": .integer(Int64(installedCount)), "revised_count": .integer(Int64(revisedCount)) ] ) return true } catch { diagnostics.logFailure( error, category: "library", event: "bundled_pet_seed_failed", throttleKey: "bundled_pet_seed_failed", minimumInterval: 30 ) statusText = "App 内置宠物加载失败:\(error.localizedDescription)" return false } } private func performBundledPetSeed() async -> Bool { if let bundledPetSeederOverride { return await bundledPetSeederOverride() } return await seedBundledPets() } private func scheduleBundledPetSeedRetry() { guard bundledPetSeedRetryTask == nil else { return } bundledPetSeedRetryTask = Task { @MainActor [weak self] in guard let store = self else { return } defer { store.bundledPetSeedRetryTask = nil } _ = await store.retryBundledPetSeedAfterBootstrapFailure() } } /// Retries only the App inventory ensure operation. This intentionally /// does not call the runtime launcher or last-known-good rollback path. /// The injected seeder and sleeper keep the bounded behavior testable /// without launching the App UI or taking over user input. func retryBundledPetSeedAfterBootstrapFailure() async -> Bool { for delay in Self.bundledPetSeedRetryDelays { do { try await bundledPetSeedSleeper(delay) } catch { return false } guard !Task.isCancelled else { return false } if await performBundledPetSeed() { let snapshotReady = await refresh() if snapshotReady, Self.isBundledPetSeedFailureStatus(statusText) { statusText = "App 内置宠物已加载" } if snapshotReady { scheduleProductConvergence(bundledPetsReady: true) } return snapshotReady } } return false } private static func isBundledPetSeedFailureStatus(_ value: String) -> Bool { value == "App 内置宠物资源不完整" || value.hasPrefix("App 内置宠物加载失败") } @discardableResult func refresh() async -> Bool { await refresh(recoveryMode: .coordinated) } private enum SnapshotRecoveryMode { case coordinated case runtimeBootstrap } private func refreshDuringRuntimeBootstrap() async -> Bool { await refresh(recoveryMode: .runtimeBootstrap) } private func refresh(recoveryMode: SnapshotRecoveryMode) async -> Bool { do { try await bootstrapHooks.refreshSnapshot(self) setServiceOnline() return true } catch { diagnostics.logFailure( error, category: "service", event: "petcore_snapshot_failed", throttleKey: "petcore_snapshot_failed", minimumInterval: 30 ) setServiceFailure( "本地服务连接失败:\(error.localizedDescription)", status: "PetCore 连接失败", operationalState: .offline ) switch recoveryMode { case .coordinated: return await recoverServiceConnection() case .runtimeBootstrap: return await runServiceRecovery() } } } func recoverServiceConnection() async -> Bool { if let runtimeBootstrap { runtimeBootstrapRetryTask?.cancel() runtimeBootstrapRetryTask = nil return await runtimeBootstrap.task.value } if runtimeBootstrapRequiresFullRecovery { runtimeBootstrapRetryTask?.cancel() runtimeBootstrapRetryTask = nil return await runRuntimeBootstrapIfNeeded(startMode: .recover) } if let serviceRecovery { return await serviceRecovery.task.value } recoverySequence &+= 1 let id = recoverySequence let task = Task { @MainActor [weak self] in await self?.runServiceRecovery() ?? false } serviceRecovery = (id, task) let recovered = await task.value if serviceRecovery?.id == id { serviceRecovery = nil } return recovered } private func runServiceRecovery() async -> Bool { setServiceRecovering() diagnostics.log(.info, category: "service", event: "petcore_recovery_started") switch await bootstrapHooks.recover() { case .alreadyHealthy, .started: do { try await bootstrapHooks.refreshSnapshot(self) diagnostics.log(.notice, category: "service", event: "petcore_recovery_succeeded") setServiceOnline() return true } catch { diagnostics.logFailure( error, category: "service", event: "petcore_recovery_snapshot_failed", throttleKey: "petcore_recovery_snapshot_failed", minimumInterval: 30 ) setServiceFailure( "本地服务连接失败:\(error.localizedDescription)", status: "PetCore 连接失败", operationalState: .offline ) return false } case let .deferred(reason): appUpdateConvergenceState = .waitingForActiveWork setServiceChecking() setServiceStatusText(reason) runtimeBootstrapCompleted = false runtimeBootstrapRequiresFullRecovery = true runtimeBootstrapRetryDelaySeconds = 2 scheduleRuntimeBootstrapRetry() return false case let .failed(reason): let failureCode = PetCoreServiceFailureClassifier.classify(reason) diagnostics.log( .error, category: "service", event: "petcore_recovery_failed", metadata: ["failure_code": .string(failureCode.rawValue)], throttleKey: "petcore_recovery_failed", minimumInterval: 30 ) setServiceFailure(reason, failureCode: failureCode) return false } } private func refreshSnapshot() async throws { let result = try await requestPetCore(method: "state.snapshot") try applyStateSnapshot(result) if latestGenerationRestoreAttemptState != .resolved { await restoreLatestGenerationSessionIfNeeded() } setServiceOnline() } func waitForStateChange() async { // The daemon revision wakes this request immediately. Active work uses // a one-second hydration budget so an asynchronous host display read // becomes visible promptly; idle waits remain long to avoid decoding // and republishing unchanged full snapshots. let timeoutMs = Self.stateWaitTimeoutMilliseconds( generationIsActive: generationSession.isActive, hasActiveAgentState: activeAgentState != nil ) do { let result = try await requestPetCore( method: "state.wait", params: [ "after_revision": stateRevision, "timeout_ms": timeoutMs ], timeout: .seconds(35) ) try applyStateSnapshot(result) setServiceOnline() } catch { if hasProtectedUserWork, (try? await requestPetCore( method: "petcore.health", timeout: .seconds(2) )) != nil { diagnostics.log( .info, category: "service", event: "petcore_state_wait_deferred_during_protected_work", metadata: [:] ) setServiceOnline() return } diagnostics.logFailure( error, category: "service", event: "petcore_state_wait_failed", throttleKey: "petcore_state_wait_failed", minimumInterval: 30 ) setServiceFailure( "本地服务连接失败:\(error.localizedDescription)", status: "PetCore 连接失败", operationalState: .offline ) if !(await recoverServiceConnection()) { try? await Task.sleep(for: .seconds(1)) } } } private func refreshPetCoreRuntimeInfo() async { guard let result = try? await requestPetCore( method: "petcore.health", timeout: .seconds(1) ), let info = PetCoreRuntimeInfo.running(healthValue: result) else { return } petCoreRuntimeInfo = info } private func setServiceChecking() { petCoreOperationalState = .checking var nextRuntimeInfo = petCoreRuntimeInfo nextRuntimeInfo.markChecking() if petCoreRuntimeInfo != nextRuntimeInfo { petCoreRuntimeInfo = nextRuntimeInfo } setServiceStatusText("正在检查本地服务版本与兼容性") } private func setServiceRecovering() { petCoreOperationalState = .recovering setServiceStatusText("正在恢复本地服务") } private func setServiceFailure( _ reason: String, status: String = "PetCore 启动失败", failureCode: PetCoreServiceFailureCode? = nil, operationalState: PetCoreOperationalState? = nil ) { let resolvedFailureCode = failureCode ?? PetCoreServiceFailureClassifier.classify(reason) lastServiceFailureCode = resolvedFailureCode petCoreOperationalState = operationalState ?? .failure(for: resolvedFailureCode) var nextRuntimeInfo = petCoreRuntimeInfo nextRuntimeInfo.markFailed(reason) if petCoreRuntimeInfo != nextRuntimeInfo { petCoreRuntimeInfo = nextRuntimeInfo } setServiceStatusText(status) } private func setServiceOnline() { let reconnected = petCoreOperationalState != .online lastServiceFailureCode = .none petCoreOperationalState = .online var nextRuntimeInfo = petCoreRuntimeInfo nextRuntimeInfo.markRunning() if petCoreRuntimeInfo != nextRuntimeInfo { petCoreRuntimeInfo = nextRuntimeInfo } setServiceStatusText("本地服务运行中") if reconnected { resumePendingOverlayPlacementSaveIfNeeded(reason: .reconnect) } } private func setServiceStatusText(_ value: String) { let shouldMirrorToStatus = statusText == serviceStatusText || statusText == "正在初始化" || statusText.hasPrefix("本地服务") || statusText.hasPrefix("PetCore") if serviceStatusText != value { serviceStatusText = value } if shouldMirrorToStatus, statusText != value { statusText = value } } func applyStateSnapshot(_ result: Any) throws { let isBootstrapSnapshot = !hasLoadedStateSnapshot let previousOverlayBubbleLayout = OverlayBubbleLayoutSignature( contents: overlayBubbleContents, bubbleDismissed: overlayBubbleDismissed ) let data = try JSONSerialization.data(withJSONObject: result) let snapshot = try JSONDecoder().decode(StateSnapshot.self, from: data) guard let overlayPlacementRevision = OverlayPlacementRevisionCodec.parse( snapshot.overlayPlacementRevision ) else { throw PetCoreClientError.invalidResponse } authoritativeBehavior = snapshot.behavior if let snapshotOnboarding = snapshot.onboarding, onboarding != snapshotOnboarding { onboarding = snapshotOnboarding } if let activeGeneration = snapshot.activeGeneration { reconcileActiveGeneration(activeGeneration) } let previousSessionGroupDisplay = behavior.sessionGroupDisplay let previousGroupSessionsByAgent = behavior.groupSessionsByAgent let previousBubbleFontScale = behavior.bubbleFontScale let previousAppearanceTheme = behavior.appearanceTheme let previousInterfaceLanguage = behavior.interfaceLanguage let behaviorChanged = behavior != snapshot.behavior if behaviorChanged { behavior = snapshot.behavior if behavior.sessionGroupDisplay != previousSessionGroupDisplay || behavior.groupSessionsByAgent != previousGroupSessionsByAgent { overlayAgentGroupExpansionOverrides.removeAll() overlayStandaloneStackExpansionOverride = nil overlayStandaloneStackDisclosureDirection = .expanding } } behaviorRevision = snapshot.behaviorRevision ?? behaviorRevision if behavior.appearanceTheme != previousAppearanceTheme || initialAppearanceReadiness != .authoritative { applyCurrentAppearance() } if behavior.interfaceLanguage != previousInterfaceLanguage || initialAppearanceReadiness != .authoritative { applyCurrentLanguage() } let activeStateChanged = switch (activeAgentState, snapshot.activeAgentState) { case (nil, nil): false case let (.some(current), .some(next)): !current.hasSamePresentation(as: next) default: true } if activeStateChanged { activeAgentState = snapshot.activeAgentState } let nextActiveAgentSessions = snapshot.activeAgentSessions ?? snapshot.activeAgentState.map { [$0] } ?? [] overlaySessionProjectionIdentities = Dictionary( nextActiveAgentSessions.map { state in ( OverlaySessionContent.stableID( source: state.source, sessionID: state.sessionID ?? state.event.sessionID, anonymousSessionAlias: state.anonymousSessionAlias, fallbackEventID: state.event.id ), OverlaySessionProjectionIdentity( eventID: state.event.id, acknowledgementID: state.acknowledgementID ) ) }, uniquingKeysWith: { _, latest in latest } ) let nextActiveAgentSessionsOmittedCount = max( 0, snapshot.activeAgentSessionsOmittedCount ?? 0 ) let activeSessionsChanged = !activeStatesHaveSamePresentation( activeAgentSessions, nextActiveAgentSessions ) reconcileOverlayStandaloneSessionOrder(with: nextActiveAgentSessions) if activeSessionsChanged { activeAgentSessions = nextActiveAgentSessions } reconcileOverlaySessionNavigationNotices() if activeAgentSessionsOmittedCount != nextActiveAgentSessionsOmittedCount { activeAgentSessionsOmittedCount = nextActiveAgentSessionsOmittedCount } let nextOverlayVisibility = snapshot.overlayVisibility ?? OverlayVisibility( petVisible: snapshot.behavior.enabled, statusBubbleVisible: snapshot.behavior.enabled && snapshot.behavior.statusBubble && (!nextActiveAgentSessions.isEmpty || (!snapshot.behavior.autoHide && snapshot.activeAgentState == nil)) ) let overlayVisibilityChanged = overlayVisibility != nextOverlayVisibility if overlayVisibilityChanged { overlayVisibility = nextOverlayVisibility } if pets != snapshot.pets { pets = snapshot.pets } let assetWarnings = snapshot.petAssetWarnings ?? [] let nextPetAssetWarningIndex = PetAssetWarningIndex(assetWarnings) if petAssetWarningIndex != nextPetAssetWarningIndex { petAssetWarningIndex = nextPetAssetWarningIndex if !assetWarnings.isEmpty { let categories = Set(assetWarnings.map(PetAssetDiagnosticCategory.classify)) .map(\.rawValue) .sorted() .joined(separator: ",") diagnostics.log( .warning, category: "render", event: "pet_asset_warnings_changed", metadata: [ "warning_count": .integer(Int64(assetWarnings.count)), "warning_categories": .string(categories) ], throttleKey: "pet_asset_warnings_changed", minimumInterval: 30 ) } } if !eventsHaveSamePresentation(events, snapshot.events) { events = snapshot.events } let nextReopenIDBySession = Dictionary( nextActiveAgentSessions.map { state in ( OverlaySessionContent.stableID( source: state.source, sessionID: state.sessionID ?? state.event.sessionID, anonymousSessionAlias: state.anonymousSessionAlias, fallbackEventID: state.event.id ), OverlaySessionContent.reopenID(for: state) ) }, uniquingKeysWith: { _, latest in latest } ) let newlyActivatedDismissalIDs = OverlayPresentedAgentState.newlyActivatedDismissalIDs( activeSessions: nextActiveAgentSessions, lastReopenIDBySession: overlayLastReopenIDBySession ) let hasNewOverlayActivation = !newlyActivatedDismissalIDs.isEmpty if snapshot.behavior.enabled { var nextDismissedBubbleEventIDs = overlayDismissedBubbleEventIDs nextDismissedBubbleEventIDs.subtract(newlyActivatedDismissalIDs) if overlayDismissedBubbleEventIDs != nextDismissedBubbleEventIDs { overlayDismissedBubbleEventIDs = nextDismissedBubbleEventIDs } } // A projected-session gap is not evidence of new work. Preserve each // session's last activation identity until that same session returns // with a genuinely different one. overlayLastReopenIDBySession.merge(nextReopenIDBySession) { _, latest in latest } if hasNewOverlayActivation, overlayBubbleDismissed { overlayBubbleDismissed = false } let nextRecentEvents = snapshot.recentEvents ?? snapshot.events if !eventsHaveSamePresentation(recentEvents, nextRecentEvents) { recentEvents = nextRecentEvents } applyAuthoritativeConnectionSnapshot(snapshot.connections) stateRevision = snapshot.revision ?? stateRevision let snapshotPlacement = snapshot.overlayPlacement ?? OverlayPlacement() applyOverlayPlacement( snapshotPlacement, remoteRevision: overlayPlacementRevision, remoteIntent: snapshot.overlayPlacementIntent ) syncOverlayVisibilityForBehavior() let overlayBubbleLayoutChanged = previousOverlayBubbleLayout != OverlayBubbleLayoutSignature( contents: overlayBubbleContents, bubbleDismissed: overlayBubbleDismissed ) if overlayBubbleLayoutChanged || overlayVisibilityChanged || behavior.groupSessionsByAgent != previousGroupSessionsByAgent || behavior.sessionGroupDisplay != previousSessionGroupDisplay || behavior.bubbleFontScale != previousBubbleFontScale { overlayController.updateLayout() } if initialAppearanceReadiness != .authoritative { resolveInitialAppearanceAsAuthoritative() } if !hasLoadedStateSnapshot { hasLoadedStateSnapshot = true } resumePendingOverlayPlacementSaveIfNeeded( reason: isBootstrapSnapshot ? .bootstrap : .ordinarySnapshot ) presentOverlayAfterFirstSnapshotIfNeeded() } private func presentOverlayAfterFirstSnapshotIfNeeded() { guard runtimeBootstrapCompleted, hasLoadedStateSnapshot, !hasPresentedOverlay else { return } hasPresentedOverlay = true overlayPresenter(overlayController, self) } private func reconcileActiveGeneration(_ snapshot: ActiveGenerationSnapshot) { let previousJobID = generationSession.jobID var restore = GenerationSessionRestore(snapshot: snapshot) let projectionIdentity = ActiveGenerationRecoveryProjectionIdentity( jobID: snapshot.jobID, form: snapshot.form, referenceReselectionCount: snapshot.referenceReselectionCount ) let projectionChanged = activeGenerationRecoveryProjection?.identity != projectionIdentity if projectionChanged { restore = sanitizedGenerationRestore(restore) activeGenerationRecoveryProjection = SanitizedGenerationRecoveryProjection( identity: projectionIdentity, form: restore.submittedForm, referenceReselectionCount: restore.referenceReselectionCount ) } else if let projection = activeGenerationRecoveryProjection { restore.submittedForm = projection.form restore.referenceReselectionCount = projection.referenceReselectionCount } _ = reduceGeneration(.restore(restore)) if projectionChanged { applyRestoredGenerationForm( restore.submittedForm, referenceReselectionCount: restore.referenceReselectionCount ) } latestGenerationRestoreAttemptState = .resolved if previousJobID != snapshot.jobID { generationReplyText = "" } } /// Coalesces concurrent recovery callers. A failed or malformed local RPC /// remains retryable on the next successful state refresh; only an empty /// valid response or an applied valid session resolves the launch restore. func restoreLatestGenerationSessionIfNeeded() async { guard !automaticLatestGenerationRestoreInvalidated, generationDraftIsPristineForAutomaticRestore else { return } switch latestGenerationRestoreAttemptState { case .resolved: return case .inFlight: if let task = latestGenerationRestoreInFlight?.task { await task.value } return case .notAttempted: break } latestGenerationRestoreAttemptSequence &+= 1 let attemptID = latestGenerationRestoreAttemptSequence let mutationRevision = makerUserMutationRevision latestGenerationRestoreAttemptState = .inFlight let task = Task { @MainActor [weak self] in guard let self else { return } await self.performLatestGenerationRestore( attemptID: attemptID, makerMutationRevision: mutationRevision ) } latestGenerationRestoreInFlight = (attemptID, task) await task.value } private func performLatestGenerationRestore( attemptID: UInt64, makerMutationRevision: UInt64 ) async { do { let result = try await requestPetCore(method: "generation.latest") let data = try JSONSerialization.data(withJSONObject: result) let snapshot = try JSONDecoder().decode( LatestGenerationSessionSnapshot.self, from: data ) if !snapshot.found { finishLatestGenerationRestoreWithoutSession(attemptID: attemptID) return } guard let decodedRestore = GenerationSessionRestore(snapshot: snapshot) else { throw PetCoreClientError.invalidResponse } finishLatestGenerationRestore( sanitizedGenerationRestore(decodedRestore), attemptID: attemptID, makerMutationRevision: makerMutationRevision ) } catch { diagnostics.logFailure( error, category: "generation", event: "latest_generation_restore_failed", throttleKey: "latest_generation_restore_failed", minimumInterval: 30 ) finishRetryableLatestGenerationRestore(attemptID: attemptID) } } private func finishLatestGenerationRestoreWithoutSession(attemptID: UInt64) { guard latestGenerationRestoreInFlight?.id == attemptID else { return } latestGenerationRestoreInFlight = nil guard latestGenerationRestoreAttemptState == .inFlight else { return } latestGenerationRestoreAttemptState = .resolved } private func finishRetryableLatestGenerationRestore(attemptID: UInt64) { guard latestGenerationRestoreInFlight?.id == attemptID else { return } latestGenerationRestoreInFlight = nil guard latestGenerationRestoreAttemptState == .inFlight else { return } latestGenerationRestoreAttemptState = .notAttempted } private func finishLatestGenerationRestore( _ restore: GenerationSessionRestore, attemptID: UInt64, makerMutationRevision: UInt64 ) { guard latestGenerationRestoreInFlight?.id == attemptID else { return } latestGenerationRestoreInFlight = nil guard latestGenerationRestoreAttemptState == .inFlight else { return } guard !automaticLatestGenerationRestoreInvalidated, self.makerUserMutationRevision == makerMutationRevision, generationDraftIsPristineForAutomaticRestore else { latestGenerationRestoreAttemptState = .notAttempted return } _ = reduceGeneration(.restore(restore)) applyRestoredGenerationForm( restore.submittedForm, referenceReselectionCount: restore.referenceReselectionCount ) generationReplyText = "" latestGenerationRestoreAttemptState = .resolved } private var generationDraftIsPristineForAutomaticRestore: Bool { generationSession == GenerationSession() && descriptionText == AIPetMakerDefaults.descriptionText && selectedStyle == AIPetMakerDefaults.style && selectedQuality == AIPetMakerDefaults.quality && referenceImages.isEmpty && referenceImageIssue == nil } private func sanitizedGenerationRestore( _ restore: GenerationSessionRestore ) -> GenerationSessionRestore { guard let form = restore.submittedForm else { return restore } let projectedPaths = form.referenceImages let validatedPaths = projectedPaths.enumerated().compactMap { index, path in MakerReferenceImagePolicy.validatedRecoveryProjectionPath( path, jobID: restore.jobID, index: index ) } var sanitized = restore let safePaths: [String] if validatedPaths.count == projectedPaths.count { safePaths = validatedPaths } else { // Projection is all-or-nothing: if even one supposedly safe copy // disappeared or fails local validation, retain no projected path. safePaths = [] sanitized.referenceReselectionCount = min( MakerReferenceImagePolicy.maximumCount, restore.referenceReselectionCount + projectedPaths.count ) } sanitized.submittedForm = GenerationForm( description: form.description, style: form.style, quality: form.quality, referenceImages: safePaths ) return sanitized } private func applyRestoredGenerationForm( _ form: GenerationForm?, referenceReselectionCount: Int ) { guard let form else { return } descriptionText = form.description if let style = StylePreset(rawValue: form.style) { selectedStyle = style } selectedQuality = form.quality referenceImages = form.referenceImages self.referenceReselectionCount = referenceReselectionCount reselectedReferenceImagePaths.removeAll() referenceImageIssue = referenceReselectionCount > 0 ? .reselectionRequired(referenceReselectionCount) : nil } private func recordMakerUserMutation() { makerUserMutationRevision &+= 1 automaticLatestGenerationRestoreInvalidated = true } private func refreshReferenceImageIssue( fallback: MakerReferenceImageIssue? = nil ) { if referenceReselectionCount > 0 { referenceImageIssue = .reselectionRequired(referenceReselectionCount) } else { referenceImageIssue = fallback ?? MakerReferenceImagePolicy.issue(for: referenceImages) } } func updateGenerationDescription(_ value: String) { guard !generationSession.isActive else { return } recordMakerUserMutation() descriptionText = GenerationPromptPolicy.truncate(value) } func selectGenerationStyle(_ style: StylePreset) { guard !generationSession.isActive else { return } recordMakerUserMutation() selectedStyle = style } func selectGenerationQuality(_ quality: QualityLevel) { guard !generationSession.isActive, quality.isStudioSupported else { return } recordMakerUserMutation() selectedQuality = quality } func refreshPetStudioCodexAvailability() async { if generationSession.isActive { petStudioCodexAvailability = .available return } guard petStudioCodexAvailability != .available else { return } petStudioCodexAvailability = .checking do { let result = try await requestPetCore(method: "codex.app_server.probe") guard let payload = result as? [String: Any] else { petStudioCodexAvailability = .unavailable return } if payload["initialized"] as? Bool == true { petStudioCodexAvailability = .available return } let errorInfo = payload["error_info"] as? [String: Any] let kind = errorInfo?["kind"] as? String petStudioCodexAvailability = payload["mode"] as? String == "missing" || kind == "not_configured" ? .missing : .unavailable } catch { petStudioCodexAvailability = .unavailable diagnostics.logFailure( error, category: "generation", event: "pet_studio_codex_probe_failed", throttleKey: "pet_studio_codex_probe_failed", minimumInterval: 30 ) } } func startGeneration() { guard canStartNewGenerationWork else { statusText = APCLocalization.text(.appUpdateConvergenceMakerBlocked) return } guard canStartGeneration else { statusText = generationSession.isActive ? generationStateTitle : petStudioGenerationBlockedStatus return } if let issue = MakerReferenceImagePolicy.issue(for: referenceImages) { referenceImageIssue = issue return } referenceImageIssue = nil let description = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines) let form = GenerationForm( description: description, style: selectedStyle.rawValue, quality: selectedQuality, referenceImages: referenceImages ) beginGeneration( with: form, initialMessage: APCLocalization.format( .studioMessageCreateRequestedFormat, APCLocalizedPresentation.styleTitle(selectedStyle) ) ) } func startPetEdit( _ pet: PetSummary, baselineRevisionID: String? = nil, instruction: String ) { guard canStartNewGenerationWork else { statusText = APCLocalization.text(.appUpdateConvergenceMakerBlocked) return } guard !pet.isBundled else { statusText = "App 内置宠物不可原地修改;请导出并使用新的宠物 ID 创建副本" return } guard pet.quality.isStudioSupported else { statusText = APCLocalization.text(.studioHighQualityUnsupported) return } let instruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines) guard !instruction.isEmpty else { statusText = "请先填写希望如何修改宠物" return } guard GenerationPromptPolicy.scalarCount(instruction) <= AIPetMakerDefaults.maximumDescriptionCharacters else { statusText = "宠物修改要求不能超过 8000 个字符" return } guard !generationSession.isActive else { statusText = "请先完成或取消当前 AI 制作任务" return } guard petStudioCodexAvailability.permitsGeneration else { selection = .maker statusText = petStudioGenerationBlockedStatus return } recordMakerUserMutation() referenceReselectionCount = 0 reselectedReferenceImagePaths.removeAll() referenceImageIssue = nil let form = GenerationForm( description: instruction, style: pet.style, quality: pet.quality, referenceImages: [] ) let initialUserMessage = GenerationMessage( role: "user", content: instruction, progress: 0.01, createdAt: "" ) _ = reduceGeneration(.editRequested( form: form, initialMessage: initialUserMessage, petID: pet.id, baselineRevisionID: baselineRevisionID )) generationReplyText = "" selection = .maker statusText = "正在建立 \(pet.name) 的修改会话" Task { do { var parameters: [String: Any] = [ "pet_id": pet.id, "instruction": instruction, ] if let baselineRevisionID { parameters["baseline_revision_id"] = baselineRevisionID } let result = try await requestPetCore( method: "generation.edit", params: parameters ) guard let dict = result as? [String: Any], let jobID = dict["job_id"] as? String, !jobID.isEmpty else { throw PetCoreClientError.invalidResponse } let acceptedBaselineRevisionID = dict["baseline_revision_id"] as? String ?? baselineRevisionID _ = reduceGeneration(.startAccepted( jobID: jobID, baselineRevisionID: acceptedBaselineRevisionID )) await selectAcceptedGenerationHistoryJobAndWait(jobID) statusText = "正在修改 \(pet.name)" } catch { let failure = GenerationMessage( role: "assistant", content: APCLocalization.text(.studioMessageStartModifyFailed), progress: 1, createdAt: "", kind: "generation_failed" ) _ = reduceGeneration(.startFailed(message: failure)) statusText = "修改启动失败:\(error.localizedDescription)" } } } func preparePetCustomizationCopy(_ pet: PetSummary) { guard pet.isBundled else { statusText = APCLocalization.text(.libraryCopyBundledOnly) return } guard !generationSession.isActive else { statusText = APCLocalization.text(.libraryCopyActiveTask) return } recordMakerUserMutation() let draft = PetLibraryCopyDraft.make( for: pet, existingPetIDs: Set(pets.map(\.id)) ) let referencePath = PetAssetLocator.coverURL(for: pet) .flatMap(Self.safeMakerReferenceImagePath) _ = reduceGeneration(.reset) descriptionText = draft.brief selectedStyle = draft.style selectedQuality = draft.quality referenceImages = referencePath.map { [$0] } ?? [] referenceReselectionCount = 0 reselectedReferenceImagePaths.removeAll() referenceImageIssue = nil generationReplyText = "" clearGenerationHistorySelection() makerDraftIsActive = true selection = .maker statusText = APCLocalization.format(.libraryCopyPreparedFormat, pet.name) } func clearStudioForm() { guard !generationSession.isActive else { statusText = "活动任务使用已提交表单,完成或取消后才能清空草稿" return } recordMakerUserMutation() descriptionText = AIPetMakerDefaults.descriptionText selectedStyle = AIPetMakerDefaults.style selectedQuality = AIPetMakerDefaults.quality referenceImages.removeAll() referenceReselectionCount = 0 reselectedReferenceImagePaths.removeAll() referenceImageIssue = nil generationReplyText = "" statusText = "已清空新建表单" } func showNewPetDraft() { guard !generationSession.isActive else { statusText = "请先完成或取消当前 AI 制作任务" return } recordMakerUserMutation() _ = reduceGeneration(.reset) descriptionText = AIPetMakerDefaults.descriptionText selectedStyle = AIPetMakerDefaults.style selectedQuality = AIPetMakerDefaults.quality referenceImages.removeAll() referenceReselectionCount = 0 reselectedReferenceImagePaths.removeAll() referenceImageIssue = MakerReferenceImagePolicy.issue(for: referenceImages) generationReplyText = "" selection = .maker statusText = "可以开始制作新宠物" } func retryGeneration() { guard canStartNewGenerationWork else { statusText = APCLocalization.text(.appUpdateConvergenceMakerBlocked) return } guard petStudioCodexAvailability.permitsGeneration else { statusText = petStudioGenerationBlockedStatus return } guard generationSession.submittedForm != nil else { startGeneration() return } guard generationSession.canRetry else { statusText = generationSession.isActive ? generationStateTitle : "当前会话不可重试" return } guard referenceReselectionCount == 0 else { refreshReferenceImageIssue() statusText = APCLocalizedPresentation.referenceImageIssue( .reselectionRequired(referenceReselectionCount) ) return } let modifying = generationSession.operation == .modify if !modifying, let issue = MakerReferenceImagePolicy.issue(for: referenceImages) { referenceImageIssue = issue return } referenceImageIssue = nil guard let form = PetStudioDraftPolicy.retryForm( session: generationSession, descriptionText: descriptionText, style: selectedStyle, quality: selectedQuality, referenceImages: referenceImages ) else { return } if modifying, generationSession.resultPetID == nil { statusText = "修改会话缺少宠物 ID,无法安全重试" return } if modifying, generationSession.jobID == nil { guard let petID = generationSession.resultPetID, let pet = pets.first(where: { $0.id == petID }), let instruction = generationSession.messages.first(where: { $0.role == "user" })? .content else { statusText = "修改会话缺少可重试的宠物基线" return } startPetEdit( pet, baselineRevisionID: generationSession.baselineRevisionID, instruction: instruction ) return } beginGeneration( with: form, initialMessage: modifying ? APCLocalization.text(.studioMessageRetryModify) : APCLocalization.format( .studioMessageRetryCreateFormat, APCLocalizedPresentation.styleTitle(selectedStyle) ), retryOfJobID: generationSession.jobID, operation: generationSession.operation, resultPetID: generationSession.resultPetID ) statusText = modifying ? "正在重试宠物修改" : "正在重试 AI 辅助会话" } func resumeGeneration() { guard canStartNewGenerationWork else { statusText = APCLocalization.text(.appUpdateConvergenceMakerBlocked) return } guard petStudioCodexAvailability.permitsGeneration else { statusText = petStudioGenerationBlockedStatus return } guard canResumeGeneration, let jobID = generationSession.jobID else { statusText = generationSession.isActive ? generationStateTitle : APCLocalization.text(.studioResumeUnavailable) return } let previousState = generationSession.state let instruction = generationReplyText.trimmingCharacters(in: .whitespacesAndNewlines) _ = reduceGeneration(.resumeRequested) statusText = APCLocalization.text(.studioResumeStarting) Task { do { var parameters: [String: Any] = [ "job_id": jobID, "request_id": UUID().uuidString, ] if !instruction.isEmpty { parameters["instruction"] = instruction } let result = try await requestPetCore( method: "generation.resume", params: parameters ) guard let object = result as? [String: Any], let resumedJobID = object["job_id"] as? String, resumedJobID == jobID else { throw PetCoreClientError.invalidResponse } _ = reduceGeneration(.startAccepted( jobID: resumedJobID, baselineRevisionID: object["baseline_revision_id"] as? String )) generationReplyText = "" statusText = APCLocalization.text(.studioResumeAccepted) } catch { _ = reduceGeneration(.resumeFailed(restoring: previousState)) statusText = APCLocalization.format( .studioResumeFailedFormat, error.localizedDescription ) } } } private func beginGeneration( with form: GenerationForm, initialMessage: String, retryOfJobID: String? = nil, operation: GenerationOperation = .create, resultPetID: String? = nil, baselineRevisionID: String? = nil ) { guard canStartNewGenerationWork else { statusText = APCLocalization.text(.appUpdateConvergenceMakerBlocked) return } guard petStudioCodexAvailability.permitsGeneration else { statusText = petStudioGenerationBlockedStatus return } let initialUserMessage = GenerationMessage( role: "user", content: initialMessage, progress: 0.05, createdAt: "" ) if retryOfJobID != nil { _ = reduceGeneration(.retryRequested( form: form, initialMessage: initialUserMessage )) } else if operation == .modify, let resultPetID { _ = reduceGeneration(.editRequested( form: form, initialMessage: initialUserMessage, petID: resultPetID, baselineRevisionID: baselineRevisionID )) } else { _ = reduceGeneration(.startRequested(form: form, initialMessage: initialUserMessage)) } generationReplyText = "" Task { do { let formData = try JSONEncoder().encode(form) let formObject = try JSONSerialization.jsonObject(with: formData) let result: Any if let retryOfJobID { var parameters: [String: Any] = ["job_id": retryOfJobID] if GenerationRetryRequestPolicy.includesForm(for: operation) { parameters["form"] = formObject } result = try await requestPetCore( method: "generation.retry", params: parameters ) } else { result = try await requestPetCore(method: "generation.start", params: formObject) } guard let dict = result as? [String: Any], let jobID = dict["job_id"] as? String, !jobID.isEmpty else { throw PetCoreClientError.invalidResponse } _ = reduceGeneration(.startAccepted( jobID: jobID, baselineRevisionID: dict["baseline_revision_id"] as? String )) await selectAcceptedGenerationHistoryJobAndWait(jobID) } catch { let failure = GenerationMessage( role: "assistant", content: APCLocalization.text( operation == .modify ? .studioMessageStartModifyFailed : .studioMessageStartCreateFailed ), progress: 1, createdAt: "", kind: "generation_failed" ) _ = reduceGeneration(.startFailed(message: failure)) } } } private var petStudioGenerationBlockedStatus: String { generationStartBlockingDetail ?? APCLocalization.text(.studioDescriptionRequired) } func refreshGenerationMessages() async { guard let generationJobID = generationSession.jobID else { return } do { let result = try await requestPetCore(method: "generation.messages", params: ["job_id": generationJobID]) let data = try JSONSerialization.data(withJSONObject: result) let messages = try JSONDecoder().decode([GenerationMessage].self, from: data) await applyGenerationMessages(messages) } catch { statusText = "生成消息暂不可用" } } private func startGenerationMessageStream(jobID: String) { generationMessagesTask?.cancel() generationMessagesTask = Task { [weak self] in while !Task.isCancelled { let shouldContinue = await self?.waitForGenerationMessages(jobID: jobID) ?? false if !shouldContinue { break } } } } private func waitForGenerationMessages(jobID: String) async -> Bool { guard generationSession.jobID == jobID else { return false } do { let result = try await requestPetCore( method: "generation.messages.wait", params: [ "job_id": jobID, "after_revision": generationSession.messageRevision, "timeout_ms": 30_000 ], timeout: .seconds(35) ) let data = try JSONSerialization.data(withJSONObject: result) let snapshot = try JSONDecoder().decode(GenerationMessagesSnapshot.self, from: data) await applyGenerationMessages( snapshot.messages, revision: snapshot.revision, resultMetadata: snapshot.resultMetadata, heartbeatAt: snapshot.heartbeatAt ) return generationSession.isActive && generationSession.jobID == jobID } catch { statusText = "生成消息暂不可用" try? await Task.sleep(for: .seconds(1)) return generationSession.isActive && generationSession.jobID == jobID } } private func applyGenerationMessages( _ messages: [GenerationMessage], revision: String? = nil, resultMetadata: GenerationResultMetadata? = nil, heartbeatAt: String? = nil ) async { if let heartbeatAt { _ = reduceGeneration(.heartbeatReceived(heartbeatAt)) } if let resultMetadata, !resultMetadata.isEmpty { _ = reduceGeneration(.resultMetadataReceived(resultMetadata)) } let previousState = generationSession.state let effects = reduceGeneration(.messagesReceived(messages, revision: revision)) if previousState != generationSession.state { notifyForGenerationTransition( from: previousState, to: generationSession.state, messages: messages ) } if let jobID = generationSession.jobID, selectedGenerationHistoryJobID == jobID { generationHistoryMessages = messages.filter { $0.kind != "generation_heartbeat" && $0.kind != "jsonl_diagnostic" } if previousState != generationSession.state { await refreshGenerationHistory() await loadGenerationHistoryDetail(jobID: jobID) } } if effects.contains(.refreshSnapshot) { await refresh() } } private func notifyForGenerationTransition( from previousState: GenerationSessionState, to state: GenerationSessionState, messages: [GenerationMessage] ) { guard previousState != state, let jobID = generationSession.jobID, !(NSApp.isActive && selection == .maker && selectedGenerationHistoryJobID == jobID) else { return } let notification: (String, String)? = switch state { case .waitingForInput: ( APCLocalization.text(.studioNotificationWaitingTitle), messages.last(where: { $0.kind == "input_request" })?.content ?? APCLocalization.text(.studioNotificationWaitingBody) ) case .paused: ( APCLocalization.text(.studioNotificationPausedTitle), generationSession.pauseReason ?? APCLocalization.text(.studioNotificationPausedBody) ) case .recoverableFailed: ( APCLocalization.text(.studioNotificationRecoverableTitle), generationSession.pauseReason ?? APCLocalization.text(.studioNotificationRecoverableBody) ) case .succeeded: ( APCLocalization.text(.studioNotificationCompletedTitle), APCLocalization.text(.studioNotificationCompletedBody) ) case .failed: ( APCLocalization.text(.studioNotificationFailedTitle), APCLocalization.text(.studioNotificationFailedBody) ) case .idle, .starting, .running, .cancelling, .cancelCleanup, .cancelled: nil } guard let notification else { return } MakerNotificationCoordinator.shared.notify( jobID: jobID, state: state, title: notification.0, body: notification.1 ) } func sendGenerationReply() { let content = generationReplyText.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { return } guard generationSession.canSendReply else { statusText = "请先等待 AI 追问或生成成功" return } guard let generationJobID = generationSession.jobID else { statusText = "请先发起 AI 辅助会话" return } let previousState = generationSession.state _ = reduceGeneration(.replySubmitted) generationReplyText = "" Task { do { let result = try await requestPetCore( method: "generation.reply", params: [ "job_id": generationJobID, "content": content, "request_id": UUID().uuidString, ] ) let data = try JSONSerialization.data(withJSONObject: result) let messages = try JSONDecoder().decode([GenerationMessage].self, from: data) await applyGenerationMessages(messages) if generationSession.isActive { _ = reduceGeneration(.resetMessageRevision) startGenerationMessageStream(jobID: generationJobID) } } catch { _ = reduceGeneration(.replyFailed(restoring: previousState)) statusText = "发送失败:\(error.localizedDescription)" generationReplyText = content } } } func cancelGeneration() { guard generationSession.canCancel, let generationJobID = generationSession.jobID else { return } _ = reduceGeneration(.cancelRequested) statusText = "正在取消生成" Task { do { let result = try await requestPetCore(method: "generation.cancel", params: ["job_id": generationJobID]) let data = try JSONSerialization.data(withJSONObject: result) let messages = try JSONDecoder().decode([GenerationMessage].self, from: data) if !messages.isEmpty { await applyGenerationMessages(messages) } else { let effects = reduceGeneration(.cancelConfirmed) if effects.contains(.refreshSnapshot) { await refresh() } } statusText = generationSession.operation == .modify ? "已取消修改" : "已取消生成" } catch { _ = reduceGeneration(.cancelFailed) let action = generationSession.operation == .modify ? "修改" : "生成" statusText = "取消\(action)失败:\(error.localizedDescription)" } } } func fetchGenerationHistory(for pet: PetSummary) async throws -> GenerationHistory { let result = try await requestPetCore( method: "generation.for_pet", params: ["pet_id": pet.id] ) let data = try JSONSerialization.data(withJSONObject: result) return try JSONDecoder().decode(GenerationHistory.self, from: data) } func refreshGenerationHistory(limit: Int = 24) async { generationHistoryListSequence &+= 1 let sequence = generationHistoryListSequence generationHistoryIsLoading = true generationHistoryLoadFailed = false do { let result = try await requestPetCore( method: "generation.history.list", params: ["limit": min(max(limit, 1), 32)] ) let data = try JSONSerialization.data(withJSONObject: result) let snapshot = try JSONDecoder().decode( GenerationStudioHistorySnapshot.self, from: data ) guard sequence == generationHistoryListSequence else { return } generationHistorySnapshot = snapshot if let selectedGenerationHistoryJobID, !snapshot.jobs.contains(where: { $0.jobID == selectedGenerationHistoryJobID }) { if generationHistorySelectionIntentJobID != selectedGenerationHistoryJobID { clearGenerationHistorySelection() } } else if let generationHistorySelectionIntentJobID, snapshot.jobs.contains(where: { $0.jobID == generationHistorySelectionIntentJobID }) { self.generationHistorySelectionIntentJobID = nil } } catch { guard sequence == generationHistoryListSequence else { return } generationHistoryLoadFailed = true diagnostics.logFailure( error, category: "generation", event: "generation_history_list_failed", throttleKey: "generation_history_list_failed", minimumInterval: 10 ) } guard sequence == generationHistoryListSequence else { return } generationHistoryIsLoading = false generationHistoryHasLoaded = true } func selectGenerationHistoryJob(_ jobID: String) { guard generationHistorySnapshot.jobs.contains(where: { $0.jobID == jobID }) else { return } generationHistorySelectionIntentJobID = nil activateGenerationHistorySelection(jobID) Task { [weak self] in guard let self else { return } async let detail: Void = loadGenerationHistoryDetail(jobID: jobID) async let messages: Void = loadGenerationHistoryMessages(jobID: jobID) _ = await (detail, messages) } } func selectGenerationHistoryJobAndWait(_ jobID: String) async { guard generationHistorySnapshot.jobs.contains(where: { $0.jobID == jobID }) else { return } generationHistorySelectionIntentJobID = nil activateGenerationHistorySelection(jobID) async let detail: Void = loadGenerationHistoryDetail(jobID: jobID) async let messages: Void = loadGenerationHistoryMessages(jobID: jobID) _ = await (detail, messages) } /// Selects the exact job acknowledged by PetCore immediately, without /// waiting for the eventually consistent history list to expose it. private func selectAcceptedGenerationHistoryJobAndWait(_ jobID: String) async { generationHistorySelectionIntentJobID = jobID activateGenerationHistorySelection(jobID) async let history: Void = refreshGenerationHistory() async let detail: Void = loadGenerationHistoryDetail(jobID: jobID) async let messages: Void = loadGenerationHistoryMessages(jobID: jobID) _ = await (history, detail, messages) } private func activateGenerationHistorySelection(_ jobID: String) { if selectedGenerationHistoryJobID != jobID { generationReplyText = "" } generationHistoryDetailSequence &+= 1 generationHistoryMessagesSequence &+= 1 selectedGenerationHistoryJobID = jobID makerDraftIsActive = false generationHistoryDetail = nil generationHistoryMessages = [] generationHistoryMessagesHasMore = false generationHistoryDetailIsLoading = false generationHistoryMessagesIsLoading = false } func clearGenerationHistorySelection() { generationHistoryDetailSequence &+= 1 generationHistoryMessagesSequence &+= 1 generationHistorySelectionIntentJobID = nil selectedGenerationHistoryJobID = nil generationHistoryDetail = nil generationHistoryMessages = [] generationHistoryMessagesHasMore = false generationHistoryDetailIsLoading = false generationHistoryMessagesIsLoading = false generationReplyText = "" } func loadGenerationHistoryDetail(jobID: String) async { generationHistoryDetailSequence &+= 1 let sequence = generationHistoryDetailSequence generationHistoryDetailIsLoading = true generationHistoryLoadFailed = false do { let result = try await requestPetCore( method: "generation.history.detail", params: ["job_id": jobID], timeout: .seconds(15) ) let data = try JSONSerialization.data(withJSONObject: result) let detail = try JSONDecoder().decode( GenerationStudioHistoryDetail.self, from: data ) guard sequence == generationHistoryDetailSequence, selectedGenerationHistoryJobID == jobID else { return } generationHistoryDetail = detail.found ? detail : nil generationHistoryLoadFailed = !detail.found } catch { guard sequence == generationHistoryDetailSequence, selectedGenerationHistoryJobID == jobID else { return } generationHistoryLoadFailed = true diagnostics.logFailure( error, category: "generation", event: "generation_history_detail_failed", throttleKey: "generation_history_detail_failed", minimumInterval: 10 ) } guard sequence == generationHistoryDetailSequence, selectedGenerationHistoryJobID == jobID else { return } generationHistoryDetailIsLoading = false } func loadGenerationHistoryMessages( jobID: String, beforeSequence: UInt64? = nil, limit: Int = 50 ) async { generationHistoryMessagesSequence &+= 1 let sequence = generationHistoryMessagesSequence generationHistoryMessagesIsLoading = true defer { if sequence == generationHistoryMessagesSequence { generationHistoryMessagesIsLoading = false } } var params: [String: Any] = [ "job_id": jobID, "limit": min(max(limit, 1), 200), ] if let beforeSequence { params["before_sequence"] = beforeSequence } do { let result = try await requestPetCore( method: "generation.messages.list", params: params, timeout: .seconds(15) ) let data = try JSONSerialization.data(withJSONObject: result) let page = try JSONDecoder().decode(GenerationMessagesPage.self, from: data) guard sequence == generationHistoryMessagesSequence, selectedGenerationHistoryJobID == jobID, page.jobID == jobID else { return } if beforeSequence == nil { generationHistoryMessages = page.messages } else { let currentIDs = Set(generationHistoryMessages.map(\.id)) generationHistoryMessages = page.messages.filter { !currentIDs.contains($0.id) } + generationHistoryMessages } generationHistoryMessagesHasMore = page.hasMore } catch { guard sequence == generationHistoryMessagesSequence, selectedGenerationHistoryJobID == jobID else { return } diagnostics.logFailure( error, category: "generation", event: "generation_history_messages_failed", throttleKey: "generation_history_messages_failed", minimumInterval: 5 ) } } func loadOlderGenerationHistoryMessages() async { guard generationHistoryMessagesHasMore, !generationHistoryMessagesIsLoading, let jobID = selectedGenerationHistoryJobID, let before = generationHistoryMessages.first?.sequence else { return } await loadGenerationHistoryMessages(jobID: jobID, beforeSequence: before) } func prepareMakerWorkspace() async { await refreshGenerationHistory() if let selectedGenerationHistoryJobID, generationHistorySelectionIntentJobID == selectedGenerationHistoryJobID { async let detail: Void = loadGenerationHistoryDetail( jobID: selectedGenerationHistoryJobID ) async let messages: Void = loadGenerationHistoryMessages( jobID: selectedGenerationHistoryJobID ) _ = await (detail, messages) return } if let selectedGenerationHistoryJobID, generationHistorySnapshot.jobs.contains(where: { $0.jobID == selectedGenerationHistoryJobID }) { await selectGenerationHistoryJobAndWait(selectedGenerationHistoryJobID) return } if let unfinished = generationHistorySnapshot.jobs.first(where: { makerHistoryJobIsUnfinished($0) }) { await selectGenerationHistoryJobAndWait(unfinished.jobID) return } if makerDraftIsActive { return } if let mostRecent = generationHistorySnapshot.jobs.first { await selectGenerationHistoryJobAndWait(mostRecent.jobID) return } beginMakerDraft() } func refreshMakerAfterLifecycleEvent() async { // didBecomeActive is also delivered during the first launch, before a // clean-home PetCore has necessarily finished staging its runtime and // seeding bundled pets. Join that single bootstrap pipeline instead of // issuing an early snapshot request that briefly marks the service // offline and starts a duplicate recovery path. guard await runRuntimeBootstrapIfNeeded(startMode: .ensureRunning) else { return } _ = await refresh() await refreshGenerationHistory() guard let jobID = selectedGenerationHistoryJobID, generationHistorySnapshot.jobs.contains(where: { $0.jobID == jobID }) else { return } async let detail: Void = loadGenerationHistoryDetail(jobID: jobID) async let messages: Void = loadGenerationHistoryMessages(jobID: jobID) _ = await (detail, messages) } func beginMakerDraft() { let unfinishedExists = generationHistorySnapshot.jobs.contains { makerHistoryJobIsUnfinished($0) } guard !unfinishedExists else { statusText = APCLocalization.text(.studioWorkspaceActiveTaskBlocksNew) return } clearGenerationHistorySelection() showNewPetDraft() makerDraftIsActive = true } func discardMakerDraft() { guard makerDraftIsActive else { return } clearStudioForm() _ = reduceGeneration(.reset) makerDraftIsActive = false clearGenerationHistorySelection() if let mostRecent = generationHistorySnapshot.jobs.first { selectGenerationHistoryJob(mostRecent.jobID) } } private func makerHistoryJobIsUnfinished(_ job: GenerationStudioHistoryRecord) -> Bool { if job.cancellationPending == true { return true } if let capabilities = job.capabilities { return capabilities.canCancel || capabilities.canReply || capabilities.canResume } return job.status == .pending || job.status == .running || job.status == .waitingForUser || (job.status == .failed && job.recoverable == true) } @discardableResult func copySelectedGenerationHistoryBriefToNewDraft() -> Bool { guard canCopySelectedGenerationHistoryBrief, let detail = selectedGenerationHistoryDetail, let form = generationHistoryDraft(from: detail) else { statusText = APCLocalization.text(.studioHistoryCopyBriefUnavailable) return false } recordMakerUserMutation() _ = reduceGeneration(.reset) applyGenerationHistoryDraft( form, referenceReselectionCount: detail.referenceCount ) clearGenerationHistorySelection() makerDraftIsActive = true generationReplyText = "" selection = .maker statusText = detail.referenceCount > 0 ? APCLocalization.format( .studioHistoryCopyBriefReferencesNoticeFormat, detail.referenceCount ) : APCLocalization.text(.studioHistoryCopyBriefSuccess) return true } func retrySelectedGenerationHistory() { guard canRetrySelectedGenerationHistory, let detail = selectedGenerationHistoryDetail, let jobID = detail.jobID, let status = detail.status, let operation = detail.operation, let form = generationHistoryDraft(from: detail) else { statusText = APCLocalization.text(.studioHistoryRetryUnavailable) return } guard detail.referenceCount == 0 else { statusText = APCLocalization.format( .studioHistoryCopyBriefReferencesNoticeFormat, detail.referenceCount ) return } recordMakerUserMutation() let state = restoredGenerationState( status: status, messages: detail.progressMessages ) _ = reduceGeneration(.restore(GenerationSessionRestore( state: state, jobID: jobID, submittedForm: form, messages: detail.progressMessages, progress: detail.progressMessages.last?.progress ?? 1, messageRevision: "", operation: operation, resultPetID: detail.resultPetID, resultRevisionID: detail.revisionID, validationSummary: detail.validationSummary, referenceReselectionCount: detail.referenceCount ))) applyGenerationHistoryDraft( form, referenceReselectionCount: detail.referenceCount ) generationReplyText = "" selection = .maker let initialMessage = operation == .modify ? APCLocalization.text(.studioMessageRetryModify) : APCLocalization.format( .studioMessageRetryCreateFormat, APCLocalizedPresentation.styleTitle(selectedStyle) ) beginGeneration( with: form, initialMessage: initialMessage, retryOfJobID: jobID, operation: operation, resultPetID: detail.resultPetID ) statusText = APCLocalization.text( operation == .modify ? .studioHistoryRetryStartingModify : .studioHistoryRetryStartingCreate ) } @discardableResult func deleteGenerationHistory(jobID: String) async -> Bool { guard !jobID.isEmpty, generationHistoryDeleteInFlightJobID == nil else { return false } generationHistoryDeleteInFlightJobID = jobID generationHistoryMutationError = nil defer { generationHistoryDeleteInFlightJobID = nil } let jobsBeforeDeletion = generationHistorySnapshot.jobs let deletedIndex = jobsBeforeDeletion.firstIndex { $0.jobID == jobID } let deletedSelection = selectedGenerationHistoryJobID == jobID let expectedResultPetID = jobsBeforeDeletion.first { $0.jobID == jobID }?.resultPetID do { let result = try await requestPetCore( method: "generation.history.delete", params: ["job_id": jobID], timeout: .seconds(15) ) let data = try JSONSerialization.data(withJSONObject: result) let receipt = try JSONDecoder().decode( GenerationStudioHistoryDeleteReceipt.self, from: data ) let deletedStatusIsTerminal = switch receipt.deletedStatus { case .completed, .failed, .canceled: true case .pending, .running, .waitingForUser: false } guard receipt.ok, receipt.jobID == jobID, deletedStatusIsTerminal, receipt.deletedMessageCount >= 0, receipt.retryChildrenRelinked >= 0, !receipt.stateRevision.isEmpty, expectedResultPetID.map({ $0 == receipt.retainedResultPetID }) ?? true else { throw NSError( domain: "AgentPetCompanion.GenerationHistoryDelete", code: 1, userInfo: [ NSLocalizedDescriptionKey: APCLocalization.text( .studioHistoryDeleteInvalidResponse ), ] ) } generationHistoryListSequence &+= 1 generationHistoryDetailSequence &+= 1 var localSnapshot = generationHistorySnapshot localSnapshot.jobs.removeAll { $0.jobID == jobID } generationHistorySnapshot = localSnapshot generationHistoryHasLoaded = true if deletedSelection { clearGenerationHistorySelection() } await refreshGenerationHistory() if deletedSelection, selectedGenerationHistoryJobID == nil { let remainingJobs = generationHistorySnapshot.jobs let preferredIndex = min( deletedIndex ?? 0, max(remainingJobs.count - 1, 0) ) if remainingJobs.indices.contains(preferredIndex) { let nextJobID = remainingJobs[preferredIndex].jobID await selectGenerationHistoryJobAndWait(nextJobID) } } statusText = APCLocalization.text( generationHistoryLoadFailed ? .studioHistoryDeleteRefreshWarning : .studioHistoryDeleteSuccess ) return true } catch { let message = error.localizedDescription generationHistoryMutationError = message statusText = APCLocalization.format( .studioHistoryDeleteFailedFormat, message ) diagnostics.logFailure( error, category: "generation", event: "generation_history_delete_failed", throttleKey: "generation_history_delete_failed", minimumInterval: 5 ) return false } } @discardableResult func showSelectedGenerationHistoryResultPetInLibrary() async -> Bool { guard let pet = selectedGenerationHistoryResultPet else { statusText = APCLocalization.text(.studioHistoryResultPetMissing) return false } guard await activatePetAndWait(pet) else { return false } selection = .library return true } private func generationHistoryDraft( from detail: GenerationStudioHistoryDetail ) -> GenerationForm? { guard let description = detail.description, let style = detail.style, let quality = detail.quality, quality.isStudioSupported, GenerationPromptPolicy.isValid(description) else { return nil } return GenerationForm( description: GenerationPromptPolicy.truncate(description), style: style, quality: quality, referenceImages: [] ) } private func applyGenerationHistoryDraft( _ form: GenerationForm, referenceReselectionCount: Int ) { descriptionText = form.description selectedStyle = StylePreset(rawValue: form.style) ?? .unspecified selectedQuality = form.quality referenceImages = [] self.referenceReselectionCount = min( MakerReferenceImagePolicy.maximumCount, max(0, referenceReselectionCount) ) reselectedReferenceImagePaths.removeAll() referenceImageIssue = self.referenceReselectionCount > 0 ? .reselectionRequired(self.referenceReselectionCount) : nil } func openGenerationHistorySession() { guard generationHistoryDetail?.capabilities?.canOpenSession == true, let session = generationHistoryDetail?.session, session.availability == .available, session.canOpen, let url = AgentSessionDeepLink.url( source: .codex, sessionID: session.routableSessionID ) else { statusText = APCLocalization.text(.studioHistorySessionUnavailable) return } Task { [weak self] in guard let self else { return } let outcome = await agentSessionRouteOpener(.url(url)) statusText = outcome.didOpen ? APCLocalization.text(.studioHistorySessionOpened) : APCLocalization.text(.studioHistorySessionOpenFailed) } } func resumeSelectedGenerationHistory() { guard canResumeSelectedGenerationHistory, let jobID = generationHistoryDetail?.jobID else { statusText = generationSession.isActive ? generationStateTitle : APCLocalization.text(.studioResumeUnavailable) return } recordMakerUserMutation() let instruction = generationReplyText.trimmingCharacters(in: .whitespacesAndNewlines) statusText = APCLocalization.text(.studioResumeStarting) Task { do { var parameters: [String: Any] = [ "job_id": jobID, "request_id": UUID().uuidString, ] if !instruction.isEmpty { parameters["instruction"] = instruction } let result = try await requestPetCore( method: "generation.resume", params: parameters ) guard let object = result as? [String: Any], object["job_id"] as? String == jobID else { throw PetCoreClientError.invalidResponse } // The resumed job is now the authoritative latest job. Read // its complete recovery projection so selecting an older // history row replaces the currently displayed terminal job // without inventing a form or dropping its message history. let latestResult = try await requestPetCore(method: "generation.latest") let data = try JSONSerialization.data(withJSONObject: latestResult) let snapshot = try JSONDecoder().decode( LatestGenerationSessionSnapshot.self, from: data ) guard snapshot.jobID == jobID, let restore = GenerationSessionRestore(snapshot: snapshot) else { throw PetCoreClientError.invalidResponse } let sanitizedRestore = sanitizedGenerationRestore(restore) _ = reduceGeneration(.restore(sanitizedRestore)) applyRestoredGenerationForm( sanitizedRestore.submittedForm, referenceReselectionCount: sanitizedRestore.referenceReselectionCount ) generationReplyText = "" selection = .maker statusText = APCLocalization.text(.studioResumeAccepted) await refreshGenerationHistory() await loadGenerationHistoryDetail(jobID: jobID) await loadGenerationHistoryMessages(jobID: jobID) } catch { statusText = APCLocalization.format( .studioResumeFailedFormat, error.localizedDescription ) } } } func sendSelectedGenerationHistoryReply() { let content = generationReplyText.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty, let detail = selectedGenerationHistoryDetail, detail.capabilities?.canReply == true, let jobID = detail.jobID else { return } generationReplyText = "" Task { do { _ = try await requestPetCore( method: "generation.reply", params: [ "job_id": jobID, "content": content, "request_id": UUID().uuidString, ] ) await refreshGenerationHistory() await loadGenerationHistoryDetail(jobID: jobID) await loadGenerationHistoryMessages(jobID: jobID) await refreshGenerationSessionAfterExternalAction(jobID: jobID) } catch { generationReplyText = content statusText = "发送失败:\(error.localizedDescription)" } } } func cancelSelectedGenerationHistory() { guard let detail = selectedGenerationHistoryDetail, detail.capabilities?.canCancel == true, let jobID = detail.jobID else { return } statusText = "正在取消生成" Task { do { _ = try await requestPetCore( method: "generation.cancel", params: ["job_id": jobID], timeout: .seconds(15) ) await refreshGenerationHistory() await loadGenerationHistoryDetail(jobID: jobID) await loadGenerationHistoryMessages(jobID: jobID) await refreshGenerationSessionAfterExternalAction(jobID: jobID) } catch { statusText = "取消失败:\(error.localizedDescription)" } } } private func refreshGenerationSessionAfterExternalAction(jobID: String) async { do { let result = try await requestPetCore(method: "generation.latest") let data = try JSONSerialization.data(withJSONObject: result) let snapshot = try JSONDecoder().decode( LatestGenerationSessionSnapshot.self, from: data ) guard snapshot.jobID == jobID, let restore = GenerationSessionRestore(snapshot: snapshot) else { return } _ = reduceGeneration(.restore(sanitizedGenerationRestore(restore))) } catch { diagnostics.logFailure( error, category: "generation", event: "generation_selected_action_refresh_failed", throttleKey: "generation_selected_action_refresh_failed", minimumInterval: 5 ) } } func fetchPetHistory( for pet: PetSummary, limit: Int = 16 ) async throws -> PetHistorySnapshot { let boundedLimit = min(max(limit, 1), 32) let result = try await requestPetCore( method: "pet.history", params: ["pet_id": pet.id, "limit": boundedLimit] ) let data = try JSONSerialization.data(withJSONObject: result) return try JSONDecoder().decode(PetHistorySnapshot.self, from: data) } func openGenerationHistory(for pet: PetSummary) { guard !generationSession.isActive else { statusText = "请先完成或取消当前 AI 制作任务,再查看其他制作记录" return } recordMakerUserMutation() statusText = "正在打开 \(pet.name) 的生成会话" Task { do { let history = try await fetchGenerationHistory(for: pet) guard history.found, let jobID = history.jobId else { statusText = "\(pet.name) 没有关联的 App 内制作记录" return } guard !generationSession.isActive else { statusText = "当前 AI 制作任务已开始,未打开其他制作记录" return } let restoredState = restoredGenerationState( status: history.status, messages: history.messages ) let restore = GenerationSessionRestore( state: restoredState, jobID: jobID, submittedForm: history.form, messages: history.messages, progress: history.messages.last?.progress ?? (restoredState.isTerminal ? 1 : 0), messageRevision: "", operation: history.operation ?? .create, resultPetID: history.resultPetId, baselineRevisionID: history.baselineRevisionID, resultRevisionID: history.revisionId, validationSummary: history.validationSummary, referenceReselectionCount: history.referenceReselectionCount ) let sanitizedRestore = sanitizedGenerationRestore(restore) _ = reduceGeneration(.restore(sanitizedRestore)) applyRestoredGenerationForm( sanitizedRestore.submittedForm, referenceReselectionCount: sanitizedRestore.referenceReselectionCount ) generationReplyText = "" selection = .maker statusText = "已打开 \(pet.name) 的生成会话" } catch { statusText = "打开生成会话失败:\(error.localizedDescription)" } } } @discardableResult func reduceGeneration( _ action: GenerationSessionAction ) -> GenerationSessionEffects { var next = generationSession let effects = next.reduce(action) if generationSession != next { generationSession = next } if effects.contains(.stopMessageStream) { generationMessagesTask?.cancel() } if effects.contains(.startMessageStream), let jobID = generationSession.jobID { startGenerationMessageStream(jobID: jobID) } return effects } private func restoredGenerationState( status: GenerationJobHistoryStatus?, messages: [GenerationMessage] ) -> GenerationSessionState { if let status { return switch status { case .pending: .starting case .running: .running case .waitingForUser: .waitingForInput case .completed: .succeeded case .failed: .failed case .canceled: .cancelled } } if GenerationConversation.needsUserInput(messages) { return .waitingForInput } if GenerationConversation.succeeded(messages) { return .succeeded } if GenerationConversation.cancelled(messages) { return .cancelled } if GenerationConversation.failed(messages) { return .failed } return .idle } func updateBehavior(_ next: BehaviorSettings) { let patch = BehaviorSettingsPatch(from: behavior, to: next) guard !patch.isEmpty else { return } applyBehaviorProjection(next) behaviorMutationSequence &+= 1 enqueueBehaviorPatch( patch, mutationSequence: behaviorMutationSequence ) } func waitForBehaviorPersistence() async { _ = await behaviorMutationTask?.value } private func enqueueBehaviorPatch( _ patch: BehaviorSettingsPatch, mutationSequence: UInt64 ) { let predecessor = behaviorMutationTask pendingBehaviorMutationCount += 1 behaviorMutationTask = Task { [weak self] in _ = await predecessor?.value guard let self else { return } await persistBehaviorPatch( patch, mutationSequence: mutationSequence ) pendingBehaviorMutationCount = max( 0, pendingBehaviorMutationCount - 1 ) } } private func persistBehaviorPatch( _ patch: BehaviorSettingsPatch, mutationSequence: UInt64 ) async { for attempt in 0..<2 { do { let data = try JSONEncoder().encode(patch) let changes = try JSONSerialization.jsonObject(with: data) let result = try await requestPetCore( method: "behavior.patch", params: [ "expected_revision": behaviorRevision, "changes": changes ] ) let resultData = try JSONSerialization.data(withJSONObject: result) let updated = try JSONDecoder().decode( VersionedBehaviorSettings.self, from: resultData ) behaviorRevision = updated.revision authoritativeBehavior = updated.behavior if mutationSequence == behaviorMutationSequence { applyBehaviorProjection(updated.behavior) } statusText = "设置已保存" return } catch let error as PetCoreClientError where attempt == 0 && error.rpcMessage?.contains("behavior revision conflict") == true { do { try await refreshSnapshot() } catch { statusText = "设置冲突且刷新失败:\(error.localizedDescription)" if mutationSequence == behaviorMutationSequence { applyBehaviorProjection(authoritativeBehavior) } return } } catch { statusText = "设置保存失败:\(error.localizedDescription)" do { try await refreshSnapshot() } catch {} if mutationSequence == behaviorMutationSequence { applyBehaviorProjection(authoritativeBehavior) } return } } } private func applyBehaviorProjection(_ next: BehaviorSettings) { let appearanceChanged = behavior.appearanceTheme != next.appearanceTheme let languageChanged = behavior.interfaceLanguage != next.interfaceLanguage let groupSessionsByAgentChanged = behavior.groupSessionsByAgent != next.groupSessionsByAgent let sessionGroupDisplayChanged = behavior.sessionGroupDisplay != next.sessionGroupDisplay // A different text tier changes measured bubble heights, so the panel // has to be resized rather than left holding stale geometry. let bubbleFontScaleChanged = behavior.bubbleFontScale != next.bubbleFontScale behavior = next if appearanceChanged { applyCurrentAppearance() } if languageChanged { applyCurrentLanguage() } if groupSessionsByAgentChanged || sessionGroupDisplayChanged { overlayAgentGroupExpansionOverrides.removeAll() overlayStandaloneStackExpansionOverride = nil overlayStandaloneStackDisclosureDirection = .expanding } if groupSessionsByAgentChanged || sessionGroupDisplayChanged || bubbleFontScaleChanged { overlayController.updateLayout() } syncOverlayVisibilityForBehavior() } private func syncOverlayVisibilityForBehavior() { let nextVisibility = OverlayVisibility( petVisible: behavior.enabled, statusBubbleVisible: behavior.enabled && behavior.statusBubble && (!activeAgentSessions.isEmpty || (!behavior.autoHide && activeAgentState == nil)) ) let visibilityChanged = overlayVisibility != nextVisibility if visibilityChanged { overlayVisibility = nextVisibility } let petVisibilityChanged = overlayVisible != nextVisibility.petVisible if petVisibilityChanged { overlayVisible = nextVisibility.petVisible overlayController.setVisible(nextVisibility.petVisible) } else if visibilityChanged { overlayController.updateLayout() } } private func applyCurrentAppearance() { applicationAppearanceApplier(behavior.appearanceTheme) overlayController.updateAppearance(behavior.appearanceTheme) } private func applyCurrentLanguage() { applicationLanguageApplier(behavior.interfaceLanguage) } private func applyCurrentPresentation() { applyCurrentLanguage() applyCurrentAppearance() } func setSource(_ source: AgentSource, enabled: Bool) { var next = behavior next.sources[source] = enabled updateBehavior(next) } func setEvent(_ event: AgentEventKind, enabled: Bool) { var next = behavior next.events[event] = enabled updateBehavior(next) } func setAttentionPreset(_ preset: AttentionPreset) { guard preset != .custom else { return } updateBehavior(behavior.applyingAttentionPreset(preset)) } func activatePet(_ pet: PetSummary) { Task { _ = await activatePetAndWait(pet) } } /// Shared activation path for the Library, Maker result, and onboarding. /// Callers that must sequence another durable mutation can await the real /// PetCore activation result instead of inferring success from local state. @discardableResult func activatePetAndWait(_ pet: PetSummary) async -> Bool { if pets.first(where: { $0.id == pet.id })?.active == true || pet.active { return true } guard !petOperationIDs.contains(pet.id) else { return false } petOperationIDs.insert(pet.id) statusText = "正在启用 \(pet.name)" defer { petOperationIDs.remove(pet.id) } return await finishPetActivation( pet, activate: { _ = try await self.requestPetCore( method: "pet.activate", params: ["id": pet.id] ) }, refreshSnapshot: { try await self.refreshSnapshot() }, recoverSnapshot: { await self.refresh() } ) } @discardableResult func finishPetActivation( _ pet: PetSummary, activate: @MainActor () async throws -> Void, refreshSnapshot: @MainActor () async throws -> Void, recoverSnapshot: @MainActor () async -> Void ) async -> Bool { do { try await activate() } catch { statusText = "启用失败:\(error.localizedDescription)" await recoverSnapshot() return false } // A successful pet.activate response means PetCore has committed the // exclusive active-pet transaction. Reflect that committed result // before requesting the broader snapshot so a transient refresh // failure cannot put the Enable button back and invite another click. applyCommittedPetActivation(pet.id) do { try await refreshSnapshot() statusText = "已启用 \(pet.name)" } catch { statusText = "已启用 \(pet.name),但状态刷新失败:\(error.localizedDescription)" await recoverSnapshot() } return true } private func applyCommittedPetActivation(_ petID: String) { guard pets.contains(where: { $0.id == petID }) else { return } let nextPets = pets.map { pet in var nextPet = pet nextPet.active = pet.id == petID return nextPet } guard nextPets != pets else { return } pets = nextPets } func dismissOnboardingForCurrentLaunch() { onboardingDismissedForCurrentLaunch = true onboardingOperationFailure = nil } func retryOnboardingService() { onboardingOperationFailure = nil Task { [weak self] in _ = await self?.recoverServiceConnection() } } func restoreIncludedCompanions() { Task { _ = await restoreIncludedCompanionsAndWait() } } /// Re-runs the signed inventory ensure operation without replacing an /// existing same-ID pet, then requires an authoritative snapshot proving /// both stable companion identities are selectable. @discardableResult func restoreIncludedCompanionsAndWait() async -> Bool { guard includedCompanionRestoreState != .restoring, onboardingAvailability == .ready else { if onboardingAvailability != .ready { includedCompanionRestoreState = .failed } return false } includedCompanionRestoreState = .restoring let seeded = await performBundledPetSeed() guard seeded, await refresh() else { includedCompanionRestoreState = .failed return false } let restoredIDs = Set(onboardingCompanionCandidates.map(\.id)) guard restoredIDs == Set(PetSummary.includedCompanionIDs) else { diagnostics.log( .error, category: "library", event: "included_companion_restore_incomplete", metadata: [ "restored_count": .integer(Int64(restoredIDs.count)) ] ) includedCompanionRestoreState = .failed return false } includedCompanionRestoreState = .restored statusText = "已恢复 App 随附桌宠" return true } @discardableResult func confirmOnboardingPet(_ candidate: PetSummary) async -> Bool { guard !onboardingMutationInFlight, onboardingAvailability == .ready, let current = onboarding, current.progress.stage == .choosePet, let pet = onboardingCompanionCandidates.first(where: { $0.id == candidate.id }), petAssetWarningIndex[pet.id] == nil else { if onboardingAvailability != .ready { onboardingOperationFailure = .serviceUnavailable } return false } onboardingMutationInFlight = true onboardingOperationFailure = nil defer { onboardingMutationInFlight = false } guard await activatePetAndWait(pet) else { onboardingOperationFailure = .petActivation return false } return await persistOnboardingTransition( from: current, to: .connectAgents ) } func petAssetRepairState(for petID: String) -> PetAssetRepairState { petAssetRepairStates[petID] ?? .idle } func repairPetAssets(_ pet: PetSummary) { Task { _ = await repairPetAssetsAndWait(pet) } } /// Forces package validation and atomic cover/frame re-extraction even /// when PetCore has cached an unchanged invalid fingerprint. @discardableResult func repairPetAssetsAndWait(_ candidate: PetSummary) async -> Bool { guard onboardingAvailability == .ready, petAssetRepairState(for: candidate.id) != .repairing, !petOperationIDs.contains(candidate.id), let pet = pets.first(where: { $0.id == candidate.id }) else { petAssetRepairStates[candidate.id] = .failed return false } petAssetRepairStates[pet.id] = .repairing petOperationIDs.insert(pet.id) statusText = "正在重新校验并提取 \(pet.name) 的资源" defer { petOperationIDs.remove(pet.id) } do { let value = try await requestPetCore( method: "pet.assets.repair", params: ["id": pet.id], timeout: .seconds(120) ) guard JSONSerialization.isValidJSONObject(value) else { throw PetCoreClientError.invalidResponse } let outcome = try JSONDecoder().decode( PetAssetRepairOutcome.self, from: JSONSerialization.data(withJSONObject: value) ) guard outcome.pet.id == pet.id, outcome.warning?.petId == nil || outcome.warning?.petId == pet.id else { throw PetCoreClientError.invalidResponse } try await refreshSnapshot() let repaired = outcome.warning == nil && petAssetWarningIndex[pet.id] == nil petAssetRepairStates[pet.id] = repaired ? .repaired : .failed statusText = repaired ? "已恢复 \(pet.name) 的预览资源" : "\(pet.name) 的资源仍需处理" return repaired } catch { petAssetRepairStates[pet.id] = .failed statusText = "资源恢复失败:\(error.localizedDescription)" diagnostics.logFailure( error, category: "library", event: "pet_asset_repair_failed", metadata: ["pet_id": .string(pet.id)] ) _ = await refresh() return false } } @discardableResult func advanceOnboarding(to nextStage: OnboardingStage) async -> Bool { guard !onboardingMutationInFlight, onboardingAvailability == .ready, let current = onboarding, current.progress.stage.canAdvance(to: nextStage) else { if onboardingAvailability != .ready { onboardingOperationFailure = .serviceUnavailable } return false } onboardingMutationInFlight = true onboardingOperationFailure = nil defer { onboardingMutationInFlight = false } return await persistOnboardingTransition(from: current, to: nextStage) } private func persistOnboardingTransition( from current: VersionedOnboardingProgress, to nextStage: OnboardingStage ) async -> Bool { let nextProgress = OnboardingProgress(stage: nextStage) do { let progressData = try JSONEncoder().encode(nextProgress) let progressObject = try JSONSerialization.jsonObject(with: progressData) let result = try await requestPetCore( method: "onboarding.update", params: [ "expected_revision": current.revision, "progress": progressObject, ] ) let resultData = try JSONSerialization.data(withJSONObject: result) let updated = try JSONDecoder().decode( VersionedOnboardingProgress.self, from: resultData ) guard updated.progress == nextProgress, let previousRevision = UInt64(current.revision), let updatedRevision = UInt64(updated.revision), updatedRevision > previousRevision else { onboardingOperationFailure = .requestRejected return false } if nextStage == .completed { // Completion atomically enables the pet in PetCore. Publish // the terminal scene only with the authoritative behavior // snapshot so the onboarding cannot disappear while the // desktop pet still looks disabled. do { try await refreshSnapshot() return onboarding?.progress.stage == .completed } catch { onboardingOperationFailure = .serviceUnavailable return false } } onboarding = updated return true } catch let error as PetCoreClientError where error.rpcMessage?.contains("onboarding revision conflict") == true { do { try await refreshSnapshot() if onboarding?.progress.stage == nextStage { return true } } catch { onboardingOperationFailure = .serviceUnavailable return false } onboardingOperationFailure = .revisionConflict return false } catch { onboardingOperationFailure = onboardingAvailability == .ready ? .requestRejected : .serviceUnavailable return false } } func deletePet(_ pet: PetSummary) { petOperationIDs.insert(pet.id) statusText = "正在删除 \(pet.name)" Task { defer { petOperationIDs.remove(pet.id) } do { let result = try await requestPetCore(method: "pet.delete", params: ["id": pet.id]) try await refreshSnapshot() let deletedAssets = (result as? [String: Any])?["deleted_assets"] as? Bool ?? true statusText = deletedAssets ? "已删除 \(pet.name)" : "已删除 \(pet.name),部分本地资源待下次清理" } catch { statusText = "删除失败:\(error.localizedDescription)" await refresh() } } } func importPetpacks() { let panel = NSOpenPanel() panel.title = APCLocalization.text(.libraryImportTitle) panel.prompt = APCLocalization.text(.libraryImportAction) panel.message = APCLocalization.text(.libraryImportMessage) panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = true panel.resolvesAliases = true panel.canCreateDirectories = false panel.treatsFilePackagesAsDirectories = false panel.allowedContentTypes = [PetpackImportPolicy.contentType] guard panel.runModal() == .OK else { return } importPetpacks(urls: panel.urls) } func importPetpacks(urls: [URL]) { guard petpackImportTask == nil else { return } let urls = urls.filter { PetpackImportPolicy.acceptsFileName($0.lastPathComponent) } guard !urls.isEmpty else { let notice = setPetLibraryImportFailure( importedCount: 0, failures: [.invalidSelection] ) statusText = notice.message.replacingOccurrences(of: "\n", with: " ") return } petLibraryNotice = nil isImportingPetpack = true petpackImportProgress = PetLibraryImportProgress( phase: .importing, totalCount: urls.count, completedCount: 0, importedCount: 0, currentFileName: urls.first?.lastPathComponent ) statusText = urls.count == 1 ? "正在导入本 App .petpack" : "正在导入 \(urls.count) 个本 App .petpack" petpackImportTask = Task { defer { petpackImportProgress = nil isImportingPetpack = false petpackImportTask = nil } var importedCount = 0 var failures: [PetLibraryImportFailure] = [] for (fileIndex, url) in urls.enumerated() { petpackImportProgress = PetLibraryImportProgress( phase: .importing, totalCount: urls.count, completedCount: fileIndex, importedCount: importedCount, currentFileName: url.lastPathComponent ) do { _ = try await requestPetCore( method: "petpack.import", params: ["path": url.standardizedFileURL.path] ) importedCount += 1 } catch { diagnostics.logFailure( error, category: "library", event: "petpack_import_failed", metadata: ["file_index": .integer(Int64(fileIndex))] ) failures.append(.requestFailure(at: url, error: error)) } petpackImportProgress = PetLibraryImportProgress( phase: .importing, totalCount: urls.count, completedCount: fileIndex + 1, importedCount: importedCount, currentFileName: fileIndex + 1 < urls.count ? urls[fileIndex + 1].lastPathComponent : nil ) } if importedCount > 0 { petpackImportProgress = PetLibraryImportProgress( phase: .refreshingLibrary, totalCount: urls.count, completedCount: urls.count, importedCount: importedCount, currentFileName: nil ) do { try await refreshSnapshot() selection = .library } catch { await refresh() } } if failures.isEmpty { petLibraryNotice = .importSuccess(importedCount: importedCount) statusText = importedCount == 1 ? "已导入本 App .petpack" : "已导入 \(importedCount) 个本 App .petpack" } else if importedCount > 0 { let notice = setPetLibraryImportFailure( importedCount: importedCount, failures: failures ) statusText = notice.message.replacingOccurrences(of: "\n", with: " ") } else { let notice = setPetLibraryImportFailure(importedCount: 0, failures: failures) statusText = notice.message.replacingOccurrences(of: "\n", with: " ") } } } func waitForPetpackImport() async { _ = await petpackImportTask?.value } func dismissPetLibraryNotice() { petLibraryNotice = nil } @discardableResult func setPetLibraryImportFailure( importedCount: Int, failures: [PetLibraryImportFailure] ) -> PetLibraryNotice { let notice = PetLibraryNotice.importFailure( importedCount: importedCount, failures: failures ) petLibraryNotice = notice return notice } func exportPet(_ pet: PetSummary) { let panel = NSSavePanel() panel.title = APCLocalization.text(.libraryExportAction) panel.prompt = APCLocalization.text(.libraryExportAction) panel.nameFieldStringValue = "\(safeExportName(pet.name)).petpack" panel.canCreateDirectories = true panel.allowedContentTypes = [PetpackImportPolicy.contentType] guard panel.runModal() == .OK, let destinationURL = panel.url else { return } petOperationIDs.insert(pet.id) statusText = "正在校验并导出 \(pet.name)" Task { defer { petOperationIDs.remove(pet.id) } do { _ = try await requestPetCore( method: "petpack.export", params: [ "id": pet.id, "path": destinationURL.standardizedFileURL.path ] ) statusText = "已导出 \(destinationURL.lastPathComponent)" } catch { statusText = "导出失败:\(error.localizedDescription)" } } } func prepareDiagnosticsExport() { guard diagnosticsExportState.primaryAction == .prepare else { return } let environment = AppDiagnosticEnvironment.capture(store: self) diagnosticsExportState = .exporting statusText = "正在打包诊断日志" diagnostics.log(.notice, category: "diagnostics", event: "diagnostics_export_started") Task { @MainActor [weak self] in guard let self else { return } await self.performDiagnosticsArchivePreparation(environment: environment) } } func savePreparedDiagnosticsArchive() { guard diagnosticsExportState.primaryAction == .save, let archive = diagnosticsExportState.preparedArchive else { return } let panel = NSSavePanel() panel.title = APCLocalization.text(.diagnosticsPackageTitle) panel.prompt = APCLocalization.text(.diagnosticsLogDownload) panel.message = APCLocalization.text(.diagnosticsPrivacy) panel.nameFieldStringValue = archive.suggestedFileName panel.canCreateDirectories = true panel.isExtensionHidden = false panel.allowedContentTypes = [.zip] guard panel.runModal() == .OK, let destinationURL = panel.url else { diagnostics.log( .debug, category: "diagnostics", event: "diagnostics_archive_save_cancelled" ) return } guard destinationURL.standardizedFileURL != archive.stagedURL.standardizedFileURL else { let message = "日志保存位置无效,请选择其他位置" diagnosticsExportState = .saveFailed(archive, message) statusText = message return } diagnosticsExportState = .saving(archive) statusText = "正在保存诊断日志" diagnostics.log( .notice, category: "diagnostics", event: "diagnostics_archive_save_started" ) Task { @MainActor [weak self] in guard let self else { return } await self.performDiagnosticsArchiveSave(archive, to: destinationURL) } } static func defaultDiagnosticsArchiveName(date: Date = Date()) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) formatter.dateFormat = "yyyyMMdd-HHmmss" return "AgentPetCompanion-Diagnostics-\(formatter.string(from: date)).zip" } static func diagnosticsExportParameters( environment: AppDiagnosticEnvironment ) throws -> [String: Any] { ["app_environment": try environment.jsonObject()] } static func validatedDiagnosticArchiveURL( from result: Any, homeURL: URL ) throws -> URL { let decoded = try AppDiagnosticRPCExportResult.decode(result) guard (1 ... 128).contains(decoded.fileCount), decoded.archiveBytes > 0, !decoded.fileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw AppDiagnosticArchiveError.invalidArchive } return try AppDiagnosticArchiveSecurity.validateTemporaryArchive( URL(fileURLWithPath: decoded.path), homeURL: homeURL, expectedFileName: decoded.fileName, expectedBytes: decoded.archiveBytes ) } private func performDiagnosticsArchivePreparation( environment: AppDiagnosticEnvironment ) async { let homeURL = diagnostics.homeURL ?? AppDiagnosticPaths.defaultHomeURL() let stagedArchive: URL do { let result = try await requestPetCore( method: "diagnostics.export", params: Self.diagnosticsExportParameters(environment: environment) ) stagedArchive = try Self.validatedDiagnosticArchiveURL( from: result, homeURL: homeURL ) diagnostics.log( .info, category: "diagnostics", event: "diagnostics_archive_received" ) } catch { diagnostics.logFailure( error, category: "diagnostics", event: "diagnostics_rpc_export_unavailable", throttleKey: "diagnostics_rpc_export_unavailable", minimumInterval: 5 ) do { stagedArchive = try await AppDiagnosticOfflineExporter.makeArchive( environment: environment, homeURL: homeURL ) diagnostics.log( .notice, category: "diagnostics", event: "diagnostics_offline_archive_created" ) } catch { diagnostics.logFailure( error, category: "diagnostics", event: "diagnostics_offline_export_failed" ) let message = "日志导出失败,请稍后重试" diagnosticsExportState = .failed(message) statusText = message return } } let archive = PreparedDiagnosticsArchive( stagedURL: stagedArchive, suggestedFileName: Self.defaultDiagnosticsArchiveName() ) diagnosticsExportState = .ready(archive) statusText = "诊断日志包已就绪" diagnostics.log( .notice, category: "diagnostics", event: "diagnostics_archive_ready" ) } private func performDiagnosticsArchiveSave( _ archive: PreparedDiagnosticsArchive, to destinationURL: URL ) async { do { try await AppDiagnosticArchiveSecurity.install(archive.stagedURL, at: destinationURL) try? FileManager.default.removeItem(at: archive.stagedURL) let message = "已导出 \(destinationURL.lastPathComponent)" diagnosticsExportState = .succeeded(message) statusText = message diagnostics.log( .notice, category: "diagnostics", event: "diagnostics_export_succeeded" ) } catch { diagnostics.logFailure( error, category: "diagnostics", event: "diagnostics_archive_install_failed" ) let message = "日志导出失败,请检查目标位置后重试" diagnosticsExportState = .saveFailed(archive, message) statusText = message } } func repairConnection(_ source: AgentSource) { launchConnectionOperation(.init(kind: .repair, sources: [source])) } func refreshPortableMakerSkillStatus() async { guard !portableMakerSkillOperation.isBusy else { return } portableMakerSkillOperation = .checking portableMakerSkillFailure = nil defer { portableMakerSkillOperation = .idle } do { portableMakerSkillStatus = try await requestPortableMakerSkill( method: "portable_skill.status" ) } catch { portableMakerSkillFailure = .load } } func installPortableMakerSkill() async { guard !portableMakerSkillOperation.isBusy else { return } portableMakerSkillOperation = .installing portableMakerSkillFailure = nil defer { portableMakerSkillOperation = .idle } do { portableMakerSkillStatus = try await requestPortableMakerSkill( method: "portable_skill.install" ) } catch { portableMakerSkillFailure = .install } } func uninstallPortableMakerSkill() async { guard !portableMakerSkillOperation.isBusy else { return } portableMakerSkillOperation = .uninstalling portableMakerSkillFailure = nil defer { portableMakerSkillOperation = .idle } do { portableMakerSkillStatus = try await requestPortableMakerSkill( method: "portable_skill.uninstall" ) } catch { portableMakerSkillFailure = .uninstall } } private func requestPortableMakerSkill( method: String ) async throws -> PortableMakerSkillStatus { let result = try await requestPetCore( method: method, timeout: .seconds(180) ) let data = try JSONSerialization.data(withJSONObject: result) let status = try JSONDecoder().decode(PortableMakerSkillStatus.self, from: data) guard status.schemaVersion == "apc.portable-skill-status.v1", status.name == "agent-pet-maker", status.targetDisplayPath == "~/agent/skills/agent-pet-maker" else { throw PetCoreClientError.invalidResponse } return status } func repairConnections(_ sources: [AgentSource]) { launchConnectionOperation(.init(kind: .repair, sources: sources)) } func uninstallConnection(_ source: AgentSource) { launchConnectionOperation(.init(kind: .uninstall, sources: [source])) } func uninstallConnections(_ sources: [AgentSource]) { launchConnectionOperation(.init(kind: .uninstall, sources: sources)) } func checkConnection(_ source: AgentSource) { launchConnectionOperation(.init(kind: .check, sources: [source])) } func checkConnections(_ sources: [AgentSource]) { launchConnectionOperation(.init(kind: .check, sources: sources)) } func checkAllConnections() { launchConnectionOperation(.init(kind: .check, sources: AgentSource.allCases)) } /// Requests one full four-Agent runtime check for this App session. /// /// The Connections page may appear before the authoritative startup /// snapshot or release connector convergence has finished. Keep the UI /// responsive, publish the startup light projection immediately, and /// begin the heavier host probes only after all four sources are loaded /// and the serialized connection-operation gate is available. func requestAutomaticConnectionCheckOnFirstPresentation() { guard !automaticConnectionCheckRequested else { return } automaticConnectionCheckRequested = true automaticConnectionCheckTask = Task { @MainActor [weak self] in while !Task.isCancelled { guard self != nil else { return } if self?.hasLoadedAllConnectionSources == true, self?.canStartConnectionOperation == true { let alreadyCurrent = self?.hasCurrentRuntimeConnectionStatusForEverySource == true self?.automaticConnectionCheckTask = nil if !alreadyCurrent { self?.checkAllConnections() } return } try? await Task.sleep(for: .milliseconds(200)) } self?.automaticConnectionCheckTask = nil } } private var hasLoadedAllConnectionSources: Bool { AgentSource.allCases.allSatisfy { source in connections.contains(where: { $0.source == source }) } } private var hasCurrentRuntimeConnectionStatusForEverySource: Bool { AgentSource.allCases.allSatisfy { source in connections.first(where: { $0.source == source })?.checkMode == .runtime } } static func connectionOperationParameters( source: AgentSource? = nil ) -> [String: String] { var params: [String: String] = [:] if let source { params["source"] = source.rawValue } return params } func sendConnectionTestEvent(_ source: AgentSource) { launchConnectionOperation(.init(kind: .test, sources: [source])) } func retryConnectionOperation() { guard let failure = connectionOperationState.failedOperation else { return } launchConnectionOperation(failure.operation) } func dismissConnectionOperationNotice() { guard !connectionOperationState.isRunning else { return } connectionOperationState = .idle } private func launchConnectionOperation(_ operation: AgentConnectionOperation) { guard productConvergenceTask == nil else { return } guard let permit = connectionOperationGate.begin(operation) else { return } connectionOperationState = .running(operation) statusText = connectionOperationStartedStatus(operation) Task { @MainActor [weak self] in guard let self else { return } do { let completion = try await self.performConnectionOperation(operation) self.connectionOperationGate.finish(permit) self.connectionOperationState = .succeeded(operation) self.statusText = completion } catch { self.connectionOperationGate.finish(permit) let reason = Self.connectionOperationFailureReason(for: error) self.diagnostics.log( .error, category: "connections", event: "connection_operation_failed", metadata: [ "operation": .string(operation.kind.rawValue), "reason": .string(reason.rawValue), "source_count": .integer(Int64(operation.sources.count)) ] ) self.connectionOperationState = .failed(.init( operation: operation, reason: reason )) self.statusText = self.connectionOperationFailurePrefix(operation) if operation.kind == .repair || operation.kind == .uninstall { _ = await self.refresh() } } } } private func performConnectionOperation( _ operation: AgentConnectionOperation ) async throws -> String { switch operation.kind { case .check: return try await performConnectionCheck(operation.sources) case .test: guard let source = operation.sources.first else { throw AgentConnectionOperationExecutionError(.invalidRequest) } let result = try await requestPetCore( method: "connections.test", params: ["source": source.rawValue], timeout: .seconds(3) ) guard (result as? [String: Any])?["ok"] as? Bool == true else { throw AgentConnectionOperationExecutionError(.rejected) } // The diagnostic event is intentionally excluded from ordinary // snapshots and connection evidence. Waiting for a full refresh // here adds no state and can turn a lightweight test into a // service-recovery operation that makes the UI appear stuck. return "\(source.title) 本地连接测试通过" case .repair: return try await performConnectionRepair(operation.sources) case .uninstall: return try await performConnectionUninstall(operation.sources) } } private func performConnectionCheck(_ sources: [AgentSource]) async throws -> String { if sources == AgentSource.allCases { let result = try await requestPetCore( method: "connections.check", params: Self.connectionOperationParameters() ) let data = try JSONSerialization.data(withJSONObject: result) connections = try JSONDecoder().decode([AgentConnectionStatus].self, from: data) sortConnections() reconcileProductConvergenceConnectorAttention( afterChecking: Set(sources) ) return "连接检查完成" } for source in sources { let result = try await requestPetCore( method: "connections.check", params: Self.connectionOperationParameters(source: source) ) try updateConnectionStatus(from: result) } reconcileProductConvergenceConnectorAttention( afterChecking: Set(sources) ) return sources.count == 1 ? "\(sources[0].title) 检查完成" : "\(sources.count) 个 Agent 连接检查完成" } private func performConnectionRepair(_ sources: [AgentSource]) async throws -> String { var repaired: [String] = [] var pending: [String] = [] var failed: [String] = [] for source in sources { do { let result = try await requestPetCore( method: "connections.repair", params: Self.connectionOperationParameters(source: source) ) let status = try updateConnectionStatus(from: result) if unresolvedConnectionItemCount(status) == 0 { repaired.append(source.shortTitle) } else { pending.append(source.shortTitle) } } catch { failed.append(source.shortTitle) } } sortConnections() if !failed.isEmpty { throw AgentConnectionOperationExecutionError(.partialFailure) } return pending.isEmpty ? "连接修复完成:\(repaired.joined(separator: "、"))" : "修复已执行,仍需处理:\(pending.joined(separator: "、"))" } private func performConnectionUninstall(_ sources: [AgentSource]) async throws -> String { var uninstalled: [String] = [] var pending: [String] = [] var failed: [String] = [] for source in sources { do { let result = try await requestPetCore( method: "connections.uninstall", params: ["source": source.rawValue] ) let status = try updateConnectionStatus(from: result) if status.hasInstalledConnectorArtifacts { pending.append(source.shortTitle) } else { uninstalled.append(source.shortTitle) } } catch { failed.append(source.shortTitle) } } sortConnections() if !failed.isEmpty { throw AgentConnectionOperationExecutionError(.partialFailure) } return pending.isEmpty ? "连接卸载完成:\(uninstalled.joined(separator: "、"))" : "卸载已执行,仍需处理:\(pending.joined(separator: "、"))" } private func connectionOperationStartedStatus( _ operation: AgentConnectionOperation ) -> String { let names = operation.sources.map(\.shortTitle).joined(separator: "、") return switch operation.kind { case .check: "正在检查 \(names)" case .test: "正在测试 \(names) 的 PetCore 通道" case .repair: "正在修复 \(names)" case .uninstall: "正在卸载 \(names)" } } private func connectionOperationFailurePrefix( _ operation: AgentConnectionOperation ) -> String { switch operation.kind { case .check: "连接检查失败" case .test: "通道自检失败" case .repair: "连接修复失败" case .uninstall: "连接卸载失败" } } static func connectionOperationFailureReason( for error: Error ) -> AgentConnectionOperationFailureReason { if let error = error as? AgentConnectionOperationExecutionError { return error.reason } if let error = error as? PetCoreClientError { return switch error { case .socketPathTooLong, .connectFailed, .writeFailed: .transportUnavailable case .invalidResponse: .invalidResponse case .rpcError, .rpcErrorResponse: .rejected } } if error is PetCoreTransportError { return .transportUnavailable } return .unknown } func toggleOverlay() { var next = behavior next.enabled.toggle() diagnostics.log( .info, category: "overlay", event: "overlay_enabled_changed", metadata: ["enabled": .bool(next.enabled)] ) updateBehavior(next) } func updateOverlayPlacement(frame: CGRect, visibleFrame: CGRect?) { recordOverlayPanelFrame(frame, visibleFrame: visibleFrame) ensureOverlayPetPosition(in: overlayScreenVisibleFrame) } func recordOverlayPanelFrame(_ frame: CGRect, visibleFrame: CGRect?) { let nextVisibleFrame = visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero if !rect(overlayScreenFrame, nearlyEquals: frame) { overlayScreenFrame = frame } if !rect(overlayScreenVisibleFrame, nearlyEquals: nextVisibleFrame) { overlayScreenVisibleFrame = nextVisibleFrame } } func moveOverlayPet(to proposedCenter: CGPoint, visibleFrame: CGRect?, commit: Bool = true) { overlayPetDragPresentationCenter = nil let targetScreen = screen(containing: proposedCenter) ?? screen(matchingVisibleFrame: visibleFrame) ?? screen(containing: overlayPetScreenCenter) ?? NSScreen.main let targetVisibleFrame = targetScreen?.visibleFrame ?? visibleFrame ?? overlayScreenVisibleFrame guard !targetVisibleFrame.isEmpty else { overlayPetScreenCenter = proposedCenter overlayPetPositionInitialized = true return } let movementFrame = OverlayGeometry.petMovementFrame( screenFrame: targetScreen?.frame ?? targetVisibleFrame, visibleFrame: targetVisibleFrame ) if !rect( overlayScreenVisibleFrame, nearlyEquals: targetVisibleFrame ) { overlayScreenVisibleFrame = targetVisibleFrame } overlayPetScreenCenter = OverlayGeometry.clampedPetScreenCenter( proposedCenter, displayWidthPt: overlayDisplayWidthPt, visibleFrame: movementFrame, clickMenuEnabled: behavior.clickMenu, petVisualEnvelope: overlayPetVisualEnvelope ) overlayPetPositionInitialized = true if commit { diagnostics.log( .info, category: "overlay", event: "overlay_position_committed", metadata: ["committed": .bool(true)] ) overlayController.updateLayout() commitCurrentOverlayPlacement( interactionID: UUID() ) } else { overlayController.updateLayoutDuringInteraction() } } func beginOverlayPetDrag(interactionID: UUID) { guard overlayDragInteractionID == nil else { return } overlayLostMouseUpFallbackTask?.cancel() overlayLostMouseUpFallbackTask = nil overlayDragInteractionID = interactionID if overlayPetDragInProgress != true { overlayPetDragInProgress = true overlayController.updateLayoutDuringInteraction() overlayController.refreshPointerPassthrough() } } /// Applies the direct-manipulation presentation position without changing /// the persistent placement contract. The drag view supplies a hard-clamped /// absolute anchor and coalesces high-frequency samples to display ticks. func presentOverlayPetDrag( at presentationCenter: CGPoint, visibleFrame: CGRect?, interactionID: UUID ) { guard overlayDragInteractionID == interactionID else { return } if overlayLostMouseUpFallbackTask != nil { OverlayInteractionTelemetry.shared.fallback( .fallbackSuppressed, interactionID: interactionID ) } overlayLostMouseUpFallbackTask?.cancel() overlayLostMouseUpFallbackTask = nil if let visibleFrame, !visibleFrame.isEmpty, !rect(overlayScreenVisibleFrame, nearlyEquals: visibleFrame) { overlayScreenVisibleFrame = visibleFrame } overlayPetDragPresentationCenter = presentationCenter overlayPetPositionInitialized = true overlayController.presentPetDrag( at: presentationCenter, visibleFrame: visibleFrame ?? overlayScreenVisibleFrame ) } func commitOverlayPetDrag( at presentationCenter: CGPoint, visibleFrame: CGRect?, interactionID: UUID ) { guard overlayDragInteractionID == interactionID else { return } let targetScreen = screen(matchingVisibleFrame: visibleFrame) ?? screen(containing: presentationCenter) ?? screen(containing: overlayPetScreenCenter) ?? NSScreen.main let targetVisibleFrame = targetScreen?.visibleFrame ?? visibleFrame ?? overlayScreenVisibleFrame guard !targetVisibleFrame.isEmpty else { overlayPetScreenCenter = presentationCenter overlayPetDragPresentationCenter = nil overlayDragInteractionID = nil overlayPetDragInProgress = false commitCurrentOverlayPlacement( interactionID: interactionID ) return } let movementFrame = OverlayGeometry.petMovementFrame( screenFrame: targetScreen?.frame ?? targetVisibleFrame, visibleFrame: targetVisibleFrame ) let targetDisplayWidthPt = pendingDisplayWidthPt ?? overlayDisplayWidthPt var targetCenter = CGPoint( x: OverlayPlacementCanonicalization.cgFloatCoordinate( presentationCenter.x ), y: OverlayPlacementCanonicalization.cgFloatCoordinate( presentationCenter.y ) ) if targetDisplayWidthPt != overlayDisplayWidthPt { targetCenter = OverlayGeometry.bottomAnchoredCenter( from: targetCenter, currentDisplayWidthPt: overlayDisplayWidthPt, proposedDisplayWidthPt: targetDisplayWidthPt ) targetCenter = OverlayPetDragGeometry.clampedCenter( targetCenter, displayWidthPt: targetDisplayWidthPt, visibleFrame: movementFrame, clickMenuEnabled: behavior.clickMenu, petVisualEnvelope: overlayPetVisualEnvelope ) } overlayPetScreenCenter = targetCenter overlayDisplayWidthPt = targetDisplayWidthPt pendingDisplayWidthPt = nil overlayPetDragPresentationCenter = nil overlayDragInteractionID = nil overlayPetDragInProgress = false overlayScreenVisibleFrame = targetVisibleFrame overlayPetPositionInitialized = true overlayController.updateDisplayWidth(targetDisplayWidthPt) diagnostics.log( .info, category: "overlay", event: "overlay_position_committed", metadata: ["committed": .bool(true)] ) commitCurrentOverlayPlacement( interactionID: interactionID ) } func endOverlayPetDrag(interactionID: UUID?) { if let interactionID, overlayDragInteractionID != nil, overlayDragInteractionID != interactionID { return } if let interactionID, overlayLostMouseUpFallbackTask != nil { OverlayInteractionTelemetry.shared.fallback( .fallbackSuppressed, interactionID: interactionID ) } overlayLostMouseUpFallbackTask?.cancel() overlayLostMouseUpFallbackTask = nil overlayDragInteractionID = nil overlayController.endPetDragInteraction(interactionID) if overlayPetDragInProgress { overlayPetDragInProgress = false overlayController.updateLayoutDuringInteraction() overlayController.refreshPointerPassthrough() } } func ensureOverlayPetPosition(in visibleFrame: CGRect) { guard !visibleFrame.isEmpty else { return } guard overlayPetDragPresentationCenter == nil else { return } if overlayPetPositionInitialized { let targetScreen = screen(matchingVisibleFrame: visibleFrame) ?? screen(containing: overlayPetScreenCenter) let movementFrame = OverlayGeometry.petMovementFrame( screenFrame: targetScreen?.frame ?? visibleFrame, visibleFrame: targetScreen?.visibleFrame ?? visibleFrame ) overlayPetScreenCenter = OverlayGeometry.clampedPetScreenCenter( overlayPetScreenCenter, displayWidthPt: overlayDisplayWidthPt, visibleFrame: movementFrame, clickMenuEnabled: behavior.clickMenu, petVisualEnvelope: overlayPetVisualEnvelope ) } else { overlayPetScreenCenter = OverlayGeometry.defaultPetScreenCenter( in: visibleFrame, displayWidthPt: overlayDisplayWidthPt ) overlayPetPositionInitialized = true } } func previewOverlayDisplayWidthPt(_ proposed: CGFloat) { let target = OverlayGeometry.clampedDisplayWidthPt(proposed) pendingDisplayWidthPt = target scheduleOverlayDisplayWidthCommit() guard !overlayPetDragInProgress else { return } let targetScreen = screen(containing: overlayPetScreenCenter) ?? NSScreen.main let cadence = OverlayDisplayRefreshCadence.resolved(for: targetScreen) overlayPlacementPreviewDriver.submit( target, targetDisplayID: cadence.displayID, screen: targetScreen, fallbackCadence: cadence ) { [weak self] target in guard let self, !self.overlayPetDragInProgress else { return } let center = self.previewCenter(forDisplayWidthPt: target) self.overlayController.previewDisplayWidth( target, petScreenCenter: center ) } } func commitOverlayDisplayWidthPt(_ proposed: CGFloat? = nil) { if let proposed { pendingDisplayWidthPt = OverlayGeometry.clampedDisplayWidthPt( proposed ) } overlayDisplayWidthCommitTask?.cancel() overlayDisplayWidthCommitTask = nil overlayDisplayWidthCommitDeadline = nil overlayPlacementPreviewDriver.cancelPending() guard !overlayPetDragInProgress, let target = pendingDisplayWidthPt else { return } pendingDisplayWidthPt = nil applyCommittedDisplayWidthPt( target, interactionID: UUID() ) } func resetOverlayDisplayWidthPt() { previewOverlayDisplayWidthPt( OverlayGeometry.defaultDisplayWidthPt ) commitOverlayDisplayWidthPt( OverlayGeometry.defaultDisplayWidthPt ) } private func scheduleOverlayDisplayWidthCommit() { overlayDisplayWidthCommitDeadline = ProcessInfo.processInfo.systemUptime + 0.150 guard overlayDisplayWidthCommitTask == nil else { return } overlayDisplayWidthCommitTask = Task { @MainActor [weak self] in while let self, !Task.isCancelled { guard let deadline = self.overlayDisplayWidthCommitDeadline else { self.overlayDisplayWidthCommitTask = nil return } let delay = max( 0, deadline - ProcessInfo.processInfo.systemUptime ) try? await Task.sleep(for: .seconds(delay)) guard !Task.isCancelled else { return } guard let currentDeadline = self.overlayDisplayWidthCommitDeadline else { self.overlayDisplayWidthCommitTask = nil return } guard currentDeadline <= ProcessInfo.processInfo.systemUptime + 0.000_001 else { continue } self.overlayDisplayWidthCommitTask = nil self.overlayDisplayWidthCommitDeadline = nil self.commitOverlayDisplayWidthPt() return } } } private func previewCenter(forDisplayWidthPt displayWidthPt: CGFloat) -> CGPoint { let proposed = OverlayGeometry.bottomAnchoredCenter( from: overlayPetScreenCenter, currentDisplayWidthPt: overlayDisplayWidthPt, proposedDisplayWidthPt: displayWidthPt ) let targetScreen = screen(containing: overlayPetScreenCenter) ?? NSScreen.main let visibleFrame = targetScreen?.visibleFrame ?? overlayScreenVisibleFrame guard !visibleFrame.isEmpty else { return proposed } let movementFrame = OverlayGeometry.petMovementFrame( screenFrame: targetScreen?.frame ?? visibleFrame, visibleFrame: visibleFrame ) return OverlayPetDragGeometry.clampedCenter( proposed, displayWidthPt: displayWidthPt, visibleFrame: movementFrame, clickMenuEnabled: behavior.clickMenu, petVisualEnvelope: overlayPetVisualEnvelope ) } private func applyCommittedDisplayWidthPt( _ proposed: CGFloat, interactionID: UUID ) { let target = OverlayGeometry.clampedDisplayWidthPt(proposed) let targetCenter = previewCenter(forDisplayWidthPt: target) let widthChanged = target != overlayDisplayWidthPt let centerChanged = hypot( targetCenter.x - overlayPetScreenCenter.x, targetCenter.y - overlayPetScreenCenter.y ) > 0.01 overlayDisplayWidthPt = target overlayPetScreenCenter = targetCenter overlayPetPositionInitialized = true overlayController.updateDisplayWidth(target) guard widthChanged || centerChanged else { return } diagnostics.log( .info, category: "overlay", event: "overlay_display_width_committed", metadata: ["display_width_pt": .double(Double(target))] ) commitCurrentOverlayPlacement( interactionID: interactionID ) } func updateOverlayLayout() { overlayController.updateLayout() } func updateOverlayBubbleAnchorDirection(_ direction: OverlayBubbleAnchorDirection) { guard overlayBubbleAnchorDirection != direction else { return } overlayBubbleAnchorDirection = direction } func updateOverlayPetVisualEnvelope( _ envelope: OverlayPetVisualEnvelope?, petID: String, semanticOwnerEntryID: String ) { guard activePet?.id == petID else { return } guard OverlayPetAnimationIdentity.stateEntryID(for: presentedActiveAgentState) == semanticOwnerEntryID else { return } guard overlayPetVisualEnvelope != envelope else { return } overlayPetVisualEnvelope = envelope let visibleFrame = screen(containing: overlayPetScreenCenter)?.visibleFrame ?? overlayScreenVisibleFrame ensureOverlayPetPosition(in: visibleFrame) overlayController.updateLayoutDuringInteraction() } func updateOverlayPetFrameHitTest( _ hitTest: OverlayPetFrameHitTest?, petID: String, semanticOwnerEntryID: String ) { guard activePet?.id == petID else { return } guard OverlayPetAnimationIdentity.stateEntryID(for: presentedActiveAgentState) == semanticOwnerEntryID else { return } if let projection = overlayPetFrameHitTestProjection, projection.petID == petID, projection.semanticOwnerEntryID == semanticOwnerEntryID, projection.hitTest == hitTest { return } overlayPetFrameHitTestProjection = OverlayPetFrameHitTestProjection( hitTest: hitTest, petID: petID, semanticOwnerEntryID: semanticOwnerEntryID ) // A frame can change under a stationary pointer. Re-evaluate the panel // immediately so a newly transparent pixel never retains the window's // mouse ownership until the next physical pointer event. overlayController.refreshPointerPassthrough() } func toggleOverlayBubble() { let wasDismissed = overlayBubbleDismissed if overlayBubbleDismissed { overlayBubbleDismissed = false } else if !overlayAvailableBubbleContents.isEmpty { overlayBubbleDismissed = true } if overlayBubbleDismissed != wasDismissed { diagnostics.log( .info, category: "overlay", event: "overlay_bubble_toggled", metadata: ["collapsed": .bool(overlayBubbleDismissed)] ) } overlayController.updateLayout( animateBubble: overlayBubbleDismissed != wasDismissed ) } /// Idempotently reveal the bubble without turning an already-visible /// bubble into a collapsed one. Keyboard focus uses this path so focus /// acquisition never hides the content it is about to enter. func revealOverlayBubble() { guard overlayBubbleDismissed, !overlayAvailableBubbleContents.isEmpty else { overlayController.updateLayout() return } overlayBubbleDismissed = false diagnostics.log( .info, category: "overlay", event: "overlay_bubble_revealed", metadata: ["collapsed": .bool(false)] ) overlayController.updateLayout(animateBubble: true) } func focusOverlayBubbleForKeyboardNavigation() { guard canFocusOverlayBubbleForKeyboardNavigation else { return } revealOverlayBubble() overlayKeyboardFocusHandler(overlayController, .bubbleSessions) } func toggleOverlayAgentGroup(_ source: AgentSource) { guard behavior.groupSessionsByAgent else { return } overlayAgentGroupExpansionOverrides[source] = !overlayAgentGroupIsExpanded(source) overlayController.updateLayout(animateBubble: true) } func toggleOverlayStandaloneStack() { guard !behavior.groupSessionsByAgent, overlayAvailableBubbleContents.reduce(0, { $0 + $1.representedSessionCount }) > 1 else { return } let expands = !overlayStandaloneStackIsExpanded overlayStandaloneStackDisclosureDirection = expands ? .expanding : .collapsing overlayStandaloneStackExpansionOverride = expands overlayController.updateLayout(animateBubble: true) } var overlayBubbleDisclosureAction: OverlayBubbleDisclosureAction? { OverlayBubbleDisclosureAction.resolve( groupSessionsByAgent: behavior.groupSessionsByAgent, sessionCount: overlayBubbleSessionCount, bubbleDismissed: overlayBubbleDismissed, standaloneStackExpanded: overlayStandaloneStackIsExpanded, standaloneStackDirection: overlayStandaloneStackDisclosureDirection ) } /// Moves the pet-side disclosure control by exactly one visual level. /// A flat multi-session tray therefore travels through the folded card in /// both directions: expanded -> folded -> hidden and hidden -> folded -> /// expanded. Pet-body clicks continue to use `toggleOverlayBubble()` and /// retain their direct visibility toggle. func stepOverlayBubbleDisclosure() { guard let action = overlayBubbleDisclosureAction else { overlayController.updateLayout() return } let wasDismissed = overlayBubbleDismissed switch action { case .revealBubble: overlayBubbleDismissed = false case .revealCollapsedStandaloneStack: overlayStandaloneStackDisclosureDirection = .expanding overlayStandaloneStackExpansionOverride = false overlayBubbleDismissed = false case .expandStandaloneStack: overlayStandaloneStackDisclosureDirection = .expanding overlayStandaloneStackExpansionOverride = true case .collapseStandaloneStack: overlayStandaloneStackDisclosureDirection = .collapsing overlayStandaloneStackExpansionOverride = false case .dismissBubble: overlayBubbleDismissed = true } if overlayBubbleDismissed != wasDismissed { diagnostics.log( .info, category: "overlay", event: "overlay_bubble_toggled", metadata: ["collapsed": .bool(overlayBubbleDismissed)] ) } overlayController.updateLayout(animateBubble: true) } private func reconcileOverlayStandaloneSessionOrder( with states: [ActiveAgentState] ) { struct Entry { let id: String let activationID: String } let entries = states.map { state in let id = OverlaySessionContent.stableID( source: state.source, sessionID: state.sessionID ?? state.event.sessionID, anonymousSessionAlias: state.anonymousSessionAlias, fallbackEventID: state.event.id ) // sessionActivatedAt is an epoch boundary, unlike the event ID or // event timestamp, which can change on every thinking/tool update. return Entry( id: id, activationID: state.sessionActivatedAt.map { "activation:\($0)" } ?? "legacy" ) } let promotions = entries.filter { entry in overlayStandaloneSessionActivationIDs[entry.id] != entry.activationID || !overlayStandaloneSessionOrder.contains(entry.id) } let promotedIDs = Set(promotions.map(\.id)) if !promotedIDs.isEmpty { overlayStandaloneSessionOrder.removeAll { promotedIDs.contains($0) } // PetCore gives us attention/latest-first. Keep that same reading // direction in the App: the folded foreground card is also the // first card when the tray expands. A true activation epoch moves // once to the front; ordinary event churn keeps the existing slot. for entry in promotions.reversed() { overlayStandaloneSessionOrder.insert(entry.id, at: 0) overlayStandaloneSessionActivationIDs[entry.id] = entry.activationID } } // A missing projected row may be a bounded/transient omission, not a // closed session. Retain a small history so it can recover its slot. let maximumRememberedSessions = 64 if overlayStandaloneSessionOrder.count > maximumRememberedSessions { let removed = overlayStandaloneSessionOrder.dropFirst(maximumRememberedSessions) overlayStandaloneSessionOrder = Array( overlayStandaloneSessionOrder.prefix(maximumRememberedSessions) ) for id in removed { overlayStandaloneSessionActivationIDs[id] = nil } } } func dismissOverlayBubble(eventID: String) { overlayDismissedBubbleEventIDs.insert(eventID) overlayController.updateLayout(animateBubble: true) } func dismissOverlayBubble(eventIDs: [String]) { overlayDismissedBubbleEventIDs.formUnion(eventIDs) overlayController.updateLayout(animateBubble: true) } func dismissAllOverlayBubbles() { overlayBubbleDismissed = true overlayController.updateLayout(animateBubble: true) } func setOverlayPointerNearPet(_ value: Bool) { if overlayPointerNearPet != value { overlayPointerNearPet = value overlayController.refreshPointerDrivenControlVisibility() } } func refreshOverlayPointerState() { overlayController.refreshPointerPassthrough() } func observeOverlayPrimaryButton(isDown: Bool) { guard !isDown, let interactionID = overlayDragInteractionID else { if isDown { overlayLostMouseUpFallbackTask?.cancel() overlayLostMouseUpFallbackTask = nil } return } guard overlayLostMouseUpFallbackTask == nil else { return } OverlayInteractionTelemetry.shared.fallback( .fallbackArmed, interactionID: interactionID ) overlayLostMouseUpFallbackTask = Task { @MainActor [weak self] in // Give the normal DragView mouseUp finalizer one main-runloop // opportunity before treating this as lost pointer capture. await Task.yield() guard let self, !Task.isCancelled else { return } self.overlayLostMouseUpFallbackTask = nil guard self.overlayDragInteractionID == interactionID else { return } OverlayInteractionTelemetry.shared.fallback( .fallbackFired, interactionID: interactionID ) self.cancelOverlayPointerInteractions() } } func cancelOverlayPointerInteractions() { guard overlayPetDragInProgress else { return } overlayLostMouseUpFallbackTask?.cancel() overlayLostMouseUpFallbackTask = nil let interactionID = overlayDragInteractionID if let interactionID, let interruptedDragCenter = overlayPetDragPresentationCenter { commitOverlayPetDrag( at: interruptedDragCenter, visibleFrame: overlayScreenVisibleFrame, interactionID: interactionID ) } else { endOverlayPetDrag(interactionID: interactionID) if let interactionID { OverlayInteractionTelemetry.shared.finish( interactionID: interactionID, result: .suppressed ) } } } private func rect(_ lhs: CGRect, nearlyEquals rhs: CGRect, tolerance: CGFloat = 0.5) -> Bool { abs(lhs.origin.x - rhs.origin.x) <= tolerance && abs(lhs.origin.y - rhs.origin.y) <= tolerance && abs(lhs.size.width - rhs.size.width) <= tolerance && abs(lhs.size.height - rhs.size.height) <= tolerance } func chooseReferenceImages() { guard !generationSession.isActive else { statusText = "活动任务的参考图已冻结" return } let panel = NSOpenPanel() panel.title = APCLocalization.text(.studioReferencesPanelTitle) panel.prompt = APCLocalization.text(.commonChoose) panel.message = APCLocalization.text(.studioReferencesPanelMessage) panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = true panel.allowedContentTypes = [.png, .jpeg, .webP] guard panel.runModal() == .OK else { return } addReferenceImageURLs(panel.urls) } func addReferenceImageURLs(_ urls: [URL]) { guard !generationSession.isActive else { statusText = "活动任务的参考图已冻结" return } recordMakerUserMutation() let admission = MakerReferenceImagePolicy.admit( existingPaths: referenceImages, urls: urls ) referenceImages.append(contentsOf: admission.acceptedPaths) let reselectionFillCount = min( referenceReselectionCount, admission.acceptedPaths.count ) if reselectionFillCount > 0 { reselectedReferenceImagePaths.formUnion( admission.acceptedPaths.prefix(reselectionFillCount) ) referenceReselectionCount -= reselectionFillCount } refreshReferenceImageIssue(fallback: admission.issue) if urls.isEmpty || admission.acceptedPaths.isEmpty && admission.issue != nil { statusText = "请选择图片文件" } else if admission.acceptedPaths.isEmpty { statusText = "参考图已在列表中" } else { statusText = "已添加 \(admission.acceptedPaths.count) 张参考图" } } func removeReferenceImage(_ path: String) { guard !generationSession.isActive else { statusText = "活动任务的参考图已冻结" return } recordMakerUserMutation() let removedReselection = reselectedReferenceImagePaths.remove(path) != nil referenceImages.removeAll { $0 == path } if removedReselection { referenceReselectionCount = min( MakerReferenceImagePolicy.maximumCount, referenceReselectionCount + 1 ) } refreshReferenceImageIssue() statusText = "已移除参考图" } private static func safeMakerReferenceImagePath(_ url: URL) -> String? { MakerReferenceImagePolicy.validatedPath(for: url) } private func safeExportName(_ name: String) -> String { let illegalCharacters = CharacterSet(charactersIn: "/\\:?%*|\"<>") let cleaned = name .components(separatedBy: illegalCharacters) .joined(separator: "-") .trimmingCharacters(in: .whitespacesAndNewlines) return cleaned.isEmpty ? "AgentPet" : cleaned } private func applyOverlayPlacement( _ placement: OverlayPlacement, remoteRevision: UInt64, remoteIntent: OverlayPlacementRemoteIntent? ) { guard let normalized = normalizedOverlayPlacement(placement) else { return } if !overlayPlacementAuthority.bootstrapCompleted { let journal = loadOverlayPlacementJournalOnce() if let journal, let recovered = normalizedOverlayPlacement(journal.placement) { if remoteIntent != nil, journal.baseRemoteRevision < remoteRevision { // A newer explicit PetCore intent wins over work recovered // from an older App process. Its consume commit atomically // replaces the stale journal entry below. } else if remoteIntent == nil, OverlayPlacementCanonicalization.areEquivalent( placement, journal.placement ) { _ = overlayPlacementAuthority.bootstrap( normalized, remoteRevision: remoteRevision ) applyPresentedOverlayPlacement(normalized) removeOverlayPlacementJournalIfMatching(journal) if !OverlayPlacementCanonicalization.areEquivalent( normalized, placement ) { commitCurrentOverlayPlacement(interactionID: UUID()) } return } else { _ = overlayPlacementAuthority.bootstrap( normalized, remoteRevision: remoteRevision ) applyPresentedOverlayPlacement(recovered) let replay = overlayPlacementAuthority.commitLocal( recovered, interactionID: UUID() ) enqueueOverlayPlacementCommit(replay) return } } _ = overlayPlacementAuthority.bootstrap( normalized, remoteRevision: remoteRevision ) applyPresentedOverlayPlacement(normalized) if !OverlayPlacementCanonicalization.areEquivalent( normalized, placement ) || remoteIntent != nil { commitCurrentOverlayPlacement(interactionID: UUID()) } return } guard overlayPlacementAuthority.allowsRemote( normalized, remoteRevision: remoteRevision, intent: remoteIntent ) else { return } _ = overlayPlacementAuthority.applyRemote( normalized, remoteRevision: remoteRevision, intent: remoteIntent ) applyPresentedOverlayPlacement(normalized) if remoteIntent != nil { commitCurrentOverlayPlacement(interactionID: UUID()) } } private func normalizedOverlayPlacement( _ placement: OverlayPlacement ) -> OverlayPlacement? { let persistedCenter = CGPoint(x: placement.x, y: placement.y) let screen = screen(matchingDisplayID: placement.displayId) ?? screen(containing: persistedCenter) ?? NSScreen.main ?? NSScreen.screens.first let visibleFrame = screen?.visibleFrame ?? (overlayScreenVisibleFrame.isEmpty ? .zero : overlayScreenVisibleFrame) guard !visibleFrame.isEmpty else { return nil } let targetDisplayWidthPt = OverlayGeometry.resolvedInitialDisplayWidthPt( persistedDisplayWidthPt: CGFloat(placement.displayWidthPt), hasPersistedPosition: persistedCenter != .zero ) let normalizedCenter: CGPoint if persistedCenter == .zero { normalizedCenter = OverlayGeometry.defaultPetScreenCenter( in: visibleFrame, displayWidthPt: targetDisplayWidthPt ) } else { let movementFrame = OverlayGeometry.petMovementFrame( screenFrame: screen?.frame ?? visibleFrame, visibleFrame: visibleFrame ) normalizedCenter = OverlayGeometry.clampedPetScreenCenter( persistedCenter, displayWidthPt: targetDisplayWidthPt, visibleFrame: movementFrame, clickMenuEnabled: behavior.clickMenu, petVisualEnvelope: overlayPetVisualEnvelope ) } return OverlayPlacement( x: Double(normalizedCenter.x), y: Double(normalizedCenter.y), displayWidthPt: Double(targetDisplayWidthPt), displayId: currentDisplayID(for: normalizedCenter) ) } private func applyPresentedOverlayPlacement( _ placement: OverlayPlacement ) { overlayDisplayWidthPt = CGFloat(placement.displayWidthPt) overlayPetScreenCenter = CGPoint(x: placement.x, y: placement.y) overlayPetDragPresentationCenter = nil overlayPetPositionInitialized = true if let screen = screen(matchingDisplayID: placement.displayId) ?? screen(containing: overlayPetScreenCenter) { overlayScreenVisibleFrame = screen.visibleFrame } overlayController.updateDisplayWidth(overlayDisplayWidthPt) } private func commitCurrentOverlayPlacement(interactionID: UUID) { let placement = currentOverlayPlacement() if let pending = overlayPlacementAuthority.pending, OverlayPlacementCanonicalization.areEquivalent( pending.placement, placement ) { OverlayInteractionTelemetry.shared.finish( interactionID: interactionID, result: .success ) return } let commit = overlayPlacementAuthority.commitLocal( placement, interactionID: interactionID ) enqueueOverlayPlacementCommit(commit) } private func enqueueOverlayPlacementCommit( _ commit: OverlayPlacementCommit ) { OverlayInteractionTelemetry.shared.commitQueued( interactionID: commit.interactionID ) saveOverlayPlacementJournal(commit) startOverlayPlacementSaveWorkerIfNeeded(reason: .newLocalCommit) } private func startOverlayPlacementSaveWorkerIfNeeded( reason: OverlayPlacementSaveResumeReason ) { guard overlayPlacementSaveTask == nil, let pending = overlayPlacementAuthority.pending else { return } if overlayPlacementExhaustedGeneration == pending.localRevision { guard reason.mayResumeExhaustedGeneration else { return } overlayPlacementExhaustedGeneration = nil } else if reason == .newLocalCommit { overlayPlacementExhaustedGeneration = nil } overlayPlacementSaveTask = Task { @MainActor [weak self] in guard let self else { return } workerLoop: while !Task.isCancelled, let commit = self.overlayPlacementAuthority.pending { let outcome = await self.saveOverlayPlacement(commit) switch outcome { case .converged, .superseded: continue workerLoop case let .exhausted(generation): if self.overlayPlacementAuthority.pending?.localRevision == generation { self.overlayPlacementExhaustedGeneration = generation } break workerLoop case .cancelled: break workerLoop } } self.overlayPlacementSaveTask = nil } } private func resumePendingOverlayPlacementSaveIfNeeded( reason: OverlayPlacementSaveResumeReason ) { guard overlayPlacementSaveTask == nil, overlayPlacementAuthority.pending != nil else { return } startOverlayPlacementSaveWorkerIfNeeded(reason: reason) } func retryPendingOverlayPlacementSave() { resumePendingOverlayPlacementSaveIfNeeded(reason: .explicitRetry) } private func saveOverlayPlacement( _ initialCommit: OverlayPlacementCommit ) async -> OverlayPlacementSaveRunOutcome { let retryDelays: [Duration] = [ .zero, .milliseconds(250), .milliseconds(500), .seconds(1), .seconds(2), ] let telemetryInteractionID = initialCommit.interactionID let saveStartedAt = ProcessInfo.processInfo.systemUptime func superseded() -> OverlayPlacementSaveRunOutcome { OverlayInteractionTelemetry.shared.finish( interactionID: telemetryInteractionID, result: .stale ) return .superseded } func cancelled() -> OverlayPlacementSaveRunOutcome { OverlayInteractionTelemetry.shared.finish( interactionID: telemetryInteractionID, result: .suppressed ) return .cancelled } var commit = initialCommit for attempt in retryDelays.indices { guard !Task.isCancelled else { return cancelled() } guard overlayPlacementAuthority.pending?.localRevision == commit.localRevision else { return superseded() } let delay = retryDelays[attempt] if delay != .zero { do { try await overlayPlacementRetrySleeper(delay) } catch { return cancelled() } } guard !Task.isCancelled, overlayPlacementAuthority.pending?.localRevision == commit.localRevision else { return superseded() } let attemptStartedAt = ProcessInfo.processInfo.systemUptime do { let data = try JSONEncoder().encode(commit.placement) guard var object = try JSONSerialization.jsonObject( with: data ) as? [String: Any] else { throw PetCoreClientError.invalidResponse } object["expected_revision"] = String(commit.baseRemoteRevision) OverlayInteractionTelemetry.shared.requestAttempt( interactionID: telemetryInteractionID, attempt: attempt + 1 ) let result = try await requestPetCore( method: "overlay.placement.update", params: object ) let response = try OverlayPlacementUpdateResponse(result: result) guard overlayPlacementAuthority.pending?.localRevision == commit.localRevision else { return superseded() } guard response.ok else { guard response.conflict else { throw PetCoreClientError.invalidResponse } OverlayInteractionTelemetry.shared.requestResult( interactionID: telemetryInteractionID, attempt: attempt + 1, result: .conflict ) if response.intent != nil { guard let normalized = normalizedOverlayPlacement( response.placement ), let consume = overlayPlacementAuthority .supersedeWithExplicitRemote( normalized, remoteRevision: response.placementRevision, interactionID: UUID() ) else { return superseded() } applyPresentedOverlayPlacement(normalized) saveOverlayPlacementJournal(consume) commit = consume continue } let reconciliation = overlayPlacementAuthority .reconcileOrdinaryConflict( for: commit, actualPlacement: response.placement, remoteRevision: response.placementRevision ) switch reconciliation { case .acknowledged: removeOverlayPlacementJournalIfMatching( journalEntry(for: commit) ) OverlayInteractionTelemetry.shared.finish( interactionID: telemetryInteractionID, result: .success ) return .converged case let .retry(rebased): saveOverlayPlacementJournal(rebased) commit = rebased continue case .stale: removeOverlayPlacementJournalIfMatching( journalEntry(for: commit) ) return superseded() } } guard response.intent == nil, OverlayPlacementCanonicalization.areEquivalent( response.placement, commit.placement ) else { throw PetCoreClientError.invalidResponse } OverlayInteractionTelemetry.shared.requestResult( interactionID: telemetryInteractionID, attempt: attempt + 1, result: .success ) let reconciliation = overlayPlacementAuthority.acknowledge( commit, remoteRevision: response.placementRevision ) switch reconciliation { case .acknowledged: removeOverlayPlacementJournalIfMatching( journalEntry(for: commit) ) OverlayInteractionTelemetry.shared.finish( interactionID: telemetryInteractionID, result: .success ) return .converged case let .retry(rebased): removeOverlayPlacementJournalIfMatching( journalEntry(for: commit) ) saveOverlayPlacementJournal(rebased) commit = rebased case .stale: removeOverlayPlacementJournalIfMatching( journalEntry(for: commit) ) return superseded() } } catch { OverlayInteractionTelemetry.shared.requestResult( interactionID: telemetryInteractionID, attempt: attempt + 1, result: .transportFailure ) overlayPlacementAuthority.failCommit(commit) diagnostics.log( .warning, category: "overlay", event: "placement_save_failed", metadata: [ "attempt": .integer(Int64(attempt + 1)), "duration_bucket": .string( OverlayInteractionDurationBucket( milliseconds: ( ProcessInfo.processInfo.systemUptime - attemptStartedAt ) * 1_000 ).rawValue ), "generation_category": .string("current"), "result_kind": .string("transport_or_protocol_failure"), ] ) if attempt >= retryDelays.count - 1 { statusText = "桌宠位置保存失败" } } } if Task.isCancelled { return cancelled() } OverlayInteractionTelemetry.shared.finish( interactionID: telemetryInteractionID, result: .exhausted ) diagnostics.log( .warning, category: "overlay", event: "placement_save_exhausted", metadata: [ "attempt": .integer(Int64(retryDelays.count)), "duration_bucket": .string( OverlayInteractionDurationBucket( milliseconds: ( ProcessInfo.processInfo.systemUptime - saveStartedAt ) * 1_000 ).rawValue ), "generation_category": .string("current"), "result_kind": .string("exhausted"), ] ) return .exhausted(generation: commit.localRevision) } private func loadOverlayPlacementJournalOnce() -> OverlayPlacementJournalEntry? { guard !overlayPlacementJournalDidLoad else { return nil } overlayPlacementJournalDidLoad = true do { return try overlayPlacementJournalStore.load() } catch { logOverlayPlacementJournalFailure("load", error: error) return nil } } private func saveOverlayPlacementJournal( _ commit: OverlayPlacementCommit ) { do { try overlayPlacementJournalStore.save(journalEntry(for: commit)) } catch { logOverlayPlacementJournalFailure("save", error: error) } } private func removeOverlayPlacementJournalIfMatching( _ entry: OverlayPlacementJournalEntry ) { do { try overlayPlacementJournalStore.removeIfMatching(entry) } catch { logOverlayPlacementJournalFailure("remove", error: error) } } private func journalEntry( for commit: OverlayPlacementCommit ) -> OverlayPlacementJournalEntry { OverlayPlacementJournalEntry( interactionID: commit.interactionID, localRevision: commit.localRevision, placement: commit.placement, baseRemoteRevision: commit.baseRemoteRevision ) } private func logOverlayPlacementJournalFailure( _ operation: String, error: Error ) { diagnostics.log( .warning, category: "overlay", event: "placement_journal_failed", metadata: [ "operation": .string(operation), "error": .string(String(describing: error)), ] ) } @discardableResult private func updateConnectionStatus(from result: Any) throws -> AgentConnectionStatus { let data = try JSONSerialization.data(withJSONObject: result) let status = try JSONDecoder().decode(AgentConnectionStatus.self, from: data) connections.removeAll { $0.source == status.source } connections.append(status) sortConnections() return status } private func unresolvedConnectionItemCount(_ status: AgentConnectionStatus) -> Int { status.blockingItems.count } private func reconcileProductConvergenceConnectorAttention( afterChecking checkedSources: Set ) { guard productConvergenceTask == nil, case let .needsAttention(.connectors(attentionIssues)) = appUpdateConvergenceState else { return } let resolvedSources: Set = Set(connections.lazy.compactMap { status -> AgentSource? in guard checkedSources.contains(status.source), ProductConvergenceConnectionRecoveryPolicy.resolvesAttention(status) else { return nil } return status.source }) guard !resolvedSources.isEmpty else { return } let remainingIssues = attentionIssues.filter { !resolvedSources.contains($0.source) } let fullyResolved = remainingIssues.isEmpty appUpdateConvergenceState = fullyResolved ? .idle : .needsAttention(.connectors(remainingIssues)) diagnostics.log( .notice, category: "update", event: "product_convergence_attention_reconciled_by_runtime_check", metadata: [ "resolved_sources": .string( resolvedSources .sorted { $0.rawValue < $1.rawValue } .map { $0.rawValue } .joined(separator: ",") ), "remaining_sources": .string( remainingIssues.map { $0.source.rawValue }.joined(separator: ",") ), ] ) if fullyResolved { scheduleProductConvergence(force: true) } } private func sortConnections() { connections.sort { let lhs = AgentSource.allCases.firstIndex(of: $0.source) ?? 0 let rhs = AgentSource.allCases.firstIndex(of: $1.source) ?? 0 return lhs < rhs } } func applyAuthoritativeConnectionSnapshot(_ snapshotConnections: [AgentConnectionStatus]) { let sorted = snapshotConnections.sorted { let lhs = AgentSource.allCases.firstIndex(of: $0.source) ?? 0 let rhs = AgentSource.allCases.firstIndex(of: $1.source) ?? 0 return lhs < rhs } if connections != sorted { connections = sorted } } private func currentOverlayPlacement() -> OverlayPlacement { OverlayPlacement( x: Double(overlayPetScreenCenter.x), y: Double(overlayPetScreenCenter.y), displayWidthPt: Double(overlayDisplayWidthPt), displayId: currentDisplayID(for: overlayPetScreenCenter) ) } private func currentDisplayID(for point: CGPoint) -> String { let screen = screen(containing: point) ?? NSScreen.main let number = screen?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber return number?.stringValue ?? "main" } private func screen(containing point: CGPoint) -> NSScreen? { NSScreen.screens.first { $0.frame.contains(point) } } private func screen(matchingDisplayID displayID: String) -> NSScreen? { if displayID == "main" { return NSScreen.main } return NSScreen.screens.first { screen in let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber return number?.stringValue == displayID } } private func screen(matchingVisibleFrame visibleFrame: CGRect?) -> NSScreen? { guard let visibleFrame, !visibleFrame.isEmpty else { return nil } return NSScreen.screens.first { screen in screen.visibleFrame == visibleFrame || screen.visibleFrame.intersects(visibleFrame) } } private func requestPetCore( method: String, params: Any = [:], timeout: Duration? = nil ) async throws -> Any { let protectsHandoff = Self.isProtectedMutationRPC(method) if protectsHandoff { inFlightProtectedMutationCount += 1 } defer { if protectsHandoff { inFlightProtectedMutationCount = max( 0, inFlightProtectedMutationCount - 1 ) } } if let petCoreRequestOverride { return try await petCoreRequestOverride(method, params, timeout) } let startedAt = Date() do { let paramsData = try JSONSerialization.data(withJSONObject: params) let responseData = try await client.requestData( method: method, paramsJSONData: paramsData, timeout: timeout ) let result = try PetCoreClient.decodeResult(from: responseData) if !Self.isPollingRPC(method) { diagnostics.log( Self.isUserMutationRPC(method) ? .info : .debug, category: "rpc", event: "rpc_succeeded", metadata: [ "method": .string(method), "duration_ms": .integer(Self.elapsedMilliseconds(since: startedAt)) ] ) } return result } catch { diagnostics.logFailure( error, category: "rpc", event: "rpc_failed", metadata: [ "method": .string(method), "duration_ms": .integer(Self.elapsedMilliseconds(since: startedAt)) ], throttleKey: "rpc_failed.\(method)", minimumInterval: Self.isPollingRPC(method) ? 30 : 5 ) throw error } } private static func elapsedMilliseconds(since date: Date) -> Int64 { Int64(max(0, Date().timeIntervalSince(date) * 1_000).rounded()) } private static func isUserMutationRPC(_ method: String) -> Bool { if method == "generation.history.list" || method == "generation.history.detail" { return false } return method.hasPrefix("generation.") || method.hasPrefix("pet.") || method.hasPrefix("petpack.") || method.hasPrefix("connections.") || method == "portable_skill.install" || method == "portable_skill.uninstall" || method == "behavior.patch" || method == "onboarding.update" || method == "overlay.placement.update" || method == "agent.session.acknowledge" || method == "diagnostics.export" || method == "product.convergence.update" } private static func isProtectedMutationRPC(_ method: String) -> Bool { switch method { case "behavior.patch", "onboarding.update", "overlay.placement.update", "agent.session.acknowledge", "pet.activate", "pet.delete", "pet.assets.repair", "petpack.import", "petpack.seed_bundled", "generation.start", "generation.retry", "generation.resume", "generation.edit", "generation.reply", "generation.cancel", "generation.history.delete", "connections.repair", "connections.uninstall", "connections.refresh_installed", "portable_skill.install", "portable_skill.uninstall", "product.convergence.update": true default: false } } private static func isPollingRPC(_ method: String) -> Bool { method == "state.wait" || method == "generation.messages.wait" } } private struct OverlayPlacementUpdateResponse { let ok: Bool let conflict: Bool let placement: OverlayPlacement let placementRevision: UInt64 let intent: OverlayPlacementRemoteIntent? init(result: Any) throws { guard let object = result as? [String: Any], let ok = object["ok"] as? Bool, let encodedRevision = object["overlay_placement_revision"] as? String, let placementRevision = OverlayPlacementRevisionCodec.parse( encodedRevision ), let placementObject = object["overlay_placement"] as? [String: Any], object.keys.contains("overlay_placement_intent") else { throw PetCoreClientError.invalidResponse } let placementData = try JSONSerialization.data( withJSONObject: placementObject ) placement = try JSONDecoder().decode( OverlayPlacement.self, from: placementData ) self.placementRevision = placementRevision self.ok = ok conflict = object["conflict"] as? Bool ?? false if object["overlay_placement_intent"] is NSNull { intent = nil } else if let encodedIntent = object["overlay_placement_intent"] as? String, let decodedIntent = OverlayPlacementRemoteIntent( rawValue: encodedIntent ) { intent = decodedIntent } else { throw PetCoreClientError.invalidResponse } if ok { guard !conflict, object["revision"] is String else { throw PetCoreClientError.invalidResponse } } else { guard conflict, object["revision"] == nil else { throw PetCoreClientError.invalidResponse } } } } private struct StateSnapshot: Codable { var revision: String? var changed: Bool? var behavior: BehaviorSettings var behaviorRevision: String? var onboarding: VersionedOnboardingProgress? var overlayPlacement: OverlayPlacement? var overlayPlacementRevision: String var overlayPlacementIntent: OverlayPlacementRemoteIntent? var pets: [PetSummary] var petAssetWarnings: [PetAssetWarning]? var activeGeneration: ActiveGenerationSnapshot? var activeAgentState: ActiveAgentState? var activeAgentSessions: [ActiveAgentState]? var activeAgentSessionsOmittedCount: Int? var overlayVisibility: OverlayVisibility? var events: [AgentEvent] var recentEvents: [AgentEvent]? var connections: [AgentConnectionStatus] enum CodingKeys: String, CodingKey { case revision case changed case behavior case behaviorRevision = "behavior_revision" case onboarding case overlayPlacement = "overlay_placement" case overlayPlacementRevision = "overlay_placement_revision" case overlayPlacementIntent = "overlay_placement_intent" case pets case petAssetWarnings = "pet_asset_warnings" case activeGeneration = "active_generation" case activeAgentState = "active_agent_state" case activeAgentSessions = "active_agent_sessions" case activeAgentSessionsOmittedCount = "active_agent_sessions_omitted_count" case overlayVisibility = "overlay_visibility" case events case recentEvents = "recent_events" case connections } } private struct GenerationMessagesSnapshot: Codable { var revision: String? var changed: Bool? var heartbeatAt: String? var messages: [GenerationMessage] var resultPetID: String? var revisionID: String? var validationSummary: GenerationValidationSummary? var resultMetadata: GenerationResultMetadata { GenerationResultMetadata( resultPetID: resultPetID, revisionID: revisionID, validationSummary: validationSummary ) } enum CodingKeys: String, CodingKey { case revision case changed case heartbeatAt = "heartbeat_at" case messages case resultPetID = "result_pet_id" case revisionID = "revision_id" case validationSummary = "validation_summary" } }