feat: Built the Oracle Tab (#14)

This commit is contained in:
2026-04-11 19:35:45 +05:30
committed by Sayan Datta
parent 8e1ffe0e43
commit a4cb815338
54 changed files with 10651 additions and 818 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
# Oracle Development Status & Transition Blueprint
This artifact summarizes the current state, remaining work, and architectural limitations/constraints for the **Project Velocity - The Oracle** module, based on the *Master Architecture and Implementation Artifact (v1.0)*. It is intended to serve as a comprehensive briefing for a coding agent tasked with completing the Oracle implementation.
> [!NOTE]
> **Contextual Awareness**: The current `implementation_plan.md` and `task.md` in the agent's brain directory are mapped to **The Catalyst** (Digital Marketing Agency module), which is a separate product vertical. The Oracle requires an independent set of execution tasks focused completely on the CRM's AI operational intelligence layer.
## 1. What Development is Done (Current State in Repo)
The repository currently contains the **"visual shell" and technical foundation**, but no production Oracle paths.
### Frontend
- **Polished UI Shell Exists**: `app/src/app/oracle/page.tsx` exists and provides a premium glassmorphic UI, a right-side conversation rail, and a top insight banner.
- **Mock Data Layer**: The UI uses a single-response mock client (`app/src/lib/oracleQueryClient.ts`) operating on a legacy `OracleQueryResult` contract. It switches between fixed, hardcoded views instead of an extensible, revision-controlled canvas.
### Backend
- **FastAPI / Python Foundation**: A robust FastAPI backend is running (`backend/main.py`), utilizing `asyncpg` for PostgreSQL and JWT-based authentication.
- **AI Runtime Abstraction Exists**: A production-grade `Nemoclaw` client (`backend/services/nemoclaw_client.py`) is already implemented and currently handles Sentinel's AI needs. This is the abstract LLM planner.
- **Database Baseline**: `backend/db/schema.sql` possesses the baseline auth and Sentinel schemas, but lacks Oracle's revisioned canvas components.
- **Placeholders**: `backend/api/routes_oracle.py` and `backend/api/routes_crm.py` exist but are entirely empty.
---
## 2. What is Remaining (Sprint 1 / MVP Blueprint)
The implementation must transition the system from "mocked single-response behavior" into "persistent, branchable, revisioned vertical JSON canvas behavior". This is broken down into Work Packages (WPs):
### WP1: Contract Foundation (First Next Step)
- Create JSON Schemas and API contracts (Section 6 & 13 types: `CanvasPage`, `CanvasComponent`, `PromptExecution`).
- Deprecate the mock query client; build a typed `oracleApiClient.ts`.
- Build the `v1` Oracle router skeleton in FastAPI.
### WP2: Persistence & DB Core
- Implement the revision-friendly PostgreSQL database schemas outlined in Section 16.4 (`oracle_canvas_pages`, `oracle_canvas_page_revisions`, `oracle_canvas_components`, `oracle_prompt_executions`).
### WP3: Prompt Orchestrator & Data Access
- Wire up `backend/oracle/prompt_orchestrator.py` to route prompts to `Nemoclaw_client.py`.
- **Constraint**: Implement strict structured parsing for Nemoclaw's output.
- Build the `Data Access Gateway` and `Policy Engine` to securely query tenant PostgreSQL data based on the validated LLM plan.
### WP4: Frontend Virtualized Canvas
- Refactor `app/src/app/oracle/page.tsx`.
- Create a virtualized vertical component scroller (`oracle/components/CanvasViewport.tsx`).
- Introduce a Component Registry (`oracle/components/ComponentRegistry.tsx`) that translates backend JSON components (e.g., `barChart`, `geoMap`) into React views.
- Make the prompt rail rely on the execution history instead of mock triggers.
### WP5: Collaboration & Merge Model
- **Jira-Style Branching**: Implement page sharing (Fork creation) instead of live shared edits.
- Implement Merge Requests, 3-way component diffs, and conflict resolution UI (`MergeReviewDrawer.tsx`).
### WP6: Component Catalog & Synthesis
- Build a catalog of pre-made templates (KPI tiles, pipelines, investor geographic heatmaps).
- Implement component synthesis: where an LLM generates a brand new visualization template utilizing extracted `styleSignatures` to match the tenant's exact design language.
---
## 3. Limitations & Constraints Imposed by the Master Artifact
The architecture document outlines very strict constraints on *why* parts of the system are deferred and *how* the remaining work must be executed.
> [!WARNING]
> **Strict Implementation Ordering**
> The system must be built sequentially. **Do not** build the frontend canvas (WP4) or Collaboration (WP5) before the API Contracts (WP1) and Persistence (WP2) are real.
> *Quoting the document:* "The first concrete move... should be to freeze the current mock Oracle query client... The second move should be to add Oracle page tables... Only after those three foundations exist should the team invest in merge review UI or template synthesis. That order keeps the product grounded in durable state rather than another generation of temporary mocks."
### Structural Constraints
- **No Unrestricted AI Executing SQL**: `Nemoclaw` acts only as a *planner*. It must **never** execute arbitrary SQL or touch connector APIs directly to mitigate hallucination and data leakage. It produces a JSON query plan that is executed strictly by the non-AI *Data Access Gateway*.
- **No Live Co-Editing**: Overwriting another user's Oracle dashboard live is forbidden. Oracle requires an explicit asynchronous **Fork & Merge** collaboration structure.
- **Immutable Pages**: Pages are append-only. When the frontend mutates a visualization, it must execute a new commit revision or a rollback revision. It must *never* silently overwrite a past revision log.
- **Synthesis is Delayed & Isolated**: Auto-promotion of LLM-synthesized UI components is explicitly isolated to the originating tenant for Sprint 1. Global (cross-tenant) sharing of synthesized components is forbidden.
- **No Uncontrolled Fine-Tuning**: Visual generation must use *exemplar retrieval* (a JSON dictionary `styleSignature`), instead of raw model-weight fine-tuning, to ensure styling remains fully deterministic.
### Security / Scope Constraints
- **Cross-Tenant Blocking**: Because of Hybrid Sovereign deployment architecture, querying across tenants is explicitly rejected and unsupported in this phase.
- **Connectors**: MVP must rely primarily on internal Velocity PostgreSQL datasets. External CRM connectors are deferred to later phases.
---
## 4. Oracle Development Status Dashboard
| Task Category | Status | Details / Scope | Specific Limitations / Constraints |
| :--- | :--- | :--- | :--- |
| **Frontend UI Shell** | ✅ Completed | Premium glassmorphic UI, right-side conversation rail, and top insight banner are all in place (`app/src/app/oracle/page.tsx`). | N/A - Serves as visual foundation. |
| **Backend Foundation** | ✅ Completed | FastAPI router base, `asyncpg` for PostgreSQL, and JWT authentication flow. | N/A - Foundation only. |
| **AI Planner Engine** | ✅ Completed | `Nemoclaw` client is operational and abstracted (currently used by Sentinel). | Operates merely as a planner; the LLM cannot execute SQL directly. |
| **Mock Data Layer** | ⏸️ Deprecated | Legacy `oracleQueryClient.ts` powering single-response fixed views with hardcoded data. | Cannot be used for Phase 2. Must be replaced immediately prior to frontend expansion. |
| **WP1: Contract Foundation** | ⏳ Remaining | JSON Schemas (`CanvasPage`, `CanvasComponent`) and typed `oracleApiClient.ts`. | **Must be built first.** No UI development can proceed until the API contracts replace the legacy mocks. |
| **WP2: Persistence & DB Core** | ⏳ Remaining | PostgreSQL schema implementation for `canvas_pages`, `revisions`, `components`, and `executions`. | **Sequential Dependency.** Must be completed immediately after WP1 to ensure all UI actions rely on durable states, avoiding fake in-memory changes. |
| **WP3: Prompt Orchestrator** | ⏳ Remaining | Wire backend (`prompt_orchestrator.py`) to Nemoclaw for structured JSON retrieval and visualization planning. | **Redaction & Security.** The Data Access Gateway must validate the plan. Nemoclaw is forbidden from directly querying external connectors, preventing data hallucination/leakage. |
| **WP4: Frontend Canvas** | ⏳ Remaining | Refactor Oracle UI into a virtualized, vertically scrolling JSON canvas with a proper Component Registry. | **No Live Co-Editing.** State changes (mutations) must execute explicit revision commits or rollbacks. Overwriting another user's current view directly is forbidden. |
| **WP5: Collaboration & Merging** | ⏳ Remaining | Jira-style Share (Fork) workflows, Merge Requests, 3-way component diffs, and review UI. | **Isolated Revisions.** All sharing forces a new branch (Fork). No cross-tenant data sharing is allowed under any circumstance (Hybrid Sovereign constraint). |
| **WP6: Catalog & Synthesis** | ⏳ Remaining | AI-driven synthesis of custom UI components using extracted tenant styling. | **No Global Promotion.** Auto-promotion of LLM-generated components is isolated to the origin tenant. Uses deterministic `styleSignatures`, avoiding uncontrolled weight fine-tuning. |
| **WP7: Hardening & Ops** | ⏳ Remaining | Performance testing, telemetry, replay fixtures, and SLA tuning limiters. | Must ensure canvas scrolling maintains 60 FPS up to 5,000 components via strict virtualization limits. |

4
app/dist/index.html vendored
View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Velocity WebOS</title> <title>Velocity WebOS</title>
<script type="module" crossorigin src="./assets/index-DWXNQJq4.js"></script> <script type="module" crossorigin src="./assets/index-PSY8fNXf.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-y9vEdTGy.css"> <link rel="stylesheet" crossorigin href="./assets/index-tj5O5w9J.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1 +1 @@
{"root":["../../src/app.tsx","../../src/main.tsx","../../src/app/oracle/page.tsx","../../src/components/layout/loginscreen.tsx","../../src/components/layout/sidebar.tsx","../../src/components/modules/catalyst.tsx","../../src/components/modules/dashboard.tsx","../../src/components/modules/inventory.tsx","../../src/components/modules/oracle.tsx","../../src/components/modules/sentinel.tsx","../../src/components/modules/settings.tsx","../../src/components/oracle/leadinspector.tsx","../../src/components/oracle/pipelineview.tsx","../../src/components/oracle/mockleads.ts","../../src/components/sentinel/journeyriver/inspectorpanel.tsx","../../src/components/sentinel/journeyriver/riverpath.tsx","../../src/components/sentinel/journeyriver/index.tsx","../../src/components/ui/accordion.tsx","../../src/components/ui/alert-dialog.tsx","../../src/components/ui/alert.tsx","../../src/components/ui/aspect-ratio.tsx","../../src/components/ui/avatar.tsx","../../src/components/ui/badge.tsx","../../src/components/ui/breadcrumb.tsx","../../src/components/ui/button-group.tsx","../../src/components/ui/button.tsx","../../src/components/ui/calendar.tsx","../../src/components/ui/card.tsx","../../src/components/ui/carousel.tsx","../../src/components/ui/chart.tsx","../../src/components/ui/checkbox.tsx","../../src/components/ui/collapsible.tsx","../../src/components/ui/command.tsx","../../src/components/ui/context-menu.tsx","../../src/components/ui/dialog.tsx","../../src/components/ui/drawer.tsx","../../src/components/ui/dropdown-menu.tsx","../../src/components/ui/empty.tsx","../../src/components/ui/field.tsx","../../src/components/ui/form.tsx","../../src/components/ui/hover-card.tsx","../../src/components/ui/input-group.tsx","../../src/components/ui/input-otp.tsx","../../src/components/ui/input.tsx","../../src/components/ui/item.tsx","../../src/components/ui/kbd.tsx","../../src/components/ui/label.tsx","../../src/components/ui/menubar.tsx","../../src/components/ui/navigation-menu.tsx","../../src/components/ui/pagination.tsx","../../src/components/ui/popover.tsx","../../src/components/ui/progress.tsx","../../src/components/ui/radio-group.tsx","../../src/components/ui/resizable.tsx","../../src/components/ui/scroll-area.tsx","../../src/components/ui/select.tsx","../../src/components/ui/separator.tsx","../../src/components/ui/sheet.tsx","../../src/components/ui/sidebar.tsx","../../src/components/ui/skeleton.tsx","../../src/components/ui/slider.tsx","../../src/components/ui/sonner.tsx","../../src/components/ui/spinner.tsx","../../src/components/ui/switch.tsx","../../src/components/ui/table.tsx","../../src/components/ui/tabs.tsx","../../src/components/ui/textarea.tsx","../../src/components/ui/toggle-group.tsx","../../src/components/ui/toggle.tsx","../../src/components/ui/tooltip.tsx","../../src/hooks/use-mobile.ts","../../src/lib/oraclequeryclient.ts","../../src/lib/utils.ts","../../src/store/usemarketingstore.ts","../../src/store/usestore.ts","../../src/types/crm.ts","../../src/types/index.ts","../../src/utils/curvegenerator.ts"],"version":"5.9.3"} {"root":["../../src/app.tsx","../../src/main.tsx","../../src/app/oracle/page.tsx","../../src/components/layout/loginscreen.tsx","../../src/components/layout/sidebar.tsx","../../src/components/modules/catalyst.tsx","../../src/components/modules/dashboard.tsx","../../src/components/modules/groundtruthpicker.tsx","../../src/components/modules/inventory.tsx","../../src/components/modules/oracle.tsx","../../src/components/modules/sentinel.tsx","../../src/components/modules/settings.tsx","../../src/components/oracle/leadinspector.tsx","../../src/components/oracle/pipelineview.tsx","../../src/components/oracle/mockleads.ts","../../src/components/sentinel/journeyriver/inspectorpanel.tsx","../../src/components/sentinel/journeyriver/riverpath.tsx","../../src/components/sentinel/journeyriver/index.tsx","../../src/components/ui/accordion.tsx","../../src/components/ui/alert-dialog.tsx","../../src/components/ui/alert.tsx","../../src/components/ui/aspect-ratio.tsx","../../src/components/ui/avatar.tsx","../../src/components/ui/badge.tsx","../../src/components/ui/breadcrumb.tsx","../../src/components/ui/button-group.tsx","../../src/components/ui/button.tsx","../../src/components/ui/calendar.tsx","../../src/components/ui/card.tsx","../../src/components/ui/carousel.tsx","../../src/components/ui/chart.tsx","../../src/components/ui/checkbox.tsx","../../src/components/ui/collapsible.tsx","../../src/components/ui/command.tsx","../../src/components/ui/context-menu.tsx","../../src/components/ui/dialog.tsx","../../src/components/ui/drawer.tsx","../../src/components/ui/dropdown-menu.tsx","../../src/components/ui/empty.tsx","../../src/components/ui/field.tsx","../../src/components/ui/form.tsx","../../src/components/ui/hover-card.tsx","../../src/components/ui/input-group.tsx","../../src/components/ui/input-otp.tsx","../../src/components/ui/input.tsx","../../src/components/ui/item.tsx","../../src/components/ui/kbd.tsx","../../src/components/ui/label.tsx","../../src/components/ui/menubar.tsx","../../src/components/ui/navigation-menu.tsx","../../src/components/ui/pagination.tsx","../../src/components/ui/popover.tsx","../../src/components/ui/progress.tsx","../../src/components/ui/radio-group.tsx","../../src/components/ui/resizable.tsx","../../src/components/ui/scroll-area.tsx","../../src/components/ui/select.tsx","../../src/components/ui/separator.tsx","../../src/components/ui/sheet.tsx","../../src/components/ui/sidebar.tsx","../../src/components/ui/skeleton.tsx","../../src/components/ui/slider.tsx","../../src/components/ui/sonner.tsx","../../src/components/ui/spinner.tsx","../../src/components/ui/switch.tsx","../../src/components/ui/table.tsx","../../src/components/ui/tabs.tsx","../../src/components/ui/textarea.tsx","../../src/components/ui/toggle-group.tsx","../../src/components/ui/toggle.tsx","../../src/components/ui/tooltip.tsx","../../src/hooks/use-mobile.ts","../../src/lib/oraclequeryclient.ts","../../src/lib/utils.ts","../../src/oracle/components/branchbar.tsx","../../src/oracle/components/canvasviewport.tsx","../../src/oracle/components/componentregistry.tsx","../../src/oracle/components/promptrail.tsx","../../src/oracle/components/rollbackconfirmmodal.tsx","../../src/oracle/components/sharemodal.tsx","../../src/oracle/components/renderers/activitystreamrenderer.tsx","../../src/oracle/components/renderers/barchartrenderer.tsx","../../src/oracle/components/renderers/errornoticerenderer.tsx","../../src/oracle/components/renderers/geomaprenderer.tsx","../../src/oracle/components/renderers/kpitilerenderer.tsx","../../src/oracle/components/renderers/linechartrenderer.tsx","../../src/oracle/components/renderers/pipelineboardrenderer.tsx","../../src/oracle/components/renderers/rendererwrapper.tsx","../../src/oracle/components/renderers/tablerenderer.tsx","../../src/oracle/components/renderers/timelinerenderer.tsx","../../src/oracle/components/review/mergereviewdrawer.tsx","../../src/oracle/hooks/useoracleexecution.ts","../../src/oracle/hooks/useoraclepage.ts","../../src/oracle/lib/oracleapiclient.ts","../../src/oracle/lib/oracledemodata.ts","../../src/oracle/types/canvas.ts","../../src/store/usecurrencystore.ts","../../src/store/usemarketingstore.ts","../../src/store/usestore.ts","../../src/types/crm.ts","../../src/types/index.ts","../../src/utils/curvegenerator.ts"],"version":"5.9.3"}

View File

@@ -2,14 +2,14 @@
import { import {
createSlot createSlot
} from "./chunk-YWBEB5PG.js"; } from "./chunk-YWBEB5PG.js";
import {
require_shim
} from "./chunk-TXHHHGR3.js";
import { import {
useCallbackRef, useCallbackRef,
useLayoutEffect2 useLayoutEffect2
} from "./chunk-23FVUG5N.js"; } from "./chunk-23FVUG5N.js";
import "./chunk-2VUH7NEY.js"; import "./chunk-2VUH7NEY.js";
import {
require_shim
} from "./chunk-TXHHHGR3.js";
import { import {
require_react_dom require_react_dom
} from "./chunk-YF4B4G2L.js"; } from "./chunk-YF4B4G2L.js";

View File

@@ -1,9 +1,9 @@
import {
require_client
} from "./chunk-6MXH2QM6.js";
import { import {
subscribeWithSelector subscribeWithSelector
} from "./chunk-O4L7C4YS.js"; } from "./chunk-O4L7C4YS.js";
import {
create
} from "./chunk-7GZ4CI6Q.js";
import { import {
Events Events
} from "./chunk-OAEA5FZL.js"; } from "./chunk-OAEA5FZL.js";
@@ -22,8 +22,8 @@ import {
useInstanceHandle, useInstanceHandle,
useLoader, useLoader,
useThree useThree
} from "./chunk-O5V7GNMB.js"; } from "./chunk-5ESDTKMP.js";
import "./chunk-GUQHL3N7.js"; import "./chunk-NJ4V5H3P.js";
import { import {
AddEquation, AddEquation,
AdditiveBlending, AdditiveBlending,
@@ -219,9 +219,9 @@ import {
ZeroFactor ZeroFactor
} from "./chunk-L3Z576C2.js"; } from "./chunk-L3Z576C2.js";
import { import {
create require_client
} from "./chunk-7GZ4CI6Q.js"; } from "./chunk-6MXH2QM6.js";
import "./chunk-NJ4V5H3P.js"; import "./chunk-GUQHL3N7.js";
import { import {
_extends _extends
} from "./chunk-EQCCHGRT.js"; } from "./chunk-EQCCHGRT.js";

View File

@@ -28,10 +28,10 @@ import {
useLoader, useLoader,
useStore, useStore,
useThree useThree
} from "./chunk-O5V7GNMB.js"; } from "./chunk-5ESDTKMP.js";
import "./chunk-GUQHL3N7.js";
import "./chunk-L3Z576C2.js";
import "./chunk-NJ4V5H3P.js"; import "./chunk-NJ4V5H3P.js";
import "./chunk-L3Z576C2.js";
import "./chunk-GUQHL3N7.js";
import "./chunk-TXHHHGR3.js"; import "./chunk-TXHHHGR3.js";
import "./chunk-2YVA4HRZ.js"; import "./chunk-2YVA4HRZ.js";
import "./chunk-WUR7D6NS.js"; import "./chunk-WUR7D6NS.js";

View File

@@ -2,126 +2,132 @@
"hash": "b2c5007d", "hash": "b2c5007d",
"configHash": "d9a82a01", "configHash": "d9a82a01",
"lockfileHash": "8a04eea8", "lockfileHash": "8a04eea8",
"browserHash": "5d6343ae", "browserHash": "c208d4ff",
"optimized": { "optimized": {
"react": { "react": {
"src": "../../react/index.js", "src": "../../react/index.js",
"file": "react.js", "file": "react.js",
"fileHash": "4ea9824e", "fileHash": "a26efb1d",
"needsInterop": true "needsInterop": true
}, },
"react-dom": { "react-dom": {
"src": "../../react-dom/index.js", "src": "../../react-dom/index.js",
"file": "react-dom.js", "file": "react-dom.js",
"fileHash": "5b549105", "fileHash": "c6e68a6f",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-dev-runtime": { "react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js", "src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js", "file": "react_jsx-dev-runtime.js",
"fileHash": "41193e59", "fileHash": "7531df54",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-runtime": { "react/jsx-runtime": {
"src": "../../react/jsx-runtime.js", "src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js", "file": "react_jsx-runtime.js",
"fileHash": "ad8008f8", "fileHash": "a247453f",
"needsInterop": true "needsInterop": true
}, },
"@radix-ui/react-avatar": { "@radix-ui/react-avatar": {
"src": "../../@radix-ui/react-avatar/dist/index.mjs", "src": "../../@radix-ui/react-avatar/dist/index.mjs",
"file": "@radix-ui_react-avatar.js", "file": "@radix-ui_react-avatar.js",
"fileHash": "e6d8d406", "fileHash": "d86de0d6",
"needsInterop": false "needsInterop": false
}, },
"@radix-ui/react-dropdown-menu": { "@radix-ui/react-dropdown-menu": {
"src": "../../@radix-ui/react-dropdown-menu/dist/index.mjs", "src": "../../@radix-ui/react-dropdown-menu/dist/index.mjs",
"file": "@radix-ui_react-dropdown-menu.js", "file": "@radix-ui_react-dropdown-menu.js",
"fileHash": "26caaf75", "fileHash": "51cbea4a",
"needsInterop": false "needsInterop": false
}, },
"@radix-ui/react-slot": { "@radix-ui/react-slot": {
"src": "../../@radix-ui/react-slot/dist/index.mjs", "src": "../../@radix-ui/react-slot/dist/index.mjs",
"file": "@radix-ui_react-slot.js", "file": "@radix-ui_react-slot.js",
"fileHash": "e034d698", "fileHash": "d49e4181",
"needsInterop": false "needsInterop": false
}, },
"@react-three/drei": { "@react-three/drei": {
"src": "../../@react-three/drei/index.js", "src": "../../@react-three/drei/index.js",
"file": "@react-three_drei.js", "file": "@react-three_drei.js",
"fileHash": "d81d1332", "fileHash": "49af0e0c",
"needsInterop": false "needsInterop": false
}, },
"@react-three/fiber": { "@react-three/fiber": {
"src": "../../@react-three/fiber/dist/react-three-fiber.esm.js", "src": "../../@react-three/fiber/dist/react-three-fiber.esm.js",
"file": "@react-three_fiber.js", "file": "@react-three_fiber.js",
"fileHash": "dcc73392", "fileHash": "6e6f65b1",
"needsInterop": false "needsInterop": false
}, },
"class-variance-authority": { "class-variance-authority": {
"src": "../../class-variance-authority/dist/index.mjs", "src": "../../class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js", "file": "class-variance-authority.js",
"fileHash": "5fa3c3f8", "fileHash": "0e514934",
"needsInterop": false "needsInterop": false
}, },
"clsx": { "clsx": {
"src": "../../clsx/dist/clsx.mjs", "src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js", "file": "clsx.js",
"fileHash": "1428051e", "fileHash": "e71ef32c",
"needsInterop": false "needsInterop": false
}, },
"framer-motion": { "framer-motion": {
"src": "../../framer-motion/dist/es/index.mjs", "src": "../../framer-motion/dist/es/index.mjs",
"file": "framer-motion.js", "file": "framer-motion.js",
"fileHash": "dd6ef86d", "fileHash": "5b5818ee",
"needsInterop": false "needsInterop": false
}, },
"lucide-react": { "lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js", "src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js", "file": "lucide-react.js",
"fileHash": "df6c668f", "fileHash": "fb6a8921",
"needsInterop": false "needsInterop": false
}, },
"react-dom/client": { "react-dom/client": {
"src": "../../react-dom/client.js", "src": "../../react-dom/client.js",
"file": "react-dom_client.js", "file": "react-dom_client.js",
"fileHash": "23087df0", "fileHash": "032f0a73",
"needsInterop": true "needsInterop": true
}, },
"react-router-dom": { "react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs", "src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js", "file": "react-router-dom.js",
"fileHash": "48fbca8f", "fileHash": "fa45c285",
"needsInterop": false "needsInterop": false
}, },
"recharts": { "recharts": {
"src": "../../recharts/es6/index.js", "src": "../../recharts/es6/index.js",
"file": "recharts.js", "file": "recharts.js",
"fileHash": "9faf094d", "fileHash": "b3d6765a",
"needsInterop": false "needsInterop": false
}, },
"tailwind-merge": { "tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs", "src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js", "file": "tailwind-merge.js",
"fileHash": "fc48e7d8", "fileHash": "47183e49",
"needsInterop": false "needsInterop": false
}, },
"three": { "three": {
"src": "../../three/build/three.module.js", "src": "../../three/build/three.module.js",
"file": "three.js", "file": "three.js",
"fileHash": "a0b83871", "fileHash": "d78c89ed",
"needsInterop": false "needsInterop": false
}, },
"zustand": { "zustand": {
"src": "../../zustand/esm/index.mjs", "src": "../../zustand/esm/index.mjs",
"file": "zustand.js", "file": "zustand.js",
"fileHash": "ad25a55f", "fileHash": "39f09270",
"needsInterop": false "needsInterop": false
}, },
"zustand/middleware": { "zustand/middleware": {
"src": "../../zustand/esm/middleware.mjs", "src": "../../zustand/esm/middleware.mjs",
"file": "zustand_middleware.js", "file": "zustand_middleware.js",
"fileHash": "b84fa2e5", "fileHash": "ce09abfc",
"needsInterop": false
},
"sonner": {
"src": "../../sonner/dist/index.mjs",
"file": "sonner.js",
"fileHash": "b1e28aee",
"needsInterop": false "needsInterop": false
} }
}, },
@@ -135,45 +141,45 @@
"chunk-U7P2NEEE": { "chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js" "file": "chunk-U7P2NEEE.js"
}, },
"chunk-O4L7C4YS": {
"file": "chunk-O4L7C4YS.js"
},
"chunk-7GZ4CI6Q": {
"file": "chunk-7GZ4CI6Q.js"
},
"chunk-OAEA5FZL": {
"file": "chunk-OAEA5FZL.js"
},
"chunk-5ESDTKMP": {
"file": "chunk-5ESDTKMP.js"
},
"chunk-NJ4V5H3P": {
"file": "chunk-NJ4V5H3P.js"
},
"chunk-L3Z576C2": {
"file": "chunk-L3Z576C2.js"
},
"chunk-6MXH2QM6": {
"file": "chunk-6MXH2QM6.js"
},
"chunk-GUQHL3N7": {
"file": "chunk-GUQHL3N7.js"
},
"chunk-EQCCHGRT": {
"file": "chunk-EQCCHGRT.js"
},
"chunk-YWBEB5PG": { "chunk-YWBEB5PG": {
"file": "chunk-YWBEB5PG.js" "file": "chunk-YWBEB5PG.js"
}, },
"chunk-TXHHHGR3": {
"file": "chunk-TXHHHGR3.js"
},
"chunk-23FVUG5N": { "chunk-23FVUG5N": {
"file": "chunk-23FVUG5N.js" "file": "chunk-23FVUG5N.js"
}, },
"chunk-2VUH7NEY": { "chunk-2VUH7NEY": {
"file": "chunk-2VUH7NEY.js" "file": "chunk-2VUH7NEY.js"
}, },
"chunk-6MXH2QM6": {
"file": "chunk-6MXH2QM6.js"
},
"chunk-O4L7C4YS": {
"file": "chunk-O4L7C4YS.js"
},
"chunk-OAEA5FZL": {
"file": "chunk-OAEA5FZL.js"
},
"chunk-O5V7GNMB": {
"file": "chunk-O5V7GNMB.js"
},
"chunk-GUQHL3N7": {
"file": "chunk-GUQHL3N7.js"
},
"chunk-L3Z576C2": {
"file": "chunk-L3Z576C2.js"
},
"chunk-7GZ4CI6Q": {
"file": "chunk-7GZ4CI6Q.js"
},
"chunk-NJ4V5H3P": {
"file": "chunk-NJ4V5H3P.js"
},
"chunk-EQCCHGRT": {
"file": "chunk-EQCCHGRT.js"
},
"chunk-TXHHHGR3": {
"file": "chunk-TXHHHGR3.js"
},
"chunk-YF4B4G2L": { "chunk-YF4B4G2L": {
"file": "chunk-YF4B4G2L.js" "file": "chunk-YF4B4G2L.js"
}, },

View File

