fix: complete Velocity-OS feature migration wiring
Some checks failed
Velocity-OS Deployment Pipeline / lint (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / notify-ingress (push) Has been cancelled
Some checks failed
Velocity-OS Deployment Pipeline / lint (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / notify-ingress (push) Has been cancelled
This commit is contained in:
176
README.md
176
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*
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: (
|
||||
<AuthGuard>
|
||||
<AdminGuard>
|
||||
@@ -87,14 +87,6 @@ const router = createBrowserRouter([
|
||||
</AdminGuard>
|
||||
</AuthGuard>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/control-room/system" replace /> },
|
||||
{ path: 'system', element: <Lazy><ControlRoom /></Lazy> },
|
||||
{ path: 'oracle-admin', element: <Lazy><ControlRoom /></Lazy> },
|
||||
{ path: 'comms-config', element: <Lazy><ControlRoom /></Lazy> },
|
||||
{ path: 'users', element: <Lazy><ControlRoom /></Lazy> },
|
||||
{ path: 'models', element: <Lazy><ControlRoom /></Lazy> },
|
||||
],
|
||||
},
|
||||
|
||||
// ── Public vault links (no auth) ──
|
||||
|
||||
@@ -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<Panel>('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 (
|
||||
<div className={styles.root}>
|
||||
@@ -64,7 +65,7 @@ export default function ControlRoom() {
|
||||
<button
|
||||
key={id}
|
||||
className={`${styles.sideItem} ${active === id ? styles.sideActive : ''}`}
|
||||
onClick={() => setActive(id)}
|
||||
onClick={() => navigate(`/control-room/${id}`)}
|
||||
aria-current={active === id ? 'page' : undefined}
|
||||
>
|
||||
<span className={styles.sideIcon}>{icon}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OracleResultCard } from './OracleResultCard';
|
||||
import styles from './OracleBar.module.css';
|
||||
|
||||
type QueryState = 'idle' | 'thinking' | 'result';
|
||||
type OracleEnvelope = { status?: string; data?: unknown; detail?: string; message?: string; error?: string };
|
||||
|
||||
/**
|
||||
* OracleBar
|
||||
@@ -47,20 +48,30 @@ export function OracleBar() {
|
||||
const resp = await fetch('/api/oracle/query', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, context: 'command_pillar' }),
|
||||
body: JSON.stringify({ prompt: query, context: { surface: 'command_pillar' } }),
|
||||
});
|
||||
const payload = await resp.json().catch(() => ({})) as OracleEnvelope;
|
||||
if (!resp.ok) {
|
||||
throw new Error(payload.detail || payload.message || payload.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
setResult(payload.data ?? payload);
|
||||
setQueryState('result');
|
||||
} catch (error) {
|
||||
setResult({
|
||||
title: 'Oracle query failed',
|
||||
summary: error instanceof Error ? error.message : 'Unable to run Oracle query.',
|
||||
rows: [],
|
||||
columns: [],
|
||||
warnings: ['Check the Oracle API route and backend logs.'],
|
||||
});
|
||||
const data = await resp.json();
|
||||
setResult(data);
|
||||
setQueryState('result');
|
||||
} catch {
|
||||
setQueryState('idle');
|
||||
}
|
||||
}, [query, queryState]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
void handleSubmit();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setIsFocused(false);
|
||||
@@ -123,7 +134,7 @@ export function OracleBar() {
|
||||
{query && queryState !== 'thinking' && (
|
||||
<motion.button
|
||||
className={styles.submitBtn}
|
||||
onClick={handleSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
/* OracleResultCard */
|
||||
.card { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.card { display: flex; flex-direction: column; gap: var(--space-4); overflow: hidden; }
|
||||
.header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
|
||||
.summary { font-size: var(--text-base); color: var(--color-text-primary); margin: 0; line-height: var(--leading-relaxed); }
|
||||
.aiStar { color: var(--color-violet-light); margin-right: var(--space-2); }
|
||||
.rowCount { font-size: var(--text-xs); color: var(--color-text-tertiary); white-space: nowrap; }
|
||||
.list { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.listRow { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-bottom: var(--glass-border); }
|
||||
.rowLabel { font-size: var(--text-sm); color: var(--color-text-secondary); }
|
||||
.rowValue { font-size: var(--text-sm); font-weight: var(--font-semibold); }
|
||||
.empty { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-6); border-radius: var(--radius-lg); background: rgba(255,255,255,0.03); color: var(--color-text-secondary); }
|
||||
.empty small { color: var(--color-text-tertiary); }
|
||||
.metric { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); padding: var(--space-4) 0; }
|
||||
.metricValue { font-size: var(--text-5xl); font-weight: var(--font-bold); color: var(--color-text-primary); }
|
||||
.metricLabel { font-size: var(--text-sm); color: var(--color-text-secondary); }
|
||||
.tableWrap { overflow-x: auto; border-radius: var(--radius-lg); border: var(--glass-border); }
|
||||
.table { width: 100%; border-collapse: collapse; min-width: 640px; }
|
||||
.table th { text-align: left; padding: var(--space-3); font-size: 10px; text-transform: uppercase; letter-spacing: var(--tracking-wider); color: var(--color-text-tertiary); background: rgba(255,255,255,0.03); }
|
||||
.table td { padding: var(--space-3); border-top: var(--glass-border); font-size: var(--text-xs); color: var(--color-text-secondary); max-width: 220px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.actionRow { display: flex; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.provenance { align-self: center; font-size: 10px; color: var(--color-text-tertiary); font-family: var(--font-mono); }
|
||||
|
||||
@@ -2,28 +2,17 @@ import { motion } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styles from './OracleResultCard.module.css';
|
||||
|
||||
/**
|
||||
* OracleResultCard
|
||||
* Renders the Oracle response. Each data row stagger-fades at 100ms intervals.
|
||||
* Always shows an AI summary line above the data.
|
||||
* NEVER shows: canvas ID, fork/merge controls, schema catalog, MCP tools,
|
||||
* revision history, query plan, execution stats, or raw SQL.
|
||||
*/
|
||||
type OracleRow = Record<string, unknown>;
|
||||
|
||||
interface OracleResult {
|
||||
summary: string; // AI-generated plain-English summary
|
||||
visualization?: {
|
||||
type: 'bar' | 'list' | 'metric' | 'table';
|
||||
data: any[];
|
||||
xKey?: string;
|
||||
yKey?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
columns?: string[];
|
||||
};
|
||||
actions?: {
|
||||
label: string;
|
||||
personId?: string;
|
||||
type: 'view_client' | 'generic';
|
||||
}[];
|
||||
rows?: OracleRow[];
|
||||
rowCount?: number;
|
||||
componentType?: 'bar' | 'list' | 'metric' | 'table' | string;
|
||||
warnings?: string[];
|
||||
sourceTables?: string[];
|
||||
}
|
||||
|
||||
interface OracleResultCardProps {
|
||||
@@ -31,9 +20,48 @@ interface OracleResultCardProps {
|
||||
query: string;
|
||||
}
|
||||
|
||||
function displayValue(value: unknown): string {
|
||||
if (value == null || value === '') return '-';
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? value.toLocaleString('en-IN') : value.toLocaleString('en-IN', { maximumFractionDigits: 2 });
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
if (value instanceof Date) return value.toLocaleString();
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function inferLabel(row: OracleRow): string {
|
||||
const preferred = ['name', 'full_name', 'client_name', 'project_name', 'title', 'label'];
|
||||
const key = preferred.find((candidate) => row[candidate] != null);
|
||||
if (key) return displayValue(row[key]);
|
||||
const first = Object.values(row).find((value) => value != null);
|
||||
return displayValue(first);
|
||||
}
|
||||
|
||||
function inferValue(row: OracleRow): string {
|
||||
const preferred = ['value', 'score', 'qd_score', 'count', 'total', 'amount', 'interest_count'];
|
||||
const key = preferred.find((candidate) => row[candidate] != null);
|
||||
if (key) return displayValue(row[key]);
|
||||
const values = Object.values(row).filter((value) => value != null);
|
||||
return displayValue(values[1] ?? values[0]);
|
||||
}
|
||||
|
||||
function personIdFromRow(row: OracleRow): string | undefined {
|
||||
const raw = row.person_id ?? row.client_id ?? row.lead_id ?? row.id;
|
||||
return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
|
||||
}
|
||||
|
||||
export function OracleResultCard({ result, query }: OracleResultCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const rows = result.visualization?.data ?? [];
|
||||
const rows = Array.isArray(result.rows) ? result.rows : [];
|
||||
const columns = Array.isArray(result.columns) && result.columns.length > 0
|
||||
? result.columns
|
||||
: rows[0]
|
||||
? Object.keys(rows[0])
|
||||
: [];
|
||||
const type = result.componentType ?? (columns.length > 4 ? 'table' : 'list');
|
||||
const firstPersonId = rows.map(personIdFromRow).find(Boolean);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -42,71 +70,85 @@ export function OracleResultCard({ result, query }: OracleResultCardProps) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
{/* AI summary line — always first */}
|
||||
<motion.p
|
||||
className={styles.summary}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<span className={styles.aiStar}>✦</span> {result.summary}
|
||||
</motion.p>
|
||||
<div className={styles.header}>
|
||||
<p className={styles.summary}>
|
||||
<span className={styles.aiStar}>✦</span>
|
||||
{result.summary || result.title || `Oracle result for "${query}"`}
|
||||
</p>
|
||||
{typeof result.rowCount === 'number' && (
|
||||
<span className={styles.rowCount}>{result.rowCount.toLocaleString('en-IN')} rows</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Data rows stagger-fade */}
|
||||
{result.visualization?.type === 'list' && (
|
||||
<div className={styles.list}>
|
||||
{rows.map((row: any, i: number) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className={styles.listRow}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.15 + i * 0.1, duration: 0.25 }}
|
||||
>
|
||||
<span className={styles.rowLabel}>{row.label ?? row.name}</span>
|
||||
<span className={styles.rowValue}
|
||||
style={{ color: 'var(--color-violet-light)' }}>
|
||||
{row.value ?? row.score ?? row.count}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
{result.title || 'No rows returned'}
|
||||
{result.warnings?.length ? <small>{result.warnings[0]}</small> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.visualization?.type === 'metric' && rows[0] && (
|
||||
{rows.length > 0 && type === 'metric' && (
|
||||
<motion.div
|
||||
className={styles.metric}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<span className={styles.metricValue}>{rows[0].value}</span>
|
||||
<span className={styles.metricLabel}>{rows[0].label}</span>
|
||||
<span className={styles.metricValue}>{inferValue(rows[0])}</span>
|
||||
<span className={styles.metricLabel}>{inferLabel(rows[0])}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Contextual actions */}
|
||||
{result.actions && result.actions.length > 0 && (
|
||||
{rows.length > 0 && type !== 'metric' && columns.length <= 4 && (
|
||||
<div className={styles.list}>
|
||||
{rows.slice(0, 8).map((row, i) => (
|
||||
<motion.div
|
||||
className={styles.actionRow}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.15 + rows.length * 0.1 }}
|
||||
>
|
||||
{result.actions.map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
if (action.type === 'view_client' && action.personId) {
|
||||
navigate(`/pipeline/${action.personId}`);
|
||||
}
|
||||
}}
|
||||
className={styles.listRow}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.15 + i * 0.08, duration: 0.25 }}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
<span className={styles.rowLabel}>{inferLabel(row)}</span>
|
||||
<span className={styles.rowValue}>{inferValue(row)}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && type !== 'metric' && columns.length > 4 && (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>{columns.slice(0, 6).map((column) => <th key={column}>{column.replace(/_/g, ' ')}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 8).map((row, rowIndex) => (
|
||||
<motion.tr
|
||||
key={rowIndex}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.12 + rowIndex * 0.05, duration: 0.2 }}
|
||||
>
|
||||
{columns.slice(0, 6).map((column) => <td key={column}>{displayValue(row[column])}</td>)}
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(firstPersonId || result.sourceTables?.length) && (
|
||||
<div className={styles.actionRow}>
|
||||
{firstPersonId && (
|
||||
<button className="btn-ghost" onClick={() => navigate(`/pipeline/${firstPersonId}`)}>
|
||||
Open top client
|
||||
</button>
|
||||
)}
|
||||
{result.sourceTables?.length ? (
|
||||
<span className={styles.provenance}>{result.sourceTables.slice(0, 4).join(', ')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [phase, setPhase] = useState<Phase>('idle');
|
||||
const [selectedStyle, setSelectedStyle] = useState<StylePreset | null>(null);
|
||||
const [health, setHealth] = useState<DreamWeaverHealth | null>(null);
|
||||
const [roomType, setRoomType] = useState<RoomType>('bedroom');
|
||||
const [keywords, setKeywords] = useState(DEFAULT_PROMPT);
|
||||
const [sourceFile, setSourceFile] = useState<File | null>(null);
|
||||
const [sourcePreview, setSourcePreview] = useState<string | null>(roomImageUrl ?? null);
|
||||
const [job, setJob] = useState<DreamWeaverJobResponse | null>(null);
|
||||
const [result, setResult] = useState<ReimagineResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
|
||||
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.');
|
||||
}
|
||||
}, [selectedStyle, propertyId, roomImageUrl]);
|
||||
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');
|
||||
}
|
||||
}, [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 (
|
||||
<div className={styles.root}>
|
||||
{/* ── Idle: single button ─────────────────────────── */}
|
||||
{phase === 'idle' && (
|
||||
<motion.button
|
||||
className={`btn-primary ${styles.reimagineBtn}`}
|
||||
onClick={handleReimagineClick}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
✨ Reimagine
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* ── Selecting: staggered style tiles ─────────────── */}
|
||||
{(phase === 'selecting' || phase === 'result') && (
|
||||
<div className={styles.presetsSection}>
|
||||
<div className={styles.presetGrid}>
|
||||
{STYLE_PRESETS.map(({ id, label, previewUrl }, idx) => (
|
||||
<motion.button
|
||||
key={id}
|
||||
className={`${styles.presetTile} ${selectedStyle === id ? styles.selected : ''}`}
|
||||
onClick={() => 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}`}
|
||||
>
|
||||
<div className={styles.previewImg}>
|
||||
<img src={previewUrl} alt={label} />
|
||||
<section className={styles.sourcePanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<span className={styles.eyebrow}>Source Room</span>
|
||||
<h3>Reimagine this space</h3>
|
||||
</div>
|
||||
<span className={health?.online ? styles.healthOk : styles.healthWarn}>{healthLabel}</span>
|
||||
</div>
|
||||
<span className={styles.presetLabel}>{label}</span>
|
||||
|
||||
{/* Active glow border */}
|
||||
{selectedStyle === id && (
|
||||
<motion.div
|
||||
layoutId="preset-selection"
|
||||
className={styles.selectionRing}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
<button className={styles.uploadFrame} onClick={() => fileInputRef.current?.click()} type="button">
|
||||
{sourcePreview ? <img src={sourcePreview} alt="Selected room" /> : (
|
||||
<span className={styles.uploadEmpty}>
|
||||
<UploadCloud size={28} />
|
||||
Upload room image
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
</button>
|
||||
<input ref={fileInputRef} className={styles.fileInput} type="file" accept="image/*" onChange={handleFileChange} />
|
||||
|
||||
<div className={styles.roomGrid}>
|
||||
{ROOM_TYPES.map((room) => (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
className={roomType === room.id ? styles.roomActive : styles.roomButton}
|
||||
onClick={() => setRoomType(room.id)}
|
||||
>
|
||||
{room.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Generate button materializes when style selected */}
|
||||
<AnimatePresence>
|
||||
{selectedStyle && phase !== 'result' && (
|
||||
<motion.button
|
||||
className={`btn-primary ${styles.generateBtn}`}
|
||||
onClick={handleGenerate}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 25 }}
|
||||
>
|
||||
<label className={styles.promptLabel}>
|
||||
Styling prompt
|
||||
<textarea value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
||||
</label>
|
||||
|
||||
<button className={styles.generateButton} onClick={() => void handleGenerate()} disabled={!canGenerate}>
|
||||
{phase === 'generating' ? <Loader2 size={18} className={styles.spin} /> : <Sparkles size={18} />}
|
||||
Generate
|
||||
</motion.button>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className={styles.resultPanel}>
|
||||
<AnimatePresence mode="wait">
|
||||
{phase === 'generating' && (
|
||||
<motion.div key="generating" className={styles.resultState} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||
<Loader2 className={styles.spin} size={34} />
|
||||
<strong>Reimagining your space</strong>
|
||||
<span>Render processing - poll {pollCount}/180</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === 'result' && result && (
|
||||
<motion.div key="result" className={styles.resultReady} initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }}>
|
||||
<img src={result.url} alt="Generated staging" />
|
||||
<div className={styles.resultActions}>
|
||||
<button className="btn-primary" onClick={() => onResultSaved?.(result.url)}>
|
||||
<ImagePlus size={16} /> Use This
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={handleDownload}>
|
||||
<Download size={16} /> Download
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={() => window.open(result.url, '_blank', 'noopener,noreferrer')}>
|
||||
<ExternalLink size={16} /> Open
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{(phase === 'ready' || phase === 'idle') && (
|
||||
<motion.div key="empty" className={styles.resultState} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||
<Sparkles size={34} />
|
||||
<strong>No generated image yet</strong>
|
||||
<span>Upload an image, tune the prompt, and generate a staged render.</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === 'error' && (
|
||||
<motion.div key="error" className={styles.errorState} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||
<strong>Dream Weaver unavailable</strong>
|
||||
<span>{error}</span>
|
||||
<button className="btn-ghost" onClick={() => setPhase(sourceFile ? 'ready' : 'idle')}>Dismiss</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Generating: shimmer overlay ──────────────────── */}
|
||||
{phase === 'generating' && (
|
||||
<motion.div
|
||||
className={styles.generatingState}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div className={`${styles.imageFrame} shimmer`} />
|
||||
<motion.p
|
||||
className={styles.generatingText}
|
||||
animate={{ opacity: [0.4, 0.7, 0.4] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
Reimagining your space…
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ── Result: cross-fade to generated image ─────────── */}
|
||||
{phase === 'result' && result && (
|
||||
<motion.div
|
||||
className={styles.resultState}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className={styles.resultImage}>
|
||||
<img src={result.imageUrl} alt="AI-generated staging" />
|
||||
</div>
|
||||
<div className={styles.resultActions}>
|
||||
<button className="btn-primary" onClick={handleUseThis}>
|
||||
Use This
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={handleTryAnother}>
|
||||
Try Another Style
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Internal: silent polling (hidden from user) ───────────────
|
||||
async function pollForResult(jobId: string): Promise<string> {
|
||||
const MAX_ATTEMPTS = 60;
|
||||
const INTERVAL_MS = 5000;
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
await new Promise(r => setTimeout(r, INTERVAL_MS));
|
||||
const resp = await fetch(`/dream-weaver/status/${jobId}`);
|
||||
const { status, result_url } = await resp.json();
|
||||
if (status === 'complete' && result_url) return result_url;
|
||||
if (status === 'failed') throw new Error('Generation failed');
|
||||
}
|
||||
throw new Error('Generation timed out');
|
||||
}
|
||||
|
||||
@@ -107,7 +107,10 @@ function CampaignsSection() {
|
||||
queryKey: ['studio-campaigns'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<unknown>('/catalyst/campaigns?limit=100&offset=0');
|
||||
return unwrapArray<CampaignSummary>(response);
|
||||
return unwrapArray<CampaignSummary>(response, ['campaigns']).map((campaign, index) => ({
|
||||
...campaign,
|
||||
id: String((campaign as CampaignSummary & { campaign_id?: string }).id ?? (campaign as CampaignSummary & { campaign_id?: string }).campaign_id ?? index),
|
||||
}));
|
||||
},
|
||||
staleTime: 60_000,
|
||||
refetchOnMount: 'always',
|
||||
|
||||
@@ -81,6 +81,17 @@ interface InventoryPropertyRecord {
|
||||
availableUnits?: number;
|
||||
}
|
||||
|
||||
interface InventoryMediaRecord {
|
||||
url?: string;
|
||||
thumbnail_url?: string;
|
||||
thumbnailUrl?: string;
|
||||
media_type?: string;
|
||||
type?: string;
|
||||
kind?: string;
|
||||
label?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function mapLocation(location: InventoryPropertyRecord['location']): string {
|
||||
if (!location) return 'Location pending';
|
||||
return [location.district, location.area, location.city, location.address].filter(Boolean).join(', ') || 'Location pending';
|
||||
@@ -101,9 +112,9 @@ function mapPriceRange(priceBands: InventoryPropertyRecord['price_bands']): stri
|
||||
}
|
||||
|
||||
function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
|
||||
const media = stableArray<{ url?: string; thumbnail_url?: string }>(record.media);
|
||||
const media = normalizeMedia(record);
|
||||
const unitMix = stableArray<{ configuration?: string; count?: number; available?: number; area?: string; price?: string }>(record.unit_mix);
|
||||
const mediaThumb = media.find((item) => item.thumbnail_url || item.url);
|
||||
const mediaThumb = media.find((item) => item.thumbnail_url || item.thumbnailUrl || item.url);
|
||||
const availableUnits = unitMix.length
|
||||
? unitMix.reduce((sum, unit) => sum + Number(unit.available ?? unit.count ?? 0), 0)
|
||||
: record.availableUnits;
|
||||
@@ -113,7 +124,7 @@ function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
|
||||
name: record.project_name ?? record.name ?? 'Unnamed property',
|
||||
location: mapLocation(record.location),
|
||||
priceRange: mapPriceRange(record.price_bands),
|
||||
thumbnailUrl: record.thumbnailUrl ?? mediaThumb?.thumbnail_url ?? mediaThumb?.url,
|
||||
thumbnailUrl: record.thumbnailUrl ?? mediaThumb?.thumbnail_url ?? mediaThumb?.thumbnailUrl ?? mediaThumb?.url,
|
||||
availableUnits,
|
||||
};
|
||||
}
|
||||
@@ -121,18 +132,62 @@ function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
|
||||
function mapInventoryPropertyDetail(record: InventoryPropertyRecord): PropertyDetail {
|
||||
const base = mapInventoryProperty(record);
|
||||
const unitMix = stableArray<{ configuration?: string; area?: string; price?: string }>(record.unit_mix);
|
||||
const media = stableArray<{ url?: string }>(record.media);
|
||||
const media = normalizeMedia(record);
|
||||
const primaryUnit = unitMix[0];
|
||||
const images = stableArray<string>(record.images).length
|
||||
? stableArray<string>(record.images)
|
||||
: media.map((item) => item.url).filter((url): url is string => Boolean(url));
|
||||
const imageUrls = stableArray<string>(record.images);
|
||||
const mediaImages = media
|
||||
.filter((item) => isImageMedia(item))
|
||||
.map((item) => item.url ?? item.thumbnail_url ?? item.thumbnailUrl)
|
||||
.filter((url): url is string => Boolean(url));
|
||||
const images = imageUrls.length ? imageUrls : mediaImages;
|
||||
const modelUrl = media.find((item) => isModelMedia(item))?.url;
|
||||
const interiorImageUrl = findInteriorImage(media) ?? images[0] ?? base.thumbnailUrl;
|
||||
return {
|
||||
...base,
|
||||
config: primaryUnit?.configuration ?? record.property_type ?? 'Mixed configuration',
|
||||
area: primaryUnit?.area ?? 'Area pending',
|
||||
price: primaryUnit?.price ?? base.priceRange ?? 'Price pending',
|
||||
description: `${record.developer_name ?? 'Developer'} property in ${base.location}`,
|
||||
interiorImageUrl,
|
||||
modelUrl,
|
||||
images,
|
||||
amenities: Array.isArray(record.amenities) ? record.amenities : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMedia(record: InventoryPropertyRecord): InventoryMediaRecord[] {
|
||||
const media = stableArray<InventoryMediaRecord>(record.media);
|
||||
const imageRecords = stableArray<InventoryMediaRecord>(record.images);
|
||||
const imageStrings: InventoryMediaRecord[] = stableArray<string>(record.images).map((url) => ({
|
||||
url,
|
||||
media_type: 'image',
|
||||
}));
|
||||
return [...media, ...imageRecords, ...imageStrings].filter((item) => Boolean(item.url ?? item.thumbnail_url ?? item.thumbnailUrl));
|
||||
}
|
||||
|
||||
function mediaKind(item: InventoryMediaRecord): string {
|
||||
return [item.media_type, item.type, item.kind, item.label]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isImageMedia(item: InventoryMediaRecord): boolean {
|
||||
const url = (item.url ?? item.thumbnail_url ?? item.thumbnailUrl ?? '').toLowerCase();
|
||||
const kind = mediaKind(item);
|
||||
return kind.includes('image') || kind.includes('photo') || /\.(png|jpe?g|webp|gif|avif)(\?|$)/.test(url);
|
||||
}
|
||||
|
||||
function isModelMedia(item: InventoryMediaRecord): boolean {
|
||||
const url = (item.url ?? '').toLowerCase();
|
||||
const kind = mediaKind(item);
|
||||
return kind.includes('model') || kind.includes('3d') || kind.includes('vr') || /\.(glb|gltf)(\?|$)/.test(url);
|
||||
}
|
||||
|
||||
function findInteriorImage(media: InventoryMediaRecord[]): string | undefined {
|
||||
const item = media.find((entry) => {
|
||||
const kind = mediaKind(entry);
|
||||
return isImageMedia(entry) && (kind.includes('interior') || kind.includes('room') || kind.includes('staging'));
|
||||
});
|
||||
return item?.url ?? item?.thumbnail_url ?? item?.thumbnailUrl;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildVelocityHeaders } from '@/lib/velocitySession';
|
||||
import { buildVelocityHeaders } from '@/shared/lib/velocitySession';
|
||||
|
||||
const rawApiBase = import.meta.env.VITE_API_URL?.trim();
|
||||
const DEPLOYED_BACKEND_ORIGIN = 'https://velocity.desineuron.in';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { API_URL } from '@/lib/api';
|
||||
import { buildVelocityHeaders } from '@/lib/velocitySession';
|
||||
import { API_URL } from '@/shared/lib/api';
|
||||
import { buildVelocityHeaders } from '@/shared/lib/velocitySession';
|
||||
|
||||
const rawDreamWeaverBase = import.meta.env.VITE_DREAM_WEAVER_URL?.trim();
|
||||
const rawDreamWeaverApiKey = import.meta.env.VITE_DREAM_WEAVER_API_KEY?.trim();
|
||||
|
||||
Reference in New Issue
Block a user