Spaces:
Sleeping
Sleeping
Download apps/web/src/api.ts from Bryan-HSLU/FP_POC: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/spaces/Bryan-HSLU/FP_POC/resolve/main/apps/web/src/api.ts
- Command line
-
hf download hf://spaces/Bryan-HSLU/FP_POC/apps/web/src/api.ts
-
curl -L -o api.ts https://huggingface.co/spaces/Bryan-HSLU/FP_POC/resolve/main/apps/web/src/api.ts
14.5 kB
| /** API-Client zum Engines-Dienst. Immer relative `/api/*`-Pfade: lokal leitet | |
| * der Vite-Proxy an FastAPI :8000 weiter, im HF-Space mountet `space.py` die | |
| * API unter /api, auf Vercel leitet ein Rewrite an den Space weiter. */ | |
| import type { CatalogItemInput, RoomInput } from "@fp/shared/rules"; | |
| import { istBackendAntwort, istJsonAntwort, warteAufBackend, type BackendStatus } from "./backend"; | |
| import type { FarbSlug } from "./farben"; | |
| import type { FlaechenKonzept } from "./oberflaechen"; | |
| export interface Placement { | |
| id: string; | |
| catalogItemId: string; | |
| pose: { pos: [number, number]; yawDeg: number }; | |
| gewerk: string; | |
| locked: boolean; | |
| source: string; | |
| mountHeight?: number; | |
| /** Gewählte Farbvariante (KI oder Nutzer), Welle 3. Fehlt = Default-Optik. */ | |
| farbe?: FarbSlug; | |
| } | |
| export interface Plan { | |
| id: string; | |
| roomRef: string; | |
| version: number; | |
| status: string; | |
| placements: Placement[]; | |
| constraintReport: unknown; | |
| meta: { seed: number; normProfile: string; solverVersion: string }; | |
| } | |
| export interface KatalogItem extends CatalogItemInput { | |
| name: string; | |
| priorityClass: "P1" | "P2" | "P3"; | |
| /** Optionale 3D-Bausatz-Variante für den Viewer (Katalog-Feld modell3d). */ | |
| modell3d?: string; | |
| /** Wählbare Farbvarianten (Welle 2); erste = Default-Optik. Grundlage für den | |
| * UI-Farbpicker und die KI-Farbwahl. */ | |
| farbVarianten?: FarbSlug[]; | |
| /** Stil-Achsen-Ausprägungen des Items (für Stil-Match/Kurator). */ | |
| achsenTags?: Record<string, number>; | |
| /** Preis-Stammdaten (für Kostenanzeige & LV). Provenance liegt im Schema. */ | |
| preis?: { | |
| value: number; | |
| currency: "CHF"; | |
| stand?: string; | |
| quelle?: string; | |
| bandbreitePct?: number; | |
| }; | |
| } | |
| export interface Room extends RoomInput { | |
| id: string; | |
| name: string; | |
| } | |
| /** Scene-Dressing-Objekt (rein visuelle Deko-Ebene, getrennt vom Plan). | |
| * Vertrag: packages/shared/schemas/dressing-item.schema.json. NICHT solver-/ | |
| * normrelevant – erscheint nie in placements/constraintReport/LV. */ | |
| export interface DressingItem { | |
| id: string; | |
| schemaVersion: string; | |
| name: string; | |
| roomTypes: string[]; | |
| /** B leicht kollisionsrelevant · C an Wand/Möbel · D Mikro-Deko auf Oberfläche. */ | |
| klasse: "B" | "C" | "D"; | |
| funktionsTyp: string; | |
| /** funktionsTypen aus dem Plan ODER die Schlüsselworte "wand"/"ecke". */ | |
| anchorTypes: string[]; | |
| platzierung: "auf_oberflaeche" | "an_wand" | "freie_ecke" | "an_moebel" | "an_decke"; | |
| masse: { w: number; d: number; h: number }; | |
| achsenTags: Record<string, number>; | |
| attributTags: string[]; | |
| assetStatus: "placeholder" | "modeled"; | |
| /** Dateiname (ohne Endung) unter public/assets/dressing/<gltfRef>.glb. */ | |
| gltfRef?: string; | |
| modell3d?: string; | |
| } | |
| export interface KV { | |
| raumName: string; | |
| mengen: { bodenflaeche_m2: number; wandflaeche_m2: number; objekte: number }; | |
| positionen: { | |
| bezeichnung: string; | |
| gewerk: string; | |
| menge: number; | |
| einheit: string; | |
| einzelpreis_chf: number; | |
| total_chf: number; | |
| }[]; | |
| summe_chf: number; | |
| bandbreitePct: number; | |
| von_chf: number; | |
| bis_chf: number; | |
| nextSteps: string[]; | |
| hinweis: string; | |
| } | |
| /** Optionen für /solve – Küche braucht normProfile/form/zoneId, sonst Kurator. */ | |
| export interface SolveOpts { | |
| kurator?: KuratorAntwort; | |
| stilprofilRef?: string; | |
| stilprofil?: unknown | null; | |
| normProfile?: string; | |
| form?: string; | |
| zoneId?: string; | |
| /** Flächen-Wünsche des Kurators (Call C); der Server reicht sie unverändert | |
| * in der Antwort zurück (der Client rendert daraus die Optik). */ | |
| flaechen?: FlaechenKonzept | null; | |
| /** Farbwahl je Item (Welle 3): itemId→Slug → placement.farbe im Plan. */ | |
| farben?: Record<string, FarbSlug> | null; | |
| /** K-Varianten (Welle 5): true → Server erzeugt K=3 Varianten und liefert die | |
| * beste (+ varianteInfo). Nur Nicht-Küche. Default false. */ | |
| varianten?: boolean; | |
| /** «3 Vorschläge» (Welle C): true → Response zusätzlich `variantenPlaene` | |
| * (ALLE K Pläne best-first). Nur zusammen mit `varianten` wirksam. */ | |
| variantenDetails?: boolean; | |
| } | |
| /** Auswahl-Spur der K-Varianten (Welle 5), additiv in der /solve-Response-Hülle. | |
| * Rein informativ – der `plan` ist bereits die gewählte beste Variante. */ | |
| export interface VarianteInfo { | |
| /** Index der gewählten Variante in `varianten`. */ | |
| gewaehlt: number; | |
| anzahl: number; | |
| varianten: { | |
| index: number; | |
| seed: number; | |
| /** Anzahl platzierter Items (Überlebensrate). */ | |
| platziert: number; | |
| /** Anzahl «knappe» harte Regeln (weniger = mehr Norm-Luft). */ | |
| knapp: number; | |
| /** Weiche Relations-/Stil-Zufriedenheit (höher = besser). */ | |
| relationScore: number; | |
| }[]; | |
| } | |
| /** Ein Vorschlag der «3 Vorschläge»-Ansicht (Welle C): kompletter Plan + die | |
| * zugehörige Score-Zeile aus varianteInfo. Liste kommt best-first vom Server. */ | |
| export interface VariantenPlan { | |
| plan: Plan; | |
| info: VarianteInfo["varianten"][number]; | |
| } | |
| export class ApiFehler extends Error { | |
| constructor( | |
| public code: string, | |
| message: string, | |
| ) { | |
| super(message); | |
| } | |
| } | |
| /** Fehlercode, wenn das Backend auch nach dem Wecken nicht antwortet. */ | |
| export const NICHT_ERREICHBAR = "BACKEND_NICHT_ERREICHBAR"; | |
| const TEXT_NICHT_ERREICHBAR = | |
| "Der Server ist gerade nicht erreichbar. Bitte in einigen Minuten erneut versuchen."; | |
| let statusHoerer: ((s: BackendStatus) => void) | null = null; | |
| /** App-weiter Beobachter des Backend-Zustands (für den «wird geweckt»-Hinweis). */ | |
| export function beobachteBackend(hoerer: ((s: BackendStatus) => void) | null): void { | |
| statusHoerer = hoerer; | |
| } | |
| async function pingHealth(): Promise<boolean> { | |
| try { | |
| const res = await fetch("/api/health", { | |
| cache: "no-store", | |
| signal: AbortSignal.timeout(20_000), | |
| }); | |
| if (!res.ok || !istJsonAntwort(res)) return false; | |
| return ((await res.json()) as { status?: string }).status === "ok"; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| let laufendesWecken: Promise<boolean> | null = null; | |
| /** Genau EIN Weckvorgang, auch wenn mehrere Aufrufe gleichzeitig warten | |
| * (z.B. die fünf Stammdaten-Abrufe nach der Raumwahl). */ | |
| function weckeBackend(): Promise<boolean> { | |
| if (laufendesWecken === null) { | |
| statusHoerer?.("wacht"); | |
| laufendesWecken = warteAufBackend({ | |
| ping: pingHealth, | |
| schlafe: (ms) => new Promise((ok) => setTimeout(ok, ms)), | |
| jetzt: () => Date.now(), | |
| }) | |
| .then((bereit) => { | |
| statusHoerer?.(bereit ? "bereit" : "nicht-erreichbar"); | |
| return bereit; | |
| }) | |
| .finally(() => { | |
| laufendesWecken = null; | |
| }); | |
| } | |
| return laufendesWecken; | |
| } | |
| async function versuche(pfad: string, init?: RequestInit): Promise<Response | null> { | |
| try { | |
| return await fetch(pfad, init); | |
| } catch { | |
| return null; // Netzfehler: wie «Backend nicht bereit» behandeln | |
| } | |
| } | |
| /** fetch, der «Backend schläft/startet» erkennt (HTML-Ladeseite, Proxy- | |
| * Fehlerseite, Netzfehler), das Backend weckt und die Anfrage EINMAL | |
| * wiederholt. Wiederholen ist unbedenklich: alle Endpunkte sind reine | |
| * Berechnungen ohne Server-Zustand. */ | |
| async function holeAntwort( | |
| pfad: string, | |
| init: RequestInit | undefined, | |
| binaer: boolean, | |
| ): Promise<Response> { | |
| const erste = await versuche(pfad, init); | |
| if (erste !== null && istBackendAntwort(erste, binaer)) return erste; | |
| if (!(await weckeBackend())) throw new ApiFehler(NICHT_ERREICHBAR, TEXT_NICHT_ERREICHBAR); | |
| const zweite = await versuche(pfad, init); | |
| if (zweite === null) throw new ApiFehler(NICHT_ERREICHBAR, TEXT_NICHT_ERREICHBAR); | |
| if (istBackendAntwort(zweite, binaer)) return zweite; | |
| throw new ApiFehler( | |
| "UNERWARTETE_ANTWORT", | |
| `Der Server hat unerwartet geantwortet (HTTP ${zweite.status}). Bitte erneut versuchen.`, | |
| ); | |
| } | |
| /** Fehler-Envelope der API ({code, message}) in einen ApiFehler übersetzen. */ | |
| async function envelopeFehler(res: Response, codeFallback: string): Promise<ApiFehler> { | |
| const body = istJsonAntwort(res) | |
| ? ((await res.json().catch(() => ({}))) as { code?: string; message?: string }) | |
| : {}; | |
| return new ApiFehler(body.code ?? codeFallback, body.message ?? res.statusText); | |
| } | |
| async function call<T>(pfad: string, init?: RequestInit): Promise<T> { | |
| const res = await holeAntwort( | |
| `/api${pfad}`, | |
| { headers: { "Content-Type": "application/json" }, ...init }, | |
| false, | |
| ); | |
| if (!res.ok) throw await envelopeFehler(res, "UNBEKANNT"); | |
| return (await res.json()) as T; | |
| } | |
| export interface KuratorAntwort { | |
| auswahl: string[]; | |
| relationaleAbsichten: { itemId: string; relation: string }[]; | |
| /** Raumprägende Haupt-Objekte (Objekt-Ebenen-Modell, ADR-0014), zuerst gewählt. */ | |
| hauptObjekte?: string[]; | |
| /** Ergänzungen mit Anzahl (Stühle zum Esstisch …). Grundlage für `mengen`. */ | |
| ergaenzungen?: { itemId: string; anzahl: number }[]; | |
| /** Flächen-Konzept (Boden-/Wand-Material je Wand). Fehlt bei Teil-Fallback. */ | |
| flaechen?: FlaechenKonzept | null; | |
| /** Design-Leitidee aus Call A (roter Faden), optional. */ | |
| konzept?: string | null; | |
| /** KI-Farbwahl je Item (itemId→Slug), Welle 3. Fehlt/leer = Default-Optik. */ | |
| farben?: Record<string, FarbSlug> | null; | |
| begruendung?: string; | |
| } | |
| /** Instanz-Mengen (itemId→anzahl) aus den Kurator-Ergänzungen für /solve. | |
| * Nur Einträge mit anzahl > 1 (Default 1 bleibt implizit) – ADR-0014. */ | |
| export function mengenAusKurator(kurator?: KuratorAntwort): Record<string, number> | undefined { | |
| const erg = kurator?.ergaenzungen?.filter((e) => e.anzahl > 1) ?? []; | |
| return erg.length ? Object.fromEntries(erg.map((e) => [e.itemId, e.anzahl])) : undefined; | |
| } | |
| /** Gemessenes AMK-Arbeitsdreieck eines Küchenplans (server-berechnet, M6-Politur). */ | |
| export interface Arbeitsdreieck { | |
| /** Die drei Seitenlängen Spüle–Kochfeld–Kühlschrank (m). */ | |
| seiten_m: number[]; | |
| /** Summe der drei Seiten (m); AMK-effizient bei ~4–8 m. */ | |
| summe_m: number; | |
| /** Ergonomie-Score in [0,1] (= constraintReport.softScore.ergonomie). */ | |
| score: number; | |
| bewertung: "effizient" | "akzeptabel" | "beengt" | "weitläufig"; | |
| } | |
| /** Eine Küchenform-Empfehlung (Formwahl, M6). */ | |
| export interface KuechenForm { | |
| form: "i" | "l" | "u" | "galley" | "insel"; | |
| score: number; | |
| begruendung: string; | |
| anchorWallIds: string[]; | |
| nutzlaenge_m: number; | |
| } | |
| export const api = { | |
| rooms: () => call<Room[]>("/samples/rooms"), | |
| /** Scan-Bundle (vorberechnetes layout.txt, optional .zip mit poses.json) → Raummodell. | |
| * FormData statt JSON – daher eigener fetch (nicht der json-`call`-Helfer). */ | |
| async scan( | |
| bundle: File, | |
| roomType: string, | |
| name: string, | |
| ): Promise<{ room: Room; warnungen: string[] }> { | |
| const fd = new FormData(); | |
| fd.append("bundle", bundle); | |
| fd.append("roomType", roomType); | |
| fd.append("name", name); | |
| const res = await holeAntwort("/api/scan", { method: "POST", body: fd }, false); | |
| if (!res.ok) throw await envelopeFehler(res, "SCAN"); | |
| return (await res.json()) as { room: Room; warnungen: string[] }; | |
| }, | |
| images: (roomType: string) => call<import("./Stil").BildItem[]>(`/images/${roomType}`), | |
| taxonomy: () => call<{ achsen: import("./Stil").Achse[] }>("/taxonomy"), | |
| styleProfile: (roomType: string, likes: string[], dislikes: string[], presetId: string | null) => | |
| call<import("./Stil").Stilprofil>("/style/profile", { | |
| method: "POST", | |
| body: JSON.stringify({ roomType, likes, dislikes, presetId }), | |
| }), | |
| curate: (room: Room, stilprofil: unknown, seed: number) => | |
| call<{ kurator: KuratorAntwort; port: string }>("/curate", { | |
| method: "POST", | |
| body: JSON.stringify({ room, stilprofil, seed }), | |
| }), | |
| catalog: (roomType: string) => call<KatalogItem[]>(`/catalog/${roomType}`), | |
| /** Scene-Dressing-Stammdaten je Raumtyp (rein visuelle Deko-Ebene, read-only). */ | |
| dressing: (roomType: string) => call<DressingItem[]>(`/dressing/${roomType}`), | |
| rules: (roomType: string) => call<unknown[]>(`/rules/${roomType}`), | |
| /** Top-3 Küchenformen (Formwahl) für einen Küchen- oder Grossraum. */ | |
| kuecheFormen: (room: Room, stilprofil: unknown | null, normProfile: string, zoneId?: string) => | |
| call<{ formen: KuechenForm[] }>("/kueche/formen", { | |
| method: "POST", | |
| body: JSON.stringify({ room, styleProfile: stilprofil ?? undefined, normProfile, zoneId }), | |
| }), | |
| solve: (room: Room, seed: number, opts: SolveOpts = {}) => | |
| call<{ | |
| plan: Plan; | |
| room: Room; | |
| hinweis?: string; | |
| arbeitsdreieck?: Arbeitsdreieck; | |
| flaechen?: FlaechenKonzept | null; | |
| varianteInfo?: VarianteInfo; | |
| variantenPlaene?: VariantenPlan[]; | |
| }>("/solve", { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| room, | |
| seed, | |
| auswahl: opts.kurator?.auswahl, | |
| relationaleAbsichten: opts.kurator?.relationaleAbsichten ?? [], | |
| // Mehrfach-Instanzen (ADR-0014): itemId→anzahl aus den Kurator-Ergänzungen. | |
| mengen: mengenAusKurator(opts.kurator), | |
| stilprofilRef: opts.stilprofilRef, | |
| stilprofil: opts.stilprofil ?? undefined, | |
| normProfile: opts.normProfile, | |
| form: opts.form, | |
| zoneId: opts.zoneId, | |
| flaechen: opts.flaechen ?? undefined, | |
| farben: opts.farben ?? undefined, | |
| varianten: opts.varianten ?? undefined, | |
| variantenDetails: opts.variantenDetails ?? undefined, | |
| }), | |
| }), | |
| /** Manuelle Flächen-Wahl (Welle 3) gegen die Norm prüfen + korrigieren lassen. | |
| * Quelle bleibt Python (kein TS-Nachbau der Regeln → kein Drift). */ | |
| pruefeFlaechen: (room: Room, flaechen: FlaechenKonzept) => | |
| call<{ verstoesse: string[]; korrigiert: FlaechenKonzept }>("/flaechen/pruefen", { | |
| method: "POST", | |
| body: JSON.stringify({ room, flaechen }), | |
| }), | |
| evaluate: (room: Room, plan: Plan) => | |
| call<KV>("/evaluate", { method: "POST", body: JSON.stringify({ room, plan }) }), | |
| /** Dokument vom Export-Endpunkt herunterladen (Blob → Browser-Download). */ | |
| async dokument(pfad: string, dateiname: string, room: Room, plan: Plan): Promise<void> { | |
| const res = await holeAntwort( | |
| `/api/export/${pfad}`, | |
| { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ room, plan }), | |
| }, | |
| true, | |
| ); | |
| if (!res.ok) throw await envelopeFehler(res, "EXPORT"); | |
| const url = URL.createObjectURL(await res.blob()); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = dateiname; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }, | |
| }; | |