feat(@projects/@cocottetech): ✨ enhance asset loading with native image support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c7a58ae9d9
commit
66a79ce47f
15 changed files with 412 additions and 245 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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..<maxRefreshAttempts {
|
||||
lastPass = await refreshOnce()
|
||||
|
|
@ -87,24 +92,64 @@ public final class CockpitModel {
|
|||
await load({ try await api.fetchActions() }) { actions = $0 }
|
||||
await load({ try await api.fetchSpecialists() }) { specialists = $0 }
|
||||
await load({ try await api.fetchMetrics() }) { metrics = $0 }
|
||||
await load({ try await api.fetchIngestStatus() }) { ingest = $0 }
|
||||
return out
|
||||
}
|
||||
|
||||
/// Drive refresh for a view's lifetime: an immediate load, then a periodic
|
||||
/// re-poll so the cockpit self-heals after a transient backend/network outage
|
||||
/// (the LAN to black is intermittently flaky — a single launch-time refresh that
|
||||
/// lands in a blip would otherwise strand the UI empty until relaunch). The
|
||||
/// hosting `.task` cancels this automatically when the view goes away.
|
||||
public func autoRefresh(every seconds: UInt64 = 30) async {
|
||||
await refresh()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
|
||||
if Task.isCancelled { break }
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load just the ingestion status (the asset library refreshes this on appear).
|
||||
public func loadIngest() async {
|
||||
if let v = try? await api.fetchIngestStatus() { ingest = v }
|
||||
}
|
||||
|
||||
/// Live cadence for the Assets view while it's open: refresh the ingest status
|
||||
/// AND the asset grid every few seconds, so the library visibly grows (newest
|
||||
/// processed at top) and the progress/counts climb as the worker runs. The
|
||||
/// hosting `.task` cancels this when the view goes away. Errors are swallowed
|
||||
/// per-call (a blip just skips a tick; the next tick recovers).
|
||||
public func pollAssetsLive(every seconds: UInt64 = 4) async {
|
||||
await refreshAssetsAndIngest()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
|
||||
if Task.isCancelled { break }
|
||||
await refreshAssetsAndIngest()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAssetsAndIngest() async {
|
||||
if let v = try? await api.fetchAssets() { assets = v }
|
||||
if let v = try? await api.fetchIngestStatus() { ingest = v }
|
||||
}
|
||||
|
||||
/// Govern ingestion from the Cockpit (run / pause / resume / enable / disable);
|
||||
/// the returned authoritative status replaces local state.
|
||||
public func controlIngest(_ action: IngestControlAction) async {
|
||||
if let v = try? await api.controlIngestion(action) { ingest = v }
|
||||
}
|
||||
|
||||
/// Load an asset's image bytes through the data seam (authenticated proxy on
|
||||
/// the live source; nil on mock). Best-effort — the tile shows its placeholder
|
||||
/// swatch until/unless this returns data.
|
||||
public func imageData(for asset: Asset) async -> 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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 <png> [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 <png> [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 <name>` 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 <path>` 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 <name>` 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)
|
||||
}
|
||||
}
|
||||
0
@platform/codebase/@features/ai-copilot/macos-fe/scripts/package-app.sh
Normal file → Executable file
0
@platform/codebase/@features/ai-copilot/macos-fe/scripts/package-app.sh
Normal file → Executable file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue