cocottetech/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/LiveCockpitAPI.swift
Natalie d114d9d375 feat(cockpit-kit): 📸 add bump screenshot overlay
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-10 05:00:56 -07:00

248 lines
11 KiB
Swift

import Foundation
import CocottePlatformModels
import CocottePlatformAPIClient
// Adapter: drives the shared V4 platform client (CocottePlatformAPIClient
// platform.api on black:3060) and maps its wire entities onto the cockpit's
// UI-facing models. The Kit owns its own presentation vocabulary (Surface,
// DropState, ContentClass); this layer is the ONLY place that knows the platform
// wire types. Endpoints platform.api doesn't expose yet throw `.unavailable`;
// CockpitModel.refresh keeps prior data per failed endpoint (partial backends
// degrade gracefully).
public struct LiveCockpitAPI: CockpitAPI {
private let client: PlatformAPIClient
private let scope: TenantScope
/// - Parameters:
/// - baseURL: platform.api host root (e.g. `http://black:3060`); the client adds `/api/v1`.
/// - auth: Bearer-token source (`InMemoryAuthProvider` for CLI/tests; Keychain-backed in prod).
/// - scope: tenant scope (person-first; org optional), sent as `x-user-id` / `x-org-id`.
public init(baseURL: URL, auth: any AuthProvider, scope: TenantScope, session: URLSession = .shared) {
self.client = PlatformAPIClient(baseURL: baseURL, auth: auth, session: session)
self.scope = scope
}
// MARK: - Reads
public func fetchDrops() async throws -> [ContentDrop] {
try await client
.sendList(Endpoint.contentDrops(), scope: scope, as: CocottePlatformModels.ContentDrop.self)
.map { Self.kitDrop($0) }
}
public func fetchPending() async throws -> [PendingApproval] {
try await client
.sendList(Endpoint.contentPosts(approvalState: .pending), scope: scope, as: ContentPost.self)
.map(Self.kitApproval)
}
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(limit: 120), scope: scope, as: ContentAsset.self)
.enumerated()
.map { Self.kitAsset($0.element, index: $0.offset) }
}
public func fetchActions() async throws -> [AgentAction] {
try await client
.sendList(Endpoint.agentActions(), scope: scope, as: CocottePlatformModels.AgentAction.self)
.map(Self.kitAction)
}
public func fetchSpecialists() async throws -> [Specialist] {
try await client
.sendList(Endpoint.specialists(), scope: scope, as: SpecialistSummary.self)
.map(Self.kitSpecialist)
}
public func fetchMetrics() async throws -> [SurfaceMetric] {
// Surfaces with no Kit equivalent (tiktok/youtube/) are dropped; the table
// is empty until adapters write rows, so today this returns [].
try await client
.sendList(Endpoint.surfaceMetrics(), scope: scope, as: SurfaceMetricSummary.self)
.compactMap(Self.kitMetric)
}
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_stateapproved and stamps the SSO user. No body.
try await client.sendVoid(Endpoint.approveContentPost(id: approvalId), scope: scope)
}
public func setAside(_ approvalId: UUID) async throws {
try await client.sendVoid(Endpoint.setAsideContentPost(id: approvalId), scope: scope)
}
/// Create the composed drop, then link the selected assets. The drop is created
/// first (POST content-drops); asset links (POST :id/assets) are best-effort
/// the drop exists regardless, and the returned count reflects only links that
/// actually landed (never an optimistic number). `created_by_specialist` is
/// `ai-copilot`: the human authored it through the copilot surface.
public func createDrop(title: String, arc: String, assetIds: [UUID], dropAt: Date) async throws -> ContentDrop {
let body = CreateContentDropBody(
userId: scope.userId, orgId: scope.orgId, title: title, arc: arc,
state: .scheduled, clusterSource: "composed", dropAt: dropAt,
createdBySpecialist: "ai-copilot")
let created = try await client.send(
Endpoint.createContentDrop(body), scope: scope, as: CocottePlatformModels.ContentDrop.self)
var linked = 0
for (i, assetId) in assetIds.enumerated() {
do {
try await client.sendVoid(
Endpoint.addDropAsset(dropId: created.id, AddDropAssetBody(assetId: assetId, sortOrder: i)),
scope: scope)
linked += 1
} catch {
// A failed link is not fatal the drop is already persisted.
}
}
return Self.kitDrop(created, assetCount: linked)
}
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)
}
public func fetchBumpScreenshot(surface: String) async -> Data? {
// 404 (none captured yet) surfaces as a thrown APIError nil; the card hides.
try? await client.sendData(Endpoint.bumpScreenshotLatest(surface: surface), scope: scope)
}
// MARK: - Platform wire Kit model mapping
private static func kitDrop(_ d: CocottePlatformModels.ContentDrop, assetCount: Int = 0) -> ContentDrop {
ContentDrop(
title: d.title,
arc: d.arc,
assetCount: assetCount, // legs/asset count live in a separate table, not on the drop row
clusterSource: d.clusterSource ?? "",
legs: [],
teaseAt: d.teaseAt,
dropAt: d.dropAt ?? Date(timeIntervalSince1970: 0),
followupAt: d.followupAt,
state: kitDropState(d.state))
}
private static func kitApproval(_ p: ContentPost) -> PendingApproval {
let surface = kitSurface(p.surface) ?? .x
return PendingApproval(
postId: p.id,
title: "Proposed \(surface.label) post",
surfaces: [surface],
kind: "content-post",
stakes: .medium,
confidence: p.confidence,
why: "Proposed by content-social.",
legPreviews: [(surface, "", .ok)])
}
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,
qualityScore: a.qualityScore ?? 0,
capturedAt: a.createdAt,
hue: Double((index * 37) % 100) / 100,
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,
backfillVideos: s.backfillVideos ?? false)
}
/// Fleet roster row Kit Specialist. `mode` maps 1:1 to the status the UI
/// renders; `trust` carries through (nil when the specialist has no actions);
/// `note` has no backing data (kept nil never fabricated).
private static func kitSpecialist(_ s: SpecialistSummary) -> Specialist {
let status: SpecialistStatus
switch s.mode {
case .auto: status = .auto
case .draftOnly: status = .draftOnly
case .idle: status = .idle
}
return Specialist(
name: s.specialistId, status: status, trust: s.trust,
countToday: s.countToday, countLabel: "today", note: nil)
}
/// Per-surface metric rollup Kit SurfaceMetric. Surfaces with no Kit `Surface`
/// case (directory / long-tail surfaces) are dropped by the caller's compactMap.
private static func kitMetric(_ m: SurfaceMetricSummary) -> SurfaceMetric? {
guard let surface = Surface(rawValue: m.surface) else { return nil }
return SurfaceMetric(
surface: surface, posts: m.posts, impressions: m.impressions,
engagementPct: m.engagementPct, conversions: m.conversions)
}
private static func kitAction(_ a: CocottePlatformModels.AgentAction) -> AgentAction {
AgentAction(
time: hm.string(from: a.createdAt),
surface: nil, // agent_actions has no surface column
summary: "\(a.specialistId) · \(a.actionType)",
ok: true)
}
/// Platform surface cockpit Surface. The cockpit is a content-only UI, so
/// directory + long-tail content surfaces (tiktok/youtube/twitch/facebook/) have
/// no Kit equivalent and map to nil (caller substitutes a default where needed).
private static func kitSurface(_ s: SurfaceKind) -> Surface? {
switch s {
case .onlyfans: return .onlyfans
case .fansly: return .fansly
case .x: return .x
case .instagram: return .instagram
case .threads: return .threads
case .bluesky: return .bluesky
case .reddit: return .reddit
default: return nil
}
}
private static func kitDropState(_ s: ContentDropState) -> DropState {
switch s {
case .clustering: return .clustering
case .arcDraft: return .arcDraft
case .derived: return .derived
case .scheduled: return .scheduled
case .dispatched: return .dispatched
case .partial: return .partial
case .done: return .done
case .cancelled: return .scheduled // Kit has no 'cancelled'; nearest neutral state
}
}
}
extension LiveCockpitAPI {
static let hm: DateFormatter = { let f = DateFormatter(); f.dateFormat = "HH:mm"; return f }()
}