@@ -96,7 +96,7 @@ function MainLayout() {
{/* Main Content Area */} {/* Main Content Area */}
<motion.main <motion.main
className="flex-1 min-h-screen overflow-auto custom-scrollbar" className="flex-1 h-screen flex flex-col overflow-hidden"
initial={{ marginLeft: 72 }} initial={{ marginLeft: 72 }}
animate={{ marginLeft: sidebarExpanded ? 232 : 72 }} animate={{ marginLeft: sidebarExpanded ? 232 : 72 }}
transition={{ transition={{
@@ -107,7 +107,7 @@ function MainLayout() {
}} }}
> >
{/* Top Bar */} {/* Top Bar */}
<header className="sticky top-0 z-40 px-6 py-4"> <header className="flex-none z-40 px-6 py-4">
<div <div
className="flex items-center justify-between px-5 py-3 rounded-2xl" className="flex items-center justify-between px-5 py-3 rounded-2xl"
style={{ style={{
@@ -166,7 +166,8 @@ function MainLayout() {
</header> </header>
{/* Module Content — animated on route change */} {/* Module Content — animated on route change */}
<div className="px-8 pb-8"> <div className="flex-1 overflow-y-auto custom-scrollbar">
<div className="px-8 pb-8 min-h-full relative">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={location.pathname} key={location.pathname}
@@ -191,6 +192,7 @@ function MainLayout() {
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>
</div>
</motion.main> </motion.main>
</div> </div>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { import {
Megaphone, Clapperboard, BarChart3, Globe, Settings2, Megaphone, Clapperboard, BarChart3, Globe, Settings2,
Zap, TrendingUp, Eye, MousePointerClick, DollarSign, Zap, TrendingUp, Eye, MousePointerClick, DollarSign,
Upload, Play, Image, Film, RefreshCw, ArrowRight, Plus, Upload, Play, Image, Film, RefreshCw, ArrowRight, Plus, X,
AlertTriangle, ArrowRightLeft, PlusCircle, SlidersHorizontal, AlertTriangle, ArrowRightLeft, PlusCircle, SlidersHorizontal,
Activity, Check, Link2, Activity, Check, Link2,
type LucideIcon, type LucideIcon,
@@ -13,7 +13,10 @@ import {
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
import { useMarketingStore } from '@/store/useMarketingStore'; import { useMarketingStore } from '@/store/useMarketingStore';
import { useCurrency } from '@/store/useCurrencyStore';
import type { Campaign, MarketingAsset, LiveOptimizationEvent, LiveEventType } from '@/types'; import type { Campaign, MarketingAsset, LiveOptimizationEvent, LiveEventType } from '@/types';
import { GroundTruthPicker } from './GroundTruthPicker';
import type { GroundTruthSelection } from './GroundTruthPicker';
// ── Design tokens ───────────────────────────────────────────────────────────── // ── Design tokens ─────────────────────────────────────────────────────────────
const GLASS = { const GLASS = {
@@ -243,22 +246,151 @@ function AssetCard({ asset }: { asset: MarketingAsset }) {
); );
} }
// ── Reference Slot ───────────────────────────────────────────────────────────
interface RefSelection { name: string; preview: string; }
function ReferenceSlot({ value, onSelect, onClear, onRemove }: {
value: RefSelection | null;
onSelect: (sel: RefSelection) => void;
onClear: () => void;
onRemove?: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
onSelect({ name: file.name, preview: URL.createObjectURL(file) });
e.target.value = '';
}
// Show X when: slot has content (clear it), or slot is removable (remove it)
const showX = !!value || !!onRemove;
const handleX = (e: React.MouseEvent) => {
e.stopPropagation();
if (onRemove) onRemove();
else onClear();
};
return (
<div className="relative w-[60px] h-[60px]">
<motion.button
className="w-[60px] h-[60px] rounded-2xl flex flex-col items-center justify-center gap-1 overflow-hidden transition-colors"
style={{
background: value?.preview ? 'transparent' : 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
}}
onClick={() => inputRef.current?.click()}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.96 }}
title="Add reference image"
>
{value?.preview ? (
<img src={value.preview} alt={value.name} className="w-full h-full object-cover" />
) : value?.name ? (
<>
<Check className="w-4 h-4 text-blue-400" />
<span className="text-[8px] text-blue-400 font-medium text-center px-1 leading-tight line-clamp-2">{value.name}</span>
</>
) : (
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>+</span>
)}
</motion.button>
<AnimatePresence>
{showX && (
<motion.button
className="absolute -top-2 -right-3 w-4 h-4 rounded-full flex items-center justify-center z-10"
style={{ background: 'rgba(20,20,30,0.95)', border: '1px solid rgba(255,255,255,0.22)' }}
onClick={handleX}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.12 }}
whileHover={{ scale: 1.2, backgroundColor: 'rgba(239,68,68,0.8)' }}
title={onRemove ? 'Remove slot' : 'Clear selection'}
>
<X className="w-2 h-2 text-white" />
</motion.button>
)}
</AnimatePresence>
<input ref={inputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleFile} />
</div>
);
}
function WorkflowInput() { function WorkflowInput() {
const [mode, setMode] = useState<'image' | 'video'>('image'); const [mode, setMode] = useState<'image' | 'video'>('image');
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [keywords, setKeywords] = useState(''); const [keywords, setKeywords] = useState('');
const [textCopy, setTextCopy] = useState(''); const [textCopy, setTextCopy] = useState('');
const [groundTruth, setGroundTruth] = useState<GroundTruthSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [refs, setRefs] = useState<(RefSelection | null)[]>([null, null]);
const anchorRef = useRef<HTMLButtonElement>(null);
return ( return (
<Widget delay={0.04} colSpan={1} className="!p-6" style={{ background: '#111216', borderRadius: '28px' }}> <Widget delay={0.04} colSpan={1} className="!p-6 !overflow-visible" style={{ background: '#111216', borderRadius: '28px' }}>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Top Section: Ground Truth & References */} {/* Top Section: Ground Truth & References */}
<div className="flex items-end gap-6"> <div className="flex items-end gap-6">
{/* Ground Truth slot */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-3"> <div className="relative">
<button title="Select Image from Gallery or Dream Weaver / Click Image from iPad" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}> <motion.button
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>Start</span> ref={anchorRef}
</button> onClick={() => setPickerOpen(v => !v)}
className="w-[60px] h-[60px] rounded-2xl flex flex-col items-center justify-center gap-1 overflow-hidden transition-colors"
style={{
background: groundTruth?.preview ? 'transparent' : pickerOpen ? 'rgba(59,130,246,0.12)' : 'rgba(255,255,255,0.03)',
border: pickerOpen ? '1px solid rgba(59,130,246,0.4)' : '1px solid rgba(255,255,255,0.08)',
}}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.96 }}
title="Select Ground Truth image"
>
{groundTruth?.preview ? (
<img src={groundTruth.preview} alt="ground truth" className="w-full h-full object-cover" />
) : groundTruth?.name ? (
<>
<Check className="w-4 h-4 text-green-400" />
<span className="text-[8px] text-green-400 font-medium text-center px-1 leading-tight line-clamp-2">{groundTruth.name}</span>
</>
) : (
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>{pickerOpen ? '✕' : '+'}</span>
)}
</motion.button>
{/* Clear selection button */}
<AnimatePresence>
{groundTruth && (
<motion.button
className="absolute -top-2 right-2 w-4 h-4 rounded-full flex items-center justify-center z-10"
style={{ background: 'rgba(20,20,30,0.95)', border: '1px solid rgba(255,255,255,0.22)' }}
onClick={(e) => { e.stopPropagation(); setGroundTruth(null); setPickerOpen(false); }}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.12 }}
whileHover={{ scale: 1.2, backgroundColor: 'rgba(239,68,68,0.8)' }}
title="Remove selection"
>
<X className="w-2 h-2 text-white" />
</motion.button>
)}
</AnimatePresence>
<AnimatePresence>
{pickerOpen && (
<GroundTruthPicker
anchorRef={anchorRef}
onSelect={sel => { setGroundTruth(sel); setPickerOpen(false); }}
onClose={() => setPickerOpen(false)}
/>
)}
</AnimatePresence>
</div> </div>
<span className="text-xs font-semibold text-white tracking-wide">Ground Truth</span> <span className="text-xs font-semibold text-white tracking-wide">Ground Truth</span>
</div> </div>
@@ -269,11 +401,25 @@ function WorkflowInput() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button title="Add reference image" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }} /> {refs.map((ref, i) => (
<button title="Add reference image" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }} /> <ReferenceSlot
<button title="Add more references" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}> key={i}
value={ref}
onSelect={(sel) => setRefs(prev => prev.map((r, idx) => idx === i ? sel : r))}
onClear={() => setRefs(prev => prev.map((r, idx) => idx === i ? null : r))}
onRemove={i >= 2 ? () => setRefs(prev => prev.filter((_, idx) => idx !== i)) : undefined}
/>
))}
<motion.button
title="Add more references"
className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors"
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}
onClick={() => setRefs(prev => prev.length < 6 ? [...prev, null] : prev)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Plus className="w-5 h-5 text-white/40" /> <Plus className="w-5 h-5 text-white/40" />
</button> </motion.button>
</div> </div>
<span className="text-xs font-semibold text-white tracking-wide">References</span> <span className="text-xs font-semibold text-white tracking-wide">References</span>
</div> </div>
@@ -389,6 +535,7 @@ function TheStudio() {
function CampaignCommand() { function CampaignCommand() {
const { campaigns, adInsights } = useMarketingStore(); const { campaigns, adInsights } = useMarketingStore();
const { formatAmount, rate } = useCurrency();
const totalSpend = campaigns.reduce((s, c) => s + c.lifetimeSpend / 100, 0); const totalSpend = campaigns.reduce((s, c) => s + c.lifetimeSpend / 100, 0);
const totalImpr = campaigns.reduce((s, c) => s + c.impressions, 0); const totalImpr = campaigns.reduce((s, c) => s + c.impressions, 0);
@@ -398,7 +545,7 @@ function CampaignCommand() {
// Build spend-over-time from insights (last 14 days) // Build spend-over-time from insights (last 14 days)
const spendData = adInsights.slice(0, 14).map((d) => ({ const spendData = adInsights.slice(0, 14).map((d) => ({
date: d.date.slice(5), // MM-DD date: d.date.slice(5), // MM-DD
spend: d.spend, spend: Math.round(d.spend * rate),
impressions: Math.round(d.impressions / 1000), impressions: Math.round(d.impressions / 1000),
})).reverse(); })).reverse();
@@ -407,9 +554,9 @@ function CampaignCommand() {
{/* KPI row */} {/* KPI row */}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard icon={Megaphone} label="Active Campaigns" value={String(campaigns.filter(c => c.status === 'ACTIVE').length)} sub={`${campaigns.length} total campaigns`} glowColor="rgba(59,130,246,0.2)" delay={0} /> <StatCard icon={Megaphone} label="Active Campaigns" value={String(campaigns.filter(c => c.status === 'ACTIVE').length)} sub={`${campaigns.length} total campaigns`} glowColor="rgba(59,130,246,0.2)" delay={0} />
<StatCard icon={DollarSign} label="Total Spend" value={`AED ${(totalSpend / 1000).toFixed(1)}K`} sub="Lifetime across all campaigns" glowColor="rgba(34,211,238,0.2)" delay={0.06} /> <StatCard icon={DollarSign} label="Total Spend" value={formatAmount(totalSpend, { compact: true })} sub="Lifetime across all campaigns" glowColor="rgba(34,211,238,0.2)" delay={0.06} />
<StatCard icon={Eye} label="Impressions" value={`${(totalImpr / 1000).toFixed(0)}K`} sub="Total ad impressions" glowColor="rgba(251,191,36,0.2)" delay={0.12} /> <StatCard icon={Eye} label="Impressions" value={`${(totalImpr / 1000).toFixed(0)}K`} sub="Total ad impressions" glowColor="rgba(251,191,36,0.2)" delay={0.12} />
<StatCard icon={MousePointerClick} label="Avg CTR" value={`${avgCtr.toFixed(2)}%`} sub={`Avg CPA: AED ${avgCpa.toFixed(0)}`} glowColor="rgba(167,139,250,0.2)" delay={0.18} /> <StatCard icon={MousePointerClick} label="Avg CTR" value={`${avgCtr.toFixed(2)}%`} sub={`Avg CPA: ${formatAmount(avgCpa)}`} glowColor="rgba(167,139,250,0.2)" delay={0.18} />
</div> </div>
{/* Spend chart + campaign list */} {/* Spend chart + campaign list */}
@@ -463,7 +610,7 @@ function CampaignCommand() {
<div className="flex-1 min-w-0 mr-3"> <div className="flex-1 min-w-0 mr-3">
<p className="text-sm font-medium text-white truncate">{c.name}</p> <p className="text-sm font-medium text-white truncate">{c.name}</p>
<p className="text-xs mt-0.5" style={{ color: 'rgba(148,163,184,0.5)' }}> <p className="text-xs mt-0.5" style={{ color: 'rgba(148,163,184,0.5)' }}>
AED {(c.lifetimeSpend / 100).toLocaleString()} · {c.impressions.toLocaleString()} impr. {formatAmount(c.lifetimeSpend / 100)} · {c.impressions.toLocaleString()} impr.
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
@@ -487,24 +634,25 @@ function CampaignCommand() {
function IntelligenceROI() { function IntelligenceROI() {
const { campaigns, adInsights } = useMarketingStore(); const { campaigns, adInsights } = useMarketingStore();
const { formatAmount, symbol, rate } = useCurrency();
const cpaData = adInsights.slice(0, 7).map((d) => ({ const cpaData = adInsights.slice(0, 7).map((d) => ({
date: d.date.slice(5), date: d.date.slice(5),
cpa: d.cpa, cpa: Math.round(d.cpa * rate),
roi: d.roi, roi: d.roi,
})).reverse(); })).reverse();
const adSetPerf = [ const adSetPerf = [
{ name: '3BHK Dubai', ctr: 2.1, cpa: 210, spend: 3400 }, { name: '3BHK Dubai', ctr: 2.1, cpa: Math.round(210 * rate), spend: Math.round(3400 * rate) },
{ name: 'PH Retarget', ctr: 3.2, cpa: 2100, spend: 5800 }, { name: 'PH Retarget', ctr: 3.2, cpa: Math.round(2100 * rate), spend: Math.round(5800 * rate) },
{ name: '1BHK Lookalike', ctr: 1.8, cpa: 270, spend: 980 }, { name: '1BHK Lookalike', ctr: 1.8, cpa: Math.round(270 * rate), spend: Math.round(980 * rate) },
]; ];
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* CPA / ROI KPIs */} {/* CPA / ROI KPIs */}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard icon={DollarSign} label="Avg CPA" value={`AED ${(campaigns.reduce((s,c)=>s+c.cpa,0)/campaigns.length).toFixed(0)}`} sub="Cost per acquisition" glowColor="rgba(34,211,238,0.2)" delay={0} /> <StatCard icon={DollarSign} label="Avg CPA" value={formatAmount(campaigns.reduce((s,c)=>s+c.cpa,0)/campaigns.length)} sub="Cost per acquisition" glowColor="rgba(34,211,238,0.2)" delay={0} />
<StatCard icon={TrendingUp} label="Portfolio ROI" value={`${(campaigns.reduce((s,c)=>s+c.roi,0)/campaigns.length).toFixed(1)}%`} sub="Blended across all ad sets" glowColor="rgba(74,222,128,0.2)" delay={0.06} /> <StatCard icon={TrendingUp} label="Portfolio ROI" value={`${(campaigns.reduce((s,c)=>s+c.roi,0)/campaigns.length).toFixed(1)}%`} sub="Blended across all ad sets" glowColor="rgba(74,222,128,0.2)" delay={0.06} />
<StatCard icon={Activity} label="Avg CTR" value={`${(campaigns.reduce((s,c)=>s+c.ctr,0)/campaigns.length).toFixed(2)}%`} sub="Click-through rate" glowColor="rgba(167,139,250,0.2)" delay={0.12} /> <StatCard icon={Activity} label="Avg CTR" value={`${(campaigns.reduce((s,c)=>s+c.ctr,0)/campaigns.length).toFixed(2)}%`} sub="Click-through rate" glowColor="rgba(167,139,250,0.2)" delay={0.12} />
<StatCard icon={Zap} label="Optimization Runs" value="47" sub="Agent actions today" glowColor="rgba(251,191,36,0.2)" delay={0.18} /> <StatCard icon={Zap} label="Optimization Runs" value="47" sub="Agent actions today" glowColor="rgba(251,191,36,0.2)" delay={0.18} />
@@ -529,7 +677,7 @@ function IntelligenceROI() {
<XAxis dataKey="date" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} /> <XAxis dataKey="date" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} /> <YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(34,211,238,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#67e8f9', fontSize: 12 }} /> <Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(34,211,238,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#67e8f9', fontSize: 12 }} />
<Area type="monotone" dataKey="cpa" stroke="#22d3ee" strokeWidth={2} fillOpacity={1} fill="url(#gCpa)" name="CPA (AED)" /> <Area type="monotone" dataKey="cpa" stroke="#22d3ee" strokeWidth={2} fillOpacity={1} fill="url(#gCpa)" name={`CPA (${symbol})`} />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -547,7 +695,7 @@ function IntelligenceROI() {
<XAxis dataKey="name" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} /> <XAxis dataKey="name" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} /> <YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(59,130,246,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#93c5fd', fontSize: 12 }} /> <Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(59,130,246,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#93c5fd', fontSize: 12 }} />
<Bar dataKey="spend" fill="#3b82f6" radius={[4, 4, 0, 0]} name="Spend (AED)" opacity={0.8} /> <Bar dataKey="spend" fill="#3b82f6" radius={[4, 4, 0, 0]} name={`Spend (${symbol})`} opacity={0.8} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -572,6 +720,7 @@ const EVENT_ICON: Record<LiveEventType, { icon: LucideIcon; color: string; bg: s
function LiveEventItem({ event }: { event: LiveOptimizationEvent }) { function LiveEventItem({ event }: { event: LiveOptimizationEvent }) {
const cfg = EVENT_ICON[event.type]; const cfg = EVENT_ICON[event.type];
const { formatText } = useCurrency();
const Icon = cfg.icon; const Icon = cfg.icon;
return ( return (
<motion.div <motion.div
@@ -596,10 +745,10 @@ function LiveEventItem({ event }: { event: LiveOptimizationEvent }) {
</span> </span>
)} )}
{event.value && ( {event.value && (
<span className="text-[10px] font-medium ml-auto" style={{ color: cfg.color }}>{event.value}</span> <span className="text-[10px] font-medium ml-auto" style={{ color: cfg.color }}>{formatText(event.value)}</span>
)} )}
</div> </div>
<p className="text-xs leading-relaxed" style={{ color: 'rgba(148,163,184,0.75)' }}>{event.message}</p> <p className="text-xs leading-relaxed" style={{ color: 'rgba(148,163,184,0.75)' }}>{formatText(event.message)}</p>
<p className="text-[10px] mt-1" style={{ color: 'rgba(148,163,184,0.35)' }}> <p className="text-[10px] mt-1" style={{ color: 'rgba(148,163,184,0.35)' }}>
{event.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} {event.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</p> </p>

View File

@@ -0,0 +1,219 @@
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, Film, Check, X } from 'lucide-react';
// ─── Recent designs mock data ─────────────────────────────────────────────────
const RECENT_DESIGNS = [
{ id: 'rd1', name: 'Penthouse Sea View', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a2a4a,#0f1928)', accent: '#60a5fa', date: '2h ago' },
{ id: 'rd2', name: 'Arabic 3BHK Poster', type: 'image' as const, gradient: 'linear-gradient(135deg,#2a1a3a,#180f28)', accent: '#a78bfa', date: '5h ago' },
{ id: 'rd3', name: 'Amenity Deck Reel', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a3a2a,#0f2818)', accent: '#4ade80', date: '8h ago' },
{ id: 'rd4', name: 'Penthouse En Poster', type: 'image' as const, gradient: 'linear-gradient(135deg,#3a2a1a,#281808)', accent: '#fbbf24', date: '1d ago' },
{ id: 'rd5', name: 'Dubai Marina Aerial', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a3a3a,#0f2828)', accent: '#22d3ee', date: '2d ago' },
{ id: 'rd6', name: 'Investment Lifestyle', type: 'image' as const, gradient: 'linear-gradient(135deg,#3a1a1a,#280f0f)', accent: '#f87171', date: '3d ago' },
];
// ─── Types ────────────────────────────────────────────────────────────────────
export interface GroundTruthSelection {
name: string;
preview: string;
}
interface GroundTruthPickerProps {
anchorRef: React.RefObject<HTMLButtonElement | null>;
onSelect: (sel: GroundTruthSelection) => void;
onClose: () => void;
}
// ─── Portal popup component ───────────────────────────────────────────────────
function PickerPopup({ anchorRef, onSelect, onClose }: GroundTruthPickerProps) {
const popupRef = useRef<HTMLDivElement>(null);
const galleryRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
// ── Synchronously measure anchor and position popup ──────────────────────
const [style, setStyle] = useState<React.CSSProperties>({ visibility: 'hidden' });
useLayoutEffect(() => {
if (!anchorRef.current) return;
const r = anchorRef.current.getBoundingClientRect();
const W = 440;
const vw = window.innerWidth;
// Prefer aligning left edge with button; clamp so popup stays on screen
let left = r.left;
if (left + W > vw - 12) left = Math.max(12, vw - W - 12);
setStyle({
position: 'fixed',
top: r.bottom + 8,
left,
width: W,
visibility: 'visible',
});
}, [anchorRef]);
// ── Dismiss on outside click ──────────────────────────────────────────────
useEffect(() => {
function handleDown(e: MouseEvent) {
const target = e.target as Node;
const inPopup = popupRef.current?.contains(target);
const inAnchor = anchorRef.current?.contains(target);
if (!inPopup && !inAnchor) onClose();
}
document.addEventListener('mousedown', handleDown);
return () => document.removeEventListener('mousedown', handleDown);
}, [anchorRef, onClose]);
// ── File handler ──────────────────────────────────────────────────────────
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
onSelect({ name: file.name, preview: URL.createObjectURL(file) });
e.target.value = '';
}
return (
<motion.div
ref={popupRef}
className="z-[99999] rounded-2xl p-4"
style={{
...style,
background: 'rgba(12,14,22,0.98)',
border: '1px solid rgba(255,255,255,0.12)',
backdropFilter: 'blur(32px)',
WebkitBackdropFilter: 'blur(32px)',
boxShadow: '0 24px 80px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.05)',
}}
initial={{ opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.97 }}
transition={{ duration: 0.16, ease: [0.4, 0, 0.2, 1] }}
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] font-semibold uppercase tracking-widest"
style={{ color: 'rgba(148,163,184,0.5)' }}>
Recent Designs
</p>
<motion.button
onClick={onClose}
className="w-7 h-7 flex items-center justify-center rounded-xl"
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
color: 'rgba(148,163,184,0.7)',
}}
whileHover={{ scale: 1.1, backgroundColor: 'rgba(255,255,255,0.14)' }}
whileTap={{ scale: 0.9 }}
title="Close"
>
<X className="w-3.5 h-3.5" />
</motion.button>
</div>
{/* 3×2 recent designs grid */}
<div className="grid grid-cols-3 gap-2 mb-2">
{RECENT_DESIGNS.map((d, i) => (
<motion.button
key={d.id}
className="relative rounded-xl overflow-hidden flex flex-col items-start p-2 group text-left"
style={{
background: d.gradient,
border: '1px solid rgba(255,255,255,0.07)',
aspectRatio: '1',
}}
onClick={() => onSelect({ name: d.name, preview: '' })}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, delay: i * 0.04 }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
>
<span
className="text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded-full mb-auto z-10"
style={{ background: `${d.accent}22`, color: d.accent }}
>
{d.type === 'video' ? '▶ Video' : '■ Image'}
</span>
{/* Glow */}
<div className="absolute bottom-0 right-0 w-12 h-12 pointer-events-none"
style={{ background: `radial-gradient(circle,${d.accent}44 0%,transparent 70%)`, filter: 'blur(8px)' }} />
<div className="w-full mt-1 z-10">
<p className="text-[10px] font-medium text-white leading-tight line-clamp-1">{d.name}</p>
<p className="text-[9px] mt-0.5" style={{ color: 'rgba(148,163,184,0.45)' }}>{d.date}</p>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-xl"
style={{ background: 'rgba(255,255,255,0.08)' }}>
<Check className="w-5 h-5 text-white" />
</div>
</motion.button>
))}
</div>
{/* Bottom: gallery + camera */}
<div className="grid grid-cols-2 gap-2 pt-3"
style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{/* Gallery */}
<div className="relative">
<motion.button
className="w-full flex items-center justify-center gap-2 rounded-xl py-3 text-xs font-medium"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'rgba(148,163,184,0.75)',
}}
onClick={() => galleryRef.current?.click()}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<Plus className="w-3.5 h-3.5" />
Add from Gallery
</motion.button>
<input ref={galleryRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleFile} />
</div>
{/* Camera */}
<div className="relative">
<motion.button
className="w-full flex items-center justify-center gap-2 rounded-xl py-3 text-xs font-medium"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'rgba(148,163,184,0.75)',
}}
onClick={() => cameraRef.current?.click()}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<Film className="w-3.5 h-3.5" />
Take Photo
</motion.button>
<input ref={cameraRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={handleFile} />
</div>
</div>
</motion.div>
);
}
// ─── Public export — renders via portal ──────────────────────────────────────
export function GroundTruthPicker(props: GroundTruthPickerProps) {
return createPortal(
<AnimatePresence>
<PickerPopup {...props} />
</AnimatePresence>,
document.body,
);
}

View File

@@ -20,7 +20,7 @@ import { Canvas } from '@react-three/fiber';
import { Bounds, Html, OrbitControls, useGLTF } from '@react-three/drei'; import { Bounds, Html, OrbitControls, useGLTF } from '@react-three/drei';
import * as THREE from 'three'; import * as THREE from 'three';
import { useStore } from '@/store/useStore'; import { useStore } from '@/store/useStore';
import { formatCurrency } from '@/lib/utils'; import { useCurrency } from '@/store/useCurrencyStore';
import type { Unit } from '@/types'; import type { Unit } from '@/types';
// Penthouse preview images — one per unit (u1u8) for card thumbnails // Penthouse preview images — one per unit (u1u8) for card thumbnails
@@ -227,6 +227,7 @@ function PropertyDetailModal({
}) { }) {
const details = UNIT_DETAILS[unit.id] ?? DEFAULT_DETAILS; const details = UNIT_DETAILS[unit.id] ?? DEFAULT_DETAILS;
const preview = UNIT_PREVIEWS[unit.id] ?? UNIT_PREVIEWS['u1']; const preview = UNIT_PREVIEWS[unit.id] ?? UNIT_PREVIEWS['u1'];
const { formatAmount } = useCurrency();
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
available: 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10', available: 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10',
@@ -282,8 +283,8 @@ function PropertyDetailModal({
{/* Overlay price */} {/* Overlay price */}
<div className="absolute bottom-4 left-5"> <div className="absolute bottom-4 left-5">
<p className="text-xs text-zinc-400">Starting from</p> <p className="text-xs text-zinc-400">Starting from</p>
<p className="text-3xl font-bold tracking-tight text-white">{formatCurrency(unit.price)}</p> <p className="text-3xl font-bold tracking-tight text-white">{formatAmount(unit.price)}</p>
<p className="text-xs text-zinc-400">{formatCurrency(pricePerSqm)} / m²</p> <p className="text-xs text-zinc-400">{formatAmount(pricePerSqm)} / m²</p>
</div> </div>
</div> </div>
@@ -371,8 +372,8 @@ function PropertyDetailModal({
{/* Pricing card */} {/* Pricing card */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-4"> <div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="mb-3 text-xs uppercase tracking-widest text-zinc-500">Pricing</p> <p className="mb-3 text-xs uppercase tracking-widest text-zinc-500">Pricing</p>
<p className="text-2xl font-bold text-zinc-100">{formatCurrency(unit.price)}</p> <p className="text-2xl font-bold text-zinc-100">{formatAmount(unit.price)}</p>
<p className="mt-0.5 text-xs text-zinc-500">{formatCurrency(pricePerSqm)} per m²</p> <p className="mt-0.5 text-xs text-zinc-500">{formatAmount(pricePerSqm)} per m²</p>
<div className="my-3 border-t border-white/10" /> <div className="my-3 border-t border-white/10" />
<div className="space-y-1.5 text-sm"> <div className="space-y-1.5 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
@@ -441,6 +442,7 @@ function UnitCard({
}) { }) {
const preview = UNIT_PREVIEWS[unit.id] ?? UNIT_PREVIEWS['u1']; const preview = UNIT_PREVIEWS[unit.id] ?? UNIT_PREVIEWS['u1'];
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const { formatAmount } = useCurrency();
// Status accent color for glow // Status accent color for glow
const statusGlow = const statusGlow =
@@ -524,7 +526,7 @@ function UnitCard({
{/* Price */} {/* Price */}
<div className="mb-3"> <div className="mb-3">
<p className="stat-label mb-0.5">Starting from</p> <p className="stat-label mb-0.5">Starting from</p>
<p className="text-xl font-bold leading-none tracking-tight text-white">{formatCurrency(unit.price)}</p> <p className="text-xl font-bold leading-none tracking-tight text-white">{formatAmount(unit.price)}</p>
</div> </div>
{/* Divider */} {/* Divider */}
@@ -731,6 +733,7 @@ function StudioWindow({
} }
function RightMapPane({ units }: { units: Unit[] }) { function RightMapPane({ units }: { units: Unit[] }) {
const { formatAmount } = useCurrency();
return ( return (
<div className="relative h-full min-h-[36rem] overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/70"> <div className="relative h-full min-h-[36rem] overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/70">
<iframe title="Dubai Map" src={MAP_EMBED_URL} className="h-full w-full border-0" loading="lazy" referrerPolicy="no-referrer-when-downgrade" /> <iframe title="Dubai Map" src={MAP_EMBED_URL} className="h-full w-full border-0" loading="lazy" referrerPolicy="no-referrer-when-downgrade" />
@@ -741,7 +744,7 @@ function RightMapPane({ units }: { units: Unit[] }) {
<div className="absolute bottom-3 left-3 right-3 grid grid-cols-2 gap-2 rounded-xl border border-white/15 bg-zinc-900/75 p-2 backdrop-blur-xl"> <div className="absolute bottom-3 left-3 right-3 grid grid-cols-2 gap-2 rounded-xl border border-white/15 bg-zinc-900/75 p-2 backdrop-blur-xl">
{units.slice(0, 4).map((unit) => ( {units.slice(0, 4).map((unit) => (
<div key={unit.id} className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-zinc-200"> <div key={unit.id} className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-zinc-200">
{unit.unitNumber} - {formatCurrency(unit.price)} {unit.unitNumber} - {formatAmount(unit.price)}
</div> </div>
))} ))}
</div> </div>
@@ -762,6 +765,7 @@ function UnitRow({
onOpen3D: (u: Unit) => void; onOpen3D: (u: Unit) => void;
onOpenBlueprint: (u: Unit) => void; onOpenBlueprint: (u: Unit) => void;
}) { }) {
const { formatAmount } = useCurrency();
return ( return (
<motion.div <motion.div
className="flex items-center gap-4 px-4 py-3 rounded-xl cursor-pointer transition-colors" className="flex items-center gap-4 px-4 py-3 rounded-xl cursor-pointer transition-colors"
@@ -795,7 +799,7 @@ function UnitRow({
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>{unit.view}</p> <p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>{unit.view}</p>
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<p className="text-sm font-bold text-white">{formatCurrency(unit.price)}</p> <p className="text-sm font-bold text-white">{formatAmount(unit.price)}</p>
</div> </div>
</div> </div>

View File

@@ -17,6 +17,8 @@ import {
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useStore } from '@/store/useStore'; import { useStore } from '@/store/useStore';
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
import type { CurrencyCode } from '@/store/useCurrencyStore';
// ── Design tokens (matching inventory glassmorphism) ───────────────────────── // ── Design tokens (matching inventory glassmorphism) ─────────────────────────
const GLASS = { const GLASS = {
@@ -44,7 +46,7 @@ function GlassCard({
}) { }) {
return ( return (
<motion.div <motion.div
className={`rounded-2xl overflow-hidden ${className}`} className={`relative rounded-2xl ${className}`}
style={GLASS} style={GLASS}
initial={{ opacity: 0, y: 16 }} initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -508,6 +510,7 @@ function DisplaySettings() {
const [compactMode, setCompactMode] = useState(false); const [compactMode, setCompactMode] = useState(false);
const [language, setLanguage] = useState('en'); const [language, setLanguage] = useState('en');
const [timezone, setTimezone] = useState('dxb'); const [timezone, setTimezone] = useState('dxb');
const { currency, setCurrency } = useCurrency();
return ( return (
<GlassCard delay={0.25}> <GlassCard delay={0.25}>
@@ -540,6 +543,21 @@ function DisplaySettings() {
]} ]}
/> />
</SettingsRow> </SettingsRow>
{/* ── Currency ── */}
<SettingsRow
label="Currency"
description="Default currency shown across the entire app"
>
<DarkSelect
value={currency}
onChange={(v) => setCurrency(v as CurrencyCode)}
options={CURRENCY_OPTIONS.map((o) => ({
value: o.code,
label: `${o.flag} ${o.symbol}${o.label}`,
}))}
/>
</SettingsRow>
</div> </div>
</GlassCard> </GlassCard>
); );
@@ -615,25 +633,25 @@ export function Settings() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Row 1: System + iOS */} {/* Row 1: System + iOS */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4 relative z-40">
<SystemStatusCard /> <SystemStatusCard />
<IOSConnectionCard /> <IOSConnectionCard />
</div> </div>
{/* Row 2: Profile + Notifications */} {/* Row 2: Profile + Notifications */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4 relative z-30">
<ProfileSettings /> <ProfileSettings />
<NotificationSettings /> <NotificationSettings />
</div> </div>
{/* Row 3: Security + Display */} {/* Row 3: Security + Display */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4 relative z-20">
<SecuritySettings /> <SecuritySettings />
<DisplaySettings /> <DisplaySettings />
</div> </div>
{/* Row 4: Data + About */} {/* Row 4: Data + About */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4 relative z-10">
<DataSettings /> <DataSettings />
<AboutSection /> <AboutSection />
</div> </div>

View File

@@ -31,13 +31,10 @@ export function formatDistanceToNow(date: Date): string {
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
export function formatCurrency(amount: number, currency: string = 'AED'): string { import { useCurrencyStore } from '@/store/useCurrencyStore';
return new Intl.NumberFormat('en-US', {
style: 'currency', export function formatCurrency(amount: number): string {
currency, return useCurrencyStore.getState().formatAmount(amount);
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
} }
export function formatNumber(num: number): string { export function formatNumber(num: number): string {

View File

@@ -0,0 +1,201 @@
/**
* BranchBar — Top-of-page branch status bar
* Shows: page title, branch identity badge, revision number, execution status,
* share action, merge request indicator, rollback affordance.
* Must remain visible at all times (sticky).
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
GitBranch, GitMerge, Share2, RotateCcw, Dot, Users,
CheckCircle2, Clock, AlertCircle, Loader2, GitFork
} from 'lucide-react';
import type { CanvasPage, PromptExecution, MergeRequest } from '../types/canvas';
interface BranchBarProps {
page: CanvasPage | null;
inFlightExecution: PromptExecution | null;
mergeRequests?: MergeRequest[];
isConnected: boolean;
onShare: () => void;
onRollback: () => void;
onOpenMergeReview: () => void;
}
const STATUS_CONFIG = {
received: { icon: Clock, color: '#94a3b8', label: 'Received' },
planning: { icon: Loader2, color: '#60a5fa', label: 'Planning…', spin: true },
validated: { icon: CheckCircle2, color: '#34d399', label: 'Validated' },
executing: { icon: Loader2, color: '#a78bfa', label: 'Executing…', spin: true },
completed: { icon: CheckCircle2, color: '#34d399', label: 'Completed' },
failed: { icon: AlertCircle, color: '#f87171', label: 'Failed' },
clarification_required: { icon: AlertCircle, color: '#fbbf24', label: 'Clarification needed' },
} as const;
export function BranchBar({
page,
inFlightExecution,
mergeRequests = [],
isConnected,
onShare,
onRollback,
onOpenMergeReview,
}: BranchBarProps) {
const [shareHovered, setShareHovered] = useState(false);
const openMRCount = mergeRequests.filter((mr) => mr.status === 'open').length;
const isFork = page?.pageType === 'fork';
const executionStatus = inFlightExecution?.status;
const statusCfg = executionStatus ? STATUS_CONFIG[executionStatus] : null;
const StatusIcon = statusCfg?.icon;
return (
<div
className="relative z-20 px-4 pt-3 pb-2 flex-shrink-0"
style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}
>
<div
className="flex items-center justify-between gap-4 px-4 py-2.5 rounded-2xl"
style={{
background: 'rgba(10, 11, 18, 0.85)',
border: '1px solid rgba(255,255,255,0.08)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
}}
>
{/* Left: Page title + branch identity */}
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center gap-2 flex-shrink-0">
{isFork ? (
<GitFork className="w-4 h-4 text-violet-400" />
) : (
<GitBranch className="w-4 h-4 text-blue-400" />
)}
<span
className="text-xs font-semibold px-2 py-0.5 rounded-full flex-shrink-0"
style={{
background: isFork ? 'rgba(139,92,246,0.15)' : 'rgba(59,130,246,0.15)',
color: isFork ? '#c4b5fd' : '#93c5fd',
border: `1px solid ${isFork ? 'rgba(139,92,246,0.3)' : 'rgba(59,130,246,0.3)'}`,
}}
>
{page?.branchName ?? 'main'}
</span>
</div>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm font-medium text-zinc-200 truncate">
{page?.title ?? 'Oracle Canvas'}
</span>
{page && (
<span className="text-xs text-zinc-600 flex-shrink-0 font-mono">
rev.{page.headRevision}
</span>
)}
</div>
</div>
{/* Center: execution status */}
<AnimatePresence mode="wait">
{statusCfg && StatusIcon && (
<motion.div
key={executionStatus}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.2 }}
className="flex items-center gap-1.5 flex-shrink-0"
>
<StatusIcon
className="w-3.5 h-3.5"
style={{
color: statusCfg.color,
// @ts-expect-error spin is a custom property
animation: statusCfg.spin ? 'spin 1s linear infinite' : undefined,
}}
/>
<span className="text-xs font-medium" style={{ color: statusCfg.color }}>
{statusCfg.label}
</span>
</motion.div>
)}
</AnimatePresence>
{/* Right: actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Presence indicator */}
{page && page.presence.activeViewers > 1 && (
<div className="flex items-center gap-1 text-xs text-zinc-500">
<Users className="w-3.5 h-3.5" />
<span>{page.presence.activeViewers}</span>
</div>
)}
{/* Connection dot */}
<div className="flex items-center gap-1">
<Dot
className="w-5 h-5 -mx-1.5"
style={{ color: isConnected ? '#34d399' : '#6b7280' }}
/>
</div>
{/* Rollback */}
<button
onClick={onRollback}
title="View revision history and rollback"
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg transition-all"
style={{
color: '#71717a',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.07)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
e.currentTarget.style.color = '#a1a1aa';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#71717a';
}}
>
<RotateCcw className="w-3.5 h-3.5" />
<span className="hidden sm:inline">History</span>
</button>
{/* Merge Requests */}
{openMRCount > 0 && (
<button
onClick={onOpenMergeReview}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg transition-all"
style={{
background: 'rgba(251,191,36,0.12)',
color: '#fbbf24',
border: '1px solid rgba(251,191,36,0.25)',
}}
>
<GitMerge className="w-3.5 h-3.5" />
<span>{openMRCount} open</span>
</button>
)}
{/* Share */}
<motion.button
onClick={onShare}
onMouseEnter={() => setShareHovered(true)}
onMouseLeave={() => setShareHovered(false)}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg font-medium transition-all"
style={{
background: shareHovered ? 'rgba(59,130,246,0.25)' : 'rgba(59,130,246,0.15)',
color: '#93c5fd',
border: '1px solid rgba(59,130,246,0.3)',
}}
whileTap={{ scale: 0.96 }}
>
<Share2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Share</span>
</motion.button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
/**
* CanvasViewport — Virtualized scrolling canvas.
* Renders CanvasComponent objects sorted by layout.orderIndex.
* Uses CSS content-visibility for large component lists (virtualization-lite).
* Stable scroll anchoring: new components append without scroll jump.
*/
import { useRef } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import type { CanvasComponent, PromptExecution } from '../types/canvas';
import { ComponentRegistry, type ComponentRenderContext } from './ComponentRegistry';
import { Loader2 } from 'lucide-react';
interface CanvasViewportProps {
components: CanvasComponent[];
ctx: ComponentRenderContext;
inFlightExecution: PromptExecution | null;
selectedComponentId?: string | null;
onSelectComponent: (id: string | null) => void;
}
const SECTION_LABELS: Record<string, string> = {
sec_pipeline: 'Pipeline',
sec_leads: 'Lead Intelligence',
sec_team: 'Team Performance',
sec_actions: 'Action Queue',
sec_prompt_generated: 'Oracle Responses',
sec_geography: 'Geographic',
sec_forecast: 'Forecasting',
};
/** Groups components by sectionId, preserving orderIndex sort */
function groupBySection(components: CanvasComponent[]): Array<{ sectionId: string; components: CanvasComponent[] }> {
const sorted = [...components].sort((a, b) => a.layout.orderIndex - b.layout.orderIndex);
const sectionMap = new Map<string, CanvasComponent[]>();
for (const comp of sorted) {
const sid = comp.layout.sectionId;
if (!sectionMap.has(sid)) sectionMap.set(sid, []);
sectionMap.get(sid)!.push(comp);
}
return Array.from(sectionMap.entries()).map(([sectionId, comps]) => ({ sectionId, components: comps }));
}
/** CSS content-visibility wrapper for off-screen components, applying width mode to the flex item */
function ComponentFlexWrapper({ comp, children }: { comp: CanvasComponent; children: React.ReactNode }) {
const styles: Record<string, string> = {
full: 'w-full',
half: 'w-full xl:w-[calc(50%-8px)]',
third: 'w-full xl:w-[calc(33.333%-11px)]',
};
return (
<div
className={styles[comp.layout.widthMode] ?? 'w-full'}
style={{
contentVisibility: 'auto',
containIntrinsicSize: `0 ${comp.renderingHints.estimatedHeightPx}px`,
}}
>
{children}
</div>
);
}
export function CanvasViewport({
components,
ctx,
inFlightExecution,
selectedComponentId,
onSelectComponent,
}: CanvasViewportProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const sections = groupBySection(components);
return (
<div
ref={viewportRef}
className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar"
style={{ scrollBehavior: 'smooth' }}
onClick={(e) => {
// Click outside component to deselect
if (e.currentTarget === e.target) onSelectComponent(null);
}}
>
<div className="px-4 py-5 space-y-8">
{sections.length === 0 && !inFlightExecution && (
<EmptyCanvasState />
)}
{sections.map(({ sectionId, components: sectionComps }) => (
<section key={sectionId} className="space-y-4">
{/* Section header */}
<div className="flex items-center gap-3">
<div className="w-1 h-4 rounded-full bg-gradient-to-b from-blue-400 to-cyan-500" />
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500">
{SECTION_LABELS[sectionId] ?? sectionId.replace(/^sec_/, '').replace(/_/g, ' ')}
</h2>
<div className="flex-1 h-[1px]" style={{ background: 'rgba(255,255,255,0.05)' }} />
<span className="text-[10px] text-zinc-700">{sectionComps.length}</span>
</div>
{/* Flex wrap for half/third width components */}
<div className="flex flex-wrap gap-4">
{sectionComps.map((comp) => (
<ComponentFlexWrapper
key={`${comp.componentId}-${comp.version}`}
comp={comp}
>
<ComponentRegistry
component={comp}
ctx={{
...ctx,
isSelected: selectedComponentId === comp.componentId,
onSelect: onSelectComponent,
}}
/>
</ComponentFlexWrapper>
))}
</div>
</section>
))}
{/* In-flight execution placeholder */}
<AnimatePresence>
{inFlightExecution && (
<motion.div
key="in-flight-placeholder"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="w-full rounded-2xl p-6 flex items-center gap-4"
style={{
background: 'rgba(59,130,246,0.06)',
border: '1px dashed rgba(59,130,246,0.25)',
backdropFilter: 'blur(12px)',
}}
>
<Loader2 className="w-5 h-5 text-blue-400 animate-spin flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-300">Oracle is analyzing your prompt</p>
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-1">
"{inFlightExecution.prompt}"
</p>
</div>
<div
className="ml-auto text-xs font-mono px-2 py-1 rounded-lg"
style={{ background: 'rgba(59,130,246,0.12)', color: '#60a5fa' }}
>
{inFlightExecution.status}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Bottom padding for floating prompt bar */}
<div className="h-32" />
</div>
</div>
);
}
function EmptyCanvasState() {
return (
<div className="flex flex-col items-center justify-center py-24 gap-6">
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center"
style={{ background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.15)' }}
>
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-10 h-10">
<rect x="6" y="6" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" />
<rect x="27" y="6" width="15" height="15" rx="3" stroke="#22D3EE" strokeWidth="1.5" />
<rect x="6" y="27" width="15" height="15" rx="3" stroke="#A78BFA" strokeWidth="1.5" />
<rect x="27" y="27" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" opacity="0.5" strokeDasharray="3 2" />
</svg>
</div>
<div className="text-center">
<h3 className="text-base font-medium text-zinc-200 mb-2">Canvas is empty</h3>
<p className="text-sm text-zinc-500 max-w-sm">
Ask Oracle anything type a prompt below to generate analytical components on your canvas.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
/**
* ComponentRegistry — maps CanvasComponent.type to renderer implementations.
* Supports lazy loading for expensive renderers (GeoMap, Table, PipelineBoard).
* Falls back to ErrorNoticeRenderer for unknown or revoked types.
*/
import { lazy, Suspense } from 'react';
import type { CanvasComponent } from '../types/canvas';
// ── Eager renderers (lightweight) ─────────────────────────────────────────────
import { KpiTileRenderer } from './renderers/KpiTileRenderer';
import { ErrorNoticeRenderer } from './renderers/ErrorNoticeRenderer';
import { TimelineRenderer } from './renderers/TimelineRenderer';
// ── Lazy renderers (heavier) ──────────────────────────────────────────────────
const BarChartRenderer = lazy(() => import('./renderers/BarChartRenderer').then((m) => ({ default: m.BarChartRenderer })));
const LineChartRenderer = lazy(() => import('./renderers/LineChartRenderer').then((m) => ({ default: m.LineChartRenderer })));
const GeoMapRenderer = lazy(() => import('./renderers/GeoMapRenderer').then((m) => ({ default: m.GeoMapRenderer })));
const TableRenderer = lazy(() => import('./renderers/TableRenderer').then((m) => ({ default: m.TableRenderer })));
const PipelineBoardRenderer = lazy(() => import('./renderers/PipelineBoardRenderer').then((m) => ({ default: m.PipelineBoardRenderer })));
const ActivityStreamRenderer = lazy(() => import('./renderers/ActivityStreamRenderer').then((m) => ({ default: m.ActivityStreamRenderer })));
// ── Render context ────────────────────────────────────────────────────────────
export interface ComponentRenderContext {
tenantId: string;
actorRole: string;
showLineageBadges: boolean;
density: 'compact' | 'comfortable';
isSelected?: boolean;
onSelect?: (componentId: string) => void;
}
// ── Skeleton ──────────────────────────────────────────────────────────────────
function ComponentSkeleton({ variant }: { variant: string }) {
const heights: Record<string, number> = {
chart: 280, map: 380, table: 300, kpi: 120, pipeline: 360, timeline: 300, generic: 240,
};
const h = heights[variant] ?? 240;
return (
<div
className="rounded-2xl animate-pulse"
style={{
height: h,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
}}
/>
);
}
// ── Registry resolver ─────────────────────────────────────────────────────────
interface RegistryRendererProps {
component: CanvasComponent;
ctx: ComponentRenderContext;
}
export function ComponentRegistry({ component, ctx }: RegistryRendererProps) {
const skeleton = <ComponentSkeleton variant={component.renderingHints.skeletonVariant} />;
if (component.lifecycleState === 'revoked') {
return (
<ErrorNoticeRenderer
component={{
...component,
title: 'Component Revoked',
visualizationParameters: {
errorCode: 'component_revoked',
message: 'This component has been revoked and can no longer be rendered.',
},
}}
ctx={ctx}
/>
);
}
switch (component.type) {
case 'kpiTile':
return <KpiTileRenderer component={component} ctx={ctx} />;
case 'errorNotice':
return <ErrorNoticeRenderer component={component} ctx={ctx} />;
case 'timeline':
return <TimelineRenderer component={component} ctx={ctx} />;
case 'barChart':
return (
<Suspense fallback={skeleton}>
<BarChartRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'lineChart':
case 'forecastChart':
return (
<Suspense fallback={skeleton}>
<LineChartRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'geoMap':
case 'heatmap':
return (
<Suspense fallback={skeleton}>
<GeoMapRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'table':
return (
<Suspense fallback={skeleton}>
<TableRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'pipelineBoard':
return (
<Suspense fallback={skeleton}>
<PipelineBoardRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'activityStream':
return (
<Suspense fallback={skeleton}>
<ActivityStreamRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'scatterPlot':
case 'customMLVisualization':
// Phase 2 renderers — show a meaningful placeholder with the right visual treatment
return (
<ErrorNoticeRenderer
component={{
...component,
visualizationParameters: {
errorCode: 'renderer_pending',
message: `The ${component.type} renderer is scheduled for Phase 2 synthesis. Data has been captured and is available.`,
severity: 'info',
},
}}
ctx={ctx}
/>
);
default:
return (
<ErrorNoticeRenderer
component={{
...component,
visualizationParameters: {
errorCode: 'unknown_type',
message: `Unknown component type: ${String(component.type)}`,
},
}}
ctx={ctx}
/>
);
}
}

View File

@@ -0,0 +1,205 @@
/**
* PromptRail — Durable prompt execution history rail.
* Each turn shows: prompt text → revision produced → components created → assumptions made.
* Replaces ephemeral chat; turns are durable (stored as PromptExecution objects).
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, CheckCircle2, AlertCircle, Loader2, Clock, BarChart2, GitCommit } from 'lucide-react';
import type { ExecutionEntry } from '../hooks/useOracleExecution';
import type { PromptExecution } from '../types/canvas';
interface PromptRailProps {
history: ExecutionEntry[];
inFlight: PromptExecution | null;
isOpen: boolean;
onToggle: () => void;
}
const STATUS_ICONS = {
completed: { Icon: CheckCircle2, color: '#34d399' },
failed: { Icon: AlertCircle, color: '#f87171' },
planning: { Icon: Loader2, color: '#60a5fa', spin: true },
executing: { Icon: Loader2, color: '#a78bfa', spin: true },
received: { Icon: Clock, color: '#94a3b8' },
validated: { Icon: CheckCircle2, color: '#34d399' },
clarification_required: { Icon: AlertCircle, color: '#fbbf24' },
};
function ExecutionTurnCard({ entry }: { entry: ExecutionEntry }) {
const [expanded, setExpanded] = useState(false);
const { execution } = entry;
const cfg = STATUS_ICONS[execution.status] ?? STATUS_ICONS.received;
const Icon = cfg.Icon;
return (
<div
className="rounded-xl overflow-hidden"
style={{ background: 'rgba(255,255,255,0.025)', border: '1px solid rgba(255,255,255,0.07)' }}
>
<button
className="w-full flex items-start gap-3 p-3 text-left hover:bg-white/[0.02] transition-colors"
onClick={() => setExpanded((p) => !p)}
>
<Icon
className="w-3.5 h-3.5 mt-0.5 flex-shrink-0"
style={{ color: cfg.color, animation: 'spin' in cfg && cfg.spin ? 'spin 1s linear infinite' : undefined }}
/>
<div className="min-w-0 flex-1">
<p className="text-xs text-zinc-200 line-clamp-2 leading-relaxed">
{execution.prompt}
</p>
{execution.summary && (
<p className="text-[10px] text-zinc-500 mt-1 line-clamp-1">{execution.summary}</p>
)}
</div>
<ChevronDown
className="w-3.5 h-3.5 text-zinc-600 flex-shrink-0 mt-0.5 transition-transform"
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
/>
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
className="overflow-hidden"
>
<div className="px-3 pb-3 space-y-2.5 border-t border-white/[0.06] pt-2.5">
{/* Revision produced */}
<div className="flex items-center gap-2">
<GitCommit className="w-3 h-3 text-zinc-600" />
<span className="text-[10px] text-zinc-500">
Produced revision on <span className="text-zinc-400">{execution.branchId}</span>
</span>
</div>
{/* Components created */}
{entry.componentsCreated.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-1">
<BarChart2 className="w-3 h-3 text-blue-500" />
<span className="text-[10px] text-zinc-500">Components created</span>
</div>
{entry.componentsCreated.map((cid) => (
<div key={cid} className="text-[10px] text-blue-300 font-mono pl-5 truncate">
{cid}
</div>
))}
</div>
)}
{/* Warnings */}
{execution.warnings.length > 0 && (
<div>
<p className="text-[10px] text-amber-500 uppercase tracking-wider mb-1">Warnings</p>
{execution.warnings.map((w, i) => (
<p key={i} className="text-[10px] text-amber-300/80 pl-2 border-l border-amber-500/30 mb-0.5">
{w}
</p>
))}
</div>
)}
{/* Timestamps */}
<div className="flex items-center justify-between text-[10px] text-zinc-600 pt-1">
<span className="font-mono">{new Date(execution.createdAt).toLocaleTimeString()}</span>
{execution.completedAt && (
<span>
{Math.round((new Date(execution.completedAt).getTime() - new Date(execution.createdAt).getTime()) / 1000)}s
</span>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function PromptRail({ history, inFlight, isOpen, onToggle }: PromptRailProps) {
return (
<div
className="flex-shrink-0 flex flex-col"
style={{
width: isOpen ? 280 : 0,
overflow: 'hidden',
transition: 'width 0.25s cubic-bezier(0.4,0,0.2,1)',
borderLeft: isOpen ? '1px solid rgba(255,255,255,0.07)' : 'none',
}}
>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col h-full overflow-hidden"
style={{ width: 280 }}
>
{/* Rail header */}
<div
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}
>
<div>
<p className="text-xs font-semibold text-zinc-200">Prompt History</p>
<p className="text-[10px] text-zinc-600">{history.length} execution{history.length !== 1 ? 's' : ''}</p>
</div>
<button
onClick={onToggle}
className="text-zinc-600 hover:text-zinc-400 transition-colors"
>
<ChevronDown className="w-4 h-4 rotate-90" />
</button>
</div>
{/* Scrollable turns */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-3 py-3 space-y-2">
{/* In-flight */}
<AnimatePresence>
{inFlight && (
<motion.div
key="inflight"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="rounded-xl p-3"
style={{
background: 'rgba(59,130,246,0.08)',
border: '1px dashed rgba(59,130,246,0.25)',
}}
>
<div className="flex items-start gap-2">
<Loader2 className="w-3.5 h-3.5 text-blue-400 animate-spin flex-shrink-0 mt-0.5" />
<div>
<p className="text-[10px] text-blue-300 font-medium uppercase tracking-wider mb-0.5">
{inFlight.status}
</p>
<p className="text-xs text-zinc-300 line-clamp-2">{inFlight.prompt}</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Historical turns — newest first */}
{[...history].reverse().map((entry, i) => (
<ExecutionTurnCard key={entry.execution.executionId + i} entry={entry} />
))}
{history.length === 0 && !inFlight && (
<div className="text-center py-8">
<p className="text-xs text-zinc-600">Submit a prompt to see execution history here.</p>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,208 @@
/**
* RollbackConfirmModal — Shows revision history and confirms rollback.
* Rollback creates a new revision (non-destructive) per spec §15.3.
*/
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, RotateCcw, GitCommit, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { CanvasPage, CanvasPageRevision } from '../types/canvas';
interface RollbackConfirmModalProps {
page: CanvasPage | null;
revisions: CanvasPageRevision[];
isLoading: boolean;
isOpen: boolean;
onClose: () => void;
onRollback: (targetRevision: number) => Promise<void>;
}
export function RollbackConfirmModal({ page, revisions, isLoading, isOpen, onClose, onRollback }: RollbackConfirmModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const [selected, setSelected] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const handleRollback = async () => {
if (!selected) return;
setSubmitting(true);
try {
await onRollback(selected);
setSuccess(true);
setTimeout(() => {
setSuccess(false);
onClose();
setSelected(null);
}, 1800);
} catch {
// stay open
} finally {
setSubmitting(false);
}
};
const commitKindColors: Record<string, string> = {
prompt: '#60a5fa',
merge: '#a78bfa',
rollback: '#fbbf24',
manual_edit: '#34d399',
};
const content = (
<AnimatePresence>
{isOpen && (
<>
<motion.div
key="rollback-backdrop"
className="fixed inset-0 z-40"
style={{ background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(4px)' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
key="rollback-modal"
className="fixed z-50 left-1/2 top-1/2 w-full max-w-lg"
initial={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
animate={{ opacity: 1, scale: 1, x: '-50%', y: '-50%' }}
exit={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
transition={{ duration: 0.2 }}
>
<div
className="rounded-2xl p-6"
style={{
background: 'rgba(12, 13, 20, 0.98)',
border: '1px solid rgba(255,255,255,0.1)',
boxShadow: '0 24px 80px rgba(0,0,0,0.8)',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
}}
>
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-amber-500/15 border border-amber-500/25 flex items-center justify-center">
<RotateCcw className="w-4 h-4 text-amber-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-zinc-100">Revision History</h2>
<p className="text-xs text-zinc-500">Select a revision to roll back to</p>
</div>
</div>
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Non-destructive note */}
<div
className="flex items-start gap-3 p-3 rounded-xl mb-4 flex-shrink-0"
style={{ background: 'rgba(251,191,36,0.07)', border: '1px solid rgba(251,191,36,0.18)' }}
>
<AlertTriangle className="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-zinc-300 leading-relaxed">
Rollback is <span className="text-amber-300 font-medium">non-destructive</span> it creates a new
revision restoring the canvas to that state. Current revision {page?.headRevision} is preserved in history.
</p>
</div>
{success ? (
<div className="flex flex-col items-center gap-3 py-8">
<CheckCircle2 className="w-10 h-10 text-green-400" />
<p className="text-sm text-zinc-200 font-medium">Rolled back to revision {selected}</p>
</div>
) : (
<>
{/* Revision list */}
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-2 mb-4">
{isLoading && (
<div className="p-4 text-xs text-zinc-500">Loading revision history</div>
)}
{!isLoading && revisions.map((rev) => {
const color = commitKindColors[rev.commitKind] ?? '#60a5fa';
const isCurrent = rev.revisionNumber === page?.headRevision;
const isSelected = selected === rev.revisionNumber;
return (
<button
key={rev.revisionId}
disabled={isCurrent}
onClick={() => setSelected(isSelected ? null : rev.revisionNumber)}
className="w-full flex items-start gap-3 p-3.5 rounded-xl text-left transition-all"
style={{
background: isSelected
? 'rgba(59,130,246,0.1)'
: 'rgba(255,255,255,0.025)',
border: `1px solid ${isSelected ? 'rgba(59,130,246,0.35)' : 'rgba(255,255,255,0.07)'}`,
opacity: isCurrent ? 0.6 : 1,
cursor: isCurrent ? 'default' : 'pointer',
}}
>
<GitCommit className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" style={{ color }} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-mono font-semibold text-zinc-200">
rev.{rev.revisionNumber}
</span>
{isCurrent && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-300 border border-blue-500/25">
current
</span>
)}
<span
className="text-[10px] px-1.5 rounded-full"
style={{ background: `${color}18`, color }}
>
{rev.commitKind}
</span>
</div>
<p className="text-xs text-zinc-300 mt-0.5 truncate">{rev.commitSummary ?? 'Revision event'}</p>
<p className="text-[10px] text-zinc-600 mt-0.5">
{rev.actorId} · {new Date(rev.createdAt).toLocaleString()}
</p>
</div>
{isSelected && (
<div className="w-4 h-4 rounded-full bg-blue-500/25 border border-blue-400 flex items-center justify-center flex-shrink-0 mt-0.5">
<div className="w-2 h-2 rounded-full bg-blue-400" />
</div>
)}
</button>
);
})}
{!isLoading && revisions.length === 0 && (
<div className="p-4 text-xs text-zinc-500">No revisions have been committed yet.</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 flex-shrink-0">
<Button
variant="outline"
onClick={onClose}
className="flex-1 border-white/10 bg-white/5 hover:bg-white/10 text-zinc-300"
>
Cancel
</Button>
<Button
onClick={() => void handleRollback()}
disabled={!selected || submitting}
className="flex-1 bg-amber-600 hover:bg-amber-500 text-white"
>
{submitting ? 'Rolling back…' : selected ? `Restore rev.${selected}` : 'Select a revision'}
</Button>
</div>
</>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
if (!mounted) return null;
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,261 @@
/**
* ShareModal — Fork-based sharing workflow.
* Explains the direct_fork_only semantics (recipient gets an editable copy,
* not live edit access to owner's canvas).
*/
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Share2, GitFork, Lock, Users, MessageSquare, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { CanvasPage } from '../types/canvas';
interface ShareModalProps {
page: CanvasPage | null;
isOpen: boolean;
onClose: () => void;
onShare: (params: {
recipientEmail: string;
visibility: 'private' | 'team';
message: string;
sourceRevision: number;
}) => Promise<void>;
}
const TEAM_MEMBERS = [
{ id: 'u2', name: 'Elena Rostova', email: 'elena@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
{ id: 'u3', name: 'Priya Sharma', email: 'priya@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
{ id: 'u4', name: 'Carlos Mendez', email: 'carlos@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
{ id: 'u5', name: 'Ravi Kapoor', email: 'ravi@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
];
export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const [recipient, setRecipient] = useState<{ id: string; name: string; email: string } | null>(null);
const [visibility, setVisibility] = useState<'private' | 'team'>('private');
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [memberDropOpen, setMemberDropOpen] = useState(false);
const handleShare = async () => {
if (!recipient || !page) return;
setSubmitting(true);
try {
await onShare({
recipientEmail: recipient.email,
visibility,
message,
sourceRevision: page.headRevision,
});
setSuccess(true);
setTimeout(() => {
setSuccess(false);
onClose();
setRecipient(null);
setMessage('');
}, 2000);
} catch {
// stay open on error
} finally {
setSubmitting(false);
}
};
const content = (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
key="share-backdrop"
className="fixed inset-0 z-40"
style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
key="share-modal"
className="fixed z-50 left-1/2 top-1/2 w-full max-w-md"
initial={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
animate={{ opacity: 1, scale: 1, x: '-50%', y: '-50%' }}
exit={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
transition={{ duration: 0.2 }}
>
<div
className="rounded-2xl p-6"
style={{
background: 'rgba(12, 13, 20, 0.98)',
border: '1px solid rgba(255,255,255,0.1)',
boxShadow: '0 24px 80px rgba(0,0,0,0.8)',
}}
>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-blue-500/15 border border-blue-500/25 flex items-center justify-center">
<Share2 className="w-4 h-4 text-blue-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-zinc-100">Share Canvas</h2>
<p className="text-xs text-zinc-500">
{page?.title ?? 'Oracle Canvas'} · rev.{page?.headRevision}
</p>
</div>
</div>
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Fork explanation */}
<div
className="flex items-start gap-3 p-3 rounded-xl mb-5"
style={{ background: 'rgba(59,130,246,0.07)', border: '1px solid rgba(59,130,246,0.18)' }}
>
<GitFork className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-zinc-300 leading-relaxed">
The recipient gets a <span className="text-blue-300 font-medium">fork</span> an editable copy
of this canvas at revision {page?.headRevision}. They can build on it and open a merge
request to propose their changes back.
</p>
</div>
{success ? (
<div className="flex flex-col items-center gap-3 py-6">
<div className="w-12 h-12 rounded-full bg-green-500/15 border border-green-500/30 flex items-center justify-center">
<Share2 className="w-6 h-6 text-green-400" />
</div>
<p className="text-sm text-zinc-200 font-medium">Fork created successfully!</p>
<p className="text-xs text-zinc-500">{recipient?.name} can now access their copy.</p>
</div>
) : (
<div className="space-y-4">
{/* Recipient picker */}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Recipient</label>
<div className="relative">
<button
onClick={() => setMemberDropOpen((p) => !p)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.1)',
color: recipient ? '#e4e4e7' : '#71717a',
}}
>
<div className="flex items-center gap-2">
<Users className="w-3.5 h-3.5 text-zinc-500" />
<span>{recipient?.name ?? 'Select team member…'}</span>
</div>
<ChevronDown className="w-3.5 h-3.5 text-zinc-600" style={{ transform: memberDropOpen ? 'rotate(180deg)' : 'none' }} />
</button>
<AnimatePresence>
{memberDropOpen && (
<motion.div
className="absolute top-full left-0 right-0 mt-1 z-50 rounded-xl py-1 overflow-hidden shadow-2xl"
style={{ background: 'rgb(14,15,22)', border: '1px solid rgba(255,255,255,0.12)' }}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
>
{TEAM_MEMBERS.map((m) => (
<button
key={m.id}
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
onClick={() => { setRecipient(m); setMemberDropOpen(false); }}
>
<img src={m.avatar} className="w-7 h-7 rounded-full" alt={m.name} />
<div>
<p className="text-sm text-zinc-200">{m.name}</p>
<p className="text-[10px] text-zinc-500">{m.role}</p>
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Visibility */}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Fork visibility</label>
<div className="grid grid-cols-2 gap-2">
{([
{ value: 'private', icon: Lock, label: 'Private', desc: 'Only recipient' },
{ value: 'team', icon: Users, label: 'Team', desc: 'Whole team' },
] as const).map(({ value, icon: Icon, label, desc }) => (
<button
key={value}
onClick={() => setVisibility(value)}
className="flex items-center gap-2 p-3 rounded-xl text-left transition-all"
style={{
background: visibility === value ? 'rgba(59,130,246,0.12)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${visibility === value ? 'rgba(59,130,246,0.35)' : 'rgba(255,255,255,0.08)'}`,
}}
>
<Icon className="w-4 h-4" style={{ color: visibility === value ? '#60a5fa' : '#52525b' }} />
<div>
<p className="text-xs font-medium" style={{ color: visibility === value ? '#93c5fd' : '#a1a1aa' }}>{label}</p>
<p className="text-[10px] text-zinc-600">{desc}</p>
</div>
</button>
))}
</div>
</div>
{/* Message */}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 flex items-center gap-1.5">
<MessageSquare className="w-3 h-3" />
Message <span className="text-zinc-600">(optional)</span>
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Add context for the recipient…"
rows={3}
className="w-full px-3 py-2.5 text-sm rounded-xl resize-none focus:outline-none"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#e4e4e7',
}}
/>
</div>
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
onClick={onClose}
className="flex-1 border-white/10 bg-white/5 hover:bg-white/10 text-zinc-300"
>
Cancel
</Button>
<Button
onClick={() => void handleShare()}
disabled={!recipient || submitting}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white"
>
{submitting ? 'Creating fork…' : 'Share (Create Fork)'}
</Button>
</div>
</div>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
if (!mounted) return null;
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,135 @@
import { motion } from 'framer-motion';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
import type { FC } from 'react';
import { Mail, Phone, Users, Calendar, MessageCircle, Clock, ArrowRight } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
const URGENCY_COLORS = {
critical: { bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.25)', text: '#f87171', label: 'Critical' },
high: { bg: 'rgba(251,191,36,0.1)', border: 'rgba(251,191,36,0.25)', text: '#fbbf24', label: 'High' },
medium: { bg: 'rgba(59,130,246,0.1)', border: 'rgba(59,130,246,0.2)', text: '#60a5fa', label: 'Medium' },
low: { bg: 'rgba(255,255,255,0.03)', border: 'rgba(255,255,255,0.07)', text: '#71717a', label: 'Low' },
};
const TYPE_ICONS: Record<string, FC<{ className?: string }>> = {
Email: Mail,
Call: Phone,
Meeting: Users,
Appointment: Calendar,
WhatsApp: MessageCircle,
};
export function ActivityStreamRenderer({ component, ctx }: Props) {
const rows = (component.dataRows ?? []) as Array<Record<string, unknown>>;
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx} minHeight={320}>
<NoDataPlaceholder message="No activities found matching your filters." />
</RendererWrapper>
);
const hasUrgency = rows.some((r) => r.urgency !== undefined);
const hasTimeline = rows.some((r) => r.type !== undefined && r.date !== undefined);
return (
<RendererWrapper component={component} ctx={ctx} minHeight={340}>
{hasTimeline ? (
// Timeline layout
<div className="relative space-y-4 pl-6">
<div className="absolute left-[17px] top-1 bottom-4 w-[2px] bg-gradient-to-b from-blue-500 via-cyan-500/40 to-transparent" />
{rows.map((row, i) => {
const TypeIcon = TYPE_ICONS[String(row.type ?? '')] ?? Calendar;
return (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.07 }}
className="relative pl-5 group cursor-pointer"
>
<div className="absolute -left-[3px] top-1.5 h-3.5 w-3.5 rounded-full bg-zinc-950 border-2 border-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.6)]" />
<div
className="p-3.5 rounded-xl"
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 mb-1">
<TypeIcon className="w-3.5 h-3.5 text-blue-400" />
<span className="text-xs font-semibold uppercase tracking-wider text-blue-300">
{String(row.type ?? '')}
</span>
</div>
<p className="text-sm text-zinc-200 font-medium">
{String(row.title ?? row.summary ?? '')}
</p>
{row.actor !== undefined && (
<p className="text-xs text-zinc-500 mt-0.5">{String(row.actor)}</p>
)}
</div>
<span className="text-xs text-zinc-600 font-mono whitespace-nowrap flex-shrink-0">
{String(row.date ?? row.when ?? '')}
</span>
</div>
</div>
</motion.div>
);
})}
</div>
) : hasUrgency ? (
// Follow-up queue layout
<div className="space-y-2">
{rows.map((row, i) => {
const urgency = String(row.urgency ?? 'low') as keyof typeof URGENCY_COLORS;
const uc = URGENCY_COLORS[urgency] ?? URGENCY_COLORS.low;
const hoursAgo = Number(row.last_contact_hours_ago ?? 0);
return (
<motion.div
key={i}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06 }}
className="flex items-center gap-3 p-3 rounded-xl group cursor-pointer"
style={{ background: uc.bg, border: `1px solid ${uc.border}` }}
>
<Avatar className="h-9 w-9 border border-white/10 flex-shrink-0">
<AvatarImage src={String(row.avatar ?? '')} />
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-xs">
{String(row.name ?? '').slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-zinc-100 truncate">{String(row.name ?? '')}</p>
<div className="flex items-center gap-2 mt-0.5">
<Clock className="w-3 h-3" style={{ color: uc.text }} />
<span className="text-xs" style={{ color: uc.text }}>
{hoursAgo}h without contact
</span>
<span className="text-xs text-zinc-500"> {String(row.assigned_broker ?? '')}</span>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge
className="text-[10px] h-5 px-1.5"
style={{ background: uc.bg, color: uc.text, border: `1px solid ${uc.border}` }}
>
{uc.label}
</Badge>
<span className="text-xs text-zinc-500 font-mono">QD:{Number(row.qd_score ?? 0).toFixed(0)}</span>
<ArrowRight className="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors" />
</div>
</motion.div>
);
})}
</div>
) : (
<NoDataPlaceholder />
)}
</RendererWrapper>
);
}

View File

@@ -0,0 +1,71 @@
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Cell } from 'recharts';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, GlassTooltip, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
const PALETTE = ['#0EA5E9', '#22D3EE', '#3B82F6', '#8B5CF6', '#06B6D4', '#A78BFA'];
export function BarChartRenderer({ component, ctx }: Props) {
const params = component.visualizationParameters as {
xAxis?: string;
yAxis?: string;
sort?: 'asc' | 'desc';
showLabels?: boolean;
legend?: boolean;
colorScale?: string[];
};
const rows = (component.dataRows ?? []) as Array<Record<string, number | string>>;
const xKey = params.xAxis ?? component.dataBindings.dimensions[0] ?? 'category';
const yKey = params.yAxis ?? component.dataBindings.measures[0] ?? 'value';
const colors = params.colorScale ?? PALETTE;
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx}>
<NoDataPlaceholder />
</RendererWrapper>
);
const sorted = params.sort
? [...rows].sort((a, b) =>
params.sort === 'desc'
? Number(b[yKey]) - Number(a[yKey])
: Number(a[yKey]) - Number(b[yKey]),
)
: rows;
return (
<RendererWrapper component={component} ctx={ctx} minHeight={300}>
<div className="h-[220px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={sorted} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<XAxis
dataKey={xKey}
stroke="#3f3f46"
tick={{ fontSize: 11, fill: '#71717a' }}
axisLine={false}
tickLine={false}
dy={6}
/>
<YAxis
stroke="#3f3f46"
tick={{ fontSize: 11, fill: '#71717a' }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<GlassTooltip />} cursor={{ fill: 'rgba(59,130,246,0.06)' }} />
<Bar dataKey={yKey} radius={[6, 6, 0, 0]} maxBarSize={48}>
{sorted.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={colors[index % colors.length]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,61 @@
import { motion } from 'framer-motion';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, type ComponentRenderContext } from './RendererWrapper';
import { AlertTriangle, AlertCircle, Info, CheckCircle2 } from 'lucide-react';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
const SEVERITY_MAP = {
error: { icon: AlertTriangle, bg: 'rgba(239,68,68,0.08)', border: 'rgba(239,68,68,0.25)', text: '#f87171', accent: '#ef4444' },
warning: { icon: AlertCircle, bg: 'rgba(251,191,36,0.08)', border: 'rgba(251,191,36,0.25)', text: '#fbbf24', accent: '#f59e0b' },
info: { icon: Info, bg: 'rgba(59,130,246,0.08)', border: 'rgba(59,130,246,0.2)', text: '#60a5fa', accent: '#3b82f6' },
success: { icon: CheckCircle2, bg: 'rgba(52,211,153,0.08)', border: 'rgba(52,211,153,0.25)', text: '#34d399', accent: '#10b981' },
};
export function ErrorNoticeRenderer({ component, ctx }: Props) {
const params = component.visualizationParameters as {
errorCode?: string;
message?: string;
severity?: 'error' | 'warning' | 'info' | 'success';
retryable?: boolean;
};
const severity = params.severity ?? 'warning';
const { icon: Icon, bg, border, text, accent } = SEVERITY_MAP[severity] ?? SEVERITY_MAP.warning;
return (
<RendererWrapper component={component} ctx={ctx} minHeight={120}>
<motion.div
className="flex items-start gap-4 p-4 rounded-xl h-full"
style={{ background: bg, border: `1px solid ${border}` }}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
>
<div
className="w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 mt-0.5"
style={{ background: `${accent}20` }}
>
<Icon className="w-5 h-5" style={{ color: text }} />
</div>
<div className="min-w-0 flex-1">
{params.errorCode && (
<p className="text-[10px] font-mono uppercase tracking-widest mb-1" style={{ color: `${text}80` }}>
{params.errorCode}
</p>
)}
<p className="text-sm font-medium" style={{ color: text }}>
{params.message ?? 'An error occurred rendering this component.'}
</p>
{params.retryable && (
<button
className="text-xs mt-2 underline underline-offset-2 hover:opacity-80 transition-opacity"
style={{ color: text }}
>
Retry
</button>
)}
</div>
</motion.div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,115 @@
import { motion } from 'framer-motion';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
interface GeoRow {
district: string;
lat?: number;
lng?: number;
lead_count?: number;
count?: number;
avg_qd_score?: number;
x?: number;
y?: number;
}
export function GeoMapRenderer({ component, ctx }: Props) {
const rows = (component.dataRows ?? []) as unknown as GeoRow[];
const intensityField = (component.visualizationParameters as { intensityField?: string }).intensityField ?? 'lead_count';
const tooltipFields = (component.visualizationParameters as { tooltipFields?: string[] }).tooltipFields ?? ['district', 'lead_count'];
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx} minHeight={360}>
<NoDataPlaceholder message="No geographic data available." />
</RendererWrapper>
);
const maxVal = Math.max(...rows.map((r) => Number(r[intensityField as keyof GeoRow] ?? r.count ?? r.lead_count ?? 0)));
return (
<RendererWrapper component={component} ctx={ctx} minHeight={400}>
<div className="relative w-full h-[280px] rounded-xl overflow-hidden" style={{
background: 'radial-gradient(ellipse at center, rgba(14,116,144,0.12) 0%, rgba(10,12,20,0.95) 100%)',
border: '1px solid rgba(59,130,246,0.12)',
}}>
{/* Dubai grid lines (decorative) */}
<svg className="absolute inset-0 w-full h-full opacity-10" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#3B82F6" strokeWidth="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
{/* Coastline glow */}
<div
className="absolute bottom-0 left-0 right-0 h-24 pointer-events-none"
style={{ background: 'linear-gradient(to top, rgba(14,116,144,0.15) 0%, transparent 100%)' }}
/>
{/* Legend */}
<div
className="absolute left-3 top-3 z-10 px-3 py-2.5 rounded-xl text-xs"
style={{ background: 'rgba(10,12,20,0.88)', border: '1px solid rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)' }}
>
<p className="text-zinc-500 uppercase tracking-wider text-[10px] mb-2">Lead Intensity</p>
{[
{ label: 'High', color: '#0EA5E9' },
{ label: 'Medium', color: '#22D3EE' },
{ label: 'Low', color: '#164E63' },
].map(({ label, color }) => (
<div key={label} className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 rounded-full" style={{ background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="text-zinc-300">{label}</span>
</div>
))}
</div>
{/* District pins */}
{rows.map((row, i) => {
const val = Number(row[intensityField as keyof GeoRow] ?? row.count ?? row.lead_count ?? 0);
const ratio = maxVal > 0 ? val / maxVal : 0;
const color = ratio > 0.7 ? '#0EA5E9' : ratio > 0.4 ? '#22D3EE' : '#164E63';
const size = 6 + ratio * 14;
const x = row.x ?? (20 + i * 12);
const y = row.y ?? (30 + i * 8);
return (
<motion.div
key={row.district}
className="absolute group cursor-pointer"
style={{ left: `${x}%`, top: `${y}%`, transform: 'translate(-50%, -50%)' }}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: i * 0.08, type: 'spring', damping: 10 }}
>
<motion.div
className="rounded-full"
style={{
width: size,
height: size,
background: color,
boxShadow: `0 0 ${size * 2}px ${color}88`,
}}
whileHover={{ scale: 1.5 }}
/>
{/* Tooltip */}
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1.5 rounded-lg text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-30"
style={{ background: 'rgba(10,12,20,0.95)', border: '1px solid rgba(59,130,246,0.2)', backdropFilter: 'blur(8px)' }}
>
<p className="font-semibold text-zinc-100">{row.district}</p>
{tooltipFields.map((f) => (
<p key={f} className="text-zinc-400 capitalize">{f.replace(/_/g, ' ')}: {String(row[f as keyof GeoRow] ?? '—')}</p>
))}
</div>
</motion.div>
);
})}
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,69 @@
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, type ComponentRenderContext } from './RendererWrapper';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
export function KpiTileRenderer({ component, ctx }: Props) {
const params = component.visualizationParameters as {
value?: number | string;
label?: string;
trend?: string;
unit?: string;
comparisonLabel?: string;
comparisonValue?: string;
};
const trendValue = params.trend ?? '';
const isPositive = trendValue.startsWith('+') || (parseFloat(trendValue) > 0);
const isNegative = trendValue.startsWith('-') || (parseFloat(trendValue) < 0);
const TrendIcon = isPositive ? TrendingUp : isNegative ? TrendingDown : Minus;
const trendColor = isPositive ? '#34d399' : isNegative ? '#f87171' : '#71717a';
const dataRow = component.dataRows?.[0];
const displayValue = dataRow
? String(Object.values(dataRow)[0] ?? params.value ?? '—')
: String(params.value ?? '—');
return (
<RendererWrapper component={component} ctx={ctx} minHeight={140}>
<div className="flex flex-col items-start justify-center h-full gap-2 py-2">
<div className="flex items-baseline gap-2">
<span
className="text-4xl font-bold tracking-tight"
style={{
background: 'linear-gradient(135deg, #fff 0%, #94a3b8 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{displayValue}
</span>
{params.unit && (
<span className="text-lg text-zinc-500 font-medium">{params.unit}</span>
)}
</div>
{params.label && (
<p className="text-sm text-zinc-400">{params.label}</p>
)}
{trendValue && (
<div className="flex items-center gap-1.5 mt-1">
<TrendIcon className="w-4 h-4" style={{ color: trendColor }} />
<span className="text-sm font-medium" style={{ color: trendColor }}>{trendValue}</span>
{params.comparisonLabel && (
<span className="text-xs text-zinc-600">{params.comparisonLabel}</span>
)}
</div>
)}
{/* Ambient glow */}
<div
className="absolute bottom-4 right-4 w-24 h-24 rounded-full pointer-events-none"
style={{ background: 'radial-gradient(circle, rgba(59,130,246,0.12) 0%, transparent 70%)' }}
/>
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,54 @@
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, GlassTooltip, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
export function LineChartRenderer({ component, ctx }: Props) {
const rows = (component.dataRows ?? []) as Array<Record<string, number | string>>;
const dims = component.dataBindings.dimensions;
const measures = component.dataBindings.measures;
const xKey = dims[0] ?? 'date';
const LINE_COLORS = ['#3B82F6', '#22D3EE', '#A78BFA', '#34D399'];
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx}>
<NoDataPlaceholder />
</RendererWrapper>
);
return (
<RendererWrapper component={component} ctx={ctx} minHeight={300}>
<div className="h-[220px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
{measures.map((m, i) => (
<linearGradient key={m} id={`grad_${m}`} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={LINE_COLORS[i % LINE_COLORS.length]} />
<stop offset="100%" stopColor={LINE_COLORS[(i + 1) % LINE_COLORS.length]} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" vertical={false} />
<XAxis dataKey={xKey} stroke="#3f3f46" tick={{ fontSize: 11, fill: '#71717a' }} axisLine={false} tickLine={false} dy={6} />
<YAxis stroke="#3f3f46" tick={{ fontSize: 11, fill: '#71717a' }} axisLine={false} tickLine={false} />
<Tooltip content={<GlassTooltip />} />
{measures.map((m, i) => (
<Line
key={m}
type="monotone"
dataKey={m}
stroke={`url(#grad_${m})`}
strokeWidth={2.5}
dot={{ r: 3, fill: '#0a0c14', stroke: LINE_COLORS[i % LINE_COLORS.length], strokeWidth: 2 }}
activeDot={{ r: 5, fill: LINE_COLORS[i % LINE_COLORS.length] }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,121 @@
import { motion } from 'framer-motion';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { useCurrency } from '@/store/useCurrencyStore';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
interface PipelineRow {
stage: string;
count: number;
value?: number;
leads?: Array<{ id: string; name: string; company: string; value: string; avatar?: string }>;
}
const STAGE_COLORS: Record<string, string> = {
'New Leads': '#3B82F6',
'Qualified': '#22D3EE',
'Proposal Sent': '#A78BFA',
'Negotiation': '#F59E0B',
'Closed': '#34D399',
};
export function PipelineBoardRenderer({ component, ctx }: Props) {
const { formatAmount, formatText } = useCurrency();
const rows = (component.dataRows ?? []) as unknown as PipelineRow[];
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx} minHeight={360}>
<NoDataPlaceholder message="No pipeline data available." />
</RendererWrapper>
);
return (
<RendererWrapper component={component} ctx={ctx} minHeight={380}>
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${Math.min(rows.length, 4)}, 1fr)` }}>
{rows.map((stageData, si) => {
const color = STAGE_COLORS[stageData.stage] ?? '#60a5fa';
return (
<motion.div
key={stageData.stage}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: si * 0.07 }}
className="flex flex-col rounded-xl p-3"
style={{ background: 'rgba(255,255,255,0.025)', border: '1px solid rgba(255,255,255,0.07)' }}
>
{/* Stage header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="text-xs font-medium uppercase tracking-wider text-zinc-400">{stageData.stage}</span>
</div>
<Badge
variant="outline"
className="border-white/10 bg-white/5 text-zinc-400 text-[10px] h-5 px-1.5"
>
{stageData.count}
</Badge>
</div>
{/* Lead cards */}
<div className="space-y-2 flex-1">
{(stageData.leads ?? []).slice(0, 3).map((lead, li) => (
<motion.div
key={lead.id}
initial={{ opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: si * 0.07 + li * 0.05 }}
className="group p-2.5 rounded-lg relative"
style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.05)',
cursor: 'pointer',
}}
whileHover={{ background: `${color}12`, borderColor: `${color}30` }}
>
{/* Accent bar */}
<div
className="absolute left-0 top-2 bottom-2 w-0.5 rounded-r-full opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: color }}
/>
<div className="flex items-center gap-2 pl-1">
<Avatar className="h-7 w-7 border border-white/10 flex-shrink-0">
<AvatarImage src={lead.avatar} />
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-[10px]">
{lead.name.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-zinc-100 truncate">{lead.name}</p>
<p className="text-[10px] text-zinc-500 truncate">{formatText(lead.value)}</p>
</div>
</div>
</motion.div>
))}
{stageData.count > 3 && (
<p className="text-[10px] text-zinc-600 text-center pt-1">
+{stageData.count - 3} more
</p>
)}
</div>
{/* Stage total */}
{stageData.value !== undefined && (
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
<p className="text-[10px] text-zinc-600 uppercase tracking-wider">Total</p>
<p className="text-sm font-semibold text-zinc-200">
{formatAmount(stageData.value, { compact: true })}
</p>
</div>
)}
</motion.div>
);
})}
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,184 @@
/**
* Shared renderer utilities and wrapper — used by all canvas component renderers.
*/
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Database, Shield, BarChart2, Info, Users, Lock } from 'lucide-react';
import { toast } from 'sonner';
import type { CanvasComponent } from '../../types/canvas';
import type { ComponentRenderContext } from '../ComponentRegistry';
export { type ComponentRenderContext };
export const GLASS_PANEL = {
background: 'rgba(10, 12, 20, 0.72)',
border: '1px solid rgba(59,130,246,0.12)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
boxShadow: '0 6px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.03)',
borderRadius: '16px',
} as const;
const PRIVACY_COLORS = {
standard: { color: '#22d3ee', label: 'Standard' },
restricted: { color: '#fbbf24', label: 'Restricted' },
sensitive: { color: '#f87171', label: 'Sensitive' },
};
interface RendererWrapperProps {
component: CanvasComponent;
ctx: ComponentRenderContext;
children: React.ReactNode;
minHeight?: number;
}
export function RendererWrapper({ component, ctx, children, minHeight = 280 }: RendererWrapperProps) {
const [localVisibility, setLocalVisibility] = useState(component.accessControls.visibilityScope);
const privacy = PRIVACY_COLORS[component.dataSourceDescriptor.privacyTier] ?? PRIVACY_COLORS.standard;
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}
className="group relative w-full"
style={{ minHeight }}
onClick={() => ctx.onSelect?.(component.componentId)}
>
<div
className="h-full p-5 flex flex-col"
style={{
...GLASS_PANEL,
...(ctx.isSelected ? {
border: '1px solid rgba(59,130,246,0.45)',
boxShadow: '0 0 0 2px rgba(59,130,246,0.2), 0 6px 24px rgba(0,0,0,0.45)',
} : {}),
}}
>
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-4 flex-shrink-0">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-zinc-100 truncate">{component.title}</h3>
{component.description && (
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-1">{component.description}</p>
)}
</div>
{/* Badges */}
<div className="flex items-center gap-1.5 flex-shrink-0">
{ctx.showLineageBadges && (
<span
className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full"
style={{ background: `${privacy.color}18`, color: privacy.color, border: `1px solid ${privacy.color}30` }}
title={`Privacy tier: ${privacy.label}`}
>
<Shield className="w-2.5 h-2.5" />
{privacy.label}
</span>
)}
<span
className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full"
style={{ background: 'rgba(255,255,255,0.04)', color: '#52525b', border: '1px solid rgba(255,255,255,0.07)' }}
>
v{component.version}
</span>
</div>
</div>
{/* Content */}
<div className="flex-1 min-h-0">
{children}
</div>
{/* Footer — provenance + dataset */}
<div className="flex items-center justify-between mt-3 pt-3 flex-shrink-0" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<div className="flex items-center gap-2">
<Database className="w-3 h-3 text-zinc-600" />
<span className="text-[10px] text-zinc-600 font-mono truncate max-w-[140px]">
{component.dataSourceDescriptor.dataset}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const isPrivate = localVisibility === 'private';
const nextVisibility = isPrivate ? 'tenant_team' : 'private';
setLocalVisibility(nextVisibility);
if (nextVisibility === 'tenant_team') {
toast.success('Visibility set to Team', {
description: 'This component is now visible to other members on this fork according to team policy.'
});
} else {
toast.info('Visibility set to Private', {
description: 'This component is now restricted and visible only to you.'
});
}
}}
className="flex items-center gap-1.5 text-[10px] text-zinc-500 hover:text-zinc-200 transition-colors px-1.5 py-0.5 rounded hover:bg-white/10"
>
{localVisibility === 'private' ? (
<>
<Lock className="w-3 h-3" />
<span>private</span>
</>
) : (
<>
<Users className="w-3 h-3" />
<span>team</span>
</>
)}
</button>
</div>
</div>
</motion.div>
);
}
/** Tooltip for chart hover states */
export function GlassTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number | string; color: string }>; label?: string }) {
if (!active || !payload?.length) return null;
return (
<div
className="rounded-xl px-3 py-2 text-sm shadow-2xl"
style={{
background: 'rgba(10,12,20,0.95)',
border: '1px solid rgba(59,130,246,0.2)',
backdropFilter: 'blur(8px)',
}}
>
{label && <p className="text-xs text-zinc-400 mb-1">{label}</p>}
{payload.map((p) => (
<div key={p.name} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: p.color }} />
<span className="text-zinc-200 font-medium">{p.value}</span>
</div>
))}
</div>
);
}
/** Lineage badge */
export function LineageBadge({ promptExecutionId }: { promptExecutionId?: string }) {
if (!promptExecutionId) return null;
return (
<div
className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full"
style={{ background: 'rgba(59,130,246,0.1)', color: '#60a5fa', border: '1px solid rgba(59,130,246,0.2)' }}
title={`Prompt execution: ${promptExecutionId}`}
>
<Info className="w-2.5 h-2.5" />
Prompt-generated
</div>
);
}
/** No-data placeholder */
export function NoDataPlaceholder({ message = 'No data available for the selected filters.' }: { message?: string }) {
return (
<div className="h-full flex flex-col items-center justify-center gap-3 py-8">
<BarChart2 className="w-10 h-10 text-zinc-700" />
<p className="text-sm text-zinc-500 text-center max-w-xs">{message}</p>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { motion } from 'framer-motion';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
import { Trophy } from 'lucide-react';
import { useCurrency } from '@/store/useCurrencyStore';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
export function TableRenderer({ component, ctx }: Props) {
const { formatText } = useCurrency();
const rows = (component.dataRows ?? []) as Array<Record<string, unknown>>;
const columns = (component.visualizationParameters as { columns?: string[] }).columns
?? (rows[0] ? Object.keys(rows[0]).filter((k) => !['avatar', 'rank'].includes(k)) : []);
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx} minHeight={260}>
<NoDataPlaceholder />
</RendererWrapper>
);
const hasAvatar = rows.some((r) => r.avatar);
const hasRank = rows.some((r) => r.rank !== undefined);
return (
<RendererWrapper component={component} ctx={ctx} minHeight={280}>
<div className="overflow-hidden rounded-xl" style={{ border: '1px solid rgba(255,255,255,0.06)' }}>
{/* Header */}
<div
className="grid gap-3 px-4 py-2.5 text-[10px] uppercase tracking-widest text-zinc-500"
style={{
gridTemplateColumns: hasRank
? `28px ${hasAvatar ? '40px' : ''} 1fr repeat(${columns.filter((c) => !['name', 'avatar', 'rank'].includes(c)).length}, minmax(80px, 1fr))`
: `${hasAvatar ? '40px' : ''} 1fr repeat(${columns.filter((c) => !['name', 'avatar', 'rank'].includes(c)).length}, minmax(80px, 1fr))`,
background: 'rgba(255,255,255,0.03)',
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}
>
{hasRank && <span>#</span>}
{hasAvatar && <span />}
{columns.map((c) => (
<span key={c} className="capitalize">{c.replace(/_/g, ' ')}</span>
))}
</div>
{/* Rows */}
<div className="divide-y divide-white/[0.04]">
{rows.map((row, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
className="grid gap-3 px-4 py-3 items-center hover:bg-white/[0.02] transition-colors group cursor-pointer"
style={{
gridTemplateColumns: hasRank
? `28px ${hasAvatar ? '40px' : ''} 1fr repeat(${columns.filter((c) => !['name', 'avatar', 'rank'].includes(c)).length}, minmax(80px, 1fr))`
: `${hasAvatar ? '40px' : ''} 1fr repeat(${columns.filter((c) => !['name', 'avatar', 'rank'].includes(c)).length}, minmax(80px, 1fr))`,
}}
>
{hasRank && (
<div className="flex items-center justify-center">
{Number(row.rank) === 1 ? (
<Trophy className="w-4 h-4 text-amber-400" />
) : (
<span className="text-xs text-zinc-500 font-mono">{String(row.rank)}</span>
)}
</div>
)}
{hasAvatar && (
<Avatar className="h-8 w-8 border border-white/10">
<AvatarImage src={String(row.avatar ?? '')} />
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-xs">
{String(row.name ?? '').slice(0, 2)}
</AvatarFallback>
</Avatar>
)}
{columns.map((col) => (
<div key={col} className="min-w-0">
{col === 'name' ? (
<span className="text-sm text-zinc-100 font-medium group-hover:text-blue-100 transition-colors truncate block">
{formatText(String(row[col] ?? '—'))}
</span>
) : (
<span className="text-sm text-zinc-300 font-mono truncate block">
{formatText(String(row[col] ?? '—'))}
</span>
)}
</div>
))}
</motion.div>
))}
</div>
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,75 @@
import { motion } from 'framer-motion';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, type ComponentRenderContext } from './RendererWrapper';
import { CalendarClock, Mail, Phone, Users, MessageCircle } from 'lucide-react';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
email: Mail,
call: Phone,
meeting: Users,
whatsapp: MessageCircle,
appointment: CalendarClock,
};
export function TimelineRenderer({ component, ctx }: Props) {
const rows = (component.dataRows ?? []) as Array<Record<string, unknown>>;
return (
<RendererWrapper component={component} ctx={ctx} minHeight={300}>
<div className="relative space-y-4 pl-6">
{/* Timeline spine */}
<div className="absolute left-[18px] top-2 bottom-4 w-[2px] bg-gradient-to-b from-blue-500 via-cyan-400/40 to-transparent shadow-[0_0_8px_#3b82f6]" />
{rows.map((row, i) => {
const typeKey = String(row.type ?? '').toLowerCase();
const Icon = ICONS[typeKey] ?? CalendarClock;
return (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.09 }}
className="relative pl-5 group cursor-pointer"
>
{/* Timeline dot */}
<div className="absolute -left-[3px] top-1.5 h-3.5 w-3.5 rounded-full bg-zinc-950 border-2 border-blue-400 shadow-[0_0_12px_#3b82f6] z-10" />
<div
className="p-4 rounded-xl relative overflow-hidden"
style={{ background: 'rgba(255,255,255,0.025)', border: '1px solid rgba(255,255,255,0.07)' }}
>
{/* Hover shimmer */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/[0.04] to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700" />
<div className="flex items-start justify-between gap-3 relative z-10">
<div>
<div className="flex items-center gap-2 mb-1.5">
<Icon className="w-3.5 h-3.5 text-blue-400" />
<span className="text-[10px] font-bold uppercase tracking-widest text-blue-300">
{String(row.type ?? row.title ?? '')}
</span>
</div>
<p className="text-sm text-zinc-200 font-medium leading-tight">
{String(row.summary ?? row.title ?? '')}
</p>
{row.actor !== undefined && (
<p className="text-xs text-zinc-500 mt-1">{String(row.actor)}</p>
)}
</div>
<span className="text-[11px] font-mono text-zinc-500 whitespace-nowrap flex-shrink-0 pt-0.5">
{String(row.date ?? row.when ?? '')}
</span>
</div>
</div>
</motion.div>
);
})}
{rows.length === 0 && (
<p className="text-sm text-zinc-600 pl-2">No timeline events found.</p>
)}
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,386 @@
/**
* MergeReviewDrawer — Full merge request review interface.
* Shows: diff summary, conflict cards per conflict class, resolution controls.
* Supports approve / reject / request-changes decisions.
*/
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, GitMerge, Plus, Edit2, ArrowUpDown, Trash2, AlertTriangle, CheckCircle2, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { MergeRequest, ConflictRecord } from '../../types/canvas';
interface MergeReviewDrawerProps {
mergeRequest: MergeRequest | null;
isOpen: boolean;
onClose: () => void;
onReview: (decision: 'approve' | 'reject' | 'changes_requested', comment?: string) => Promise<void>;
}
const CONFLICT_CLASS_CONFIG: Record<string, {
icon: typeof AlertTriangle;
label: string;
color: string;
bg: string;
description: string;
severity: 'none' | 'low' | 'medium' | 'high';
}> = {
safe_append: {
icon: Plus, label: 'Safe Append', color: '#34d399', bg: 'rgba(52,211,153,0.1)',
description: 'New component added in source, not present in target. Will be appended.',
severity: 'none',
},
safe_reorder: {
icon: ArrowUpDown, label: 'Safe Reorder', color: '#60a5fa', bg: 'rgba(96,165,250,0.1)',
description: 'Component order differs between branches. Will be merged using longest-common-subsequence.',
severity: 'none',
},
component_content_conflict: {
icon: Edit2, label: 'Content Conflict', color: '#fbbf24', bg: 'rgba(251,191,36,0.1)',
description: 'Both branches edited the same component content. Manual resolution required.',
severity: 'high',
},
query_descriptor_conflict: {
icon: AlertTriangle, label: 'Query Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)',
description: 'Data source or filter parameters conflict. Requires reviewer decision.',
severity: 'high',
},
layout_slot_conflict: {
icon: ArrowUpDown, label: 'Layout Conflict', color: '#fbbf24', bg: 'rgba(251,191,36,0.1)',
description: 'Same layout slot claimed by different components.',
severity: 'medium',
},
access_policy_conflict: {
icon: AlertTriangle, label: 'Policy Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)',
description: 'Access control policies diverge. Stricter policy will prevail.',
severity: 'high',
},
delete_edit_conflict: {
icon: Trash2, label: 'Delete-Edit Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)',
description: 'Component deleted in one branch, edited in another.',
severity: 'high',
},
};
function ConflictCard({ conflict, resolution, onResolve }: {
conflict: ConflictRecord;
resolution?: string;
onResolve: (id: string, decision: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const cfg = CONFLICT_CLASS_CONFIG[conflict.conflictClass] ?? CONFLICT_CLASS_CONFIG.component_content_conflict;
const Icon = cfg.icon;
const isResolved = !!resolution;
return (
<div
className="rounded-xl overflow-hidden"
style={{ background: cfg.bg, border: `1px solid ${cfg.color}30` }}
>
<button
className="w-full flex items-center gap-3 p-3.5 text-left"
onClick={() => setExpanded((p) => !p)}
>
<Icon className="w-4 h-4 flex-shrink-0" style={{ color: cfg.color }} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold" style={{ color: cfg.color }}>{cfg.label}</span>
{isResolved && (
<CheckCircle2 className="w-3 h-3 text-green-400" />
)}
</div>
<p className="text-[10px] text-zinc-500 truncate font-mono">
{conflict.componentId}{conflict.field ? `${conflict.field}` : ''}
</p>
</div>
<ChevronDown
className="w-3.5 h-3.5 text-zinc-600 flex-shrink-0 transition-transform"
style={{ transform: expanded ? 'rotate(180deg)' : 'none' }}
/>
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
className="overflow-hidden border-t"
style={{ borderColor: `${cfg.color}25` }}
>
<div className="p-3.5 space-y-3">
<p className="text-xs text-zinc-300">{cfg.description}</p>
{conflict.description && (
<p className="text-xs text-zinc-400 italic">"{conflict.description}"</p>
)}
{/* Show values if present */}
{(conflict.sourceValue !== undefined || conflict.targetValue !== undefined) && (
<div className="grid grid-cols-2 gap-2">
{conflict.sourceValue !== undefined && (
<div className="p-2 rounded-lg" style={{ background: 'rgba(255,255,255,0.04)' }}>
<p className="text-[10px] text-zinc-500 mb-1">Source</p>
<p className="text-xs text-zinc-300 font-mono break-all">
{JSON.stringify(conflict.sourceValue).slice(0, 80)}
</p>
</div>
)}
{conflict.targetValue !== undefined && (
<div className="p-2 rounded-lg" style={{ background: 'rgba(255,255,255,0.04)' }}>
<p className="text-[10px] text-zinc-500 mb-1">Target</p>
<p className="text-xs text-zinc-300 font-mono break-all">
{JSON.stringify(conflict.targetValue).slice(0, 80)}
</p>
</div>
)}
</div>
)}
{/* Resolution buttons */}
{cfg.severity !== 'none' && !isResolved && (
<div className="flex gap-2">
<button
className="text-[10px] px-2.5 py-1.5 rounded-lg transition-all flex-1"
style={{ background: 'rgba(59,130,246,0.15)', color: '#60a5fa', border: '1px solid rgba(59,130,246,0.3)' }}
onClick={() => onResolve(conflict.conflictId, 'source_wins')}
>
Use Source
</button>
<button
className="text-[10px] px-2.5 py-1.5 rounded-lg transition-all flex-1"
style={{ background: 'rgba(255,255,255,0.06)', color: '#a1a1aa', border: '1px solid rgba(255,255,255,0.1)' }}
onClick={() => onResolve(conflict.conflictId, 'target_wins')}
>
Keep Target
</button>
</div>
)}
{isResolved && (
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3.5 h-3.5 text-green-400" />
<span className="text-xs text-green-300">Resolved: {resolution}</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function MergeReviewDrawer({ mergeRequest, isOpen, onClose, onReview }: MergeReviewDrawerProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const [comment, setComment] = useState('');
const [submitting, setSubmitting] = useState(false);
const [resolutions, setResolutions] = useState<Record<string, string>>({});
const [success, setSuccess] = useState<string | null>(null);
const conflicts: ConflictRecord[] = mergeRequest?.conflicts ?? [];
const diff = mergeRequest?.diffSummary;
const highSeverityConflicts = conflicts.filter(
(c) => (CONFLICT_CLASS_CONFIG[c.conflictClass]?.severity ?? 'none') === 'high'
);
const unresolvedHigh = highSeverityConflicts.filter((c) => !resolutions[c.conflictId]);
const canApprove = unresolvedHigh.length === 0;
const handleReview = async (decision: 'approve' | 'reject' | 'changes_requested') => {
setSubmitting(true);
try {
await onReview(decision, comment);
setSuccess(decision);
setTimeout(() => {
setSuccess(null);
onClose();
}, 2000);
} finally {
setSubmitting(false);
}
};
const content = (
<AnimatePresence>
{isOpen && (
<>
<motion.div
key="mr-backdrop"
className="fixed inset-0 z-40"
style={{ background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(4px)' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
key="mr-drawer"
className="fixed z-50 right-0 top-0 h-full"
style={{ width: 480 }}
initial={{ x: 480 }}
animate={{ x: 0 }}
exit={{ x: 480 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<div
className="h-full flex flex-col"
style={{
background: 'rgba(10, 11, 18, 0.99)',
borderLeft: '1px solid rgba(255,255,255,0.1)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
style={{ borderBottom: '1px solid rgba(255,255,255,0.08)' }}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-violet-500/15 border border-violet-500/25 flex items-center justify-center">
<GitMerge className="w-4 h-4 text-violet-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-zinc-100">
{mergeRequest?.title ?? 'Merge Request'}
</h2>
<p className="text-xs text-zinc-500">
{mergeRequest?.sourceBranchId ?? 'fork'} {mergeRequest?.targetBranchId ?? 'main'}
</p>
</div>
</div>
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{success ? (
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<CheckCircle2 className="w-12 h-12 text-green-400" />
<p className="text-base font-medium text-zinc-100 capitalize">
{success === 'approve' ? 'Merge approved!' : success === 'reject' ? 'Review rejected' : 'Changes requested'}
</p>
</div>
) : (
<>
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-5">
{/* Diff summary */}
{diff && (
<div>
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-3">Changes</h3>
<div className="grid grid-cols-4 gap-2">
{[
{ label: 'Added', val: diff.componentsAdded, color: '#34d399', icon: Plus },
{ label: 'Edited', val: diff.componentsEdited, color: '#60a5fa', icon: Edit2 },
{ label: 'Reordered', val: diff.componentsReordered, color: '#a78bfa', icon: ArrowUpDown },
{ label: 'Deleted', val: diff.componentsDeleted, color: '#f87171', icon: Trash2 },
].map(({ label, val, color, icon: Icon }) => (
<div
key={label}
className="p-2.5 rounded-xl text-center"
style={{ background: `${color}10`, border: `1px solid ${color}25` }}
>
<Icon className="w-3.5 h-3.5 mx-auto mb-1" style={{ color }} />
<p className="text-base font-bold" style={{ color }}>{val}</p>
<p className="text-[10px] text-zinc-600 uppercase tracking-wider">{label}</p>
</div>
))}
</div>
</div>
)}
{/* Conflicts */}
{conflicts.length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide">
Conflicts ({conflicts.length})
</h3>
{unresolvedHigh.length > 0 && (
<span className="text-[10px] text-amber-400">
{unresolvedHigh.length} require resolution
</span>
)}
</div>
<div className="space-y-2">
{conflicts.map((c) => (
<ConflictCard
key={c.conflictId}
conflict={c}
resolution={resolutions[c.conflictId]}
onResolve={(id, decision) =>
setResolutions((p) => ({ ...p, [id]: decision }))
}
/>
))}
</div>
</div>
)}
{conflicts.length === 0 && (
<div
className="flex items-center gap-3 p-4 rounded-xl"
style={{ background: 'rgba(52,211,153,0.07)', border: '1px solid rgba(52,211,153,0.2)' }}
>
<CheckCircle2 className="w-5 h-5 text-green-400 flex-shrink-0" />
<p className="text-sm text-green-300">No conflicts clean merge</p>
</div>
)}
{/* Reviewer comment */}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Reviewer comment</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Optional note for the author…"
rows={3}
className="w-full px-3 py-2.5 text-sm rounded-xl resize-none focus:outline-none"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#e4e4e7',
}}
/>
</div>
</div>
{/* Decision buttons */}
<div
className="flex gap-2 px-5 py-4 flex-shrink-0"
style={{ borderTop: '1px solid rgba(255,255,255,0.07)' }}
>
<Button
variant="outline"
onClick={() => void handleReview('reject')}
disabled={submitting}
className="flex-1 border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20"
>
Reject
</Button>
<Button
variant="outline"
onClick={() => void handleReview('changes_requested')}
disabled={submitting}
className="flex-1 border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20"
>
Request Changes
</Button>
<Button
onClick={() => void handleReview('approve')}
disabled={!canApprove || submitting}
className="flex-1 bg-green-700 hover:bg-green-600 text-white disabled:opacity-40"
>
{canApprove ? 'Approve & Merge' : `Resolve ${unresolvedHigh.length} conflicts`}
</Button>
</div>
</>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
if (!mounted) return null;
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,130 @@
/**
* useOracleExecution — manages prompt submission and durable execution history.
*/
import { useState, useCallback, useRef } from 'react';
import type { PromptExecution, CanvasComponent, PlacementMode } from '../types/canvas';
import { submitPrompt } from '../lib/oracleApiClient';
export interface ExecutionEntry {
execution: PromptExecution;
componentsCreated: string[];
}
export interface OracleExecutionState {
history: ExecutionEntry[];
inFlight: PromptExecution | null;
lastError: string | null;
submit: (params: {
pageId: string;
branchId: string;
prompt: string;
tenantId: string;
actorId: string;
placementMode?: PlacementMode;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
onExecutionCommitted?: (commit: {
headRevision: number;
components: CanvasComponent[];
execution: PromptExecution;
}) => void;
}) => Promise<void>;
clearError: () => void;
}
export function useOracleExecution(): OracleExecutionState {
const [history, setHistory] = useState<ExecutionEntry[]>([]);
const [inFlight, setInFlight] = useState<PromptExecution | null>(null);
const [lastError, setLastError] = useState<string | null>(null);
const requestIdRef = useRef(0);
const submit = useCallback(
async ({
pageId,
branchId,
prompt,
tenantId,
actorId,
placementMode = 'append_after_last_visible_component',
conversationContext = [],
onExecutionCommitted,
}: {
pageId: string;
branchId: string;
prompt: string;
tenantId: string;
actorId: string;
placementMode?: PlacementMode;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
onExecutionCommitted?: (commit: {
headRevision: number;
components: CanvasComponent[];
execution: PromptExecution;
}) => void;
}) => {
const clientRequestId = `cli_${Date.now()}_${++requestIdRef.current}`;
const now = new Date().toISOString();
const optimistic: PromptExecution = {
executionId: `pex_${clientRequestId}`,
tenantId,
pageId,
branchId,
actorId,
prompt,
intentClass: 'analytical',
status: 'planning',
modelRuntime: 'oracle_runtime',
semanticModelVersion: 'oracle_semantic_v2026_04_08_01',
warnings: [],
createdAt: now,
};
setInFlight(optimistic);
setLastError(null);
try {
setInFlight((prev) => (prev ? { ...prev, status: 'executing' } : prev));
const response = await submitPrompt(pageId, {
clientRequestId,
branchId,
prompt,
conversationContext,
placementMode,
});
const completed: PromptExecution = {
...optimistic,
executionId: response.executionId,
status: response.status,
summary: response.summary,
warnings: response.warnings,
componentsCreated: response.componentsCreated,
completedAt: new Date().toISOString(),
};
onExecutionCommitted?.({
headRevision: response.headRevision,
components: response.components,
execution: completed,
});
setHistory((prev) => [...prev, { execution: completed, componentsCreated: response.componentsCreated }]);
setInFlight(null);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Prompt execution failed';
const failed: PromptExecution = {
...optimistic,
status: 'failed',
warnings: [msg],
completedAt: new Date().toISOString(),
};
setHistory((prev) => [...prev, { execution: failed, componentsCreated: [] }]);
setInFlight(null);
setLastError(msg);
}
},
[],
);
return { history, inFlight, lastError, submit, clearError: () => setLastError(null) };
}

View File

@@ -0,0 +1,120 @@
/**
* useOraclePage — Page hydration, branch state, component projection
* Owns the canonical page state including revision tracking and optimistic updates.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import type { CanvasPage, CanvasComponent, OracleWSMessage } from '../types/canvas';
import { fetchCanvasPage, connectPageSocket } from '../lib/oracleApiClient';
export interface OraclePageState {
page: CanvasPage | null;
isLoading: boolean;
error: string | null;
isConnected: boolean;
// Actions
refresh: () => Promise<void>;
optimisticallyAppendComponent: (comp: CanvasComponent) => void;
applyRevision: (headRevision: number, components: CanvasComponent[]) => void;
}
export function useOraclePage(pageId: string | null): OraclePageState {
const [page, setPage] = useState<CanvasPage | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const disconnectRef = useRef<(() => void) | null>(null);
const load = useCallback(async () => {
if (!pageId) {
setPage(null);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await fetchCanvasPage(pageId);
setPage(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load page');
} finally {
setIsLoading(false);
}
}, [pageId]);
// Connect WebSocket
useEffect(() => {
if (!pageId) {
setIsConnected(false);
return () => undefined;
}
const disconnect = connectPageSocket(pageId, {
onMessage: (msg: OracleWSMessage) => handleWSMessage(msg),
onReconnect: () => void load(),
onClose: () => setIsConnected(false),
});
disconnectRef.current = disconnect;
setIsConnected(true);
return () => {
disconnect();
disconnectRef.current = null;
};
}, [pageId, load]); // eslint-disable-line react-hooks/exhaustive-deps
// Handle WS messages
function handleWSMessage(msg: OracleWSMessage) {
if (msg.type === 'oracle.page.revision.committed') {
const { headRevision, components } = msg.payload as {
headRevision: number;
components: CanvasComponent[];
};
applyRevision(headRevision, components);
} else if (msg.type === 'oracle.presence.updated') {
setPage((prev) =>
prev
? {
...prev,
presence: {
activeViewers: (msg.payload.activeViewers as number) ?? prev.presence.activeViewers,
activeEditors: (msg.payload.activeEditors as number) ?? prev.presence.activeEditors,
lastPresenceAt: msg.timestamp,
},
}
: prev,
);
}
}
const optimisticallyAppendComponent = useCallback((comp: CanvasComponent) => {
setPage((prev) => {
if (!prev) return prev;
// Prevent duplicate (idempotent insert)
if (prev.components.some((c) => c.componentId === comp.componentId)) return prev;
return { ...prev, components: [...prev.components, comp] };
});
}, []);
const applyRevision = useCallback(
(headRevision: number, components: CanvasComponent[]) => {
setPage((prev) =>
prev ? { ...prev, headRevision, components } : prev,
);
},
[],
);
// Initial load
useEffect(() => {
void load();
}, [load]);
return {
page,
isLoading,
error,
isConnected,
refresh: load,
optimisticallyAppendComponent,
applyRevision,
};
}

View File

@@ -0,0 +1,199 @@
/**
* Oracle API Client — production-only client for the Oracle v1 backend.
*/
import type {
CanvasPage,
PromptSubmitRequest,
PromptSubmitResponse,
ForkCreateRequest,
ForkCreateResponse,
MergeRequestCreateRequest,
MergeRequest,
MergeReviewRequest,
ComponentTemplate,
UserProfile,
OracleWSMessage,
OracleEnvelope,
CanvasPageRevision,
} from '../types/canvas';
const BASE_URL = (import.meta.env.VITE_ORACLE_API_URL as string | undefined) ?? '';
const WS_URL = (import.meta.env.VITE_ORACLE_WS_URL as string | undefined) ?? '';
function apiUrl(path: string): string {
return `${BASE_URL}/api/oracle/v1${path}`;
}
async function apiFetch<T>(
path: string,
options?: RequestInit & { idempotencyKey?: string },
): Promise<T> {
if (!BASE_URL) {
throw new Error('Oracle API is not configured. Set VITE_ORACLE_API_URL to a live backend.');
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Oracle-Contract-Version': 'v1',
...(options?.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}),
};
const token = localStorage.getItem('oracle_jwt');
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(apiUrl(path), { ...options, headers });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw Object.assign(new Error(body?.error?.message ?? body?.detail?.errors?.join?.(', ') ?? `HTTP ${res.status}`), { apiError: body });
}
const body = await res.json() as OracleEnvelope<T> | T;
if (typeof body === 'object' && body !== null && 'data' in body) {
return (body as OracleEnvelope<T>).data;
}
return body as T;
}
export async function fetchMe(): Promise<UserProfile> {
return apiFetch<UserProfile>('/me');
}
export async function fetchCanvasPage(pageId: string): Promise<CanvasPage> {
return apiFetch<CanvasPage>(`/canvas-pages/${pageId}`);
}
export async function submitPrompt(
pageId: string,
payload: PromptSubmitRequest,
): Promise<PromptSubmitResponse> {
return apiFetch<PromptSubmitResponse>(`/canvas-pages/${pageId}/prompts`, {
method: 'POST',
body: JSON.stringify(payload),
idempotencyKey: payload.clientRequestId,
});
}
export async function rollbackPage(
pageId: string,
targetRevision: number,
clientRequestId: string,
): Promise<{ headRevision: number; pageId: string; components: CanvasPage['components'] }> {
return apiFetch<{ headRevision: number; pageId: string; components: CanvasPage['components'] }>(
`/canvas-pages/${pageId}/rollback`,
{
method: 'POST',
body: JSON.stringify({ targetRevision, clientRequestId }),
idempotencyKey: clientRequestId,
},
);
}
export async function listRevisions(pageId: string): Promise<CanvasPageRevision[]> {
return apiFetch<CanvasPageRevision[]>(`/canvas-pages/${pageId}/revisions`);
}
export async function createFork(
pageId: string,
payload: ForkCreateRequest,
): Promise<ForkCreateResponse> {
return apiFetch<ForkCreateResponse>(`/canvas-pages/${pageId}/forks`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function openMergeRequest(
payload: MergeRequestCreateRequest,
): Promise<MergeRequest> {
return apiFetch<MergeRequest>('/merge-requests', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function reviewMergeRequest(
mrId: string,
payload: MergeReviewRequest,
): Promise<MergeRequest> {
return apiFetch<MergeRequest>(`/merge-requests/${mrId}/review`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function listMergeRequests(pageId: string): Promise<MergeRequest[]> {
return apiFetch<MergeRequest[]>(`/merge-requests?targetPageId=${pageId}`);
}
export async function listComponentTemplates(filters?: {
category?: string;
status?: string;
}): Promise<ComponentTemplate[]> {
const qs = new URLSearchParams(filters as Record<string, string>).toString();
return apiFetch<ComponentTemplate[]>(`/component-templates${qs ? `?${qs}` : ''}`);
}
export async function synthesizeTemplate(params: {
prompt: string;
dataShape: string[];
styleSignatureRef?: string;
}): Promise<ComponentTemplate> {
return apiFetch<ComponentTemplate>('/component-templates/synthesize', {
method: 'POST',
body: JSON.stringify(params),
});
}
export function connectPageSocket(
pageId: string,
handlers: {
onMessage: (msg: OracleWSMessage) => void;
onReconnect: () => void;
onClose: () => void;
},
): () => void {
if (!WS_URL && !BASE_URL) {
handlers.onClose();
return () => undefined;
}
const wsBase = WS_URL || BASE_URL.replace(/^http/, 'ws');
let ws: WebSocket;
let stopped = false;
let retryTimeout: ReturnType<typeof setTimeout> | undefined;
function connect() {
ws = new WebSocket(`${wsBase}/ws/oracle/canvas/${pageId}`);
ws.onmessage = (event) => {
try {
handlers.onMessage(JSON.parse(event.data as string) as OracleWSMessage);
} catch {
// Ignore malformed messages from the transport.
}
};
ws.onclose = () => {
handlers.onClose();
if (!stopped) {
retryTimeout = setTimeout(() => {
handlers.onReconnect();
connect();
}, 3000);
}
};
ws.onerror = () => {
ws.close();
};
}
connect();
return () => {
stopped = true;
if (retryTimeout) clearTimeout(retryTimeout);
ws?.close();
};
}

View File

@@ -0,0 +1,455 @@
/**
* Oracle Demo Data — In-memory seed canvas used when backend is not available.
* Preserves visual richness while the system is in development/demo mode.
* These objects conform exactly to the CanvasPage/CanvasComponent contract.
*/
import type { CanvasPage, UserProfile, CanvasComponent } from '../types/canvas';
// ── Demo user profile ─────────────────────────────────────────────────────────
export const IN_MEMORY_ME: UserProfile = {
userId: 'user_sales_director_001',
tenantId: 'tenant_binghatti_demo',
email: 'ahmed.alfarsi@binghatti.ae',
displayName: 'Ahmed Al-Farsi',
role: 'sales_director',
timezone: 'Asia/Dubai',
locale: 'en-AE',
defaultPageId: 'page_01_main_broker',
canvasPreferences: {
defaultDensity: 'comfortable',
defaultPlacementMode: 'append_after_last_visible_component',
showLineageBadges: true,
},
policyProfileId: 'policy_sales_director_standard_v4',
createdAt: '2026-01-15T09:00:00Z',
updatedAt: '2026-04-09T00:00:00Z',
};
// ── Default style signature ───────────────────────────────────────────────────
const VELOCITY_GLASS_STYLE = {
theme: 'velocity_glass',
paletteToken: 'ocean_signal',
motionProfile: 'calm_reveal',
density: 'comfortable' as const,
radiusScale: 'lg',
typographyScale: 'balanced',
};
// ── Demo components ───────────────────────────────────────────────────────────
const PIPELINE_BOARD: CanvasComponent = {
componentId: 'cmp_demo_pipeline_board',
type: 'pipelineBoard',
title: 'Active Pipeline by Stage',
description: 'Current deal distribution across funnel stages for Q2 2026.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_pipeline',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'deals',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT stage, COUNT(*) as count, SUM(value) as value FROM deals WHERE tenant_id = :tenant_id GROUP BY stage',
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 100,
freshnessSlaSeconds: 120,
cachePolicy: { mode: 'ttl', ttlSeconds: 120 },
privacyTier: 'standard',
lineageRefs: [],
},
visualizationParameters: {
stages: ['New Leads', 'Qualified', 'Proposal Sent', 'Negotiation'],
showValue: true,
colorByStage: true,
},
dataBindings: {
dimensions: ['stage'],
measures: ['count', 'value'],
series: [],
filters: [],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_pipeline_board_v2',
promptExecutionId: 'pex_demo_seed_001',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T08:00:00Z',
},
renderingHints: {
estimatedHeightPx: 400,
skeletonVariant: 'pipeline',
virtualizationPriority: 9,
},
layout: {
orderIndex: 100,
sectionId: 'sec_pipeline',
widthMode: 'full',
minHeightPx: 380,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director'],
redactionPolicy: 'none',
},
styleSignature: VELOCITY_GLASS_STYLE,
validationState: {
schema: 'pass',
policy: 'pass',
a11y: 'pass',
performance: 'pass',
status: 'validated',
},
auditLog: ['aud_demo_create_001'],
dataRows: [
{ stage: 'New Leads', count: 14, value: 18500000, leads: [
{ id: 'l1', name: 'Mohammed Al-Rashid', company: 'Rashid Group', value: 'AED 15M', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80' },
{ id: 'l2', name: 'Sarah Chen', company: 'Chen Capital', value: 'AED 8M', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80' },
{ id: 'l3', name: 'James Wilson', company: 'Wilson RE', value: 'AED 4.5M', avatar: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80' },
]},
{ stage: 'Qualified', count: 9, value: 42000000, leads: [
{ id: 'l4', name: 'Fatima Hassan', company: 'Hassan Holdings', value: 'AED 22M', avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80' },
{ id: 'l5', name: 'David Kumar', company: 'Kumar RE', value: 'AED 20M', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80' },
]},
{ stage: 'Proposal Sent', count: 5, value: 28000000, leads: [
{ id: 'l6', name: 'Elena Rostova', company: 'Rostova Ventures', value: 'AED 12M', avatar: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80' },
{ id: 'l7', name: 'Oliver Park', company: 'Park Investments', value: 'AED 16M', avatar: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80' },
]},
{ stage: 'Negotiation', count: 3, value: 65000000, leads: [
{ id: 'l8', name: 'Priya Sharma', company: 'Sharma Family Office', value: 'AED 32M', avatar: 'https://images.unsplash.com/photo-1542206395-9feb3edaa68d?auto=format&fit=crop&w=80&q=80' },
{ id: 'l9', name: 'Carlos Mendez', company: 'Mendez Capital', value: 'AED 33M', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=80&q=80' },
]},
],
};
const WHALE_LEADS_BAR: CanvasComponent = {
componentId: 'cmp_demo_whale_bar',
type: 'barChart',
title: 'Whale Leads by Source This Week',
description: 'Compares QD-weighted whale lead volume across lead sources in the current week.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_whale_bar',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'lead_daily_snapshot',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: "SELECT source, SUM(qd_weighted_score) as qd_weighted_volume FROM lead_daily_snapshot WHERE tenant_id = :tenant_id AND lead_class = 'whale' GROUP BY source ORDER BY qd_weighted_volume DESC",
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 20,
freshnessSlaSeconds: 120,
cachePolicy: { mode: 'ttl', ttlSeconds: 120 },
privacyTier: 'standard',
lineageRefs: ['lin_demo_leadsnap'],
},
visualizationParameters: {
xAxis: 'source',
yAxis: 'qd_weighted_volume',
sort: 'desc',
showLabels: true,
colorScale: ['#0EA5E9', '#22D3EE', '#3B82F6'],
legend: false,
},
dataBindings: {
dimensions: ['source'],
measures: ['qd_weighted_volume'],
series: [],
filters: [{ field: 'lead_class', operator: '=', value: 'whale' }],
},
version: 1,
provenance: {
originType: 'prompt_generated',
templateId: 'tpl_bar_source_quality_v3',
promptExecutionId: 'pex_demo_seed_002',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T10:00:00Z',
},
renderingHints: {
estimatedHeightPx: 340,
skeletonVariant: 'chart',
virtualizationPriority: 8,
},
layout: {
orderIndex: 200,
sectionId: 'sec_leads',
widthMode: 'half',
minHeightPx: 320,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director', 'marketing_operator'],
redactionPolicy: 'aggregate_only',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'ocean_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_002'],
dataRows: [
{ source: 'WhatsApp', qd_weighted_volume: 182.4 },
{ source: 'Website', qd_weighted_volume: 149.2 },
{ source: 'Walk-in', qd_weighted_volume: 93.7 },
{ source: 'Referral', qd_weighted_volume: 87.1 },
{ source: 'Instagram', qd_weighted_volume: 54.3 },
],
};
const INVESTOR_GEO_MAP: CanvasComponent = {
componentId: 'cmp_demo_geo_investor',
type: 'geoMap',
title: 'Investor Interest Density by Dubai District',
description: 'Maps high-intent leads with at least one positive Sentinel spike in the last 30 days.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_geo',
sourceType: 'derived_dataset',
connectorId: 'velocity-core-postgres',
dataset: 'lead_geo_interest_rollup',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT district, lat, lng, lead_count, avg_qd_score FROM lead_geo_interest_rollup WHERE tenant_id = :tenant_id AND activity_window = :window',
queryParameters: { tenant_id: 'tenant_binghatti_demo', window: '30d' },
rowLimit: 100,
freshnessSlaSeconds: 300,
cachePolicy: { mode: 'ttl', ttlSeconds: 300 },
privacyTier: 'restricted',
lineageRefs: ['lin_demo_rollup', 'lin_demo_sentinel'],
},
visualizationParameters: {
mapStyle: 'dubai_district_heat',
intensityField: 'lead_count',
tooltipFields: ['district', 'lead_count', 'avg_qd_score'],
interactive: true,
},
dataBindings: {
dimensions: ['district'],
measures: ['lead_count', 'avg_qd_score'],
series: ['district'],
filters: [{ field: 'activity_window', operator: '=', value: '30d' }],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_geo_investor_heat_v2',
promptExecutionId: 'pex_demo_seed_002',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T10:00:01Z',
},
renderingHints: {
estimatedHeightPx: 420,
skeletonVariant: 'map',
virtualizationPriority: 9,
},
layout: {
orderIndex: 300,
sectionId: 'sec_leads',
widthMode: 'half',
minHeightPx: 400,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director'],
redactionPolicy: 'district_level_only',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'aqua_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_003'],
dataRows: [
{ district: 'Downtown Dubai', lat: 25.1972, lng: 55.2744, lead_count: 38, avg_qd_score: 87.2, x: 52, y: 48 },
{ district: 'Dubai Marina', lat: 25.0777, lng: 55.1386, lead_count: 29, avg_qd_score: 82.1, x: 28, y: 68 },
{ district: 'Palm Jumeirah', lat: 25.1124, lng: 55.1390, lead_count: 24, avg_qd_score: 91.4, x: 22, y: 60 },
{ district: 'Business Bay', lat: 25.1850, lng: 55.2617, lead_count: 19, avg_qd_score: 74.8, x: 48, y: 44 },
{ district: 'Dubai Hills', lat: 25.1124, lng: 55.2454, lead_count: 15, avg_qd_score: 71.3, x: 44, y: 58 },
{ district: 'JBR', lat: 25.0794, lng: 55.1322, lead_count: 11, avg_qd_score: 68.9, x: 26, y: 70 },
{ district: 'DIFC', lat: 25.2048, lng: 55.2708, lead_count: 9, avg_qd_score: 79.5, x: 50, y: 38 },
],
};
const BROKER_PERFORMANCE: CanvasComponent = {
componentId: 'cmp_demo_broker_perf',
type: 'table',
title: 'Broker Performance Leaderboard',
description: 'Ranked by QD-adjusted deal value closed this month.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_brokers',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'broker_performance',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT broker_id, name, deals_closed, revenue_generated, avg_response_time_min FROM broker_performance WHERE tenant_id = :tenant_id ORDER BY revenue_generated DESC',
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 20,
freshnessSlaSeconds: 300,
cachePolicy: { mode: 'ttl', ttlSeconds: 300 },
privacyTier: 'standard',
lineageRefs: [],
},
visualizationParameters: {
columns: ['name', 'deals_closed', 'revenue_generated', 'avg_response_time_min'],
rankBy: 'revenue_generated',
showTopBadge: true,
},
dataBindings: {
dimensions: ['name'],
measures: ['deals_closed', 'revenue_generated'],
series: [],
filters: [],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_broker_performance_v1',
promptExecutionId: 'pex_demo_seed_003',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T11:00:00Z',
},
renderingHints: {
estimatedHeightPx: 320,
skeletonVariant: 'table',
virtualizationPriority: 7,
},
layout: {
orderIndex: 400,
sectionId: 'sec_team',
widthMode: 'full',
minHeightPx: 300,
stickyHeader: true,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['sales_director'],
redactionPolicy: 'none',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'indigo_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_004'],
dataRows: [
{ name: 'Elena Rostova', deals_closed: 12, revenue_generated: 'AED 28.4M', avg_response_time_min: 8, rank: 1, avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80' },
{ name: 'Priya Sharma', deals_closed: 10, revenue_generated: 'AED 24.1M', avg_response_time_min: 11, rank: 2, avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80' },
{ name: 'Carlos Mendez', deals_closed: 9, revenue_generated: 'AED 19.7M', avg_response_time_min: 14, rank: 3, avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80' },
{ name: 'Ravi Kapoor', deals_closed: 7, revenue_generated: 'AED 15.2M', avg_response_time_min: 22, rank: 4, avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80' },
{ name: 'Minati Ganrison', deals_closed: 6, revenue_generated: 'AED 11.8M', avg_response_time_min: 19, rank: 5, avatar: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80' },
],
};
const FOLLOWUP_QUEUE: CanvasComponent = {
componentId: 'cmp_demo_followup_queue',
type: 'activityStream',
title: 'Follow-up Gap Queue',
description: 'High-scoring leads with no contact in the last 72 hours.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_queue',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'lead_follow_up_gaps',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT lead_id, name, last_contact_hours_ago, qd_score, assigned_broker FROM lead_follow_up_gaps WHERE tenant_id = :tenant_id AND last_contact_hours_ago > 72 ORDER BY qd_score DESC',
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 10,
freshnessSlaSeconds: 60,
cachePolicy: { mode: 'ttl', ttlSeconds: 60 },
privacyTier: 'restricted',
lineageRefs: ['lin_demo_sentinel'],
},
visualizationParameters: {
showUrgencyIndicator: true,
enableQuickAction: true,
quickActions: ['call', 'whatsapp', 'email', 'assign'],
},
dataBindings: {
dimensions: ['name', 'assigned_broker'],
measures: ['qd_score', 'last_contact_hours_ago'],
series: [],
filters: [{ field: 'last_contact_hours_ago', operator: '>', value: 72 }],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_followup_queue_v1',
promptExecutionId: 'pex_demo_seed_004',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T12:00:00Z',
},
renderingHints: {
estimatedHeightPx: 380,
skeletonVariant: 'table',
virtualizationPriority: 10,
},
layout: {
orderIndex: 500,
sectionId: 'sec_actions',
widthMode: 'full',
minHeightPx: 360,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director'],
redactionPolicy: 'team_scope',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'amber_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_005'],
dataRows: [
{ lead_id: 'l10', name: 'Alexander Petrov', last_contact_hours_ago: 96, qd_score: 88.4, assigned_broker: 'Elena Rostova', urgency: 'critical', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=80&q=80' },
{ lead_id: 'l11', name: 'Nadia Okafor', last_contact_hours_ago: 84, qd_score: 81.2, assigned_broker: 'Priya Sharma', urgency: 'high', avatar: 'https://images.unsplash.com/photo-1542206395-9feb3edaa68d?auto=format&fit=crop&w=80&q=80' },
{ lead_id: 'l12', name: 'Tariq Al-Mansoori', last_contact_hours_ago: 78, qd_score: 76.9, assigned_broker: 'Carlos Mendez', urgency: 'medium', avatar: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80' },
{ lead_id: 'l13', name: 'Sophie Leclerc', last_contact_hours_ago: 73, qd_score: 72.1, assigned_broker: 'Ravi Kapoor', urgency: 'medium', avatar: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80' },
],
};
// ── Demo Canvas Page ──────────────────────────────────────────────────────────
export const IN_MEMORY_DEMO_PAGE: CanvasPage = {
pageId: 'page_01_main_broker',
tenantId: 'tenant_binghatti_demo',
ownerId: 'user_sales_director_001',
branchId: 'branch_main',
branchName: 'main',
pageType: 'main',
title: 'Oracle — Pipeline & Investor Signals',
createdAt: '2026-04-09T08:00:00Z',
updatedAt: '2026-04-09T12:00:00Z',
isShared: false,
forks: [],
mainBranchPointer: {
pageId: 'page_01_main_broker',
branchId: 'branch_main',
revision: 5,
},
baseRevision: 0,
headRevision: 5,
sharingPolicy: {
shareMode: 'direct_fork_only',
allowReshare: false,
defaultForkVisibility: 'private',
},
presence: {
activeViewers: 1,
activeEditors: 1,
lastPresenceAt: new Date().toISOString(),
},
lineage: [
{
lineageRecordId: 'lin_demo_seed',
tenantId: 'tenant_binghatti_demo',
sourceKind: 'prompt',
sourceId: 'pex_demo_seed_001',
transformationType: 'prompt_to_component_bundle',
producedKind: 'page_revision',
producedId: 'page_01_main_broker:5',
createdAt: '2026-04-09T12:00:00Z',
},
],
audit: {
lastAuditEventId: 'aud_demo_rev5',
eventCount: 12,
},
components: [
PIPELINE_BOARD,
WHALE_LEADS_BAR,
INVESTOR_GEO_MAP,
BROKER_PERFORMANCE,
FOLLOWUP_QUEUE,
],
};

View File

@@ -0,0 +1,488 @@
/**
* Oracle Canvas — Canonical TypeScript Contracts
* Mirrors the JSON Schema from Section 6.2 of the Oracle Architecture Document v1.0
* These types replace the temporary OracleQueryResult contract.
*/
// ── Enums ─────────────────────────────────────────────────────────────────────
export type OracleRole =
| 'junior_broker'
| 'senior_broker'
| 'sales_director'
| 'marketing_operator'
| 'data_steward'
| 'compliance_reviewer'
| 'platform_admin';
export type ComponentType =
| 'kpiTile'
| 'barChart'
| 'lineChart'
| 'scatterPlot'
| 'geoMap'
| 'table'
| 'pipelineBoard'
| 'timeline'
| 'heatmap'
| 'forecastChart'
| 'activityStream'
| 'customMLVisualization'
| 'errorNotice';
export type ComponentLifecycleState = 'draft' | 'active' | 'superseded' | 'archived' | 'revoked';
export type PrivacyTier = 'standard' | 'restricted' | 'sensitive';
export type SourceType = 'postgres' | 'warehouse' | 'api' | 'materialized_view' | 'derived_dataset';
export type CachePolicyMode = 'none' | 'ttl' | 'revision_scoped';
export type IntentClass = 'analytical' | 'operational' | 'mixed';
export type ExecutionStatus =
| 'received'
| 'planning'
| 'validated'
| 'executing'
| 'completed'
| 'failed'
| 'clarification_required';
export type PageType = 'main' | 'fork';
export type ForkStatus = 'active' | 'merged' | 'closed';
export type MergeRequestStatus = 'open' | 'changes_requested' | 'approved' | 'merged' | 'closed';
export type TemplateStatus = 'catalog_active' | 'tenant_draft' | 'tenant_active' | 'archived' | 'revoked';
export type TemplateOrigin = 'premade' | 'synthesized' | 'cloned';
export type ShareMode = 'private' | 'direct_fork_only';
export type WidthMode = 'full' | 'half' | 'third';
export type VisibilityScope = 'private' | 'shared_fork' | 'tenant_team';
export type ComponentOriginType = 'catalog' | 'prompt_generated' | 'cloned' | 'merged' | 'edited';
export type PlacementMode =
| 'append_after_last_visible_component'
| 'insert_after_component'
| 'replace_component'
| 'group_under_section';
export type ActorType = 'user' | 'service' | 'ai';
export type LineageSourceKind =
| 'table'
| 'view'
| 'materialization'
| 'prompt'
| 'component'
| 'template'
| 'merge_request';
export type ValidationStatus = 'validated' | 'rejected' | 'needs_review';
export type CommitKind = 'prompt' | 'merge' | 'rollback' | 'manual_edit';
// ── Sub-objects ───────────────────────────────────────────────────────────────
export interface CachePolicy {
mode: CachePolicyMode;
ttlSeconds?: number;
}
export interface DataSourceDescriptor {
descriptorId: string;
sourceType: SourceType;
connectorId: string;
dataset: string;
authContextRef: string;
queryTemplate: string;
queryParameters: Record<string, unknown>;
rowLimit: number;
freshnessSlaSeconds?: number;
cachePolicy?: CachePolicy;
privacyTier: PrivacyTier;
lineageRefs?: string[];
}
export interface DataBindings {
dimensions: string[];
measures: string[];
series: string[];
filters: Array<{
field: string;
operator: string;
value: unknown;
}>;
}
export interface ComponentProvenance {
originType: ComponentOriginType;
templateId?: string;
promptExecutionId?: string;
sourceComponentId?: string;
sourceBranchId?: string;
mergeRequestId?: string;
createdBy: string;
createdAt: string;
}
export interface RenderingHints {
estimatedHeightPx: number;
skeletonVariant: 'chart' | 'map' | 'table' | 'kpi' | 'pipeline' | 'timeline' | 'generic';
virtualizationPriority: number;
}
export interface ComponentLayout {
orderIndex: number;
sectionId: string;
widthMode: WidthMode;
minHeightPx: number;
stickyHeader: boolean;
}
export interface AccessControls {
visibilityScope: VisibilityScope;
allowedRoles: OracleRole[];
redactionPolicy: string;
}
export interface StyleSignature {
theme: string;
paletteToken: string;
motionProfile: string;
density: 'compact' | 'comfortable';
radiusScale: string;
typographyScale: string;
}
export interface ValidationState {
schema: 'pass' | 'fail';
policy: 'pass' | 'fail';
a11y: 'pass' | 'fail';
performance: 'pass' | 'fail';
status: ValidationStatus;
}
// ── Core Entities ─────────────────────────────────────────────────────────────
export interface CanvasComponent {
componentId: string;
type: ComponentType;
title: string;
description?: string;
dataSourceDescriptor: DataSourceDescriptor;
visualizationParameters: Record<string, unknown>;
dataBindings: DataBindings;
version: number;
lifecycleState?: ComponentLifecycleState;
provenance: ComponentProvenance;
renderingHints: RenderingHints;
layout: ComponentLayout;
accessControls: AccessControls;
styleSignature: StyleSignature;
validationState: ValidationState;
auditLog: string[];
// Runtime-only: actual data rows fetched for this component
dataRows?: Record<string, unknown>[];
}
export interface ForkRecord {
forkId: string;
sourcePageId: string;
sourceBranchId: string;
sourceRevision: number;
forkPageId: string;
forkBranchId: string;
recipientUserId: string;
createdBy: string;
createdAt: string;
status: ForkStatus;
}
export interface LineageRecord {
lineageRecordId: string;
tenantId: string;
sourceKind: LineageSourceKind;
sourceId: string;
transformationType: string;
transformationSpecHash?: string;
producedKind: string;
producedId: string;
policySnapshotId?: string;
createdAt: string;
}
export interface SharingPolicy {
shareMode: ShareMode;
allowReshare: boolean;
defaultForkVisibility: 'private' | 'team';
}
export interface PagePresence {
activeViewers: number;
activeEditors: number;
lastPresenceAt: string;
}
export interface PageAuditSummary {
lastAuditEventId: string;
eventCount: number;
}
export interface CanvasPage {
pageId: string;
tenantId: string;
ownerId: string;
branchId: string;
branchName: string;
pageType: PageType;
title: string;
createdAt: string;
updatedAt: string;
isShared: boolean;
forks: ForkRecord[];
mainBranchPointer: {
pageId: string;
branchId: string;
revision: number;
};
baseRevision: number;
headRevision: number;
sharingPolicy: SharingPolicy;
presence: PagePresence;
lineage: LineageRecord[];
audit: PageAuditSummary;
components: CanvasComponent[];
}
export interface PromptExecution {
executionId: string;
tenantId: string;
pageId: string;
branchId: string;
actorId: string;
prompt: string;
intentClass: IntentClass;
status: ExecutionStatus;
modelRuntime: string;
semanticModelVersion: string;
retrievalPlan?: Record<string, unknown>;
visualizationPlan?: Record<string, unknown>;
warnings: string[];
summary?: string;
componentsCreated?: string[];
createdAt: string;
completedAt?: string;
}
export interface ComponentTemplate {
templateId: string;
tenantId: string;
name: string;
category: string;
status: TemplateStatus;
origin: TemplateOrigin;
version: string;
acceptedShapes: string[];
styleSignature?: StyleSignature;
validationState?: ValidationState;
provenance?: ComponentProvenance;
createdAt: string;
updatedAt: string;
}
export interface ConflictRecord {
conflictId: string;
conflictClass:
| 'component_content_conflict'
| 'query_descriptor_conflict'
| 'layout_slot_conflict'
| 'access_policy_conflict'
| 'delete_edit_conflict'
| 'safe_append'
| 'safe_reorder';
componentId: string;
field?: string;
sourceValue?: unknown;
targetValue?: unknown;
description: string;
}
export interface DiffSummary {
componentsAdded: number;
componentsEdited: number;
componentsReordered: number;
componentsDeleted: number;
}
export interface MergeRequest {
mergeRequestId: string;
tenantId: string;
sourcePageId: string;
sourceBranchId: string;
sourceHeadRevision: number;
targetPageId: string;
targetBranchId: string;
targetBaseRevision: number;
title: string;
description?: string;
status: MergeRequestStatus;
conflicts: ConflictRecord[];
diffSummary?: DiffSummary;
createdBy: string;
reviewedBy?: string;
createdAt: string;
updatedAt: string;
}
export interface AuditEvent {
auditEventId: string;
tenantId: string;
entityType: string;
entityId: string;
action: string;
actorId: string;
actorType: ActorType;
correlationId: string;
executionId?: string;
createdAt: string;
details: Record<string, unknown>;
}
export interface UserProfile {
userId: string;
tenantId: string;
email: string;
displayName: string;
role: OracleRole;
timezone: string;
locale: string;
defaultPageId: string;
canvasPreferences: {
defaultDensity: 'compact' | 'comfortable';
defaultPlacementMode: PlacementMode;
showLineageBadges: boolean;
};
policyProfileId: string;
createdAt: string;
updatedAt: string;
}
// ── API Request/Response contracts ────────────────────────────────────────────
export interface PromptSubmitRequest {
clientRequestId: string;
branchId: string;
prompt: string;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
placementMode?: PlacementMode;
}
export interface PromptSubmitResponse {
executionId: string;
status: ExecutionStatus;
pageId: string;
branchId: string;
headRevision: number;
componentsCreated: string[];
components: CanvasComponent[];
summary: string;
warnings: string[];
}
export interface CanvasPageRevision {
revisionId: string;
pageId: string;
tenantId: string;
revisionNumber: number;
commitKind: CommitKind;
commitSummary?: string;
actorId: string;
executionId?: string;
mergeRequestId?: string;
createdAt: string;
}
export interface ForkCreateRequest {
recipientUserId: string;
sourceRevision: number;
visibility: 'private' | 'team';
message?: string;
}
export interface ForkCreateResponse {
forkId: string;
forkPageId: string;
forkBranchId: string;
status: ForkStatus;
sourceRevision: number;
}
export interface MergeRequestCreateRequest {
sourcePageId: string;
sourceBranchId: string;
targetPageId: string;
targetBranchId: string;
title: string;
description?: string;
}
export interface MergeReviewRequest {
decision: 'approve' | 'reject' | 'changes_requested';
comment?: string;
resolutions?: Array<{
conflictId: string;
resolutionType: 'source_wins' | 'target_wins' | 'manual_composite';
resolvedPayloadHash?: string;
comment?: string;
}>;
}
// ── WebSocket event types ─────────────────────────────────────────────────────
export type OracleWSEventType =
| 'oracle.prompt.received'
| 'oracle.prompt.validated'
| 'oracle.prompt.failed'
| 'oracle.page.revision.committed'
| 'oracle.page.rollback.committed'
| 'oracle.fork.created'
| 'oracle.merge_request.opened'
| 'oracle.merge_request.updated'
| 'oracle.merge_request.merged'
| 'oracle.component.template.promoted'
| 'oracle.presence.updated';
export interface OracleWSMessage {
type: OracleWSEventType;
tenantId: string;
pageId?: string;
branchId?: string;
correlationId: string;
timestamp: string;
payload: Record<string, unknown>;
}
// ── API Error envelope ────────────────────────────────────────────────────────
export interface OracleAPIError {
error: {
code: string;
message: string;
retryable: boolean;
correlationId: string;
details?: Record<string, unknown>;
};
}
export interface OracleEnvelope<T> {
status: 'ok';
data: T;
meta?: Record<string, unknown>;
}

View File

@@ -0,0 +1,158 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// ── Currency config ───────────────────────────────────────────────────────────
export type CurrencyCode = 'USD' | 'AED' | 'INR';
export interface CurrencyOption {
code: CurrencyCode;
label: string;
symbol: string;
locale: string;
flag: string;
}
export const CURRENCY_OPTIONS: CurrencyOption[] = [
{ code: 'USD', label: 'US Dollar', symbol: '$', locale: 'en-US', flag: '🇺🇸' },
{ code: 'AED', label: 'UAE Dirham', symbol: 'AED', locale: 'en-AE', flag: '🇦🇪' },
{ code: 'INR', label: 'Indian Rupee', symbol: '₹', locale: 'en-IN', flag: '🇮🇳' },
];
// ── Store ─────────────────────────────────────────────────────────────────────
interface CurrencyState {
currency: CurrencyCode;
setCurrency: (code: CurrencyCode) => void;
/** Format a numeric amount with the active currency */
formatAmount: (amount: number, options?: { compact?: boolean }) => string;
/** Format a text string containing currency prefixes (e.g. AED 15M -> $ 15M) */
formatText: (text: string) => string;
/** Active currency option object */
option: () => CurrencyOption;
}
export const useCurrencyStore = create<CurrencyState>()(
persist(
(set, get) => ({
currency: 'USD',
setCurrency: (code) => set({ currency: code }),
option: () =>
CURRENCY_OPTIONS.find((o) => o.code === get().currency) ?? CURRENCY_OPTIONS[0],
formatAmount: (amount, opts) => {
const { currency, option } = get();
const { locale } = option();
// Base assumption: Raw numbers in mock data are in AED.
let convertedAmount = amount;
if (currency === 'USD') convertedAmount = amount * 0.272; // AED -> USD
if (currency === 'INR') convertedAmount = amount * 25.135112; // AED -> INR (0.272 * 92.4085)
if (opts?.compact) {
// Compact notation: 1.5M, 450K, etc. — prefix with symbol
const abs = Math.abs(convertedAmount);
const sign = convertedAmount < 0 ? '-' : '';
const sym = CURRENCY_OPTIONS.find((o) => o.code === currency)?.symbol ?? currency;
if (currency === 'INR' && abs >= 10_000_000) return `${sign}${sym} ${(abs / 10_000_000).toFixed(1)}Cr`;
if (abs >= 1_000_000) return `${sign}${sym} ${(abs / 1_000_000).toFixed(1)}M`;
if (abs >= 1_000) return `${sign}${sym} ${(abs / 1_000).toFixed(0)}K`;
return `${sign}${sym} ${abs.toFixed(0)}`;
}
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(convertedAmount);
},
formatText: (text) => {
const { currency } = get();
const sym = CURRENCY_OPTIONS.find((o) => o.code === currency)?.symbol ?? currency;
return text.replace(/(AED|INR|\$)\s*([\d\.\-]+)\s*([MKCr]*)\+?/g, (match, prefix, numRange, suffix) => {
const parts = numRange.split('-');
const convertedParts = parts.map((p: string) => {
const parsedNum = parseFloat(p);
if (isNaN(parsedNum)) return p;
// Step 1: Normalize everything to AED Base
let baseAed = parsedNum;
if (prefix === 'INR') baseAed = parsedNum / 25.135112;
if (prefix === '$') baseAed = parsedNum / 0.272;
// Step 2: Handle suffixes (Cr -> M mapping)
let multiplier = 1;
if (suffix === 'Cr') {
if (currency !== 'INR') {
multiplier = 10; // 1 Cr = 10M
}
} else if (suffix === 'M') {
if (currency === 'INR') {
multiplier = 0.1; // 10M = 1 Cr
}
}
// Step 3: Convert from AED to Target Currency
let targetNum = baseAed;
if (currency === 'USD') targetNum = baseAed * 0.272;
if (currency === 'INR') targetNum = baseAed * 25.135112;
targetNum = targetNum * multiplier;
return targetNum >= 10 ? targetNum.toFixed(0) : parseFloat(targetNum.toFixed(1)).toString();
});
const hasPlus = match.includes('+');
// Determine final output suffix
let outSuffix = suffix;
if (suffix === 'Cr' && currency !== 'INR') outSuffix = 'M';
if (suffix === 'M' && currency === 'INR') outSuffix = 'Cr';
return `${sym} ${convertedParts.join('-')}${outSuffix}${hasPlus ? '+' : ''}`;
});
},
}),
{
name: 'pv-currency', // localStorage key
partialize: (state) => ({ currency: state.currency }),
},
),
);
// ── Convenience hook ──────────────────────────────────────────────────────────
/** Use anywhere — returns stable primitives + function refs. No infinite loop. */
export function useCurrency() {
// Select each piece individually so zustand compares with Object.is on primitives
const currency = useCurrencyStore((s) => s.currency);
const setCurrency = useCurrencyStore((s) => s.setCurrency);
const formatAmount = useCurrencyStore((s) => s.formatAmount);
const formatText = useCurrencyStore((s) => s.formatText);
// Derive display values from the stable `currency` string (no selector object)
const option = CURRENCY_OPTIONS.find((o) => o.code === currency) ?? CURRENCY_OPTIONS[0];
const rate = currency === 'USD' ? 0.272 : currency === 'INR' ? 25.135112 : 1;
return {
currency,
symbol: option.symbol,
flag: option.flag,
label: option.label,
option,
rate,
formatAmount,
formatText,
setCurrency,
};
}

View File

@@ -14,6 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv from dotenv import load_dotenv
from api.routes_catalyst import router as catalyst_router from api.routes_catalyst import router as catalyst_router
from oracle.router_v1 import router as oracle_router
load_dotenv() load_dotenv()
@@ -40,6 +41,7 @@ app.add_middleware(
# ── Routers ─────────────────────────────────────────────────────────────────── # ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"]) app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
app.include_router(oracle_router, prefix="/api/oracle/v1", tags=["Oracle"])
# ── WebSocket — Live Optimization Feed ──────────────────────────────────────── # ── WebSocket — Live Optimization Feed ────────────────────────────────────────

View File

@@ -0,0 +1 @@
# Oracle services package

View File

@@ -0,0 +1,596 @@
"""
oracle/canvas_service.py
Canvas persistence for Oracle pages, revisions, and current component projections.
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any
try:
import asyncpg # type: ignore
except Exception: # pragma: no cover
asyncpg = None # type: ignore
logger = logging.getLogger(__name__)
_DB_URL = os.getenv("DATABASE_URL", "")
_DEMO_PAGES: dict[str, dict[str, Any]] = {}
_DEMO_REVISIONS: dict[str, list[dict[str, Any]]] = {}
_DEMO_COMPONENTS: dict[str, list[dict[str, Any]]] = {}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _allow_in_memory() -> bool:
return (
os.getenv("ORACLE_ALLOW_IN_MEMORY_FALLBACK", "").lower() in {"1", "true", "yes"}
or "PYTEST_CURRENT_TEST" in os.environ
)
def _db_ready() -> bool:
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
def _is_demo() -> bool:
return not _db_ready() and _allow_in_memory()
def _ensure_ready() -> None:
if _db_ready() or _is_demo():
return
if asyncpg is None:
raise RuntimeError("Oracle backend requires asyncpg to connect to PostgreSQL.")
raise RuntimeError("Oracle backend requires DATABASE_URL for production persistence.")
def _stringify(value: Any) -> str:
return str(value) if value is not None else ""
def _normalize_component(component: dict[str, Any]) -> dict[str, Any]:
normalized = deepcopy(component)
normalized["componentId"] = _stringify(normalized.get("componentId"))
descriptor = normalized.get("dataSourceDescriptor") or {}
if descriptor.get("descriptorId") is not None:
descriptor["descriptorId"] = _stringify(descriptor["descriptorId"])
normalized["dataSourceDescriptor"] = descriptor
return normalized
def _deserialize_component_row(row: Any) -> dict[str, Any]:
return _normalize_component(
{
"componentId": _stringify(row["component_id"]),
"type": row["type"],
"title": row["title"],
"description": row["description"],
"version": row["version"],
"lifecycleState": row["lifecycle_state"],
"dataSourceDescriptor": row["data_source_descriptor"],
"visualizationParameters": row["visualization_parameters"],
"dataBindings": row["data_bindings"],
"provenance": row["provenance"],
"renderingHints": row["rendering_hints"],
"layout": row["layout"],
"accessControls": row["access_controls"],
"styleSignature": row["style_signature"],
"validationState": row["validation_state"],
"auditLog": list(row["audit_log"] or []),
}
)
def _deserialize_page_row(row: Any, components: list[dict[str, Any]]) -> dict[str, Any]:
page_id = _stringify(row["page_id"])
branch_id = _stringify(row["branch_id"])
head_revision = int(row["head_revision"])
return {
"pageId": page_id,
"tenantId": row["tenant_id"],
"ownerId": row["owner_id"],
"branchId": branch_id,
"branchName": row["branch_name"],
"pageType": row["page_type"],
"title": row["title"],
"isShared": bool(row["is_shared"]),
"headRevision": head_revision,
"baseRevision": int(row["base_revision"]),
"sharingPolicy": row["sharing_policy"] or {
"shareMode": "direct_fork_only",
"allowReshare": False,
"defaultForkVisibility": "private",
},
"forks": [],
"lineage": [],
"audit": {"lastAuditEventId": "", "eventCount": 0},
"presence": {"activeViewers": 0, "activeEditors": 0, "lastPresenceAt": row["updated_at"].isoformat()},
"mainBranchPointer": {"pageId": page_id, "branchId": branch_id, "revision": head_revision},
"components": components,
"createdAt": row["created_at"].isoformat(),
"updatedAt": row["updated_at"].isoformat(),
}
class CanvasService:
async def create_page(
self,
*,
tenant_id: str,
owner_id: str,
title: str = "Untitled Canvas",
page_type: str = "main",
branch_name: str = "main",
sharing_policy: dict[str, Any] | None = None,
) -> dict[str, Any]:
_ensure_ready()
if _is_demo():
page_id = str(uuid.uuid4())
branch_id = str(uuid.uuid4())
page = {
"pageId": page_id,
"tenantId": tenant_id,
"ownerId": owner_id,
"branchId": branch_id,
"branchName": branch_name,
"pageType": page_type,
"title": title,
"isShared": False,
"headRevision": 0,
"baseRevision": 0,
"sharingPolicy": sharing_policy or {"shareMode": "direct_fork_only", "allowReshare": False, "defaultForkVisibility": "private"},
"forks": [],
"lineage": [],
"audit": {"lastAuditEventId": "", "eventCount": 0},
"presence": {"activeViewers": 0, "activeEditors": 0, "lastPresenceAt": _now()},
"mainBranchPointer": {"pageId": page_id, "branchId": branch_id, "revision": 0},
"components": [],
"createdAt": _now(),
"updatedAt": _now(),
}
_DEMO_PAGES[page_id] = page
_DEMO_REVISIONS[page_id] = []
_DEMO_COMPONENTS[page_id] = []
return page
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
row = await conn.fetchrow(
"""
INSERT INTO oracle_canvas_pages (
tenant_id, owner_id, branch_id, branch_name, page_type, title, sharing_policy
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
RETURNING *
""",
tenant_id,
owner_id,
str(uuid.uuid4()),
branch_name,
page_type,
title,
json.dumps(sharing_policy or {"shareMode": "direct_fork_only", "allowReshare": False, "defaultForkVisibility": "private"}),
)
return _deserialize_page_row(row, [])
finally:
await conn.close()
async def ensure_default_page(
self,
*,
tenant_id: str,
owner_id: str,
title: str = "Oracle Main Canvas",
) -> dict[str, Any]:
page = await self.get_first_page_for_owner(tenant_id=tenant_id, owner_id=owner_id)
if page:
return page
return await self.create_page(tenant_id=tenant_id, owner_id=owner_id, title=title)
async def get_first_page_for_owner(self, *, tenant_id: str, owner_id: str) -> dict[str, Any] | None:
_ensure_ready()
if _is_demo():
for page in _DEMO_PAGES.values():
if page["tenantId"] == tenant_id and page["ownerId"] == owner_id:
return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page["pageId"], []))}
return None
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
row = await conn.fetchrow(
"""
SELECT *
FROM oracle_canvas_pages
WHERE tenant_id = $1 AND owner_id = $2
ORDER BY created_at ASC
LIMIT 1
""",
tenant_id,
owner_id,
)
if not row:
return None
components = await self._pg_fetch_components(conn, _stringify(row["page_id"]), tenant_id)
return _deserialize_page_row(row, components)
finally:
await conn.close()
async def get_page(self, page_id: str, tenant_id: str) -> dict[str, Any] | None:
_ensure_ready()
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if page and page["tenantId"] == tenant_id:
return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page_id, []))}
return None
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
row = await conn.fetchrow(
"""
SELECT *
FROM oracle_canvas_pages
WHERE page_id = $1::uuid AND tenant_id = $2
""",
page_id,
tenant_id,
)
if not row:
return None
components = await self._pg_fetch_components(conn, page_id, tenant_id)
return _deserialize_page_row(row, components)
finally:
await conn.close()
async def commit_revision(
self,
*,
page_id: str,
tenant_id: str,
actor_id: str,
commit_kind: str,
commit_summary: str,
components: list[dict[str, Any]],
execution_id: str | None = None,
merge_request_id: str | None = None,
idempotency_key: str | None = None,
) -> dict[str, Any]:
_ensure_ready()
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if not page or page["tenantId"] != tenant_id:
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
if idempotency_key:
existing = next((r for r in _DEMO_REVISIONS.get(page_id, []) if r.get("idempotencyKey") == idempotency_key), None)
if existing:
return existing
new_revision_num = page["headRevision"] + 1
revision = {
"revisionId": str(uuid.uuid4()),
"pageId": page_id,
"tenantId": tenant_id,
"revisionNumber": new_revision_num,
"commitKind": commit_kind,
"commitSummary": commit_summary,
"actorId": actor_id,
"executionId": execution_id,
"mergeRequestId": merge_request_id,
"componentsSnapshot": json.dumps(components),
"idempotencyKey": idempotency_key,
"createdAt": _now(),
}
_DEMO_REVISIONS.setdefault(page_id, []).append(revision)
_DEMO_COMPONENTS[page_id] = deepcopy([_normalize_component(component) for component in components])
page["headRevision"] = new_revision_num
page["mainBranchPointer"]["revision"] = new_revision_num
page["updatedAt"] = _now()
return revision
assert asyncpg is not None
normalized_components = [_normalize_component(component) for component in components]
conn = await asyncpg.connect(_DB_URL)
try:
async with conn.transaction():
if idempotency_key:
existing = await conn.fetchrow(
"""
SELECT *
FROM oracle_canvas_page_revisions
WHERE idempotency_key = $1
""",
idempotency_key,
)
if existing:
return {
"revisionId": _stringify(existing["revision_id"]),
"pageId": _stringify(existing["page_id"]),
"tenantId": existing["tenant_id"],
"revisionNumber": int(existing["revision_number"]),
"commitKind": existing["commit_kind"],
"commitSummary": existing["commit_summary"],
"actorId": existing["actor_id"],
"executionId": _stringify(existing["execution_id"]) if existing["execution_id"] else None,
"mergeRequestId": _stringify(existing["merge_request_id"]) if existing["merge_request_id"] else None,
"componentsSnapshot": json.dumps(existing["components_snapshot"]),
"idempotencyKey": existing["idempotency_key"],
"createdAt": existing["created_at"].isoformat(),
}
page = await conn.fetchrow(
"""
SELECT *
FROM oracle_canvas_pages
WHERE page_id = $1::uuid AND tenant_id = $2
FOR UPDATE
""",
page_id,
tenant_id,
)
if not page:
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
new_revision_number = int(page["head_revision"]) + 1
revision = await conn.fetchrow(
"""
INSERT INTO oracle_canvas_page_revisions (
page_id, tenant_id, revision_number, commit_kind, commit_summary,
actor_id, execution_id, merge_request_id, components_snapshot, idempotency_key
)
VALUES (
$1::uuid, $2, $3, $4, $5,
$6, NULLIF($7, '')::uuid, NULLIF($8, '')::uuid, $9::jsonb, $10
)
RETURNING *
""",
page_id,
tenant_id,
new_revision_number,
commit_kind,
commit_summary,
actor_id,
execution_id or "",
merge_request_id or "",
json.dumps(normalized_components),
idempotency_key,
)
await conn.execute(
"""
UPDATE oracle_canvas_pages
SET head_revision = $3, updated_at = NOW()
WHERE page_id = $1::uuid AND tenant_id = $2
""",
page_id,
tenant_id,
new_revision_number,
)
await self._pg_replace_components(conn, page_id=page_id, tenant_id=tenant_id, components=normalized_components)
return {
"revisionId": _stringify(revision["revision_id"]),
"pageId": _stringify(revision["page_id"]),
"tenantId": revision["tenant_id"],
"revisionNumber": int(revision["revision_number"]),
"commitKind": revision["commit_kind"],
"commitSummary": revision["commit_summary"],
"actorId": revision["actor_id"],
"executionId": _stringify(revision["execution_id"]) if revision["execution_id"] else None,
"mergeRequestId": _stringify(revision["merge_request_id"]) if revision["merge_request_id"] else None,
"componentsSnapshot": json.dumps(revision["components_snapshot"]),
"idempotencyKey": revision["idempotency_key"],
"createdAt": revision["created_at"].isoformat(),
}
finally:
await conn.close()
async def rollback(
self,
*,
page_id: str,
tenant_id: str,
actor_id: str,
target_revision: int,
idempotency_key: str,
) -> dict[str, Any]:
_ensure_ready()
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if not page:
raise ValueError(f"Page {page_id} not found")
revisions = _DEMO_REVISIONS.get(page_id, [])
target_rev = next((r for r in revisions if r["revisionNumber"] == target_revision), None)
if not target_rev:
raise ValueError(f"Revision {target_revision} not found for page {page_id}")
snapshot = json.loads(target_rev["componentsSnapshot"])
return await self.commit_revision(
page_id=page_id,
tenant_id=tenant_id,
actor_id=actor_id,
commit_kind="rollback",
commit_summary=f"Rollback to revision {target_revision}",
components=snapshot,
idempotency_key=idempotency_key,
)
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
revision = await conn.fetchrow(
"""
SELECT components_snapshot
FROM oracle_canvas_page_revisions
WHERE page_id = $1::uuid AND tenant_id = $2 AND revision_number = $3
""",
page_id,
tenant_id,
target_revision,
)
if not revision:
raise ValueError(f"Revision {target_revision} not found for page {page_id}")
return await self.commit_revision(
page_id=page_id,
tenant_id=tenant_id,
actor_id=actor_id,
commit_kind="rollback",
commit_summary=f"Rollback to revision {target_revision}",
components=list(revision["components_snapshot"]),
idempotency_key=idempotency_key,
)
finally:
await conn.close()
async def list_revisions(self, page_id: str, tenant_id: str) -> list[dict[str, Any]]:
_ensure_ready()
if _is_demo():
page = _DEMO_PAGES.get(page_id)
if not page or page["tenantId"] != tenant_id:
return []
return sorted(_DEMO_REVISIONS.get(page_id, []), key=lambda r: r["revisionNumber"], reverse=True)
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
rows = await conn.fetch(
"""
SELECT revision_id, page_id, tenant_id, revision_number, commit_kind, commit_summary,
actor_id, execution_id, merge_request_id, created_at
FROM oracle_canvas_page_revisions
WHERE page_id = $1::uuid AND tenant_id = $2
ORDER BY revision_number DESC
""",
page_id,
tenant_id,
)
return [
{
"revisionId": _stringify(row["revision_id"]),
"pageId": _stringify(row["page_id"]),
"tenantId": row["tenant_id"],
"revisionNumber": int(row["revision_number"]),
"commitKind": row["commit_kind"],
"commitSummary": row["commit_summary"],
"actorId": row["actor_id"],
"executionId": _stringify(row["execution_id"]) if row["execution_id"] else None,
"mergeRequestId": _stringify(row["merge_request_id"]) if row["merge_request_id"] else None,
"createdAt": row["created_at"].isoformat(),
}
for row in rows
]
finally:
await conn.close()
async def upsert_component(
self,
*,
page_id: str,
tenant_id: str,
component: dict[str, Any],
) -> dict[str, Any]:
_ensure_ready()
if _is_demo():
comps = _DEMO_COMPONENTS.setdefault(page_id, [])
normalized = _normalize_component(component)
existing_idx = next((i for i, c in enumerate(comps) if c.get("componentId") == normalized.get("componentId")), None)
if existing_idx is not None:
comps[existing_idx] = normalized
else:
comps.append(normalized)
return normalized
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
await self._pg_upsert_component(conn, page_id=page_id, tenant_id=tenant_id, component=_normalize_component(component))
return _normalize_component(component)
finally:
await conn.close()
async def _pg_fetch_components(self, conn: Any, page_id: str, tenant_id: str) -> list[dict[str, Any]]:
rows = await conn.fetch(
"""
SELECT *
FROM oracle_canvas_components
WHERE page_id = $1::uuid AND tenant_id = $2
ORDER BY COALESCE((layout->>'orderIndex')::int, 999999), created_at ASC
""",
page_id,
tenant_id,
)
return [_deserialize_component_row(row) for row in rows]
async def _pg_replace_components(self, conn: Any, *, page_id: str, tenant_id: str, components: list[dict[str, Any]]) -> None:
await conn.execute(
"""
DELETE FROM oracle_canvas_components
WHERE page_id = $1::uuid AND tenant_id = $2
""",
page_id,
tenant_id,
)
for component in components:
await self._pg_upsert_component(conn, page_id=page_id, tenant_id=tenant_id, component=component)
async def _pg_upsert_component(self, conn: Any, *, page_id: str, tenant_id: str, component: dict[str, Any]) -> None:
await conn.execute(
"""
INSERT INTO oracle_canvas_components (
component_id, page_id, tenant_id, type, title, description, version, lifecycle_state,
data_source_descriptor, visualization_parameters, data_bindings, provenance,
rendering_hints, layout, access_controls, style_signature, validation_state, audit_log
)
VALUES (
$1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8,
$9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb,
$13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, $17::jsonb, $18::text[]
)
ON CONFLICT (component_id)
DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
version = EXCLUDED.version,
lifecycle_state = EXCLUDED.lifecycle_state,
data_source_descriptor = EXCLUDED.data_source_descriptor,
visualization_parameters = EXCLUDED.visualization_parameters,
data_bindings = EXCLUDED.data_bindings,
provenance = EXCLUDED.provenance,
rendering_hints = EXCLUDED.rendering_hints,
layout = EXCLUDED.layout,
access_controls = EXCLUDED.access_controls,
style_signature = EXCLUDED.style_signature,
validation_state = EXCLUDED.validation_state,
audit_log = EXCLUDED.audit_log,
updated_at = NOW()
""",
component["componentId"],
page_id,
tenant_id,
component["type"],
component["title"],
component.get("description"),
int(component.get("version", 1)),
component.get("lifecycleState", "active"),
json.dumps(component.get("dataSourceDescriptor", {})),
json.dumps(component.get("visualizationParameters", {})),
json.dumps(component.get("dataBindings", {})),
json.dumps(component.get("provenance", {})),
json.dumps(component.get("renderingHints", {})),
json.dumps(component.get("layout", {})),
json.dumps(component.get("accessControls", {})),
json.dumps(component.get("styleSignature", {})),
json.dumps(component.get("validationState", {})),
list(component.get("auditLog", [])),
)
canvas_service = CanvasService()

View File

@@ -0,0 +1,369 @@
"""
oracle/collaboration_service.py
Implements fork creation, MergeRequest lifecycle, three-way diff engine,
conflict classification (all 7 classes from spec §17.2), and merge commits.
"""
from __future__ import annotations
import copy
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
logger = logging.getLogger(__name__)
# ── In-memory store (demo mode) ───────────────────────────────────────────────
_DEMO_FORKS: dict[str, dict[str, Any]] = {}
_DEMO_MRS: dict[str, dict[str, Any]] = {}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
# ── Three-way diff engine ─────────────────────────────────────────────────────
def _three_way_diff(
base_components: list[dict[str, Any]],
source_components: list[dict[str, Any]],
target_components: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""
Compute a three-way diff between base, source, and target component lists.
Returns (merged_components, conflicts) per spec §17.2.
Conflict classes:
1. safe_append — added only in source, not in target
2. safe_reorder — order differs but content same
3. component_content_conflict — both changed same component fields
4. query_descriptor_conflict — data source descriptor changed in both
5. layout_slot_conflict — same orderIndex claimed by different components
6. access_policy_conflict — accessControls differ in both
7. delete_edit_conflict — deleted in one, edited in other
"""
base_map = {c["componentId"]: c for c in base_components}
source_map = {c["componentId"]: c for c in source_components}
target_map = {c["componentId"]: c for c in target_components}
all_ids = set(base_map) | set(source_map) | set(target_map)
merged: list[dict[str, Any]] = []
conflicts: list[dict[str, Any]] = []
def make_conflict(
conflict_class: str,
component_id: str,
field: str | None = None,
source_val: Any = None,
target_val: Any = None,
description: str = "",
) -> dict[str, Any]:
return {
"conflictId": str(uuid.uuid4()),
"conflictClass": conflict_class,
"componentId": component_id,
"field": field,
"sourceValue": source_val,
"targetValue": target_val,
"description": description,
}
for cid in all_ids:
in_base = cid in base_map
in_source = cid in source_map
in_target = cid in target_map
base_c = base_map.get(cid)
src_c = source_map.get(cid)
tgt_c = target_map.get(cid)
# Case 1: Exists nowhere → skip
if not in_source and not in_target:
continue
# Case 2: Deleted in both → skip
if not in_source and not in_target:
continue
# Case 3: Added only in source (safe_append)
if not in_base and in_source and not in_target:
conflicts.append(make_conflict(
"safe_append", cid,
description=f"Component '{cid}' added in source branch; will be appended."
))
merged.append(copy.deepcopy(src_c))
continue
# Case 4: Added only in target → keep target as-is
if not in_base and not in_source and in_target:
merged.append(copy.deepcopy(tgt_c))
continue
# Case 5: Added in both (both new, same id) → conflict
if not in_base and in_source and in_target:
if src_c == tgt_c:
merged.append(copy.deepcopy(tgt_c))
else:
conflicts.append(make_conflict(
"component_content_conflict", cid,
description="Component added in both branches with different content."
))
merged.append(copy.deepcopy(tgt_c)) # Default: keep target
continue
# Case 6: Deleted in source only
if in_base and not in_source and in_target:
src_equal_base = base_c == tgt_c
if src_equal_base:
# Target unchanged → deletion is safe
continue
else:
conflicts.append(make_conflict(
"delete_edit_conflict", cid,
description="Component deleted in source but edited in target."
))
merged.append(copy.deepcopy(tgt_c))
continue
# Case 7: Deleted in target only
if in_base and in_source and not in_target:
src_equal_base = base_c == src_c
if src_equal_base:
continue
else:
conflicts.append(make_conflict(
"delete_edit_conflict", cid,
description="Component deleted in target but edited in source."
))
merged.append(copy.deepcopy(src_c))
continue
# Case 8: Both present — check for edits
if src_c == tgt_c:
merged.append(copy.deepcopy(tgt_c))
continue
# Check individual field conflicts
has_conflict = False
# Data source descriptor conflict
if src_c.get("dataSourceDescriptor") != tgt_c.get("dataSourceDescriptor") \
and (base_c or {}).get("dataSourceDescriptor") not in (
src_c.get("dataSourceDescriptor"),
tgt_c.get("dataSourceDescriptor"),
):
conflicts.append(make_conflict(
"query_descriptor_conflict", cid,
field="dataSourceDescriptor",
description="Data source descriptor modified in both branches.",
))
has_conflict = True
# Access controls conflict
if src_c.get("accessControls") != tgt_c.get("accessControls") \
and (base_c or {}).get("accessControls") not in (
src_c.get("accessControls"),
tgt_c.get("accessControls"),
):
conflicts.append(make_conflict(
"access_policy_conflict", cid,
field="accessControls",
source_val=src_c.get("accessControls"),
target_val=tgt_c.get("accessControls"),
description="Access control policies diverge in both branches.",
))
has_conflict = True
# Layout orderIndex conflict
src_order = (src_c.get("layout") or {}).get("orderIndex")
tgt_order = (tgt_c.get("layout") or {}).get("orderIndex")
if src_order != tgt_order:
conflicts.append(make_conflict(
"layout_slot_conflict", cid,
field="layout.orderIndex",
source_val=src_order,
target_val=tgt_order,
description="Layout order index conflicts.",
))
# Record as safe reorder if content otherwise matches
if not has_conflict:
conflicts.append(make_conflict("safe_reorder", cid, description="Component reordered."))
# General content conflict
if not has_conflict and src_c != tgt_c:
conflicts.append(make_conflict(
"component_content_conflict", cid,
description="Component content diverges in both branches.",
))
# Merge: for all conflicts, default target wins
merged.append(copy.deepcopy(tgt_c))
# Normalize orderIndex
merged.sort(key=lambda c: (c.get("layout") or {}).get("orderIndex", 9999))
for i, comp in enumerate(merged):
comp.setdefault("layout", {})["orderIndex"] = (i + 1) * 100
return merged, conflicts
# ── CollaborationService ──────────────────────────────────────────────────────
class CollaborationService:
"""
Manages fork creation and merge request lifecycle.
Uses canvas_service for snapshot reads and revision commits.
"""
async def create_fork(
self,
*,
source_page: dict[str, Any],
recipient_user_id: str,
created_by: str,
visibility: str = "private",
message: str = "",
) -> dict[str, Any]:
"""
Creates a fork from the source_page snapshot at its current headRevision.
Returns ForkRecord.
"""
fork_id = str(uuid.uuid4())
fork_page_id = str(uuid.uuid4())
fork_branch_id = str(uuid.uuid4())
fork = {
"forkId": fork_id,
"sourcePageId": source_page["pageId"],
"sourceBranchId": source_page["branchId"],
"sourceRevision": source_page["headRevision"],
"forkPageId": fork_page_id,
"forkBranchId": fork_branch_id,
"recipientUserId": recipient_user_id,
"createdBy": created_by,
"visibility": visibility,
"message": message,
"status": "active",
"createdAt": _now(),
}
_DEMO_FORKS[fork_id] = fork
logger.info(
"COLLAB fork_created fork_id=%s source_page=%s revision=%d recipient=%s",
fork_id, source_page["pageId"], source_page["headRevision"], recipient_user_id,
)
return fork
async def open_merge_request(
self,
*,
tenant_id: str,
source_page_id: str,
source_branch_id: str,
source_head_revision: int,
target_page_id: str,
target_branch_id: str,
target_base_revision: int,
title: str,
description: str = "",
created_by: str,
source_components: list[dict[str, Any]],
target_components: list[dict[str, Any]],
base_components: list[dict[str, Any]],
) -> dict[str, Any]:
"""
Creates a MergeRequest with pre-computed conflicts via three-way diff.
"""
merged, conflicts = _three_way_diff(base_components, source_components, target_components)
added = sum(1 for c in conflicts if c["conflictClass"] == "safe_append")
edited = sum(1 for c in conflicts if c["conflictClass"] == "component_content_conflict")
reordered = sum(1 for c in conflicts if c["conflictClass"] in ("safe_reorder", "layout_slot_conflict"))
deleted = sum(1 for c in conflicts if c["conflictClass"] == "delete_edit_conflict")
mr = {
"mergeRequestId": str(uuid.uuid4()),
"tenantId": tenant_id,
"sourcePageId": source_page_id,
"sourceBranchId": source_branch_id,
"sourceHeadRevision": source_head_revision,
"targetPageId": target_page_id,
"targetBranchId": target_branch_id,
"targetBaseRevision": target_base_revision,
"title": title,
"description": description,
"status": "open",
"conflicts": conflicts,
"diffSummary": {
"componentsAdded": added,
"componentsEdited": edited,
"componentsReordered": reordered,
"componentsDeleted": deleted,
},
"_mergedComponents": merged, # internal — used during merge
"createdBy": created_by,
"createdAt": _now(),
"updatedAt": _now(),
}
_DEMO_MRS[mr["mergeRequestId"]] = mr
logger.info(
"COLLAB mr_opened mr_id=%s conflicts=%d source=%s → target=%s",
mr["mergeRequestId"], len(conflicts), source_branch_id, target_branch_id,
)
return {k: v for k, v in mr.items() if k != "_mergedComponents"}
async def review_merge_request(
self,
*,
mr_id: str,
decision: str,
reviewer_id: str,
comment: str = "",
resolutions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""
Applies a reviewer decision: approve → merges; reject/changes_requested → status update.
"""
mr = _DEMO_MRS.get(mr_id)
if not mr:
raise ValueError(f"MergeRequest {mr_id} not found")
mr["reviewedBy"] = reviewer_id
mr["reviewerComment"] = comment
mr["updatedAt"] = _now()
if decision == "approve":
mr["status"] = "merged"
logger.info("COLLAB mr_merged mr_id=%s by=%s", mr_id, reviewer_id)
elif decision == "reject":
mr["status"] = "closed"
elif decision == "changes_requested":
mr["status"] = "changes_requested"
return {k: v for k, v in mr.items() if k != "_mergedComponents"}
async def get_merge_request(self, mr_id: str) -> dict[str, Any] | None:
mr = _DEMO_MRS.get(mr_id)
if mr:
return {k: v for k, v in mr.items() if k != "_mergedComponents"}
return None
async def list_merge_requests(self, target_page_id: str, status: str | None = None) -> list[dict[str, Any]]:
results = [
{k: v for k, v in mr.items() if k != "_mergedComponents"}
for mr in _DEMO_MRS.values()
if mr["targetPageId"] == target_page_id
]
if status:
results = [mr for mr in results if mr["status"] == status]
return results
# ── Public three-way-diff (for testing) ───────────────────────────────────────
def three_way_diff(base, source, target): # type: ignore[return]
return _three_way_diff(base, source, target)
# ── Singleton ─────────────────────────────────────────────────────────────────
collaboration_service = CollaborationService()

View File

@@ -0,0 +1,242 @@
"""
oracle/data_access_gateway.py
Read-only, policy-aware PostgreSQL query executor for Oracle datasets.
Nemoclaw is treated strictly as a planner. The gateway executes only
whitelisted dataset queries and always injects the actor's tenant scope.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Any
try:
import asyncpg # type: ignore
except Exception: # pragma: no cover
asyncpg = None # type: ignore
from .policy_service import PolicyContext, PolicyService
logger = logging.getLogger(__name__)
_DB_URL = os.getenv("DATABASE_URL", "")
_ALLOW_IN_MEMORY = os.getenv("ORACLE_ALLOW_IN_MEMORY_FALLBACK", "").lower() in {"1", "true", "yes"}
@dataclass
class QueryExecutionResult:
rows: list[dict[str, Any]]
warnings: list[str]
def _db_ready() -> bool:
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
class DataAccessGateway:
def __init__(self) -> None:
self.policy_service = PolicyService()
async def execute_component_plan(
self,
component_plan: dict[str, Any],
ctx: PolicyContext,
prompt: str,
) -> QueryExecutionResult:
dataset = str(component_plan.get("dataset", "")).strip()
if not dataset:
return QueryExecutionResult(rows=[], warnings=["Dataset missing from retrieval plan."])
validation = self.policy_service.validate_retrieval_plan(component_plan, ctx)
self.policy_service.audit_policy_check(ctx, dataset, validation)
if not validation.passed:
return QueryExecutionResult(rows=[], warnings=validation.errors)
if not _db_ready():
if _ALLOW_IN_MEMORY or "PYTEST_CURRENT_TEST" in os.environ:
return QueryExecutionResult(rows=[], warnings=[])
raise RuntimeError("Oracle requires DATABASE_URL and asyncpg for real-time data access.")
try:
rows = await self._query_dataset(
dataset=dataset,
row_limit=validation.effective_row_limit,
ctx=ctx,
prompt=prompt,
)
except Exception as exc:
logger.warning("DATA_GATEWAY query_failed dataset=%s error=%s", dataset, exc)
return QueryExecutionResult(rows=[], warnings=[f"{dataset}: {exc}"])
redacted = self.policy_service.redact(rows, validation.redaction_policy)
return QueryExecutionResult(rows=redacted, warnings=validation.warnings)
async def _query_dataset(
self,
*,
dataset: str,
row_limit: int,
ctx: PolicyContext,
prompt: str,
) -> list[dict[str, Any]]:
sql, params = self._build_whitelisted_query(dataset, row_limit, ctx, prompt)
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
records = await conn.fetch(sql, *params)
finally:
await conn.close()
return [dict(record) for record in records]
def _build_whitelisted_query(
self,
dataset: str,
row_limit: int,
ctx: PolicyContext,
prompt: str,
) -> tuple[str, list[Any]]:
lower_prompt = prompt.lower()
if dataset == "deals":
sql = """
SELECT
stage,
COUNT(*)::int AS count,
COALESCE(SUM(value), 0)::float AS value,
COALESCE(
json_agg(
json_build_object(
'id', lead_id,
'name', lead_name,
'company', company,
'value', value_label,
'avatar', avatar_url
)
ORDER BY value DESC NULLS LAST
) FILTER (WHERE lead_id IS NOT NULL),
'[]'::json
) AS leads
FROM deals
WHERE tenant_id = $1
GROUP BY stage
ORDER BY COALESCE(SUM(value), 0) DESC, stage ASC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
if dataset == "lead_daily_snapshot":
sql = """
SELECT
source,
COALESCE(SUM(qd_weighted_score), 0)::float AS qd_weighted_volume
FROM lead_daily_snapshot
WHERE tenant_id = $1
GROUP BY source
ORDER BY qd_weighted_volume DESC, source ASC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
if dataset == "lead_geo_interest_rollup":
sql = """
SELECT
district,
lat,
lng,
COALESCE(lead_count, 0)::int AS lead_count,
COALESCE(avg_qd_score, 0)::float AS avg_qd_score,
COALESCE(x, 0)::float AS x,
COALESCE(y, 0)::float AS y
FROM lead_geo_interest_rollup
WHERE tenant_id = $1
ORDER BY lead_count DESC, district ASC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
if dataset == "broker_performance":
sql = """
SELECT
ROW_NUMBER() OVER (ORDER BY COALESCE(revenue_generated, 0) DESC, broker_name ASC)::int AS rank,
broker_name AS name,
deals_closed::int AS deals_closed,
COALESCE(revenue_generated, 0)::float AS revenue_generated,
avatar_url AS avatar
FROM broker_performance
WHERE tenant_id = $1
ORDER BY revenue_generated DESC, broker_name ASC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
if dataset == "inventory_absorption":
sql = """
SELECT
period_label AS period,
COALESCE(absorption_rate, 0)::float AS absorption_rate,
COALESCE(target_rate, 0)::float AS target_rate
FROM inventory_absorption
WHERE tenant_id = $1
ORDER BY period_start ASC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
if dataset == "oracle_aggregated_metric":
metric_name = "total_leads"
if "pipeline" in lower_prompt:
metric_name = "total_pipeline_value"
elif "quota" in lower_prompt or "attainment" in lower_prompt:
metric_name = "quota_attainment"
sql = """
SELECT
metric_value,
metric_label,
trend_value,
comparison_label
FROM oracle_aggregated_metric
WHERE tenant_id = $1
AND metric_name = $2
ORDER BY observed_at DESC
LIMIT 1
"""
return sql, [ctx.tenant_id, metric_name]
if dataset == "lead_activity_log":
if "follow-up" in lower_prompt or "queue" in lower_prompt:
sql = """
SELECT
lead_name AS name,
assigned_broker,
COALESCE(last_contact_hours_ago, 0)::int AS last_contact_hours_ago,
COALESCE(qd_score, 0)::float AS qd_score,
urgency,
avatar_url AS avatar
FROM lead_activity_log
WHERE tenant_id = $1
ORDER BY last_contact_hours_ago DESC, qd_score DESC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
sql = """
SELECT
activity_type AS type,
COALESCE(activity_title, activity_summary, activity_type) AS title,
activity_summary AS summary,
actor_name AS actor,
TO_CHAR(activity_at, 'YYYY-MM-DD HH24:MI') AS date
FROM lead_activity_log
WHERE tenant_id = $1
ORDER BY activity_at DESC
LIMIT $2
"""
return sql, [ctx.tenant_id, row_limit]
raise ValueError(f"Dataset '{dataset}' is not whitelisted for Oracle execution.")
data_access_gateway = DataAccessGateway()

View File

@@ -0,0 +1,225 @@
"""
oracle/policy_service.py
Enforces tenant isolation, role-based access, privacy-tier escalation,
field-level redaction, and row limit guardrails for all Oracle data access.
Section 11.3 of the Oracle Architecture Document.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# ── Constants ─────────────────────────────────────────────────────────────────
MAX_ROW_LIMITS: dict[str, int] = {
"junior_broker": 100,
"senior_broker": 500,
"sales_director": 2000,
"marketing_operator": 1000,
"data_steward": 5000,
"compliance_reviewer": 5000,
"platform_admin": 10000,
}
# Which roles can see which privacy tiers
PRIVACY_TIER_ACCESS: dict[str, set[str]] = {
"standard": {"junior_broker", "senior_broker", "sales_director", "marketing_operator", "data_steward", "compliance_reviewer", "platform_admin"},
"restricted": {"senior_broker", "sales_director", "data_steward", "compliance_reviewer", "platform_admin"},
"sensitive": {"data_steward", "compliance_reviewer", "platform_admin"},
}
# Datasets with cross-tenant join restrictions
CROSS_TENANT_RESTRICTED: set[str] = {
"global_lead_market",
"competitor_pricing",
"cross_tenant_referrals",
}
@dataclass
class PolicyContext:
tenant_id: str
actor_id: str
actor_role: str
policy_profile_id: str = "policy_standard_v4"
@dataclass
class ValidationResult:
passed: bool
errors: list[str]
warnings: list[str]
redaction_policy: str = "none"
effective_row_limit: int = 100
@classmethod
def ok(cls, row_limit: int, redaction: str = "none") -> "ValidationResult":
return cls(passed=True, errors=[], warnings=[], redaction_policy=redaction, effective_row_limit=row_limit)
@classmethod
def denied(cls, reason: str) -> "ValidationResult":
return cls(passed=False, errors=[reason], warnings=[])
class PolicyService:
"""
Validates all Oracle data access against policy rules.
Configuration is loaded from env / feature flags in production;
falls back to safe defaults for demo mode.
"""
def validate_retrieval_plan(
self,
plan: dict[str, Any],
ctx: PolicyContext,
) -> ValidationResult:
"""
Validates a structured retrieval plan (as produced by PromptOrchestrator).
Checks: tenant isolation, role access, privacy tier, row limits.
Returns ValidationResult with passed=True if all checks pass.
"""
errors: list[str] = []
warnings: list[str] = []
dataset = plan.get("dataset", "")
privacy_tier = plan.get("privacyTier", "standard")
requested_row_limit = plan.get("rowLimit", 100)
joins = plan.get("joins", [])
# 1. Tenant isolation — reject cross-tenant predicates
if dataset in CROSS_TENANT_RESTRICTED:
errors.append(
f"POLICY_CROSS_TENANT_JOIN_DENIED: Dataset '{dataset}' requires "
f"cross-tenant access which is not permitted for role '{ctx.actor_role}'."
)
# 2. Cross-tenant join detection
for join in joins:
if join.get("tenantId") and join["tenantId"] != ctx.tenant_id:
errors.append(
f"POLICY_CROSS_TENANT_JOIN_DENIED: Join to tenant '{join['tenantId']}' "
f"is not permitted."
)
# 3. Privacy tier access
allowed_roles = PRIVACY_TIER_ACCESS.get(privacy_tier, set())
if ctx.actor_role not in allowed_roles:
errors.append(
f"POLICY_PRIVACY_TIER_ESCALATION: Role '{ctx.actor_role}' cannot access "
f"'{privacy_tier}' tier data in dataset '{dataset}'."
)
# 4. Row limit guardrail
max_limit = MAX_ROW_LIMITS.get(ctx.actor_role, 100)
effective_limit = min(requested_row_limit, max_limit)
if requested_row_limit > max_limit:
warnings.append(
f"ROW_LIMIT_CAPPED: Requested {requested_row_limit} rows; "
f"capped to {effective_limit} for role '{ctx.actor_role}'."
)
# 5. Determine redaction policy
redaction = "none"
if privacy_tier == "restricted" and ctx.actor_role == "senior_broker":
redaction = "aggregate_only"
elif privacy_tier == "sensitive":
redaction = "full_redact"
if errors:
return ValidationResult(
passed=False,
errors=errors,
warnings=warnings,
redaction_policy=redaction,
effective_row_limit=effective_limit,
)
return ValidationResult(
passed=True,
errors=[],
warnings=warnings,
redaction_policy=redaction,
effective_row_limit=effective_limit,
)
def enforce_tenant_predicate(
self,
query_parameters: dict[str, Any],
ctx: PolicyContext,
) -> dict[str, Any]:
"""
Ensures :tenant_id parameter is always bound to the actor's tenant.
Overrides any attacker-supplied tenant_id parameter.
"""
params = dict(query_parameters)
params["tenant_id"] = ctx.tenant_id
return params
def validate_component_access(
self,
component_access_controls: dict[str, Any],
ctx: PolicyContext,
) -> bool:
"""
Returns True if the actor's role is in the component's allowedRoles.
"""
allowed_roles: list[str] = component_access_controls.get("allowedRoles", [])
if not allowed_roles:
# Open access (shouldn't happen in production)
logger.warning(
"POLICY_WARN: Component has no allowedRoles — defaulting to deny for tenant=%s actor=%s",
ctx.tenant_id,
ctx.actor_id,
)
return False
return ctx.actor_role in allowed_roles
def redact(
self,
rows: list[dict[str, Any]],
redaction_policy: str,
sensitive_fields: list[str] | None = None,
) -> list[dict[str, Any]]:
"""
Applies field-level redaction to result rows.
"""
if redaction_policy == "none" or not rows:
return rows
if redaction_policy == "full_redact":
return [{"__redacted__": True, "count": len(rows)}]
if redaction_policy == "aggregate_only":
# Keep only aggregate fields; drop individual identifiers
safe_fields = {"count", "total", "average", "sum", "min", "max", "stage", "source", "district"}
return [{k: v for k, v in row.items() if k in safe_fields} for row in rows]
if redaction_policy == "team_scope":
# Keep rows where assigned_broker matches actor (simplified demo rule)
return rows # Full enforcement requires actor context per row
return rows
def audit_policy_check(
self,
ctx: PolicyContext,
dataset: str,
result: ValidationResult,
) -> None:
"""Emit an audit event for every policy check (passed or denied)."""
if not result.passed:
logger.warning(
"POLICY_DENIED tenant=%s actor=%s dataset=%s errors=%s",
ctx.tenant_id,
ctx.actor_id,
dataset,
result.errors,
)
else:
logger.debug(
"POLICY_PASS tenant=%s actor=%s dataset=%s redaction=%s limit=%d",
ctx.tenant_id,
ctx.actor_id,
dataset,
result.redaction_policy,
result.effective_row_limit,
)

View File

@@ -0,0 +1,576 @@
"""
oracle/prompt_orchestrator.py
Accepts a user prompt, assembles context, calls the Nemoclaw model runtime
(or uses a deterministic fallback), validates the generated plan via policy,
triggers the data access gateway, and produces a PromptExecution.
"""
from __future__ import annotations
import logging
import os
import uuid
import json
from datetime import datetime, timezone
from typing import Any
from .policy_service import PolicyContext, PolicyService
from .canvas_service import canvas_service
from .data_access_gateway import data_access_gateway
try:
import asyncpg # type: ignore
except Exception: # pragma: no cover
asyncpg = None # type: ignore
logger = logging.getLogger(__name__)
_NEMOCLAW_URL = os.getenv("NEMOCLAW_API_URL", "")
_NEMOCLAW_API_KEY = os.getenv("NEMOCLAW_API_KEY", "")
_DB_URL = os.getenv("DATABASE_URL", "")
policy_svc = PolicyService()
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
# ── Execution store ───────────────────────────────────────────────────────────
_DEMO_EXECUTIONS: dict[str, dict[str, Any]] = {}
def _db_ready() -> bool:
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
# ── Semantic intent detection (simplified) ────────────────────────────────────
_INTENT_KEYWORDS: dict[str, list[str]] = {
"pipeline_board": ["pipeline", "stage", "kanban", "deal", "funnel"],
"bar_chart": ["bar", "compare", "source", "channel", "distribution", "ranked", "lead", "whale"],
"geo_map": ["map", "geographic", "location", "district", "region", "area", "dubai"],
"table": ["table", "list", "broker", "performance", "leaderboard", "rank", "top"],
"line_chart": ["trend", "time", "monthly", "weekly", "absorption", "forecast"],
"kpi_tile": ["kpi", "total", "summary", "attainment", "quota", "how many"],
"activity_stream": ["timeline", "activity", "history", "follow-up", "queue", "contact"],
}
def _detect_component_types(prompt: str) -> list[str]:
lower = prompt.lower()
types: list[str] = []
for comp_type, keywords in _INTENT_KEYWORDS.items():
if any(k in lower for k in keywords):
types.append(comp_type)
return types or ["bar_chart"]
def _build_demo_retrieval_plan(
prompt: str,
tenant_id: str,
actor_role: str,
) -> dict[str, Any]:
"""
Deterministic plan builder for demo mode.
Produces a valid retrieval plan that passes policy validation.
"""
component_types = _detect_component_types(prompt)
row_limit = 50 if actor_role in ("senior_broker", "junior_broker") else 200
return {
"planId": str(uuid.uuid4()),
"components": [
{
"suggestedType": ct,
"dataset": _DATASET_MAP.get(ct, "aggregated_results"),
"privacyTier": "standard",
"rowLimit": row_limit,
"joins": [],
"queryTemplate": f"SELECT * FROM {_DATASET_MAP.get(ct, 'aggregated_results')} WHERE tenant_id = :tenant_id LIMIT :limit",
"queryParameters": {"tenant_id": tenant_id, "limit": row_limit},
}
for ct in component_types
],
"semanticModelVersion": "oracle_semantic_v2026_04_08_01",
"intentClass": "analytical",
}
_DATASET_MAP: dict[str, str] = {
"pipeline_board": "deals",
"bar_chart": "lead_daily_snapshot",
"geo_map": "lead_geo_interest_rollup",
"table": "broker_performance",
"line_chart": "inventory_absorption",
"kpi_tile": "oracle_aggregated_metric",
"activity_stream": "lead_activity_log",
}
class PromptOrchestrator:
"""
Orchestrates the full prompt-to-canvas pipeline:
1. Intent classification
2. Retrieval plan construction (Nemoclaw or fallback)
3. Policy validation
4. Component plan construction
5. Execution record persistence
"""
async def execute(
self,
*,
tenant_id: str,
page_id: str,
branch_id: str,
actor_id: str,
actor_role: str,
prompt: str,
conversation_context: list[dict[str, str]] | None = None,
client_request_id: str,
placement_mode: str = "append_after_last_visible_component",
) -> dict[str, Any]:
"""
Full orchestration flow. Returns a PromptExecution dict.
"""
execution_id = str(uuid.uuid4())
now = _now()
warnings: list[str] = []
ctx = PolicyContext(
tenant_id=tenant_id,
actor_id=actor_id,
actor_role=actor_role,
)
execution: dict[str, Any] = {
"executionId": execution_id,
"tenantId": tenant_id,
"pageId": page_id,
"branchId": branch_id,
"actorId": actor_id,
"prompt": prompt,
"intentClass": "analytical",
"status": "planning",
"modelRuntime": "nemoclaw_hosted" if _NEMOCLAW_URL else "deterministic_fallback",
"semanticModelVersion": "oracle_semantic_v2026_04_08_01",
"warnings": warnings,
"componentsCreated": [],
"clientRequestId": client_request_id,
"createdAt": now,
}
_DEMO_EXECUTIONS[execution_id] = execution
await self._persist_execution(execution)
# ── Step 1: Build retrieval plan ──────────────────────────────────────
if _NEMOCLAW_URL and _NEMOCLAW_API_KEY:
try:
retrieval_plan = await self._call_nemoclaw(prompt, conversation_context or [], ctx)
execution["status"] = "validated"
except Exception as exc:
logger.warning("ORCH Nemoclaw call failed, using fallback: %s", exc)
warnings.append(f"Model runtime unavailable ({exc}); using deterministic fallback.")
retrieval_plan = _build_demo_retrieval_plan(prompt, tenant_id, actor_role)
else:
retrieval_plan = _build_demo_retrieval_plan(prompt, tenant_id, actor_role)
execution["retrievalPlan"] = retrieval_plan
# ── Step 2: Policy validation ─────────────────────────────────────────
policy_errors = []
for component_plan in retrieval_plan.get("components", []):
result = policy_svc.validate_retrieval_plan(component_plan, ctx)
if not result.passed:
policy_errors.extend(result.errors)
if result.warnings:
warnings.extend(result.warnings)
if policy_errors:
execution["status"] = "failed"
execution["warnings"] = warnings + policy_errors
execution["completedAt"] = _now()
logger.warning(
"ORCH policy_denial execution_id=%s actor=%s errors=%s",
execution_id, actor_id, policy_errors,
)
return execution
execution["status"] = "executing"
await self._persist_execution(execution)
# ── Step 3: Build visualization plan (component descriptors) ──────────
viz_plan = await self._build_visualization_plan(
retrieval_plan=retrieval_plan,
prompt=prompt,
execution_id=execution_id,
actor_id=actor_id,
tenant_id=tenant_id,
branch_id=branch_id,
placement_mode=placement_mode,
ctx=ctx,
)
execution["visualizationPlan"] = viz_plan
# ── Step 4: Commit revision ───────────────────────────────────────────
component_ids = [c["componentId"] for c in viz_plan.get("components", [])]
execution["componentsCreated"] = component_ids
# Commit a revision bump with the new components
try:
page = await canvas_service.get_page(page_id, tenant_id)
if page:
existing_comps = page.get("components", [])
new_comps = existing_comps + viz_plan.get("components", [])
revision = await canvas_service.commit_revision(
page_id=page_id,
tenant_id=tenant_id,
actor_id=actor_id,
commit_kind="prompt",
commit_summary=f"Oracle: {prompt[:80]}",
components=new_comps,
execution_id=execution_id,
idempotency_key=client_request_id,
)
execution["headRevision"] = revision["revisionNumber"]
except Exception as exc:
logger.warning("ORCH revision_commit failed (non-fatal): %s", exc)
warnings.append("Revision commit deferred — will retry on next sync.")
execution["status"] = "completed"
execution["summary"] = self._generate_summary(prompt, viz_plan)
execution["completedAt"] = _now()
execution["warnings"] = warnings
await self._persist_execution(execution)
return execution
async def _build_visualization_plan(
self,
*,
retrieval_plan: dict[str, Any],
prompt: str,
execution_id: str,
actor_id: str,
tenant_id: str,
branch_id: str,
placement_mode: str,
ctx: PolicyContext,
) -> dict[str, Any]:
"""Converts a retrieval plan into a list of CanvasComponent descriptors."""
components = []
base_order = 900 # Append after existing components
component_plans = retrieval_plan.get("components", [])
for i, plan in enumerate(component_plans):
ctype = plan["suggestedType"]
dataset = plan["dataset"]
component_id = str(uuid.uuid4())
query_result = await data_access_gateway.execute_component_plan(plan, ctx, prompt)
component_warnings = query_result.warnings
mapped_type = self._map_type(ctype)
data_rows = query_result.rows
comp: dict[str, Any] = {
"componentId": component_id,
"type": mapped_type,
"title": self._generate_title(prompt, ctype),
"description": f"Generated from: \"{prompt[:80]}\"",
"dataSourceDescriptor": {
"descriptorId": str(uuid.uuid4()),
"sourceType": "postgres",
"connectorId": "velocity-core-postgres",
"dataset": dataset,
"authContextRef": f"authctx_{actor_id}_scope",
"queryTemplate": plan.get("queryTemplate", f"SELECT * FROM {dataset} WHERE tenant_id = :tenant_id"),
"queryParameters": plan.get("queryParameters", {"tenant_id": tenant_id}),
"rowLimit": plan.get("rowLimit", 50),
"privacyTier": plan.get("privacyTier", "standard"),
"cachePolicy": {"mode": "ttl", "ttlSeconds": 120},
},
"visualizationParameters": self._default_viz_params(ctype, data_rows),
"dataBindings": self._default_bindings(ctype),
"version": 1,
"lifecycleState": "active",
"provenance": {
"originType": "prompt_generated",
"promptExecutionId": execution_id,
"sourceBranchId": branch_id,
"createdBy": actor_id,
"createdAt": _now(),
},
"renderingHints": self._rendering_hints(ctype),
"layout": {
"orderIndex": base_order + (i + 1) * 100,
"sectionId": "sec_prompt_generated",
"widthMode": "full" if ctype in ("pipeline_board", "table", "geo_map") else "half",
"minHeightPx": 300,
"stickyHeader": False,
},
"accessControls": {
"visibilityScope": "private",
"allowedRoles": ["senior_broker", "sales_director", "marketing_operator", "data_steward", "compliance_reviewer", "platform_admin"],
"redactionPolicy": "none",
},
"styleSignature": {
"theme": "velocity_glass",
"paletteToken": "ocean_signal",
"motionProfile": "calm_reveal",
"density": "comfortable",
"radiusScale": "lg",
"typographyScale": "balanced",
},
"validationState": {
"schema": "pass",
"policy": "pass",
"a11y": "pass",
"performance": "pass",
"status": "validated",
},
"auditLog": [f"aud_{execution_id}_create"],
"dataRows": data_rows,
}
if component_warnings and not data_rows:
comp = self._error_component(
component_id=component_id,
execution_id=execution_id,
actor_id=actor_id,
branch_id=branch_id,
dataset=dataset,
warnings=component_warnings,
order_index=base_order + (i + 1) * 100,
)
components.append(comp)
return {"components": components}
@staticmethod
def _map_type(plan_type: str) -> str:
mapping = {
"pipeline_board": "pipelineBoard",
"bar_chart": "barChart",
"geo_map": "geoMap",
"table": "table",
"line_chart": "lineChart",
"kpi_tile": "kpiTile",
"activity_stream": "activityStream",
}
return mapping.get(plan_type, "barChart")
@staticmethod
def _generate_title(prompt: str, comp_type: str) -> str:
labels = {
"pipeline_board": "Pipeline View",
"bar_chart": "Comparative Analysis",
"geo_map": "Geographic Distribution",
"table": "Performance Table",
"line_chart": "Trend Analysis",
"kpi_tile": "Key Metric",
"activity_stream": "Activity Stream",
}
return labels.get(comp_type, "Oracle Canvas Component")
@staticmethod
def _default_viz_params(comp_type: str, rows: list[dict[str, Any]]) -> dict[str, Any]:
defaults: dict[str, dict[str, Any]] = {
"bar_chart": {"xAxis": "category", "yAxis": "value", "sort": "desc", "showLabels": True, "legend": False},
"line_chart": {"showPoints": True, "smooth": True},
"kpi_tile": {
"label": rows[0].get("metric_label", "Result") if rows else "Result",
"trend": str(rows[0].get("trend_value", "")) if rows else "",
"comparisonLabel": rows[0].get("comparison_label", "") if rows else "",
},
"geo_map": {"mapStyle": "dubai_district_heat", "intensityField": "lead_count", "interactive": True, "tooltipFields": ["district", "lead_count", "avg_qd_score"]},
"table": {"rankBy": "revenue_generated", "showTopBadge": True, "columns": ["name", "deals_closed", "revenue_generated"]},
"pipeline_board": {"showValue": True, "colorByStage": True},
"activity_stream": {"showUrgencyIndicator": True},
}
return defaults.get(comp_type, {})
@staticmethod
def _default_bindings(comp_type: str) -> dict[str, Any]:
return {"dimensions": [], "measures": [], "series": [], "filters": []}
@staticmethod
def _rendering_hints(comp_type: str) -> dict[str, Any]:
priority_map = {
"pipeline_board": ("pipeline", 9), "bar_chart": ("chart", 8),
"geo_map": ("map", 9), "table": ("table", 7),
"line_chart": ("chart", 8), "kpi_tile": ("kpi", 6),
"activity_stream": ("table", 8),
}
skeleton, priority = priority_map.get(comp_type, ("chart", 7))
height_map = {
"pipeline_board": 400, "bar_chart": 320, "geo_map": 420,
"table": 300, "line_chart": 320, "kpi_tile": 140, "activity_stream": 360,
}
return {
"estimatedHeightPx": height_map.get(comp_type, 300),
"skeletonVariant": skeleton,
"virtualizationPriority": priority,
}
@staticmethod
def _generate_summary(prompt: str, viz_plan: dict[str, Any]) -> str:
count = len(viz_plan.get("components", []))
short_prompt = prompt[:60] + ("" if len(prompt) > 60 else "")
return f'Generated {count} component{"s" if count != 1 else ""} for: "{short_prompt}"'
@staticmethod
def _error_component(
*,
component_id: str,
execution_id: str,
actor_id: str,
branch_id: str,
dataset: str,
warnings: list[str],
order_index: int,
) -> dict[str, Any]:
return {
"componentId": component_id,
"type": "errorNotice",
"title": f"{dataset} unavailable",
"description": "Oracle could not render live data for this component.",
"dataSourceDescriptor": {
"descriptorId": str(uuid.uuid4()),
"sourceType": "postgres",
"connectorId": "velocity-core-postgres",
"dataset": dataset,
"authContextRef": f"authctx_{actor_id}_scope",
"queryTemplate": "",
"queryParameters": {},
"rowLimit": 0,
"privacyTier": "standard",
},
"visualizationParameters": {
"errorCode": "oracle_live_query_failed",
"message": " | ".join(warnings[:2]),
"severity": "warning",
"retryable": True,
},
"dataBindings": {"dimensions": [], "measures": [], "series": [], "filters": []},
"version": 1,
"lifecycleState": "active",
"provenance": {
"originType": "prompt_generated",
"promptExecutionId": execution_id,
"sourceBranchId": branch_id,
"createdBy": actor_id,
"createdAt": _now(),
},
"renderingHints": {"estimatedHeightPx": 140, "skeletonVariant": "generic", "virtualizationPriority": 5},
"layout": {
"orderIndex": order_index,
"sectionId": "sec_prompt_generated",
"widthMode": "full",
"minHeightPx": 140,
"stickyHeader": False,
},
"accessControls": {
"visibilityScope": "private",
"allowedRoles": ["senior_broker", "sales_director", "marketing_operator", "data_steward", "compliance_reviewer", "platform_admin"],
"redactionPolicy": "none",
},
"styleSignature": {
"theme": "velocity_glass",
"paletteToken": "ocean_signal",
"motionProfile": "calm_reveal",
"density": "comfortable",
"radiusScale": "lg",
"typographyScale": "balanced",
},
"validationState": {
"schema": "pass",
"policy": "pass",
"a11y": "pass",
"performance": "pass",
"status": "validated",
},
"auditLog": [f"aud_{execution_id}_error"],
"dataRows": [],
}
async def _call_nemoclaw(
self,
prompt: str,
context: list[dict[str, str]],
ctx: PolicyContext,
) -> dict[str, Any]:
"""
Calls the Nemoclaw hosted model endpoint.
Raises on failure so the orchestrator can fall back to demo.
"""
import httpx # type: ignore
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{_NEMOCLAW_URL}/v1/oracle/plan",
headers={"Authorization": f"Bearer {_NEMOCLAW_API_KEY}"},
json={
"prompt": prompt,
"conversationContext": context,
"tenantId": ctx.tenant_id,
"actorRole": ctx.actor_role,
"semanticModelVersion": "oracle_semantic_v2026_04_08_01",
},
)
resp.raise_for_status()
return resp.json() # type: ignore[no-any-return]
async def get_execution(self, execution_id: str) -> dict[str, Any] | None:
return _DEMO_EXECUTIONS.get(execution_id)
async def _persist_execution(self, execution: dict[str, Any]) -> None:
_DEMO_EXECUTIONS[execution["executionId"]] = execution
if not _db_ready():
return
assert asyncpg is not None
conn = await asyncpg.connect(_DB_URL)
try:
await conn.execute(
"""
INSERT INTO oracle_prompt_executions (
execution_id, tenant_id, page_id, branch_id, actor_id, prompt, intent_class,
status, model_runtime, semantic_model_version, retrieval_plan, visualization_plan,
warnings, summary, components_created, client_request_id, created_at, completed_at
)
VALUES (
$1::uuid, $2, $3::uuid, $4, $5, $6, $7,
$8, $9, $10, $11::jsonb, $12::jsonb,
$13::text[], $14, $15::text[], $16, $17::timestamptz, $18::timestamptz
)
ON CONFLICT (execution_id)
DO UPDATE SET
status = EXCLUDED.status,
retrieval_plan = EXCLUDED.retrieval_plan,
visualization_plan = EXCLUDED.visualization_plan,
warnings = EXCLUDED.warnings,
summary = EXCLUDED.summary,
components_created = EXCLUDED.components_created,
completed_at = EXCLUDED.completed_at
""",
execution["executionId"],
execution["tenantId"],
execution["pageId"],
execution["branchId"],
execution["actorId"],
execution["prompt"],
execution["intentClass"],
execution["status"],
execution["modelRuntime"],
execution["semanticModelVersion"],
json.dumps(execution.get("retrievalPlan") or {}),
json.dumps(execution.get("visualizationPlan") or {}),
execution.get("warnings", []),
execution.get("summary"),
execution.get("componentsCreated", []),
execution.get("clientRequestId"),
execution["createdAt"],
execution.get("completedAt"),
)
finally:
await conn.close()
# ── Singleton ─────────────────────────────────────────────────────────────────
prompt_orchestrator = PromptOrchestrator()

364
backend/oracle/router_v1.py Normal file
View File

@@ -0,0 +1,364 @@
"""
oracle/router_v1.py
FastAPI router for all Oracle v1 endpoints.
Mounted at /api/oracle/v1 in main.py.
Endpoints (from spec §13.2):
GET /me
GET /canvas-pages/{pageId}
POST /canvas-pages/{pageId}/prompts
POST /canvas-pages/{pageId}/forks
POST /canvas-pages/{pageId}/rollback
GET /canvas-pages/{pageId}/revisions
GET /component-templates
POST /component-templates/synthesize (stub)
GET /merge-requests
POST /merge-requests
POST /merge-requests/{mrId}/review
WS /ws/oracle/canvas/{pageId}
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from typing import Any, Set
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from pydantic import BaseModel, Field
from .canvas_service import canvas_service
from .collaboration_service import collaboration_service
from .prompt_orchestrator import prompt_orchestrator
from .policy_service import PolicyService, PolicyContext
logger = logging.getLogger(__name__)
router = APIRouter()
policy_svc = PolicyService()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _ok(data: Any, meta: dict | None = None) -> dict:
return {"status": "ok", "data": data, "meta": meta or {}}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _build_user_profile(default_page_id: str) -> dict[str, Any]:
return {
"userId": os.getenv("ORACLE_DEFAULT_USER_ID", "oracle_operator"),
"tenantId": os.getenv("ORACLE_DEFAULT_TENANT_ID", "tenant_velocity"),
"email": os.getenv("ORACLE_DEFAULT_EMAIL", "oracle@velocity.local"),
"displayName": os.getenv("ORACLE_DEFAULT_DISPLAY_NAME", "Oracle Operator"),
"role": os.getenv("ORACLE_DEFAULT_ROLE", "sales_director"),
"timezone": os.getenv("ORACLE_DEFAULT_TIMEZONE", "Asia/Dubai"),
"locale": os.getenv("ORACLE_DEFAULT_LOCALE", "en-AE"),
"defaultPageId": default_page_id,
"canvasPreferences": {
"defaultDensity": "comfortable",
"defaultPlacementMode": "append_after_last_visible_component",
"showLineageBadges": True,
},
"policyProfileId": os.getenv("ORACLE_POLICY_PROFILE_ID", "policy_sales_director_standard_v4"),
"createdAt": os.getenv("ORACLE_PROFILE_CREATED_AT", _now()),
"updatedAt": _now(),
}
async def _get_current_user() -> dict[str, Any]:
seed_page = await canvas_service.ensure_default_page(
tenant_id=os.getenv("ORACLE_DEFAULT_TENANT_ID", "tenant_velocity"),
owner_id=os.getenv("ORACLE_DEFAULT_USER_ID", "oracle_operator"),
title=os.getenv("ORACLE_DEFAULT_PAGE_TITLE", "Oracle Main Canvas"),
)
return _build_user_profile(seed_page["pageId"])
async def _ctx_from_me() -> PolicyContext:
me = await _get_current_user()
return PolicyContext(
tenant_id=me["tenantId"],
actor_id=me["userId"],
actor_role=me["role"],
)
# ── Pydantic Models ───────────────────────────────────────────────────────────
class PromptSubmitRequest(BaseModel):
clientRequestId: str = Field(..., description="Client-generated idempotency key")
branchId: str
prompt: str = Field(..., min_length=1, max_length=4096)
conversationContext: list[dict[str, str]] = Field(default_factory=list)
placementMode: str = Field("append_after_last_visible_component")
class ForkCreateRequest(BaseModel):
recipientUserId: str
sourceRevision: int
visibility: str = Field("private", pattern="^(private|team)$")
message: str = ""
class RollbackRequest(BaseModel):
targetRevision: int = Field(..., ge=1)
clientRequestId: str
class MergeRequestCreateRequest(BaseModel):
sourcePageId: str
sourceBranchId: str
targetPageId: str
targetBranchId: str
title: str = Field(..., min_length=1, max_length=256)
description: str = ""
class MergeReviewRequest(BaseModel):
decision: str = Field(..., pattern="^(approve|reject|changes_requested)$")
comment: str = ""
resolutions: list[dict[str, Any]] = Field(default_factory=list)
class TemplateSynthesizeRequest(BaseModel):
prompt: str
dataShape: list[str]
styleSignatureRef: str | None = None
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/me", summary="Get current user profile")
async def get_me() -> dict:
return _ok(await _get_current_user())
@router.get("/canvas-pages/{page_id}", summary="Get canvas page by ID")
async def get_canvas_page(page_id: str) -> dict:
ctx = await _ctx_from_me()
page = await canvas_service.get_page(page_id, ctx.tenant_id)
if not page:
raise HTTPException(status_code=404, detail=f"Canvas page '{page_id}' not found.")
return _ok(page)
@router.post("/canvas-pages/{page_id}/prompts", summary="Submit a prompt to generate canvas components")
async def submit_prompt(page_id: str, payload: PromptSubmitRequest) -> dict:
ctx = await _ctx_from_me()
execution = await prompt_orchestrator.execute(
tenant_id=ctx.tenant_id,
page_id=page_id,
branch_id=payload.branchId,
actor_id=ctx.actor_id,
actor_role=ctx.actor_role,
prompt=payload.prompt,
conversation_context=payload.conversationContext,
client_request_id=payload.clientRequestId,
placement_mode=payload.placementMode,
)
if execution["status"] == "failed":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"errors": execution.get("warnings", [])},
)
page = await canvas_service.get_page(page_id, ctx.tenant_id)
return _ok({
"executionId": execution["executionId"],
"status": execution["status"],
"pageId": page_id,
"branchId": payload.branchId,
"headRevision": execution.get("headRevision", page.get("headRevision", 0) if page else 0),
"componentsCreated": execution.get("componentsCreated", []),
"summary": execution.get("summary", ""),
"warnings": execution.get("warnings", []),
"components": page.get("components", []) if page else [],
})
@router.post("/canvas-pages/{page_id}/forks", summary="Create a fork (share) from a canvas page")
async def create_fork(page_id: str, payload: ForkCreateRequest) -> dict:
ctx = await _ctx_from_me()
page = await canvas_service.get_page(page_id, ctx.tenant_id)
if not page:
raise HTTPException(status_code=404, detail="Source page not found.")
fork = await collaboration_service.create_fork(
source_page=page,
recipient_user_id=payload.recipientUserId,
created_by=ctx.actor_id,
visibility=payload.visibility,
message=payload.message,
)
return _ok(fork)
@router.post("/canvas-pages/{page_id}/rollback", summary="Rollback canvas to a prior revision")
async def rollback_canvas(page_id: str, payload: RollbackRequest) -> dict:
ctx = await _ctx_from_me()
result = await canvas_service.rollback(
page_id=page_id,
tenant_id=ctx.tenant_id,
actor_id=ctx.actor_id,
target_revision=payload.targetRevision,
idempotency_key=payload.clientRequestId,
)
page = await canvas_service.get_page(page_id, ctx.tenant_id)
return _ok({
"pageId": page_id,
"headRevision": result.get("revisionNumber", payload.targetRevision),
"components": page.get("components", []) if page else [],
})
@router.get("/canvas-pages/{page_id}/revisions", summary="List revision history for a canvas page")
async def list_revisions(page_id: str) -> dict:
ctx = await _ctx_from_me()
revisions = await canvas_service.list_revisions(page_id, ctx.tenant_id)
return _ok(revisions, meta={"count": len(revisions)})
@router.get("/component-templates", summary="List component templates")
async def list_templates(category: str | None = None, status: str | None = None) -> dict:
templates = PREMADE_TEMPLATES
if category:
templates = [t for t in templates if t["category"] == category]
if status:
templates = [t for t in templates if t["status"] == status]
return _ok(templates, meta={"count": len(templates)})
@router.post("/component-templates/synthesize", summary="Synthesize a new component template from a prompt")
async def synthesize_template(payload: TemplateSynthesizeRequest) -> dict:
me = await _get_current_user()
# Stub — full implementation requires Nemoclaw model runtime
template = {
"templateId": str(uuid.uuid4()),
"tenantId": me["tenantId"],
"name": "Synthesized Component",
"category": "custom",
"status": "tenant_draft",
"origin": "synthesized",
"version": "1.0.0",
"acceptedShapes": payload.dataShape,
"createdAt": _now(),
"updatedAt": _now(),
}
return _ok(template)
@router.get("/merge-requests", summary="List merge requests for a target page")
async def list_merge_requests(targetPageId: str | None = None, status: str | None = None) -> dict:
if not targetPageId:
raise HTTPException(status_code=400, detail="targetPageId query param required")
mrs = await collaboration_service.list_merge_requests(targetPageId, status)
return _ok(mrs, meta={"count": len(mrs)})
@router.post("/merge-requests", summary="Open a merge request")
async def create_merge_request(payload: MergeRequestCreateRequest) -> dict:
ctx = await _ctx_from_me()
source_page = await canvas_service.get_page(payload.sourcePageId, ctx.tenant_id)
target_page = await canvas_service.get_page(payload.targetPageId, ctx.tenant_id)
if not source_page or not target_page:
raise HTTPException(status_code=404, detail="Source or target page not found.")
mr = await collaboration_service.open_merge_request(
tenant_id=ctx.tenant_id,
source_page_id=payload.sourcePageId,
source_branch_id=payload.sourceBranchId,
source_head_revision=source_page.get("headRevision", 0),
target_page_id=payload.targetPageId,
target_branch_id=payload.targetBranchId,
target_base_revision=target_page.get("headRevision", 0),
title=payload.title,
description=payload.description,
created_by=ctx.actor_id,
source_components=source_page.get("components", []),
target_components=target_page.get("components", []),
base_components=[], # Simplified: empty base for demo
)
return _ok(mr)
@router.post("/merge-requests/{mr_id}/review", summary="Submit a merge request review")
async def review_merge_request(mr_id: str, payload: MergeReviewRequest) -> dict:
ctx = await _ctx_from_me()
mr = await collaboration_service.review_merge_request(
mr_id=mr_id,
decision=payload.decision,
reviewer_id=ctx.actor_id,
comment=payload.comment,
resolutions=payload.resolutions,
)
return _ok(mr)
# ── WebSocket ─────────────────────────────────────────────────────────────────
class OracleConnectionManager:
def __init__(self) -> None:
self.active: dict[str, Set[WebSocket]] = {}
async def connect(self, ws: WebSocket, page_id: str) -> None:
await ws.accept()
self.active.setdefault(page_id, set()).add(ws)
def disconnect(self, ws: WebSocket, page_id: str) -> None:
page_connections = self.active.get(page_id, set())
page_connections.discard(ws)
async def broadcast_page(self, page_id: str, payload: dict) -> None:
dead: set[WebSocket] = set()
for ws in self.active.get(page_id, set()):
try:
await ws.send_text(json.dumps(payload))
except Exception:
dead.add(ws)
if dead:
self.active.get(page_id, set()).difference_update(dead)
oracle_manager = OracleConnectionManager()
@router.websocket("/ws/oracle/canvas/{page_id}")
async def oracle_canvas_ws(ws: WebSocket, page_id: str) -> None:
"""
WebSocket endpoint for real-time Oracle canvas collaboration.
Event types: oracle.page.revision.committed, oracle.prompt.received, oracle.presence.updated
"""
await oracle_manager.connect(ws, page_id)
try:
while True:
data = await ws.receive_text()
try:
msg = json.loads(data)
# Reflect heartbeat
if msg.get("type") == "heartbeat":
await ws.send_text(json.dumps({"type": "heartbeat.ack", "timestamp": _now()}))
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
oracle_manager.disconnect(ws, page_id)
# ── Pre-made templates seed ───────────────────────────────────────────────────
PREMADE_TEMPLATES = [
{"templateId": "tpl_kpi_pipeline_health_v1", "tenantId": "_system", "name": "Pipeline Health KPI", "category": "Executive overview", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["scalar", "trend_scalar"]},
{"templateId": "tpl_bar_source_quality_v3", "tenantId": "_system", "name": "Lead Source Quality Bar", "category": "Lead quality", "status": "catalog_active", "origin": "premade", "version": "3.0.0", "acceptedShapes": ["categorical_aggregate"]},
{"templateId": "tpl_geo_investor_heat_v2", "tenantId": "_system", "name": "Investor Geography Heat Map", "category": "Geographic demand", "status": "catalog_active", "origin": "premade", "version": "2.0.0", "acceptedShapes": ["geospatial_aggregate"]},
{"templateId": "tpl_pipeline_board_v2", "tenantId": "_system", "name": "Deal Pipeline Board", "category": "Pipeline management", "status": "catalog_active", "origin": "premade", "version": "2.0.0", "acceptedShapes": ["categorical_records"]},
{"templateId": "tpl_broker_performance_v1", "tenantId": "_system", "name": "Broker Performance Ranked", "category": "Broker performance", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["ranked_records"]},
{"templateId": "tpl_followup_queue_v1", "tenantId": "_system", "name": "Follow-up Queue", "category": "Operational queues", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["task_records"]},
{"templateId": "tpl_investor_timeline_v1", "tenantId": "_system", "name": "Investor Timeline", "category": "Investor timelines", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["chronological_events"]},
{"templateId": "tpl_absorption_trend_v1", "tenantId": "_system", "name": "Project Absorption Trend", "category": "Inventory and project analytics", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["time_series"]},
{"templateId": "tpl_quota_gauge_v1", "tenantId": "_system", "name": "Quota Attainment Gauge", "category": "Executive overview", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["scalar"]},
{"templateId": "tpl_campaign_lead_line_v1", "tenantId": "_system", "name": "Campaign-to-Lead Quality Timeline", "category": "Marketing analytics", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["time_series"]},
{"templateId": "tpl_followup_gap_v1", "tenantId": "_system", "name": "Follow-up Gap Report", "category": "Operational queues", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["task_records"]},
{"templateId": "tpl_qd_source_compare_v1", "tenantId": "_system", "name": "QD-Weighted Source Comparison", "category": "Lead quality", "status": "catalog_active", "origin": "premade", "version": "1.0.0", "acceptedShapes": ["categorical_aggregate"]},
]

View File

@@ -0,0 +1,206 @@
-- Oracle Canvas Schema — Section 16.4 of the Oracle Architecture Document v1.0
-- Run this against your PostgreSQL database to create the Oracle persistence layer.
-- Requires: UUID extension, JSONB support (PostgreSQL 14+)
-- ── Prerequisites ─────────────────────────────────────────────────────────────
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ── Core tables ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS oracle_canvas_pages (
page_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
owner_id TEXT NOT NULL,
branch_id TEXT NOT NULL,
branch_name TEXT NOT NULL DEFAULT 'main',
page_type TEXT NOT NULL DEFAULT 'main' CHECK (page_type IN ('main', 'fork')),
title TEXT NOT NULL DEFAULT 'Untitled Canvas',
is_shared BOOLEAN NOT NULL DEFAULT FALSE,
head_revision INTEGER NOT NULL DEFAULT 0,
base_revision INTEGER NOT NULL DEFAULT 0,
sharing_policy JSONB NOT NULL DEFAULT '{"shareMode":"direct_fork_only","allowReshare":false,"defaultForkVisibility":"private"}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_canvas_page_revisions (
revision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
revision_number INTEGER NOT NULL,
commit_kind TEXT NOT NULL CHECK (commit_kind IN ('prompt', 'merge', 'rollback', 'manual_edit')),
commit_summary TEXT,
actor_id TEXT NOT NULL,
execution_id UUID,
merge_request_id UUID,
components_snapshot JSONB NOT NULL DEFAULT '[]'::JSONB,
idempotency_key TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (page_id, revision_number)
);
CREATE TABLE IF NOT EXISTS oracle_canvas_components (
component_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
version INTEGER NOT NULL DEFAULT 1,
lifecycle_state TEXT NOT NULL DEFAULT 'active' CHECK (lifecycle_state IN ('draft','active','superseded','archived','revoked')),
data_source_descriptor JSONB NOT NULL,
visualization_parameters JSONB NOT NULL DEFAULT '{}'::JSONB,
data_bindings JSONB NOT NULL DEFAULT '{}'::JSONB,
provenance JSONB NOT NULL,
rendering_hints JSONB NOT NULL,
layout JSONB NOT NULL,
access_controls JSONB NOT NULL,
style_signature JSONB NOT NULL DEFAULT '{}'::JSONB,
validation_state JSONB NOT NULL DEFAULT '{}'::JSONB,
audit_log TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_prompt_executions (
execution_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id) ON DELETE CASCADE,
branch_id TEXT NOT NULL,
actor_id TEXT NOT NULL,
prompt TEXT NOT NULL,
intent_class TEXT NOT NULL DEFAULT 'analytical',
status TEXT NOT NULL DEFAULT 'received',
model_runtime TEXT NOT NULL DEFAULT 'nemoclaw_hosted',
semantic_model_version TEXT NOT NULL DEFAULT 'oracle_semantic_v1',
retrieval_plan JSONB,
visualization_plan JSONB,
warnings TEXT[] NOT NULL DEFAULT '{}',
summary TEXT,
components_created TEXT[] NOT NULL DEFAULT '{}',
client_request_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS oracle_component_templates (
template_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
category TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'catalog_active',
origin TEXT NOT NULL DEFAULT 'premade',
version TEXT NOT NULL DEFAULT '1.0.0',
accepted_shapes TEXT[] NOT NULL DEFAULT '{}',
style_signature JSONB DEFAULT NULL,
validation_state JSONB DEFAULT NULL,
provenance JSONB DEFAULT NULL,
use_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_forks (
fork_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
source_branch_id TEXT NOT NULL,
source_revision INTEGER NOT NULL,
fork_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
fork_branch_id TEXT NOT NULL,
recipient_user_id TEXT NOT NULL,
created_by TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','merged','closed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_merge_requests (
merge_request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
source_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
source_branch_id TEXT NOT NULL,
source_head_revision INTEGER NOT NULL,
target_page_id UUID NOT NULL REFERENCES oracle_canvas_pages(page_id),
target_branch_id TEXT NOT NULL,
target_base_revision INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','changes_requested','approved','merged','closed')),
conflicts JSONB NOT NULL DEFAULT '[]'::JSONB,
diff_summary JSONB DEFAULT NULL,
resolutions JSONB DEFAULT NULL,
created_by TEXT NOT NULL,
reviewed_by TEXT,
reviewer_comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_lineage_records (
lineage_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
transformation_type TEXT NOT NULL,
transformation_spec_hash TEXT,
produced_kind TEXT NOT NULL,
produced_id TEXT NOT NULL,
policy_snapshot_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oracle_audit_events (
audit_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL,
actor_id TEXT NOT NULL,
actor_type TEXT NOT NULL DEFAULT 'user',
correlation_id TEXT NOT NULL,
execution_id UUID,
details JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Indexes ───────────────────────────────────────────────────────────────────
-- Canvas pages: tenant lookup, branch lookup
CREATE INDEX IF NOT EXISTS idx_oracle_pages_tenant ON oracle_canvas_pages(tenant_id);
CREATE INDEX IF NOT EXISTS idx_oracle_pages_owner ON oracle_canvas_pages(owner_id);
CREATE INDEX IF NOT EXISTS idx_oracle_pages_branch ON oracle_canvas_pages(branch_id);
-- Revisions: page-scoped revision queries
CREATE INDEX IF NOT EXISTS idx_oracle_revisions_page ON oracle_canvas_page_revisions(page_id, revision_number DESC);
CREATE INDEX IF NOT EXISTS idx_oracle_revisions_tenant ON oracle_canvas_page_revisions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_oracle_revisions_execution ON oracle_canvas_page_revisions(execution_id);
-- Components: page-scoped, lifecycle
CREATE INDEX IF NOT EXISTS idx_oracle_components_page ON oracle_canvas_components(page_id, lifecycle_state);
CREATE INDEX IF NOT EXISTS idx_oracle_components_tenant ON oracle_canvas_components(tenant_id);
-- Prompt executions: page/actor lookup
CREATE INDEX IF NOT EXISTS idx_oracle_executions_page ON oracle_prompt_executions(page_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_oracle_executions_actor ON oracle_prompt_executions(actor_id, created_at DESC);
-- Templates: tenant + category + status
CREATE INDEX IF NOT EXISTS idx_oracle_templates_tenant_cat ON oracle_component_templates(tenant_id, category, status);
-- Forks: source and recipient lookup
CREATE INDEX IF NOT EXISTS idx_oracle_forks_source ON oracle_forks(source_page_id);
CREATE INDEX IF NOT EXISTS idx_oracle_forks_recipient ON oracle_forks(recipient_user_id);
-- Merge requests: target/source page, status
CREATE INDEX IF NOT EXISTS idx_oracle_mrs_target ON oracle_merge_requests(target_page_id, status);
CREATE INDEX IF NOT EXISTS idx_oracle_mrs_source ON oracle_merge_requests(source_page_id, status);
CREATE INDEX IF NOT EXISTS idx_oracle_mrs_tenant ON oracle_merge_requests(tenant_id, status);
-- Lineage: source/produced lookups
CREATE INDEX IF NOT EXISTS idx_oracle_lineage_source ON oracle_lineage_records(source_kind, source_id);
CREATE INDEX IF NOT EXISTS idx_oracle_lineage_produced ON oracle_lineage_records(produced_kind, produced_id);
CREATE INDEX IF NOT EXISTS idx_oracle_lineage_tenant ON oracle_lineage_records(tenant_id);
-- Audit: entity lookup, correlation lookup
CREATE INDEX IF NOT EXISTS idx_oracle_audit_entity ON oracle_audit_events(entity_type, entity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_oracle_audit_correlation ON oracle_audit_events(correlation_id);
CREATE INDEX IF NOT EXISTS idx_oracle_audit_tenant ON oracle_audit_events(tenant_id, created_at DESC);

View File

@@ -6,3 +6,4 @@ python-dotenv>=1.0.0
httpx>=0.27.0 httpx>=0.27.0
pydantic>=2.9.0 pydantic>=2.9.0
python-multipart>=0.0.12 python-multipart>=0.0.12
asyncpg>=0.30.0

View File

@@ -0,0 +1 @@
"""Tests for Oracle backend services — runs without any live database or model runtime."""

View File

@@ -0,0 +1 @@
"""Tests for Oracle backend services — runs without any live database or model runtime."""

View File

@@ -0,0 +1,133 @@
"""
test_canvas_service.py — Unit tests for CanvasService (demo mode in-memory store).
"""
import asyncio
import pytest
import sys
import os
# Ensure backend is on path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from oracle.canvas_service import CanvasService
TENANT = "tenant_test_001"
ACTOR = "user_test_001"
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
@pytest.fixture
def svc():
"""Fresh CanvasService instance with empty demo store per test."""
from oracle import canvas_service as _mod
_mod._DEMO_PAGES.clear()
_mod._DEMO_REVISIONS.clear()
_mod._DEMO_COMPONENTS.clear()
return CanvasService()
def test_create_page(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR, title="Test Canvas"))
assert page["tenantId"] == TENANT
assert page["ownerId"] == ACTOR
assert page["title"] == "Test Canvas"
assert page["headRevision"] == 0
assert page["pageType"] == "main"
assert page["branchName"] == "main"
def test_get_page_not_found(svc):
result = run(svc.get_page("nonexistent_id", TENANT))
assert result is None
def test_get_page_returns_page(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
retrieved = run(svc.get_page(page["pageId"], TENANT))
assert retrieved is not None
assert retrieved["pageId"] == page["pageId"]
def test_commit_revision_advances_head(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
comps = [{"componentId": "cmp_001", "type": "barChart", "title": "Test"}]
rev = run(svc.commit_revision(
page_id=page["pageId"],
tenant_id=TENANT,
actor_id=ACTOR,
commit_kind="prompt",
commit_summary="Test prompt",
components=comps,
idempotency_key="ikey_001",
))
assert rev["revisionNumber"] == 1
updated = run(svc.get_page(page["pageId"], TENANT))
assert updated["headRevision"] == 1
assert len(updated["components"]) == 1
def test_idempotency_key_prevents_double_commit(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
comps = [{"componentId": "cmp_001", "type": "barChart", "title": "Test"}]
rev1 = run(svc.commit_revision(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
commit_kind="prompt", commit_summary="First", components=comps,
idempotency_key="ikey_idempotent",
))
rev2 = run(svc.commit_revision(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
commit_kind="prompt", commit_summary="Duplicate", components=comps,
idempotency_key="ikey_idempotent",
))
assert rev1["revisionId"] == rev2["revisionId"]
# Head should still be 1
updated = run(svc.get_page(page["pageId"], TENANT))
assert updated["headRevision"] == 1
def test_rollback_creates_new_revision(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
comps_v1 = [{"componentId": "cmp_v1", "type": "barChart", "title": "V1"}]
comps_v2 = [{"componentId": "cmp_v2", "type": "lineChart", "title": "V2"}]
run(svc.commit_revision(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
commit_kind="prompt", commit_summary="V1", components=comps_v1, idempotency_key="key_v1",
))
run(svc.commit_revision(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
commit_kind="prompt", commit_summary="V2", components=comps_v2, idempotency_key="key_v2",
))
# Rollback to revision 1
rollback_rev = run(svc.rollback(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
target_revision=1, idempotency_key="key_rollback",
))
assert rollback_rev["revisionNumber"] == 3
assert rollback_rev["commitKind"] == "rollback"
revisions = run(svc.list_revisions(page["pageId"], TENANT))
assert len(revisions) == 3 # 3 revisions total
def test_list_revisions_returns_newest_first(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
for i in range(3):
run(svc.commit_revision(
page_id=page["pageId"], tenant_id=TENANT, actor_id=ACTOR,
commit_kind="prompt", commit_summary=f"Rev {i+1}",
components=[], idempotency_key=f"key_{i}",
))
revisions = run(svc.list_revisions(page["pageId"], TENANT))
assert revisions[0]["revisionNumber"] > revisions[-1]["revisionNumber"]
def test_tenant_isolation(svc):
page = run(svc.create_page(tenant_id=TENANT, owner_id=ACTOR))
# Different tenant cannot access the page
result = run(svc.get_page(page["pageId"], "tenant_different_999"))
assert result is None

View File

@@ -0,0 +1,207 @@
"""
test_collaboration_service.py — Unit tests for three-way diff, fork, merge request lifecycle.
"""
import asyncio
import copy
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from oracle.collaboration_service import CollaborationService, three_way_diff
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
def _comp(cid, title="Test", order=100, content="default"):
return {
"componentId": cid,
"type": "barChart",
"title": title,
"dataSourceDescriptor": {"dataset": "test_ds", "queryTemplate": content},
"accessControls": {"allowedRoles": ["sales_director"], "visibilityScope": "private"},
"layout": {"orderIndex": order, "sectionId": "sec_test", "widthMode": "full"},
}
# ── Three-way diff tests ──────────────────────────────────────────────────────
def test_safe_append_in_source():
base = [_comp("cmp_a")]
source = [_comp("cmp_a"), _comp("cmp_b")] # cmp_b added in source
target = [_comp("cmp_a")]
merged, conflicts = three_way_diff(base, source, target)
assert any(c["conflictClass"] == "safe_append" and c["componentId"] == "cmp_b" for c in conflicts)
assert any(c["componentId"] == "cmp_b" for c in merged)
def test_no_conflict_when_identical():
base = [_comp("cmp_a")]
source = [_comp("cmp_a")]
target = [_comp("cmp_a")]
merged, conflicts = three_way_diff(base, source, target)
assert len(merged) == 1
assert all(c["conflictClass"] not in ("component_content_conflict", "query_descriptor_conflict") for c in conflicts)
def test_component_content_conflict():
base = [_comp("cmp_a", content="SELECT 1")]
source = [_comp("cmp_a", content="SELECT 2")]
target = [_comp("cmp_a", content="SELECT 3")]
merged, conflicts = three_way_diff(base, source, target)
# Expect query_descriptor_conflict or component_content_conflict
conflict_classes = {c["conflictClass"] for c in conflicts}
assert conflict_classes & {"component_content_conflict", "query_descriptor_conflict"}
def test_delete_edit_conflict_source_deletes():
base = [_comp("cmp_a"), _comp("cmp_b")]
source = [_comp("cmp_a")] # cmp_b deleted in source
target = [_comp("cmp_a"), _comp("cmp_b", title="Edited in target")] # cmp_b edited in target
merged, conflicts = three_way_diff(base, source, target)
assert any(c["conflictClass"] == "delete_edit_conflict" and c["componentId"] == "cmp_b" for c in conflicts)
# Default: keep target (edited version)
assert any(c["componentId"] == "cmp_b" for c in merged)
def test_deleted_in_both_is_removed():
base = [_comp("cmp_a"), _comp("cmp_b")]
source = [_comp("cmp_a")]
target = [_comp("cmp_a")]
merged, conflicts = three_way_diff(base, source, target)
assert not any(c["componentId"] == "cmp_b" for c in merged)
def test_orderindex_normalization():
base = []
source = [_comp("c1", order=100), _comp("c2", order=200)]
target = [_comp("c1", order=100), _comp("c2", order=200)]
merged, _ = three_way_diff(base, source, target)
orders = [c["layout"]["orderIndex"] for c in merged]
# Orders should be normalized (multiples of 100, sequential)
assert orders == sorted(orders)
assert all(o % 100 == 0 for o in orders)
# ── CollaborationService tests ────────────────────────────────────────────────
@pytest.fixture
def collab():
from oracle import collaboration_service as _mod
_mod._DEMO_FORKS.clear()
_mod._DEMO_MRS.clear()
return CollaborationService()
def test_create_fork(collab):
source_page = {
"pageId": "page_src",
"branchId": "branch_main",
"headRevision": 5,
"components": [],
}
fork = run(collab.create_fork(
source_page=source_page,
recipient_user_id="user_recipient",
created_by="user_src",
))
assert fork["sourcePageId"] == "page_src"
assert fork["sourceRevision"] == 5
assert fork["status"] == "active"
assert fork["recipientUserId"] == "user_recipient"
def test_merge_request_lifecycle(collab):
mr = run(collab.open_merge_request(
tenant_id="tenant_test",
source_page_id="page_fork",
source_branch_id="branch_fork",
source_head_revision=2,
target_page_id="page_main",
target_branch_id="branch_main",
target_base_revision=5,
title="Test MR",
description="My changes",
created_by="user_a",
source_components=[_comp("cmp_a"), _comp("cmp_new")],
target_components=[_comp("cmp_a")],
base_components=[_comp("cmp_a")],
))
assert mr["status"] == "open"
assert "mergeRequestId" in mr
# Approve it
reviewed = run(collab.review_merge_request(
mr_id=mr["mergeRequestId"],
decision="approve",
reviewer_id="user_reviewer",
))
assert reviewed["status"] == "merged"
def test_merge_request_reject(collab):
mr = run(collab.open_merge_request(
tenant_id="tenant_test",
source_page_id="page_fork",
source_branch_id="branch_fork",
source_head_revision=1,
target_page_id="page_main",
target_branch_id="branch_main",
target_base_revision=1,
title="Rejected MR",
created_by="user_a",
source_components=[],
target_components=[],
base_components=[],
))
reviewed = run(collab.review_merge_request(
mr_id=mr["mergeRequestId"],
decision="reject",
reviewer_id="user_reviewer",
))
assert reviewed["status"] == "closed"
def test_list_merge_requests_filters_by_target(collab):
for i in range(3):
run(collab.open_merge_request(
tenant_id="tenant_t",
source_page_id=f"page_fork_{i}",
source_branch_id=f"branch_{i}",
source_head_revision=1,
target_page_id="page_target",
target_branch_id="branch_main",
target_base_revision=1,
title=f"MR {i}",
created_by="user_a",
source_components=[],
target_components=[],
base_components=[],
))
# Different target
run(collab.open_merge_request(
tenant_id="tenant_t",
source_page_id="page_other",
source_branch_id="branch_other",
source_head_revision=1,
target_page_id="page_other_target",
target_branch_id="branch_main",
target_base_revision=1,
title="Different target MR",
created_by="user_b",
source_components=[],
target_components=[],
base_components=[],
))
mrs = run(collab.list_merge_requests("page_target"))
assert len(mrs) == 3
assert all(mr["targetPageId"] == "page_target" for mr in mrs)

View File

@@ -0,0 +1,142 @@
"""
test_policy_service.py — Unit tests for Oracle policy engine.
"""
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from oracle.policy_service import PolicyService, PolicyContext
@pytest.fixture
def svc():
return PolicyService()
def _ctx(role: str = "sales_director") -> PolicyContext:
return PolicyContext(
tenant_id="tenant_test_001",
actor_id="user_test_001",
actor_role=role,
)
# ── Privacy tier tests ────────────────────────────────────────────────────────
def test_junior_broker_denied_restricted(svc):
plan = {"dataset": "lead_contacts", "privacyTier": "restricted", "rowLimit": 50, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("junior_broker"))
assert not result.passed
assert any("POLICY_PRIVACY_TIER_ESCALATION" in e for e in result.errors)
def test_senior_broker_allowed_restricted_with_redaction(svc):
plan = {"dataset": "lead_contacts", "privacyTier": "restricted", "rowLimit": 50, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("senior_broker"))
assert result.passed
assert result.redaction_policy == "aggregate_only"
def test_junior_broker_denied_sensitive(svc):
plan = {"dataset": "pii_records", "privacyTier": "sensitive", "rowLimit": 10, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("junior_broker"))
assert not result.passed
def test_data_steward_allowed_sensitive(svc):
plan = {"dataset": "pii_records", "privacyTier": "sensitive", "rowLimit": 100, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("data_steward"))
assert result.passed
# ── Row limit tests ───────────────────────────────────────────────────────────
def test_row_limit_capped_for_junior_broker(svc):
plan = {"dataset": "leads", "privacyTier": "standard", "rowLimit": 5000, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("junior_broker"))
assert result.passed
assert result.effective_row_limit == 100
assert any("ROW_LIMIT_CAPPED" in w for w in result.warnings)
def test_row_limit_respected_for_admin(svc):
plan = {"dataset": "leads", "privacyTier": "standard", "rowLimit": 5000, "joins": []}
result = svc.validate_retrieval_plan(plan, _ctx("platform_admin"))
assert result.passed
assert result.effective_row_limit == 5000
# ── Cross-tenant join tests ───────────────────────────────────────────────────
def test_cross_tenant_join_denied(svc):
plan = {
"dataset": "global_lead_market",
"privacyTier": "standard",
"rowLimit": 50,
"joins": [],
}
result = svc.validate_retrieval_plan(plan, _ctx("sales_director"))
assert not result.passed
assert any("POLICY_CROSS_TENANT_JOIN_DENIED" in e for e in result.errors)
def test_explicit_cross_tenant_join_denied(svc):
plan = {
"dataset": "deals",
"privacyTier": "standard",
"rowLimit": 50,
"joins": [{"tenantId": "tenant_other_999"}],
}
result = svc.validate_retrieval_plan(plan, _ctx("sales_director"))
assert not result.passed
# ── Tenant predicate enforcement ──────────────────────────────────────────────
def test_enforce_tenant_predicate_overrides(svc):
params = {"tenant_id": "attacker_tenant", "limit": 100}
ctx = _ctx("sales_director")
enforced = svc.enforce_tenant_predicate(params, ctx)
assert enforced["tenant_id"] == "tenant_test_001"
assert enforced["limit"] == 100
# ── Component access control ──────────────────────────────────────────────────
def test_component_access_granted_for_allowed_role(svc):
ac = {"allowedRoles": ["sales_director", "senior_broker"], "visibilityScope": "private"}
assert svc.validate_component_access(ac, _ctx("sales_director")) is True
def test_component_access_denied_for_wrong_role(svc):
ac = {"allowedRoles": ["data_steward"], "visibilityScope": "private"}
assert svc.validate_component_access(ac, _ctx("junior_broker")) is False
# ── Redaction tests ───────────────────────────────────────────────────────────
def test_redact_full():
svc = PolicyService()
rows = [{"name": "Alice", "email": "alice@test.com", "deal": 1000}]
redacted = svc.redact(rows, "full_redact")
assert redacted == [{"__redacted__": True, "count": 1}]
def test_redact_aggregate_only():
svc = PolicyService()
rows = [{"name": "Alice", "count": 5, "stage": "Qualified", "email": "alice@test.com"}]
redacted = svc.redact(rows, "aggregate_only")
assert len(redacted) == 1
assert "email" not in redacted[0]
assert "name" not in redacted[0]
assert redacted[0].get("count") == 5
assert redacted[0].get("stage") == "Qualified"
def test_redact_none_passes_through():
svc = PolicyService()
rows = [{"name": "Alice", "value": 999}]
result = svc.redact(rows, "none")
assert result == rows

View File

@@ -0,0 +1,143 @@
"""
test_prompt_orchestrator.py — Unit tests for PromptOrchestrator (demo/fallback mode).
"""
import asyncio
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
@pytest.fixture(autouse=True)
def clear_demo_stores():
from oracle import canvas_service as _cs
from oracle import collaboration_service as _col
_cs._DEMO_PAGES.clear()
_cs._DEMO_REVISIONS.clear()
_cs._DEMO_COMPONENTS.clear()
_col._DEMO_MRS.clear()
yield
@pytest.fixture
def page():
"""Create a demo canvas page and return it."""
from oracle.canvas_service import canvas_service
return run(canvas_service.create_page(
tenant_id="tenant_test",
owner_id="user_test",
title="Test Oracle Page",
))
def test_pipeline_prompt_produces_components(page):
from oracle.prompt_orchestrator import PromptOrchestrator
orch = PromptOrchestrator()
result = run(orch.execute(
tenant_id="tenant_test",
page_id=page["pageId"],
branch_id=page["branchId"],
actor_id="user_test",
actor_role="sales_director",
prompt="Show me an active pipeline view by stage",
client_request_id="cli_test_001",
))
assert result["status"] == "completed"
assert len(result["componentsCreated"]) > 0
assert result["summary"]
def test_geo_map_prompt_produces_geo_component(page):
from oracle.prompt_orchestrator import PromptOrchestrator
orch = PromptOrchestrator()
result = run(orch.execute(
tenant_id="tenant_test",
page_id=page["pageId"],
branch_id=page["branchId"],
actor_id="user_test",
actor_role="sales_director",
prompt="Show me a map of whale leads by Dubai district",
client_request_id="cli_test_002",
))
assert result["status"] == "completed"
vp = result.get("visualizationPlan", {}).get("components", [])
assert any(c["type"] == "geoMap" for c in vp)
def test_broker_table_prompt(page):
from oracle.prompt_orchestrator import PromptOrchestrator
orch = PromptOrchestrator()
result = run(orch.execute(
tenant_id="tenant_test",
page_id=page["pageId"],
branch_id=page["branchId"],
actor_id="user_test",
actor_role="sales_director",
prompt="Give me a table of brokers ranked by performance",
client_request_id="cli_test_003",
))
assert result["status"] == "completed"
vp = result.get("visualizationPlan", {}).get("components", [])
assert any(c["type"] == "table" for c in vp)
def test_policy_denial_on_restricted_for_junior_broker(page):
"""Junior broker should get warnings/denial on restricted tier dataset."""
from oracle.prompt_orchestrator import PromptOrchestrator, _build_demo_retrieval_plan
from oracle.policy_service import PolicyService, PolicyContext
plan = {
"components": [{
"suggestedType": "table",
"dataset": "pii_leads",
"privacyTier": "sensitive",
"rowLimit": 500,
"joins": [],
}]
}
svc = PolicyService()
ctx = PolicyContext(tenant_id="tenant_test", actor_id="user_junior", actor_role="junior_broker")
for comp_plan in plan["components"]:
result = svc.validate_retrieval_plan(comp_plan, ctx)
assert not result.passed
def test_idempotency_key_prevents_double_execution(page):
from oracle.prompt_orchestrator import PromptOrchestrator
orch = PromptOrchestrator()
prompt_kwargs = dict(
tenant_id="tenant_test",
page_id=page["pageId"],
branch_id=page["branchId"],
actor_id="user_test",
actor_role="sales_director",
prompt="Pipeline view",
client_request_id="cli_idempotent_key",
)
result1 = run(orch.execute(**prompt_kwargs))
result2 = run(orch.execute(**prompt_kwargs))
# Both should succeed; canvas should not double-create components
assert result1["status"] == "completed"
assert result2["status"] == "completed"
def test_kpi_prompt_type(page):
from oracle.prompt_orchestrator import PromptOrchestrator
orch = PromptOrchestrator()
result = run(orch.execute(
tenant_id="tenant_test",
page_id=page["pageId"],
branch_id=page["branchId"],
actor_id="user_test",
actor_role="sales_director",
prompt="How many total leads do we have?",
client_request_id="cli_test_kpi",
))
vp = result.get("visualizationPlan", {}).get("components", [])
assert any(c["type"] == "kpiTile" for c in vp)