diff --git a/README.md b/README.md index 3ec78d3..b75f971 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,149 @@ # Velocity-OS -> **The AI-Augmented Real Estate Operating System** — a production-grade, air-gapped, containerized appliance for luxury property sales teams. +Velocity-OS is the simplified production shell for Project Velocity: an AI-assisted operating system for real estate sales, client intelligence, property media, campaign work, and admin control. -Built on the Jobs/Ive mandate: *design is how it works, not just how it looks.* +The goal is not to copy the old Project_Velocity UI screen-for-screen. The goal is to preserve the useful capabilities, reduce the surface area, and make the system usable through three clear work modes. ---- +## Architecture At A Glance -## Architecture at a Glance - -``` +```text Velocity-OS/ -├── webos/ React 19 + Framer Motion + Zustand — 3-Pillar UX -├── core/ FastAPI + PostgreSQL + Oracle NL Engine -├── agents/ NemoClaw sandboxes (OpenShell policy) -├── media-engine/ Dream Weaver gateway (ComfyUI async) -├── perception/ MediaPipe WASM + QD scoring -└── infrastructure/ K3s + ECR + MIG GPU + s5cmd model hydration +|-- webos/ React 19, React Router, Framer Motion, Zustand, TanStack Query +|-- core/ FastAPI, PostgreSQL access, CRM APIs, Oracle natural DB agent +|-- media-engine/ Dream Weaver gateway for image generation / virtual staging +|-- agents/ Agent/runtime integration surface +|-- infrastructure/ Deployment, model hydration, gateway, and runtime operations ``` -## The 3-Pillar Model +## The Three Pillars -| Pillar | Route | Purpose | -|---|---|---| -| ⚡ **Command** | `/command` | Morning briefing — KPIs, Oracle NL, AI priority cards | -| 🎯 **Pipeline** | `/pipeline` | Deal intelligence — Kanban, Client 360, Showroom | -| 🎨 **Studio** | `/studio` | Asset hub — Properties, Dream Weaver, Campaigns | -| ⚙ Control Room | `/control-room` | Admin-only — system, schema, users, GPU | +| Pillar | Route | What It Replaces From Project_Velocity | +| --- | --- | --- | +| Command | `/command` | Dashboard, Oracle prompt surface, priority signals | +| Pipeline | `/pipeline` and `/pipeline/:personId` | CRM, leads, Client 360, conversations, tasks, showroom flow | +| Studio | `/studio` and `/studio/:propertyId` | Inventory, property pages, media gallery, Dream Weaver / Reimagine, campaigns | +| Control Room | `/control-room/:panel?` | Admin settings, Oracle/schema controls, comms settings, users, model hydration | -## GPU Architecture +## Runtime Truths -Target: **NVIDIA RTX 6000 Blackwell (96GB VRAM)** +- The public app is served from `https://velocity.desineuron.in/`. +- Frontend API calls go through the same origin unless `VITE_API_URL` is set. +- Dream Weaver uses multipart image upload through `/dream-weaver`, then polls `/dream-weaver/status/{job_id}`, then downloads from `/dream-weaver/result/{job_id}`. +- The deployed GPU reality is the Desineuron AWS GPU worker, currently oriented around `4 x NVIDIA L4` for inference/runtime work. Do not assume the placeholder RTX 6000/MIG text from older mock screens is operational truth. +- Large model and media runtime assets belong on GPU NVMe, not Linux root disk. -MIG partitioned into two concurrent slices: -- **Slice 0 (48GB)** — SGLang: Qwen3.6 35B LLM inference -- **Slice 1 (48GB)** — ComfyUI: Wan 2.2 + Qwen-Image staging +## Oracle Canvas And JSON Schemas -Zero-contention. No operator toggle required. +Velocity-OS imports the Project_Velocity Oracle JSON codebook assets required for canvas/component planning: -## Deployment (Air-Gapped Workstation) +- `core/oracle/oracle/oracle_runtime_codebook_merged.json` +- `core/oracle/oracle/oracle_template_seed_db.json` -```bash -# 1. Hydrate AI models from S3 (one-time, ~105GB) -cd infrastructure/model-hydration -./hydrate_models.sh +The loader lives at: -# 2. Apply K3s manifests -kubectl apply -f infrastructure/k3s/namespaces/ -kubectl apply -f infrastructure/k3s/volumes/ -kubectl apply -f infrastructure/k3s/secrets/secrets-template.yaml # fill real values first -kubectl apply -f infrastructure/k3s/deployments/gpu-mig-config.yaml -kubectl apply -f infrastructure/k3s/deployments/deployments.yaml -kubectl apply -f infrastructure/k3s/services/services.yaml -kubectl apply -f infrastructure/k3s/ingress/ingress.yaml +- `core/oracle/oracle/codebook_service.py` -# 3. Run DB init job -kubectl wait --for=condition=complete job/db-init -n velocity-os --timeout=120s +The loader now resolves the native Velocity-OS paths first, with legacy `backend/oracle` paths only as compatibility inputs. A quick sanity check should report the large runtime codebook: -# 4. Access at https://velocity.local +```powershell +python -c "from core.oracle.oracle.codebook_service import codebook_service; print(codebook_service.stats())" ``` -## CI/CD Pipeline +Expected current result: -Internet-connected CI (GitLab) builds and cosign-signs images → pushes to AWS ECR. -Ingress Box (LAN node) polls ECR every 5 min → verifies signature → SCP to air-gapped workstation → `k3s ctr images import` → `kubectl rollout restart`. +```text +example_count: 2395 +template_count: 1320 +source_summary includes runtime_merged:2395 +``` -**Unsigned images are physically rejected at the Ingress Box.** +Oracle natural query flow: -## Design System +1. The user asks a question in Command. +2. `webos/src/pillars/command/OracleBar.tsx` posts `{ prompt, context }` to `/api/oracle/query`. +3. `core/api/api/routes_oracle.py` calls the Oracle natural DB agent. +4. The result returns SQL-backed rows, columns, warnings, and source tables. +5. `OracleResultCard` renders the result as a metric, list, or table based on the returned data shape. -- **Base**: `hsl(225, 25%, 8%)` deep navy -- **Accent**: `#7C3AED` Velocity Violet (AI/actions) -- **Intent**: `#F59E0B` Amber (QD / alerts) -- **Glass**: `backdrop-filter: blur(20px)` · `rgba(255,255,255,0.05)` panels -- **Motion**: `300ms cubic-bezier(0.4, 0, 0.2, 1)` standard · spring for reveals +## Studio And Reimagine -## Immutability Constraint +Studio uses: -`Project_Velocity` (source) is **read-only**. All files in this repository are copies + refactors. No source files were modified or deleted. +- `webos/src/shared/hooks/useStudio.ts` for inventory/property normalization. +- `webos/src/pillars/studio/StudioPillar.tsx` for property and campaign lists. +- `webos/src/pillars/studio/PropertyEntity.tsx` for the property detail page. +- `webos/src/pillars/studio/ReimaginePanel.tsx` for Dream Weaver. ---- +Reimagine currently supports: + +- source room image upload +- room type selection +- freeform styling prompt +- async Dream Weaver job submission +- polling without blocking the page +- generated image preview +- generated image download/open actions + +## Control Room + +Control Room is admin-only and route-driven: + +- `/control-room/system` +- `/control-room/oracle-admin` +- `/control-room/comms-config` +- `/control-room/users` +- `/control-room/models` +- `/control-room/meta` + +The selected panel is derived from the URL. This avoids stale local state when users navigate directly or return from another page. + +## Local Development + +Frontend: + +```powershell +cd webos +npm install +npm run dev +``` + +Production build check: + +```powershell +cd webos +npm run type-check +npm run build +``` + +Backend: + +```powershell +cd core +python -m uvicorn api.main:app --host 127.0.0.1 --port 8001 --reload +``` + +Important environment variables: + +- `VITE_API_URL` overrides the frontend API origin. +- `VITE_DREAM_WEAVER_URL` overrides Dream Weaver gateway origin. +- `VITE_DREAM_WEAVER_API_KEY` adds the optional Dream Weaver gateway key. +- Backend database settings are provided by the running deployment environment. + +## Verification Checklist + +Run before handoff: + +- `npm run type-check` from `webos` +- `npm run build` from `webos` +- Oracle codebook stats check from repo root +- Login to `https://velocity.desineuron.in/` +- Open Command and submit an Oracle question +- Open Pipeline, switch Board/List, open a client, return to Pipeline without refresh +- Open Studio, switch Properties/Campaigns, open a property, use Reimagine upload + prompt +- Open Control Room and verify dark theme across all panels + +## Known Engineering Notes + +- The production build still warns that the Three.js vendor chunk is large. This is not a functional failure, but Studio 3D/media should remain lazy-loaded and can be split further later. +- The app intentionally keeps Project_Velocity as a source/reference repository. Velocity-OS should import only the required code/data assets, not blindly mirror the old structure. -*Velocity-OS v2.0 · Desineuron · © 2026* diff --git a/core/oracle/oracle/codebook_service.py b/core/oracle/oracle/codebook_service.py index bae6f89..358827c 100644 --- a/core/oracle/oracle/codebook_service.py +++ b/core/oracle/oracle/codebook_service.py @@ -50,7 +50,11 @@ class CodebookExample: def _repo_root() -> Path: - return Path(__file__).resolve().parents[2] + current = Path(__file__).resolve() + for parent in current.parents: + if (parent / "webos").exists() and (parent / "core").exists(): + return parent + return current.parents[3] def _safe_load_json(path: Path) -> dict[str, Any]: @@ -140,26 +144,33 @@ def _normalize_examples(payload: dict[str, Any], source_pack: str) -> list[Codeb class OracleCodebookService: def __init__(self) -> None: root = _repo_root() - self.runtime_merged_path = root / "backend" / "oracle" / "oracle_runtime_codebook_merged.json" - self.primary_path = root / ".Agent Context" / "Sprint 1" / "Sayan Multi-Surface and Oracle Delivery Pack" / "Sample JSON Schema" / "GPT 5.4" / "oracle_canvas_json_expansion_pack" / "db" / "oracle_template_seed_db_expanded_v1.pretty.json" - self.secondary_path = root / ".Agent Context" / "Sprint 1" / "Sayan Multi-Surface and Oracle Delivery Pack" / "Sample JSON Schema" / "Claude Sonnet 4.6" / "oracle_template_expansion" / "oracle_template_seed_db_expanded.json" - self.fallback_path = root / "backend" / "oracle" / "oracle_template_seed_db.json" + oracle_dir = root / "core" / "oracle" / "oracle" + legacy_oracle_dir = root / "backend" / "oracle" + self.runtime_merged_path = oracle_dir / "oracle_runtime_codebook_merged.json" + self.primary_path = oracle_dir / "oracle_template_seed_db_expanded_v1.pretty.json" + self.secondary_path = oracle_dir / "oracle_template_seed_db_expanded_claude.json" + self.fallback_path = oracle_dir / "oracle_template_seed_db.json" + self.legacy_runtime_merged_path = legacy_oracle_dir / "oracle_runtime_codebook_merged.json" + self.legacy_fallback_path = legacy_oracle_dir / "oracle_template_seed_db.json" @lru_cache(maxsize=1) def load(self) -> dict[str, Any]: corpora: list[CodebookExample] = [] sources_loaded: list[str] = [] source_paths: list[tuple[Path, str]] - if self.runtime_merged_path.exists(): + if self.runtime_merged_path.exists() or self.legacy_runtime_merged_path.exists(): source_paths = [ (self.runtime_merged_path, "runtime_merged"), + (self.legacy_runtime_merged_path, "legacy_runtime_merged"), (self.fallback_path, "runtime_seed_fallback"), + (self.legacy_fallback_path, "legacy_seed_fallback"), ] else: source_paths = [ (self.primary_path, "gpt_5_4"), (self.secondary_path, "claude_sonnet_4_6"), (self.fallback_path, "runtime_seed_fallback"), + (self.legacy_fallback_path, "legacy_seed_fallback"), ] for path, label in source_paths: diff --git a/webos/src/App.tsx b/webos/src/App.tsx index fe50349..d31a154 100644 --- a/webos/src/App.tsx +++ b/webos/src/App.tsx @@ -79,7 +79,7 @@ const router = createBrowserRouter([ // ── Admin-Only Control Room (RBAC gated at component + API level) ── { - path: '/control-room', + path: '/control-room/:panel?', element: ( @@ -87,14 +87,6 @@ const router = createBrowserRouter([ ), - children: [ - { index: true, element: }, - { path: 'system', element: }, - { path: 'oracle-admin', element: }, - { path: 'comms-config', element: }, - { path: 'users', element: }, - { path: 'models', element: }, - ], }, // ── Public vault links (no auth) ── diff --git a/webos/src/control-room/ControlRoom.tsx b/webos/src/control-room/ControlRoom.tsx index c6fa34f..3285bc1 100644 --- a/webos/src/control-room/ControlRoom.tsx +++ b/webos/src/control-room/ControlRoom.tsx @@ -1,6 +1,5 @@ -import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import styles from './ControlRoom.module.css'; /** @@ -39,7 +38,9 @@ const PANELS: { id: Panel; label: string; icon: string }[] = [ export default function ControlRoom() { const navigate = useNavigate(); - const [active, setActive] = useState('system'); + const location = useLocation(); + const routePanel = location.pathname.split('/').filter(Boolean)[1] as Panel | undefined; + const active: Panel = PANELS.some((panel) => panel.id === routePanel) ? routePanel! : 'system'; return (
@@ -64,7 +65,7 @@ export default function ControlRoom() { + {inferLabel(row)} + {inferValue(row)} + ))} - +
+ )} + + {rows.length > 0 && type !== 'metric' && columns.length > 4 && ( +
+ + + {columns.slice(0, 6).map((column) => )} + + + {rows.slice(0, 8).map((row, rowIndex) => ( + + {columns.slice(0, 6).map((column) => )} + + ))} + +
{column.replace(/_/g, ' ')}
{displayValue(row[column])}
+
+ )} + + {(firstPersonId || result.sourceTables?.length) && ( +
+ {firstPersonId && ( + + )} + {result.sourceTables?.length ? ( + {result.sourceTables.slice(0, 4).join(', ')} + ) : null} +
)} ); diff --git a/webos/src/pillars/studio/ReimaginePanel.module.css b/webos/src/pillars/studio/ReimaginePanel.module.css index 738aaf6..d3dafe7 100644 --- a/webos/src/pillars/studio/ReimaginePanel.module.css +++ b/webos/src/pillars/studio/ReimaginePanel.module.css @@ -1,22 +1,237 @@ -/* ReimaginePanel */ -.root { display: flex; flex-direction: column; gap: var(--space-5); } -.reimagineBtn { align-self: flex-start; } -.presetsSection { display: flex; flex-direction: column; gap: var(--space-4); } -.presetGrid { display: flex; gap: var(--space-3); } -.presetTile { position: relative; display: flex; flex-direction: column; align-items: center; gap: var(--space-2); background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); padding: var(--space-3); cursor: pointer; transition: all var(--duration-fast) var(--ease-standard); width: 120px; flex-shrink: 0; } -.presetTile:hover { background: var(--glass-bg-hover); } -.selected { border-color: var(--color-violet) !important; } -.previewImg { width: 100%; height: 72px; border-radius: var(--radius-md); overflow: hidden; background: var(--glass-bg); } -.previewImg img { width: 100%; height: 100%; object-fit: cover; } -.presetLabel { font-size: 10px; font-weight: var(--font-medium); color: var(--color-text-secondary); text-align: center; } -.selectionRing { position: absolute; inset: -2px; border-radius: var(--radius-lg); border: 2px solid var(--color-violet); pointer-events: none; box-shadow: var(--glass-shadow-violet); } -.generateBtn { align-self: flex-start; } -/* Generating */ -.generatingState { display: flex; flex-direction: column; gap: var(--space-4); } -.imageFrame { height: 200px; border-radius: var(--radius-xl); } -.generatingText { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; } -/* Result */ -.resultState { display: flex; flex-direction: column; gap: var(--space-4); } -.resultImage { border-radius: var(--radius-xl); overflow: hidden; } -.resultImage img { width: 100%; height: 200px; object-fit: cover; } -.resultActions { display: flex; gap: var(--space-3); } +.root { + display: grid; + grid-template-columns: minmax(320px, 0.92fr) minmax(360px, 1.08fr); + gap: var(--space-5); +} + +.sourcePanel, +.resultPanel { + border: var(--glass-border); + border-radius: var(--radius-xl); + background: + radial-gradient(circle at 20% 0%, rgba(124, 58, 237, 0.16), transparent 38%), + rgba(255, 255, 255, 0.04); + box-shadow: var(--glass-shadow); +} + +.sourcePanel { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-5); +} + +.resultPanel { + min-height: 520px; + display: flex; + align-items: stretch; + justify-content: stretch; + overflow: hidden; +} + +.panelHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); +} + +.panelHeader h3 { + margin: var(--space-1) 0 0; + font-size: var(--text-xl); + color: var(--color-text-primary); +} + +.eyebrow { + font-size: 10px; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--color-violet-light); + font-weight: var(--font-semibold); +} + +.healthOk, +.healthWarn { + max-width: 220px; + font-size: 10px; + line-height: 1.4; + text-align: right; +} + +.healthOk { color: var(--color-green); } +.healthWarn { color: var(--color-amber); } + +.uploadFrame { + width: 100%; + aspect-ratio: 16 / 10; + border: 1px dashed rgba(255, 255, 255, 0.16); + border-radius: var(--radius-xl); + background: rgba(255, 255, 255, 0.03); + color: var(--color-text-secondary); + overflow: hidden; + cursor: pointer; +} + +.uploadFrame img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.uploadEmpty { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-2); +} + +.fileInput { display: none; } + +.roomGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); +} + +.roomButton, +.roomActive { + border: var(--glass-border); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.035); + color: var(--color-text-secondary); + padding: var(--space-3); + font-size: var(--text-sm); + cursor: pointer; +} + +.roomActive { + color: var(--color-text-primary); + border-color: rgba(124, 58, 237, 0.5); + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.14); +} + +.promptLabel { + display: flex; + flex-direction: column; + gap: var(--space-2); + font-size: 10px; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--color-text-tertiary); + font-weight: var(--font-semibold); +} + +.promptLabel textarea { + min-height: 108px; + resize: vertical; + border: var(--glass-border); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.04); + color: var(--color-text-primary); + padding: var(--space-3); + font-family: var(--font-sans); + font-size: var(--text-sm); + line-height: 1.6; + outline: none; + text-transform: none; + letter-spacing: 0; + font-weight: var(--font-normal); +} + +.promptLabel textarea:focus { + border-color: rgba(124, 58, 237, 0.44); + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.14); +} + +.generateButton { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: var(--space-2); + border: none; + border-radius: var(--radius-lg); + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: white; + padding: var(--space-3) var(--space-5); + font-weight: var(--font-semibold); + cursor: pointer; +} + +.generateButton:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.resultState, +.errorState { + width: 100%; + min-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + color: var(--color-text-secondary); + text-align: center; + padding: var(--space-8); +} + +.resultState strong, +.errorState strong { + color: var(--color-text-primary); + font-size: var(--text-lg); +} + +.resultState span, +.errorState span { + max-width: 420px; + font-size: var(--text-sm); +} + +.errorState { + background: rgba(127, 29, 29, 0.14); + color: var(--color-red); +} + +.resultReady { + width: 100%; + display: flex; + flex-direction: column; +} + +.resultReady img { + width: 100%; + min-height: 420px; + flex: 1; + object-fit: contain; + background: rgba(0, 0, 0, 0.28); +} + +.resultActions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + padding: var(--space-4); + border-top: var(--glass-border); + background: rgba(0, 0, 0, 0.18); +} + +.resultActions button { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.spin { animation: spin 1s linear infinite; } + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 1100px) { + .root { grid-template-columns: 1fr; } + .resultPanel { min-height: 380px; } +} diff --git a/webos/src/pillars/studio/ReimaginePanel.tsx b/webos/src/pillars/studio/ReimaginePanel.tsx index 377bee5..ccbe855 100644 --- a/webos/src/pillars/studio/ReimaginePanel.tsx +++ b/webos/src/pillars/studio/ReimaginePanel.tsx @@ -1,236 +1,229 @@ -import { useState, useCallback } from 'react'; +import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Download, ExternalLink, ImagePlus, Loader2, Sparkles, UploadCloud } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { + checkDreamWeaverHealth, + fetchDreamWeaverResult, + getDreamWeaverStatus, + submitDreamWeaverJob, + type DreamWeaverHealth, + type DreamWeaverJobResponse, +} from '@/shared/lib/dreamWeaverApi'; import styles from './ReimaginePanel.module.css'; -/** - * ReimaginePanel - * One-tap AI room staging. Replaces the raw Dream Weaver tab. - * UX Master Plan §4.4 + Part 2 §2.4 spec: - * - * - Tap "Reimagine" → 3 style tiles stagger-reveal (50ms intervals) - * - Tiles show AI-representative preview images, not labels - * - Select style → "Generate" materializes with spring - * - Generation → property view blurs + shimmer sweeps - * - Result → cross-fade, two actions: Use This / Try Another - * - * All ComfyUI machinery hidden. No job IDs, no queue position. - * User sees: "Reimagining your space…" - */ - -type StylePreset = 'modern-luxury' | 'warm-contemporary' | 'minimalist-zen'; -type Phase = 'idle' | 'selecting' | 'generating' | 'result'; +type RoomType = 'bedroom' | 'living_room' | 'bathroom' | 'kitchen' | 'dining_room' | 'office'; +type Phase = 'idle' | 'ready' | 'generating' | 'result' | 'error'; interface ReimagineResult { - imageUrl: string; - jobId: string; // internal only, never shown + url: string; + jobId: string; + blob: Blob; } interface ReimaginePanelProps { propertyId: string; - roomImageUrl?: string; // Source room photo + roomImageUrl?: string; onResultSaved?: (url: string) => void; } -const STYLE_PRESETS: { id: StylePreset; label: string; previewUrl: string }[] = [ - { - id: 'modern-luxury', - label: 'Modern Luxury', - // Curated representative preview images - previewUrl: '/assets/style-previews/modern-luxury.jpg', - }, - { - id: 'warm-contemporary', - label: 'Warm Contemporary', - previewUrl: '/assets/style-previews/warm-contemporary.jpg', - }, - { - id: 'minimalist-zen', - label: 'Minimalist Zen', - previewUrl: '/assets/style-previews/minimalist-zen.jpg', - }, +const ROOM_TYPES: { id: RoomType; label: string }[] = [ + { id: 'bedroom', label: 'Bedroom' }, + { id: 'living_room', label: 'Living Room' }, + { id: 'bathroom', label: 'Bathroom' }, + { id: 'kitchen', label: 'Kitchen' }, + { id: 'dining_room', label: 'Dining Room' }, + { id: 'office', label: 'Office' }, ]; -export function ReimaginePanel({ - propertyId, - roomImageUrl, - onResultSaved, -}: ReimaginePanelProps) { +const DEFAULT_PROMPT = 'Modern luxury staging, warm practical lighting, premium materials, uncluttered real estate brochure finish'; + +export function ReimaginePanel({ propertyId, roomImageUrl, onResultSaved }: ReimaginePanelProps) { + const fileInputRef = useRef(null); const [phase, setPhase] = useState('idle'); - const [selectedStyle, setSelectedStyle] = useState(null); + const [health, setHealth] = useState(null); + const [roomType, setRoomType] = useState('bedroom'); + const [keywords, setKeywords] = useState(DEFAULT_PROMPT); + const [sourceFile, setSourceFile] = useState(null); + const [sourcePreview, setSourcePreview] = useState(roomImageUrl ?? null); + const [job, setJob] = useState(null); const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [pollCount, setPollCount] = useState(0); - const handleReimagineClick = () => setPhase('selecting'); + useEffect(() => { + let alive = true; + checkDreamWeaverHealth() + .then((value) => { + if (!alive) return; + setHealth(value); + setPhase('ready'); + }) + .catch((err) => { + if (!alive) return; + setError(err instanceof Error ? err.message : 'Dream Weaver health check failed.'); + setPhase('error'); + }); + return () => { alive = false; }; + }, []); - const handleStyleSelect = (style: StylePreset) => { - setSelectedStyle(style); + useEffect(() => { + return () => { + if (sourcePreview?.startsWith('blob:')) URL.revokeObjectURL(sourcePreview); + if (result?.url.startsWith('blob:')) URL.revokeObjectURL(result.url); + }; + }, [sourcePreview, result]); + + const canGenerate = Boolean(sourceFile) && phase !== 'generating'; + const healthLabel = useMemo(() => { + if (!health) return 'Checking Dream Weaver'; + if (health.online && health.routeMounted) return 'Dream Weaver online'; + return health.detail || health.status || 'Dream Weaver unavailable'; + }, [health]); + + const handleFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0] ?? null; + if (!file) return; + if (sourcePreview?.startsWith('blob:')) URL.revokeObjectURL(sourcePreview); + setSourceFile(file); + setSourcePreview(URL.createObjectURL(file)); + setResult(null); + setError(null); + setPhase('ready'); }; const handleGenerate = useCallback(async () => { - if (!selectedStyle) return; + if (!sourceFile) { + setError('Upload a room image before generating.'); + setPhase('error'); + return; + } setPhase('generating'); + setError(null); + setPollCount(0); try { - // POST to Dream Weaver gateway (hidden from user) - const resp = await fetch('/dream-weaver', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - property_id: propertyId, - style_preset: selectedStyle, - source_image_url: roomImageUrl, - }), - }); - const { job_id } = await resp.json(); + const submitted = await submitDreamWeaverJob({ image: sourceFile, roomType, keywords }); + setJob(submitted); - // Poll silently (user sees shimmer, not polling) - const imageUrl = await pollForResult(job_id); - setResult({ imageUrl, jobId: job_id }); - setPhase('result'); - } catch { - setPhase('selecting'); + for (let attempt = 1; attempt <= 180; attempt += 1) { + setPollCount(attempt); + const status = await getDreamWeaverStatus(submitted); + if (status.status === 'failed') { + throw new Error(status.error || 'Dream Weaver generation failed.'); + } + if (status.ready || status.status === 'complete') { + const blob = await fetchDreamWeaverResult(submitted.job_id, status.result_url ?? submitted.result_url); + const url = URL.createObjectURL(blob); + setResult({ url, jobId: submitted.job_id, blob }); + setPhase('result'); + return; + } + await new Promise((resolve) => window.setTimeout(resolve, 2000)); + } + throw new Error('Dream Weaver timed out before returning an image.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Dream Weaver request failed.'); + setPhase('error'); } - }, [selectedStyle, propertyId, roomImageUrl]); + }, [keywords, roomType, sourceFile]); - const handleUseThis = async () => { + const handleDownload = () => { if (!result) return; - // Save to property media library + generate vault link (handled by Studio) - onResultSaved?.(result.imageUrl); - setPhase('idle'); - setResult(null); - setSelectedStyle(null); - }; - - const handleTryAnother = () => { - setPhase('selecting'); - setResult(null); - setSelectedStyle(null); + const anchor = document.createElement('a'); + anchor.href = result.url; + anchor.download = `${propertyId}-reimagine-${result.jobId}.png`; + anchor.click(); }; return (
- {/* ── Idle: single button ─────────────────────────── */} - {phase === 'idle' && ( - - ✨ Reimagine - - )} - - {/* ── Selecting: staggered style tiles ─────────────── */} - {(phase === 'selecting' || phase === 'result') && ( -
-
- {STYLE_PRESETS.map(({ id, label, previewUrl }, idx) => ( - handleStyleSelect(id)} - initial={{ opacity: 0, scale: 0.92 }} - animate={{ - opacity: selectedStyle && selectedStyle !== id ? 0.4 : 1, - scale: selectedStyle === id ? 1.04 : 1, - }} - transition={{ - delay: idx * 0.05, - duration: 0.25, - ease: [0.4, 0, 0.2, 1], - }} - aria-pressed={selectedStyle === id} - aria-label={`Style: ${label}`} - > -
- {label} -
- {label} - - {/* Active glow border */} - {selectedStyle === id && ( - - )} -
- ))} +
+
+
+ Source Room +

Reimagine this space

- - {/* Generate button materializes when style selected */} - - {selectedStyle && phase !== 'result' && ( - - Generate - - )} - + {healthLabel}
- )} - {/* ── Generating: shimmer overlay ──────────────────── */} - {phase === 'generating' && ( - -
- - Reimagining your space… - - - )} + + - {/* ── Result: cross-fade to generated image ─────────── */} - {phase === 'result' && result && ( - -
- AI-generated staging -
-
- - -
-
- )} + ))} +
+ +