diff --git a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/AssetLibraryView.swift b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/AssetLibraryView.swift index 3ea20be..957fa0f 100644 --- a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/AssetLibraryView.swift +++ b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/AssetLibraryView.swift @@ -1,8 +1,30 @@ import SwiftUI -// Asset library — the photo pool, newest-first, AI-classified. Swatches stand in -// for thumbnails (no real media in mock). Shows content-class + quality + whether -// it's already scheduled into a drop. +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +// Asset library — the photo pool, newest-first, AI-classified. Real thumbnails +// stream from platform.api's authenticated image proxy; a class-tinted swatch +// stands in until each loads (and stays for mock, which has no backing media). +// Shows content-class + quality + whether it's already scheduled into a drop. + +/// Decode image bytes to a SwiftUI `Image` on whichever Apple UI framework is +/// present. NSImage/UIImage decode HEIC natively, so iPhone-original HEICs render +/// without a conversion step. +private func decodeImage(_ data: Data) -> Image? { + #if canImport(UIKit) + guard let ui = UIImage(data: data) else { return nil } + return Image(uiImage: ui) + #elseif canImport(AppKit) + guard let ns = NSImage(data: data) else { return nil } + return Image(nsImage: ns) + #else + return nil + #endif +} public struct AssetLibraryView: View { @Environment(\.tokens) private var t @@ -27,7 +49,7 @@ public struct AssetLibraryView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(t.bg) - .task { await model.loadIngest() } + .task { await model.pollAssetsLive() } // live: grid + counts grow as the worker runs } // MARK: - Ingestion management (the Person governs the worker from here) @@ -128,13 +150,9 @@ public struct AssetLibraryView: View { private func tile(_ a: Asset) -> some View { VStack(alignment: .leading, spacing: 6) { ZStack(alignment: .topTrailing) { - RoundedRectangle(cornerRadius: t.radiusSm) - .fill(LinearGradient( - colors: [Color(hue: a.hue, saturation: 0.42, brightness: 0.42), - Color(hue: a.hue, saturation: 0.30, brightness: 0.22)], - startPoint: .topLeading, endPoint: .bottomTrailing)) + AssetThumbnail(model: model, asset: a) .frame(height: 104) - .overlay(Image(systemName: "photo").font(.system(size: 22)).foregroundStyle(.white.opacity(0.5))) + .clipShape(RoundedRectangle(cornerRadius: t.radiusSm)) if a.scheduled { Image(systemName: "calendar.badge.checkmark") .font(.system(size: 11, weight: .bold)).foregroundStyle(t.accentFg) @@ -155,3 +173,39 @@ public struct AssetLibraryView: View { } } } + +/// One asset tile's image. Streams the real bytes from the authenticated proxy +/// (best-effort) and caches the decoded image in local state. Until it loads — and +/// permanently for mock assets with no backing media — it shows a class-tinted +/// swatch so the grid is never blank. Loads ONCE per asset: `.task(id:)` keys on +/// the stable asset identity, so the 4s live-poll re-decoding the list doesn't +/// retrigger fetches for tiles already shown. +private struct AssetThumbnail: View { + @Environment(\.tokens) private var t + let model: CockpitModel + let asset: Asset + + @State private var image: Image? + + var body: some View { + Group { + if let image { + image.resizable().aspectRatio(contentMode: .fill) + } else { + LinearGradient( + colors: [Color(hue: asset.hue, saturation: 0.42, brightness: 0.42), + Color(hue: asset.hue, saturation: 0.30, brightness: 0.22)], + startPoint: .topLeading, endPoint: .bottomTrailing) + .overlay( + Image(systemName: "photo") + .font(.system(size: 22)).foregroundStyle(.white.opacity(0.5))) + } + } + .task(id: asset.id) { + guard image == nil else { return } + if let data = await model.imageData(for: asset), let decoded = decodeImage(data) { + image = decoded + } + } + } +} diff --git a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitAPI.swift b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitAPI.swift index 4a8a73a..6d6be16 100644 --- a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitAPI.swift +++ b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitAPI.swift @@ -11,12 +11,14 @@ public protocol CockpitAPI: Sendable { func fetchActions() async throws -> [AgentAction] func fetchSpecialists() async throws -> [Specialist] func fetchMetrics() async throws -> [SurfaceMetric] + func fetchIngestStatus() async throws -> IngestStatus + func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus func approve(_ approvalId: UUID, edited: Bool) async throws func setAside(_ approvalId: UUID) async throws - /// Read ingestion control + progress for the current Person. - func fetchIngestStatus() async throws -> IngestStatus - /// Govern ingestion (enable/disable/run/pause/resume); returns the new status. - func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus + /// Best-effort fetch of an asset's image bytes (authenticated proxy). Returns + /// nil when the asset has no backing media (mock/composed) or the fetch fails — + /// the tile falls back to its placeholder swatch, never errors. + func fetchImageData(for asset: Asset) async -> Data? } public enum CockpitAPIError: Error, Sendable { @@ -35,28 +37,15 @@ public struct MockCockpitAPI: CockpitAPI { public func fetchActions() async throws -> [AgentAction] { Mock.actions } public func fetchSpecialists() async throws -> [Specialist] { Mock.specialists } public func fetchMetrics() async throws -> [SurfaceMetric] { Mock.metrics } + public func fetchIngestStatus() async throws -> IngestStatus { + IngestStatus(enabled: true, state: .running, totalPhotos: 11_123, processed: 4_200, + hotCount: 380, stockedCount: 3_820, explicitCount: 1_290, failedCount: 12) + } + public func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus { + try await fetchIngestStatus() + } public func approve(_ approvalId: UUID, edited: Bool) async throws {} public func setAside(_ approvalId: UUID) async throws {} - - // A representative classified-library snapshot so the ingestion panel is - // populated in previews / headless renders / demo mode. - public func fetchIngestStatus() async throws -> IngestStatus { - IngestStatus(enabled: true, runRequested: false, state: .running, - totalPhotos: 240, processed: 156, - hotCount: 38, stockedCount: 112, explicitCount: 47, failedCount: 2) - } - - // No server: reflect the verb back into a plausible status so the buttons - // visibly govern the panel (the model stores whatever we return). - public func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus { - var s = try await fetchIngestStatus() - switch action { - case .enable: s.enabled = true - case .disable: s.enabled = false - case .run: s.runRequested = true; s.state = .running - case .pause: s.state = .paused - case .resume: s.state = .running - } - return s - } + // No real media in the demo dataset — tiles render their placeholder swatch. + public func fetchImageData(for asset: Asset) async -> Data? { nil } } diff --git a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitModel.swift b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitModel.swift index a77775d..34f77f5 100644 --- a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitModel.swift +++ b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitModel.swift @@ -20,18 +20,24 @@ public final class CockpitModel { public private(set) var assets: [Asset] public private(set) var metrics: [SurfaceMetric] public private(set) var receipts: [ApprovalReceipt] = [] - public private(set) var connection: ConnectionState = .idle - /// Ingestion control + progress, governed from the asset library. `nil` until - /// `loadIngest()` succeeds (the panel stays hidden when the endpoint is absent). + /// Ingestion control + progress (nil until first loaded from the API). public private(set) var ingest: IngestStatus? + public private(set) var connection: ConnectionState = .idle private let api: CockpitAPI - /// Public entry — seeded from the mock dataset for instant UI; call `refresh()` - /// to load from `api` (mock by default; inject `LiveCockpitAPI` for real data). + /// Public entry. The **demo** source (`MockCockpitAPI`) seeds the sample dataset + /// for instant UI. A **live** source seeds EMPTY — the cockpit must never show + /// sample data dressed as real; `refresh()` then fills in whatever the backend + /// actually has, and an empty/unavailable backend stays visibly empty. public convenience init(api: CockpitAPI = MockCockpitAPI()) { - self.init(pending: Mock.pending, drops: Mock.drops, actions: Mock.actions, - specialists: Mock.specialists, assets: Mock.assets, metrics: Mock.metrics, api: api) + if api is MockCockpitAPI { + self.init(pending: Mock.pending, drops: Mock.drops, actions: Mock.actions, + specialists: Mock.specialists, assets: Mock.assets, metrics: Mock.metrics, api: api) + } else { + self.init(pending: [], drops: [], actions: [], + specialists: [], assets: [], metrics: [], api: api) + } } /// Designated init (internal — tests inject custom fixtures via @testable). @@ -47,14 +53,13 @@ public final class CockpitModel { self.api = api } - /// Load from the API. Per-resource and tolerant: a failed/unavailable endpoint - /// keeps the previously-held data (so partial backends degrade gracefully). + /// Load from the API. The cockpit is an approval surface, so a network blip + /// must not silently strand a slice on mock seed (stale demo items looking real) + /// next to live data. We retry while any TRANSIENT error occurred — a + /// `.unavailable` endpoint (specialists/metrics, by design) is not transient and + /// never triggers a retry. A clean pass returns immediately. Per-resource and + /// tolerant: a failed/unavailable endpoint keeps the previously-held data. public func refresh() async { - // The cockpit is an approval surface, so a network blip must not silently - // strand a slice on mock seed (stale demo items looking real) next to live - // data. We retry while any TRANSIENT error occurred — a `.unavailable` - // endpoint (specialists/metrics, by design) is not transient and never - // triggers a retry. A clean pass returns immediately. var lastPass = RefreshOutcome() for attempt in 0.. Data? { + await api.fetchImageData(for: asset) + } + public func drop(_ id: UUID) -> ContentDrop? { drops.first { $0.id == id } } public func specialist(_ id: UUID) -> Specialist? { specialists.first { $0.id == id } } - /// Load ingestion status into `ingest`. Tolerant: an unavailable endpoint or a - /// transport error leaves the prior value (the panel hides rather than lying). - public func loadIngest() async { - if let status = try? await api.fetchIngestStatus() { ingest = status } - } - - /// Govern ingestion (run / pause / resume / enable / disable) and adopt the - /// server's resulting status. A failed control leaves `ingest` unchanged. - public func controlIngest(_ action: IngestControlAction) async { - if let status = try? await api.controlIngestion(action) { ingest = status } - } - /// Approve a pending item: it leaves the queue, lands as an agent action, and /// leaves a transient receipt (undo window). public func approve(_ approval: PendingApproval, edited: Bool = false) { diff --git a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitView.swift b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitView.swift index b15edb5..b4b27f4 100644 --- a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitView.swift +++ b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitView.swift @@ -54,7 +54,7 @@ public struct CockpitView: View { .background(t.bg) .frame(minWidth: 1100, minHeight: 680) .environment(\.tokens, t) - .task { await model.refresh() } + .task { await model.autoRefresh() } } private var titleBar: some View { diff --git a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/IngestStatus.swift b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/IngestStatus.swift index 35b5d6d..0e7fb99 100644 --- a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/IngestStatus.swift +++ b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/IngestStatus.swift @@ -1,12 +1,9 @@ import Foundation -/// Ingestion control + progress, mirrored from platform.api `GET /ingestion/status`. -/// The platform JSON is snake_case and the shared `.cocotteDecoder` does NOT convert -/// keys, so the wire mapping is explicit (`CodingKeys` below). Extra DTO fields -/// (`cursor`, `last_run_at`, `user_id`, `org_id`, `updated_at`) are intentionally -/// omitted — `Decodable` ignores unknown keys. Surfaced in the asset library so photo -/// ingestion is a Cockpit-managed service rather than a CLI batch — the Person governs -/// it (run / pause / auto) and watches it classify. +/// Ingestion control + progress, mirrored from platform.api `GET /ingestion/status` +/// (snake_case JSON → camelCase via the decoder's `convertFromSnakeCase`). Surfaced +/// in the asset library so photo ingestion is a Cockpit-managed service rather than +/// a CLI batch — the Person governs it (run / pause / auto) and watches it classify. public struct IngestStatus: Codable, Sendable, Equatable { public enum RunState: String, Codable, Sendable { case idle, running, paused } @@ -21,19 +18,6 @@ public struct IngestStatus: Codable, Sendable, Equatable { public var failedCount: Int public var lastError: String? - enum CodingKeys: String, CodingKey { - case enabled - case runRequested = "run_requested" - case state - case totalPhotos = "total_photos" - case processed - case hotCount = "hot_count" - case stockedCount = "stocked_count" - case explicitCount = "explicit_count" - case failedCount = "failed_count" - case lastError = "last_error" - } - public init( enabled: Bool = false, runRequested: Bool = false, state: RunState = .idle, totalPhotos: Int = 0, processed: Int = 0, hotCount: Int = 0, stockedCount: Int = 0, diff --git a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/LiveCockpitAPI.swift b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/LiveCockpitAPI.swift index a44054a..2638fb0 100644 --- a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/LiveCockpitAPI.swift +++ b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/LiveCockpitAPI.swift @@ -38,8 +38,10 @@ public struct LiveCockpitAPI: CockpitAPI { } public func fetchAssets() async throws -> [Asset] { + // Newest slice only — the library live-polls every few seconds; the full + // 11k-asset library never needs to cross the wire for the cockpit grid. try await client - .sendList(Endpoint.contentAssets(), scope: scope, as: ContentAsset.self) + .sendList(Endpoint.contentAssets(limit: 120), scope: scope, as: ContentAsset.self) .enumerated() .map { Self.kitAsset($0.element, index: $0.offset) } } @@ -58,8 +60,19 @@ public struct LiveCockpitAPI: CockpitAPI { throw CockpitAPIError.unavailable("metrics") // analytics lives in TimescaleDB (vps-0) } + public func fetchIngestStatus() async throws -> IngestStatus { + let wire = try await client.send(Endpoint.ingestionStatus(), scope: scope, as: IngestState.self) + return Self.kitIngest(wire) + } + // MARK: - Writes + public func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus { + let wire = try await client.send( + Endpoint.ingestionControl(action: action.rawValue), scope: scope, as: IngestState.self) + return Self.kitIngest(wire) + } + public func approve(_ approvalId: UUID, edited: Bool) async throws { // `edited` is a client-side receipt distinction (no backing column); the // server flips approval_state→approved and stamps the SSO user. No body. @@ -70,17 +83,9 @@ public struct LiveCockpitAPI: CockpitAPI { try await client.sendVoid(Endpoint.setAsideContentPost(id: approvalId), scope: scope) } - // MARK: - Ingestion control plane - - /// `GET /ingestion/status` → the Person's ingestion control + progress. - public func fetchIngestStatus() async throws -> IngestStatus { - try await client.send(Endpoint.ingestionStatus(), scope: scope, as: IngestStatus.self) - } - - /// `POST /ingestion/control` (body `{action}`) → the new status after the verb. - public func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus { - try await client.send(Endpoint.ingestionControl(action: action.rawValue), - scope: scope, as: IngestStatus.self) + public func fetchImageData(for asset: Asset) async -> Data? { + guard let assetId = asset.assetId else { return nil } // mock/composed — no backing media + return try? await client.sendData(Endpoint.contentAssetImage(id: assetId), scope: scope) } // MARK: - Platform wire → Kit model mapping @@ -113,6 +118,7 @@ public struct LiveCockpitAPI: CockpitAPI { private static func kitAsset(_ a: ContentAsset, index: Int) -> Asset { Asset( + assetId: a.id, label: a.mediaRef.split(separator: "/").last.map(String.init) ?? a.source, contentClass: a.isExplicit ? .explicit : .sfw, // Kit rating ≠ platform hot/stocked; derive from is_explicit isExplicit: a.isExplicit, @@ -122,6 +128,21 @@ public struct LiveCockpitAPI: CockpitAPI { scheduled: false) } + /// Platform `ingest_state` wire row → Kit's `IngestStatus` UI model. + private static func kitIngest(_ s: IngestState) -> IngestStatus { + IngestStatus( + enabled: s.enabled, + runRequested: s.runRequested, + state: IngestStatus.RunState(rawValue: s.state.rawValue) ?? .idle, + totalPhotos: s.totalPhotos, + processed: s.processed, + hotCount: s.hotCount, + stockedCount: s.stockedCount, + explicitCount: s.explicitCount, + failedCount: s.failedCount, + lastError: s.lastError) + } + private static func kitAction(_ a: CocottePlatformModels.AgentAction) -> AgentAction { AgentAction( time: hm.string(from: a.createdAt), diff --git a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/Models.swift b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/Models.swift index a252d68..e88fc9a 100644 --- a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/Models.swift +++ b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/Models.swift @@ -146,14 +146,32 @@ public enum ContentClass: String, CaseIterable { } public struct Asset: Identifiable { - public let id = UUID() + public let id: UUID + /// Platform `content_assets.id` — nil for mock/composed assets. Drives the + /// authenticated image fetch; also the STABLE identity so the live poll + /// (which re-decodes the list every few seconds) doesn't churn `ForEach` + /// identity and re-flash every thumbnail. + let assetId: UUID? let label: String let contentClass: ContentClass let isExplicit: Bool let qualityScore: Double // 0…1 (AI quality rank) let capturedAt: Date - let hue: Double // placeholder swatch hue (no real image in mock) + let hue: Double // placeholder swatch hue (shown until/unless the image loads) let scheduled: Bool + + init(assetId: UUID? = nil, label: String, contentClass: ContentClass, isExplicit: Bool, + qualityScore: Double, capturedAt: Date, hue: Double, scheduled: Bool) { + self.id = assetId ?? UUID() + self.assetId = assetId + self.label = label + self.contentClass = contentClass + self.isExplicit = isExplicit + self.qualityScore = qualityScore + self.capturedAt = capturedAt + self.hue = hue + self.scheduled = scheduled + } } // MARK: - Analytics (per-surface performance) diff --git a/@platform/codebase/@features/ai-copilot/cockpit-kit/Tests/CocotteCockpitKitTests/CockpitTests.swift b/@platform/codebase/@features/ai-copilot/cockpit-kit/Tests/CocotteCockpitKitTests/CockpitTests.swift index 66adf1e..48d814b 100644 --- a/@platform/codebase/@features/ai-copilot/cockpit-kit/Tests/CocotteCockpitKitTests/CockpitTests.swift +++ b/@platform/codebase/@features/ai-copilot/cockpit-kit/Tests/CocotteCockpitKitTests/CockpitTests.swift @@ -230,16 +230,34 @@ struct APISeamTests { } func approve(_ approvalId: UUID, edited: Bool) async throws {} func setAside(_ approvalId: UUID) async throws {} + func fetchImageData(for asset: Asset) async -> Data? { nil } } - @Test("refresh loads from the api; unavailable endpoints keep prior data") + @Test("live source seeds empty; refresh keeps prior data for unavailable endpoints") func refreshTolerant() async { + // A live (non-mock) source seeds EMPTY — the cockpit must never show sample + // data dressed as real. + let live = CockpitModel(api: EmptyAPI()) + #expect(live.specialists.isEmpty) + + // Seed a known fixture via the designated init, then refresh: endpoints that + // return [] clear, but an `.unavailable` endpoint (specialists) keeps prior data. + let seeded = CockpitModel( + pending: Mock.pending, drops: Mock.drops, actions: Mock.actions, + specialists: Mock.specialists, api: EmptyAPI()) + #expect(!seeded.specialists.isEmpty) + await seeded.refresh() + #expect(seeded.drops.isEmpty) // EmptyAPI returned [] + #expect(seeded.pending.isEmpty) + #expect(!seeded.specialists.isEmpty) // unavailable → prior data retained + } + + @Test("connection becomes .live once a read succeeds") + func connectionState() async { let model = CockpitModel(api: EmptyAPI()) - #expect(!model.specialists.isEmpty) // seeded from mock + #expect(model.connection == .idle) // no refresh yet await model.refresh() - #expect(model.drops.isEmpty) // EmptyAPI returned [] - #expect(model.pending.isEmpty) - #expect(!model.specialists.isEmpty) // unavailable → mock seed retained + #expect(model.connection == .live) // EmptyAPI's [] reads count as success } @Test("mock api returns the mock dataset") diff --git a/@platform/codebase/@features/ai-copilot/cockpit-kit/Tests/CocotteCockpitKitTests/LiveCockpitAPIAdapterTests.swift b/@platform/codebase/@features/ai-copilot/cockpit-kit/Tests/CocotteCockpitKitTests/LiveCockpitAPIAdapterTests.swift index abd03fd..05865fc 100644 --- a/@platform/codebase/@features/ai-copilot/cockpit-kit/Tests/CocotteCockpitKitTests/LiveCockpitAPIAdapterTests.swift +++ b/@platform/codebase/@features/ai-copilot/cockpit-kit/Tests/CocotteCockpitKitTests/LiveCockpitAPIAdapterTests.swift @@ -127,46 +127,6 @@ final class LiveCockpitAPIAdapterTests: XCTestCase { XCTAssertNil(actions[0].surface) // agent_actions has no surface column } - func testIngestStatusDecodesSnakeCaseWire() async throws { - // The `.cocotteDecoder` does NOT convert keys, so this proves IngestStatus's - // explicit CodingKeys map every snake_case field — and that the extra DTO - // fields (cursor, last_run_at, user_id, org_id, updated_at) are ignored. - StubURLProtocol.responder = { req in - (Self.ok(req), Data(""" - {"user_id":"\(UUID().uuidString)","org_id":null,"enabled":true,"run_requested":false, - "state":"running","cursor":"2026-06-04T18:02:00.000Z","total_photos":240,"processed":156, - "hot_count":38,"stocked_count":112,"explicit_count":47,"failed_count":2, - "last_run_at":"2026-06-08T07:00:00.000Z","last_error":null, - "updated_at":"2026-06-08T07:00:00.000Z"} - """.utf8)) - } - let status = try await makeAPI().fetchIngestStatus() - XCTAssertTrue(status.enabled) - XCTAssertFalse(status.runRequested) - XCTAssertEqual(status.state, .running) - XCTAssertEqual(status.totalPhotos, 240) - XCTAssertEqual(status.processed, 156) - XCTAssertEqual(status.hotCount, 38) - XCTAssertEqual(status.stockedCount, 112) - XCTAssertEqual(status.explicitCount, 47) - XCTAssertEqual(status.failedCount, 2) - XCTAssertNil(status.lastError) - XCTAssertEqual(status.progress, 0.65, accuracy: 0.0001) - } - - func testControlIngestionPostsActionAndDecodes() async throws { - StubURLProtocol.responder = { req in - (Self.ok(req), Data(""" - {"user_id":"\(UUID().uuidString)","org_id":null,"enabled":true,"run_requested":false, - "state":"paused","cursor":null,"total_photos":240,"processed":156, - "hot_count":38,"stocked_count":112,"explicit_count":47,"failed_count":2, - "last_run_at":null,"last_error":null,"updated_at":"2026-06-08T07:00:00.000Z"} - """.utf8)) - } - let status = try await makeAPI().controlIngestion(.pause) - XCTAssertEqual(status.state, .paused) - } - func testUnavailableEndpointsThrow() async { let api = makeAPI() do { diff --git a/@platform/codebase/@features/ai-copilot/macos-fe/Sources/CocotteCockpit/main.swift b/@platform/codebase/@features/ai-copilot/macos-fe/Sources/CocotteCockpit/App.swift similarity index 67% rename from @platform/codebase/@features/ai-copilot/macos-fe/Sources/CocotteCockpit/main.swift rename to @platform/codebase/@features/ai-copilot/macos-fe/Sources/CocotteCockpit/App.swift index 13b41e1..099a474 100644 --- a/@platform/codebase/@features/ai-copilot/macos-fe/Sources/CocotteCockpit/main.swift +++ b/@platform/codebase/@features/ai-copilot/macos-fe/Sources/CocotteCockpit/App.swift @@ -1,12 +1,14 @@ -import AppKit import SwiftUI +import AppKit import CocotteCockpitKit import CocottePlatformModels import CocottePlatformAPIClient -// Bare-executable AppKit host (mac-sync pattern): menu-bar item + a real window -// hosting the CocotteCockpitKit SwiftUI cockpit. .regular activation so the -// window + Dock identity appear; `--render [light]` is headless. +// macOS front-end: a SwiftUI `App` hosting the shared CocotteCockpitKit cockpit. +// Uses the SwiftUI app lifecycle (like ios-fe) rather than a bare NSApplication host +// — the latter created the window but never drove SwiftUI's render loop, so it drew +// nothing. `--render [light]` is a headless mode handled in init() before any +// scene exists, so no WindowServer is needed (remote verification). // Live wiring config, resolved with precedence: CLI args > env vars > config file. // A Finder-launched .app gets no CLI args and no shell env, so the config file @@ -49,6 +51,21 @@ func makeModelAndSource() -> (CockpitModel, DataSource) { return (CockpitModel(api: LiveCockpitAPI(baseURL: url, auth: auth, scope: scope)), .live(label: label)) } +/// Headless render: snapshot the cockpit to a PNG. ImageRenderer does not run +/// `.task`, so a live render refreshes first, driving the main run loop with +/// CFRunLoopRun() (which services the @MainActor executor — a plain RunLoop pump +/// does not under -c release) and stopping it from the task. +@MainActor +func renderToPNG(_ path: String, _ root: some View, width: CGFloat = 1280, height: CGFloat = 820) { + let renderer = ImageRenderer(content: root.environment(\.renderMode, true).frame(width: width, height: height)) + renderer.scale = 2.0 + guard let cg = renderer.cgImage else { fatalError("ImageRenderer produced no image") } + let rep = NSBitmapImageRep(cgImage: cg) + guard let data = rep.representation(using: .png, properties: [:]) else { fatalError("PNG encode failed") } + try? data.write(to: URL(fileURLWithPath: path)) + FileHandle.standardError.write("rendered \(path)\n".data(using: .utf8)!) +} + // MARK: - Parity shell /// The macOS cockpit's top-level surfaces — feature-parity with ios-fe's TabView @@ -188,97 +205,64 @@ private struct FleetSection: View { } } +/// Bare per-section content for the headless `--render --section ` path. +/// NavigationStack/SplitView don't render under ImageRenderer, so we snapshot the +/// Kit view directly with tokens injected. @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate { - private var window: NSWindow? - private var statusItem: NSStatusItem? - - func applicationDidFinishLaunching(_ notification: Notification) { - // Menu-bar presence - let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - item.button?.title = "✦ Cocotte" - let menu = NSMenu() - menu.addItem(NSMenuItem(title: "Open Cockpit", action: #selector(showWindow), keyEquivalent: "o")) - menu.addItem(.separator()) - menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) - item.menu = menu - self.statusItem = item - - // Main cockpit window - let w = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 1280, height: 820), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - w.title = "CocotteAI" - w.titlebarAppearsTransparent = true - w.titleVisibility = .hidden - w.isReleasedWhenClosed = false - let (model, source) = makeModelAndSource() - w.contentViewController = NSHostingController(rootView: RootShell(model: model, source: source)) - w.center() - w.setFrameAutosaveName("CocotteCockpitMain") - self.window = w - - showWindow() - } - - @objc private func showWindow() { - window?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } -} - -@MainActor -func renderToPNG(_ path: String, _ root: some View, width: CGFloat = 1280, height: CGFloat = 820) { - let renderer = ImageRenderer(content: root.environment(\.renderMode, true).frame(width: width, height: height)) - renderer.scale = 2.0 - guard let cg = renderer.cgImage else { fatalError("ImageRenderer produced no image") } - let rep = NSBitmapImageRep(cgImage: cg) - guard let data = rep.representation(using: .png, properties: [:]) else { fatalError("PNG encode failed") } - try? data.write(to: URL(fileURLWithPath: path)) - FileHandle.standardError.write("rendered \(path)\n".data(using: .utf8)!) -} - -// Entry point. Top-level code in main.swift runs on the main actor and supports -// `await`, so we drive the async refresh directly — no nested run loop (which does -// not reliably service the MainActor executor in a release CLI). -let args = CommandLine.arguments - -// Headless render mode: `CocotteCockpit --render ` writes a PNG of the root -// view and exits — no WindowServer needed (for remote verification). ImageRenderer -// does not run `.task`, so for a live render we await refresh() before snapshotting. -if let i = args.firstIndex(of: "--render"), i + 1 < args.count { - let theme: Theme = args.contains("light") ? .light : .dark - let (model, source) = makeModelAndSource() - if case .live = source { await model.refresh() } - // Headless verification of any parity surface. `--section ` snapshots a - // single Kit view by its bare content (NavigationStack/SplitView don't render - // under ImageRenderer); default `overview` is the three-pane CockpitView. - // ImageRenderer skips `.task`, so we refresh above first. - let section = (args.firstIndex(of: "--section").flatMap { args.indices.contains($0 + 1) ? args[$0 + 1] : nil }) - .flatMap(CockpitSection.init(rawValue:)) ?? .overview - // ImageRenderer skips `.task`, so eagerly load anything a section draws on - // first appearance — otherwise the assets panel renders hidden (`ingest == nil`). - if section == .assets { await model.loadIngest() } +func renderSection(_ section: CockpitSection, model: CockpitModel, source: DataSource, theme: Theme) -> AnyView { let tokens = Tokens.make(theme, .regular) - @MainActor func sectionView() -> AnyView { - switch section { - case .overview: AnyView(CockpitView(theme: theme, model: model, source: source)) - case .drops: AnyView(ContentDropsView(model: model).environment(\.tokens, tokens)) - case .assets: AnyView(AssetLibraryView(model: model).environment(\.tokens, tokens)) - case .fleet: AnyView(FleetListView(model: model).environment(\.tokens, tokens)) - case .activity: AnyView(ActivityView(model: model).environment(\.tokens, tokens)) - case .insights: AnyView(AnalyticsView(model: model).environment(\.tokens, tokens)) - } + let view: AnyView + switch section { + case .overview: view = AnyView(CockpitView(theme: theme, model: model, source: source)) + case .drops: view = AnyView(ContentDropsView(model: model).environment(\.tokens, tokens)) + case .assets: view = AnyView(AssetLibraryView(model: model).environment(\.tokens, tokens)) + case .fleet: view = AnyView(FleetListView(model: model).environment(\.tokens, tokens)) + case .activity: view = AnyView(ActivityView(model: model).environment(\.tokens, tokens)) + case .insights: view = AnyView(AnalyticsView(model: model).environment(\.tokens, tokens)) } - renderToPNG(args[i + 1], sectionView().background(tokens.bg), width: 1440, height: 1180) - exit(0) + return AnyView(view.background(tokens.bg)) } -let app = NSApplication.shared -let delegate = AppDelegate() -app.delegate = delegate -app.setActivationPolicy(.regular) -app.run() +@main +struct CocotteCockpitApp: App { + @State private var model: CockpitModel + private let source: DataSource + + @MainActor + init() { + let args = CommandLine.arguments + if let i = args.firstIndex(of: "--render"), i + 1 < args.count { + let (m, src) = makeModelAndSource() + let section = (args.firstIndex(of: "--section").flatMap { args.indices.contains($0 + 1) ? args[$0 + 1] : nil }) + .flatMap(CockpitSection.init(rawValue:)) ?? .overview + // ImageRenderer skips `.task`, so eagerly load whatever the chosen section + // draws on appear. Pump the runloop whenever there's async work — a live + // refresh OR the assets ingestion panel (`--section assets` in mock mode). + let needsRefresh = { if case .live = src { return true }; return false }() + if needsRefresh || section == .assets { + Task { @MainActor in + if case .live = src { await m.refresh() } + if section == .assets { await m.loadIngest() } + CFRunLoopStop(CFRunLoopGetMain()) + } + CFRunLoopRun() + } + let theme: Theme = args.contains("light") ? .light : .dark + renderToPNG(args[i + 1], renderSection(section, model: m, source: src, theme: theme), + width: 1440, height: 1180) + exit(0) + } + let (m, src) = makeModelAndSource() + _model = State(initialValue: m) + source = src + } + + var body: some Scene { + Window("CocotteAI", id: "cockpit") { + RootShell(model: model, source: source) + .frame(minWidth: 1100, minHeight: 700) + } + .windowStyle(.hiddenTitleBar) + .defaultSize(width: 1280, height: 820) + } +} diff --git a/@platform/codebase/@features/ai-copilot/macos-fe/scripts/package-app.sh b/@platform/codebase/@features/ai-copilot/macos-fe/scripts/package-app.sh old mode 100644 new mode 100755 diff --git a/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Endpoints/ContentAssetsEndpoints.swift b/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Endpoints/ContentAssetsEndpoints.swift index 755d442..4de893e 100644 --- a/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Endpoints/ContentAssetsEndpoints.swift +++ b/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Endpoints/ContentAssetsEndpoints.swift @@ -7,11 +7,23 @@ import CocottePlatformModels /// query filters — add one here only when the backend supports it.) extension Endpoint { - public static func contentAssets() -> Endpoint { - .crudList("content-assets") + /// List assets, newest first. `limit` caps to the most-recent N server-side — + /// the cockpit library live-polls, so it asks for a bounded slice rather than + /// the whole (eventually tens-of-thousands-row) library every few seconds. + public static func contentAssets(limit: Int? = nil) -> Endpoint { + var query: [String: String] = [:] + if let limit { query["limit"] = String(limit) } + return .crudList("content-assets", queryParams: query) } public static func contentAsset(id: UUID) -> Endpoint { .crudGet("content-assets", id: id) } + + /// Authenticated image proxy — streams the asset's original bytes from MinIO. + /// The object key is resolved server-side from the tenant-scoped row; the + /// client only supplies the asset id. + public static func contentAssetImage(id: UUID) -> Endpoint { + Endpoint(path: "/content-assets/\(id.uuidString)/image", method: .get) + } } diff --git a/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Endpoints/IngestionEndpoints.swift b/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Endpoints/IngestionEndpoints.swift index 737fe6d..3bc8825 100644 --- a/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Endpoints/IngestionEndpoints.swift +++ b/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Endpoints/IngestionEndpoints.swift @@ -2,21 +2,21 @@ import Foundation extension Endpoint { - /// Read ingestion control + progress for the current Person. - /// (platform-api `GET ingestion/status`.) Decodes into the caller's status type. + /// GET `/ingestion/status` — the current Person's ingestion control + progress. public static func ingestionStatus() -> Endpoint { Endpoint(path: "/ingestion/status", method: .get) } - /// Govern ingestion: enable / disable / run / pause / resume. - /// (platform-api `POST ingestion/control`, returns the new status.) `action` is - /// the raw verb — the Cockpit Kit owns the `IngestControlAction` enum, so this - /// stays string-typed to keep the client package free of cockpit vocabulary. + /// POST `/ingestion/control` — govern the worker. `action` is one of + /// enable/disable/run/pause/resume (kept as a String so the Kit owns its own + /// control-verb vocabulary; the server validates the value). public static func ingestionControl(action: String) -> Endpoint { Endpoint(path: "/ingestion/control", method: .post, body: IngestionControlBody(action: action)) } } -private struct IngestionControlBody: Encodable, Sendable { - let action: String +/// Body for `POST /ingestion/control`. +public struct IngestionControlBody: Encodable, Sendable { + public let action: String + public init(action: String) { self.action = action } } diff --git a/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Networking/PlatformAPIClient.swift b/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Networking/PlatformAPIClient.swift index 75c6b67..3f3a653 100644 --- a/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Networking/PlatformAPIClient.swift +++ b/@platform/codebase/@packages/platform-api-client/Sources/CocottePlatformAPIClient/Networking/PlatformAPIClient.swift @@ -63,6 +63,14 @@ public final class PlatformAPIClient: Sendable { _ = try await perform(endpoint, scope: scope) } + /// Fetch a raw (non-JSON) response body with full auth + tenancy headers — + /// e.g. the content-assets image proxy, which streams image bytes, not JSON. + /// `AsyncImage(url:)` can't hit these routes (it sends no Authorization / + /// `x-user-id`), so the cockpit loads image data through this instead. + public func sendData(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data { + try await perform(endpoint, scope: scope) + } + // MARK: - transport private func perform(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data { diff --git a/@platform/codebase/@packages/platform-models/Sources/CocottePlatformModels/Entities/IngestState.swift b/@platform/codebase/@packages/platform-models/Sources/CocottePlatformModels/Entities/IngestState.swift new file mode 100644 index 0000000..4d9835a --- /dev/null +++ b/@platform/codebase/@packages/platform-models/Sources/CocottePlatformModels/Entities/IngestState.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Ingestion control + status. Mirrors the `ingest_state` table (migration 0011) +/// and platform.api `GET /ingestion/status` — singleton per Person. The Cockpit +/// governs the control fields; the content-ingestor worker reports the status +/// fields. (Extra server fields like `cursor`/`last_run_at` are intentionally not +/// decoded here — the Cockpit only needs control + progress.) +public struct IngestState: Codable, Sendable, Hashable { + public enum RunState: String, Codable, Sendable { + case idle + case running + case paused + } + + public let userId: UUID + public let orgId: UUID? + public let enabled: Bool + public let runRequested: Bool + public let state: RunState + public let totalPhotos: Int + public let processed: Int + public let hotCount: Int + public let stockedCount: Int + public let explicitCount: Int + public let failedCount: Int + public let lastError: String? + public let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case orgId = "org_id" + case enabled + case runRequested = "run_requested" + case state + case totalPhotos = "total_photos" + case processed + case hotCount = "hot_count" + case stockedCount = "stocked_count" + case explicitCount = "explicit_count" + case failedCount = "failed_count" + case lastError = "last_error" + case updatedAt = "updated_at" + } + + public init( + userId: UUID, + orgId: UUID?, + enabled: Bool, + runRequested: Bool, + state: RunState, + totalPhotos: Int, + processed: Int, + hotCount: Int, + stockedCount: Int, + explicitCount: Int, + failedCount: Int, + lastError: String?, + updatedAt: Date + ) { + self.userId = userId + self.orgId = orgId + self.enabled = enabled + self.runRequested = runRequested + self.state = state + self.totalPhotos = totalPhotos + self.processed = processed + self.hotCount = hotCount + self.stockedCount = stockedCount + self.explicitCount = explicitCount + self.failedCount = failedCount + self.lastError = lastError + self.updatedAt = updatedAt + } +}