forked from sagnik/Velocity-OS
fix: complete Velocity-OS feature migration wiring
This commit is contained in:
31
README.md
31
README.md
@@ -15,15 +15,43 @@ Velocity-OS/
|
||||
|-- infrastructure/ Deployment, model hydration, gateway, and runtime operations
|
||||
```
|
||||
|
||||
## The Three Pillars
|
||||
## Primary Surfaces
|
||||
|
||||
| Pillar | Route | What It Replaces From Project_Velocity |
|
||||
| --- | --- | --- |
|
||||
| Command | `/command` | Dashboard, Oracle prompt surface, priority signals |
|
||||
| Oracle | `/oracle` | Full Oracle canvas/chat workspace, chat recents, SQL-backed result cards |
|
||||
| Pipeline | `/pipeline` and `/pipeline/:personId` | CRM, leads, Client 360, conversations, tasks, showroom flow |
|
||||
| Studio | `/studio` and `/studio/:propertyId` | Inventory, property pages, media gallery, Dream Weaver / Reimagine, campaigns |
|
||||
| Control Room | `/control-room/:panel?` | Admin settings, Oracle/schema controls, comms settings, users, model hydration |
|
||||
|
||||
## Project_Velocity To Velocity-OS Migration Matrix
|
||||
|
||||
| Project_Velocity capability | Velocity-OS location | Migration state | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Login, session, current user, guarded routes | `webos/src/auth`, `core/api/api/routes_auth.py` | Migrated | Uses token-backed authenticated shell. |
|
||||
| Dashboard KPIs and priority signals | `/command` | Migrated | Command keeps the old dashboard intent but removes low-value page sprawl. |
|
||||
| Lightweight Oracle ask box | `/command` | Migrated | The prompt bar now submits to Oracle and saves a chat session. |
|
||||
| Full Oracle Canvas workspace | `/oracle` | Migrated | Added as a first-class nav item so the chat/canvas surface is no longer orphaned. |
|
||||
| Oracle recents, new chat, search, share/export | `/oracle` | Migrated locally | Recents persist in browser storage; share currently exports chat JSON to clipboard. |
|
||||
| Oracle natural DB query | `core/oracle/oracle/natural_db_agent.py`, `/api/oracle/query` | Migrated | Backend remains the source of truth for SQL-backed CRM answers. |
|
||||
| Oracle JSON codebook and component templates | `core/oracle/oracle/*.json` | Migrated | JSON is not counted as JavaScript in repo language stats; absence of a JS bar does not mean the codebook is missing. |
|
||||
| Oracle schema catalog and data health | `core/api/api/routes_oracle.py` | Migrated | Exposed for diagnostics and planner context. |
|
||||
| Canvas revisions/share/fork server workflow | Legacy Oracle backend plus new `/oracle` shell | Partial | Backend concepts exist; new Velocity-OS UI currently prioritizes chat/result generation and local export. |
|
||||
| CRM pipeline board | `/pipeline` board view | Migrated | Uses canonical CRM API and normalized response shapes. |
|
||||
| CRM pipeline list | `/pipeline` list view | Migrated | Board/list now render different layouts from the same normalized data. |
|
||||
| Client 360 profile | `/pipeline/:personId` | Migrated | Uses tolerant ID and response normalization for old/new backend shapes. |
|
||||
| Conversations, intelligence, properties, tasks | `/pipeline/:personId` tabs | Migrated | Consolidates old CRM/comms/intelligence surfaces into one entity page. |
|
||||
| Showroom/webcam mode | `/pipeline/:personId` showroom action | Migrated | Preserves the Project_Velocity video/webcam concept inside Client 360. |
|
||||
| WhatsApp/comms provider config | `/control-room/comms-config` plus Client 360 | Partial | UI and API wiring are present; live send/record/transcribe depends on configured provider credentials. |
|
||||
| Inventory properties | `/studio` properties tab | Migrated | Uses Studio API normalization. |
|
||||
| Property detail and media gallery | `/studio/:propertyId` | Migrated | Includes floorplan, media, overview, and actions. |
|
||||
| Dream Weaver / Reimagine | `/studio/:propertyId` Reimagine | Migrated | Supports image upload, prompt text, async job polling, generated preview, open, and download. |
|
||||
| Campaigns / Meta ads | `/studio` campaigns tab and `/control-room/meta` | Partial | Campaign list UI/API exists; Meta execution depends on live integration credentials. |
|
||||
| Control Room admin panels | `/control-room/:panel?` | Migrated | Reworked to match the dark Velocity-OS theme and current L4/SGLang runtime truth. |
|
||||
| Runtime LLM/SGLang operational truth | Control Room model hydration and docs | Migrated as ops surface | Heavy model runtime remains backend/infrastructure, not a WebOS feature page. |
|
||||
| iPad/mobile backend support | `core/api` shared endpoints | API migrated | Native iPad client consumes the same backend contracts; not duplicated as a WebOS page. |
|
||||
|
||||
## Runtime Truths
|
||||
|
||||
- The public app is served from `https://velocity.desineuron.in/`.
|
||||
@@ -146,4 +174,3 @@ Run before handoff:
|
||||
|
||||
- The production build still warns that the Three.js vendor chunk is large. This is not a functional failure, but Studio 3D/media should remain lazy-loaded and can be split further later.
|
||||
- The app intentionally keeps Project_Velocity as a source/reference repository. Velocity-OS should import only the required code/data assets, not blindly mirror the old structure.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { lazy, Suspense } from 'react';
|
||||
import { PillarSkeleton } from './shared/layout/PillarSkeleton';
|
||||
|
||||
const CommandPillar = lazy(() => import('./pillars/command/CommandPillar'));
|
||||
const OracleWorkspacePage = lazy(() => import('./pillars/command/OracleWorkspacePage'));
|
||||
const PipelinePillar = lazy(() => import('./pillars/pipeline/PipelinePillar'));
|
||||
const Client360 = lazy(() => import('./pillars/pipeline/client360/Client360'));
|
||||
const ShowroomMode = lazy(() => import('./pillars/pipeline/ShowroomMode'));
|
||||
@@ -47,6 +48,10 @@ const router = createBrowserRouter([
|
||||
path: 'command',
|
||||
element: <Lazy><CommandPillar /></Lazy>,
|
||||
},
|
||||
{
|
||||
path: 'oracle',
|
||||
element: <Lazy><OracleWorkspacePage /></Lazy>,
|
||||
},
|
||||
|
||||
// Pillar 2: PIPELINE — Deal Intelligence (CRM + Comms + Sentinel)
|
||||
{
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import styles from './ControlRoom.module.css';
|
||||
|
||||
/**
|
||||
* ControlRoom
|
||||
* Admin-only surface. Accessible at /control-room.
|
||||
* Protected by AdminGuard at router level AND FastAPI RBAC middleware.
|
||||
*
|
||||
* Panels (left sidebar → content area):
|
||||
* System Health — service status, GPU utilization, queue depths
|
||||
* Oracle Admin — schema catalog, canvas management, MCP tools
|
||||
* Comms Config — WAHA/Evolution provider, webhook setup
|
||||
* Users & Roles — broker roster, role assignment, audit log
|
||||
* Model Hydration — s5cmd sync status, model inventory
|
||||
* Meta Integration — OAuth, ad account, Lookalike sync
|
||||
*
|
||||
* Design: deliberately more "operator" than "broker" — still glassmorphic
|
||||
* but denser information, monospace for technical values.
|
||||
*/
|
||||
|
||||
type Panel =
|
||||
| 'system'
|
||||
| 'oracle-admin'
|
||||
| 'comms-config'
|
||||
| 'users'
|
||||
| 'models'
|
||||
| 'meta';
|
||||
type Panel = 'system' | 'oracle-admin' | 'comms-config' | 'users' | 'models' | 'meta';
|
||||
|
||||
const PANELS: { id: Panel; label: string; icon: string }[] = [
|
||||
{ id: 'system', label: 'System Health', icon: '⬡' },
|
||||
{ id: 'oracle-admin', label: 'Oracle Admin', icon: '◈' },
|
||||
{ id: 'comms-config', label: 'Comms Config', icon: '⟐' },
|
||||
{ id: 'users', label: 'Users & Roles', icon: '⊛' },
|
||||
{ id: 'models', label: 'Model Hydration', icon: '⊗' },
|
||||
{ id: 'meta', label: 'Meta Integration', icon: '⊕' },
|
||||
{ id: 'system', label: 'System Health', icon: 'S' },
|
||||
{ id: 'oracle-admin', label: 'Oracle Admin', icon: 'O' },
|
||||
{ id: 'comms-config', label: 'Comms Config', icon: 'C' },
|
||||
{ id: 'users', label: 'Users & Roles', icon: 'U' },
|
||||
{ id: 'models', label: 'Model Hydration', icon: 'M' },
|
||||
{ id: 'meta', label: 'Meta Integration', icon: 'A' },
|
||||
];
|
||||
|
||||
export default function ControlRoom() {
|
||||
@@ -44,10 +21,9 @@ export default function ControlRoom() {
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<span className={styles.headerIcon}>⚙</span>
|
||||
<span className={styles.headerIcon}>CR</span>
|
||||
<div>
|
||||
<h1 className={styles.title}>Control Room</h1>
|
||||
<p className={styles.subtitle}>System Administration · Admin Access Only</p>
|
||||
@@ -59,7 +35,6 @@ export default function ControlRoom() {
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{/* Left sidebar */}
|
||||
<nav className={styles.sidebar}>
|
||||
{PANELS.map(({ id, label, icon }) => (
|
||||
<button
|
||||
@@ -81,7 +56,6 @@ export default function ControlRoom() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Panel content */}
|
||||
<main className={styles.content}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
@@ -92,12 +66,12 @@ export default function ControlRoom() {
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={styles.panelWrap}
|
||||
>
|
||||
{active === 'system' && <SystemHealthPanel />}
|
||||
{active === 'oracle-admin' && <OracleAdminPanel />}
|
||||
{active === 'comms-config' && <CommsConfigPanel />}
|
||||
{active === 'users' && <UsersPanel />}
|
||||
{active === 'models' && <ModelHydrationPanel />}
|
||||
{active === 'meta' && <MetaIntegrationPanel />}
|
||||
{active === 'system' && <SystemHealthPanel />}
|
||||
{active === 'oracle-admin' && <OracleAdminPanel />}
|
||||
{active === 'comms-config' && <CommsConfigPanel />}
|
||||
{active === 'users' && <UsersPanel />}
|
||||
{active === 'models' && <ModelHydrationPanel />}
|
||||
{active === 'meta' && <MetaIntegrationPanel />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
@@ -106,21 +80,20 @@ export default function ControlRoom() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── System Health ─────────────────────────────────────────────
|
||||
function SystemHealthPanel() {
|
||||
const services = [
|
||||
{ name: 'core-api', status: 'healthy', latency: '42ms', replicas: '2/2' },
|
||||
{ name: 'webos', status: 'healthy', latency: '—', replicas: '2/2' },
|
||||
{ name: 'media-engine', status: 'healthy', latency: '—', replicas: '1/1' },
|
||||
{ name: 'postgres', status: 'healthy', latency: '3ms', replicas: '1/1' },
|
||||
{ name: 'redis', status: 'healthy', latency: '0.4ms', replicas: '1/1' },
|
||||
{ name: 'core-api', status: 'healthy', latency: '42ms', replicas: '2/2' },
|
||||
{ name: 'webos', status: 'healthy', latency: '-', replicas: '2/2' },
|
||||
{ name: 'media-engine', status: 'healthy', latency: '-', replicas: '1/1' },
|
||||
{ name: 'postgres', status: 'healthy', latency: '3ms', replicas: '1/1' },
|
||||
{ name: 'redis', status: 'healthy', latency: '0.4ms', replicas: '1/1' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<h2 className={styles.panelTitle}>System Health</h2>
|
||||
<div className={styles.serviceGrid}>
|
||||
{services.map(svc => (
|
||||
{services.map((svc) => (
|
||||
<div key={svc.name} className={`${styles.svcCard} glass-card`}>
|
||||
<div className={styles.svcTop}>
|
||||
<span
|
||||
@@ -131,25 +104,24 @@ function SystemHealthPanel() {
|
||||
</div>
|
||||
<div className={styles.svcMeta}>
|
||||
<span>Replicas: <code>{svc.replicas}</code></span>
|
||||
{svc.latency !== '—' && <span>Latency: <code>{svc.latency}</code></span>}
|
||||
{svc.latency !== '-' && <span>Latency: <code>{svc.latency}</code></span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* GPU MIG status */}
|
||||
<div className={`${styles.gpuCard} glass-card`}>
|
||||
<h3 className={styles.subTitle}>GPU · RTX 6000 Blackwell · MIG Active</h3>
|
||||
<h3 className={styles.subTitle}>GPU · 4 x NVIDIA L4 · Shared runtime plane</h3>
|
||||
<div className={styles.migSlices}>
|
||||
<div className={styles.migSlice}>
|
||||
<span className={styles.migLabel}>Slice 0 · 48GB</span>
|
||||
<span className={styles.migLabel}>LLM runtime</span>
|
||||
<code className={styles.migService}>SGLang → Qwen3.6 35B</code>
|
||||
<span className={styles.migStatus} style={{ color: 'var(--color-green)' }}>● Loaded</span>
|
||||
<span className={styles.migStatus} style={{ color: 'var(--color-green)' }}>Loaded</span>
|
||||
</div>
|
||||
<div className={styles.migSlice}>
|
||||
<span className={styles.migLabel}>Slice 1 · 48GB</span>
|
||||
<code className={styles.migService}>ComfyUI → Wan 2.2 + Qwen-Image</code>
|
||||
<span className={styles.migStatus} style={{ color: 'var(--color-green)' }}>● Loaded</span>
|
||||
<span className={styles.migLabel}>Media runtime</span>
|
||||
<code className={styles.migService}>ComfyUI → Dream Weaver</code>
|
||||
<span className={styles.migStatus} style={{ color: 'var(--color-green)' }}>Loaded</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,19 +129,17 @@ function SystemHealthPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Oracle Admin ──────────────────────────────────────────────
|
||||
function OracleAdminPanel() {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<h2 className={styles.panelTitle}>Oracle Administration</h2>
|
||||
<p className={styles.panelSubtitle}>
|
||||
Canvas management, schema catalog, and MCP tools.
|
||||
These controls are not visible to broker-role users.
|
||||
Canvas management, schema catalog, data health, and component codebook controls.
|
||||
</p>
|
||||
<div className={styles.adminSection}>
|
||||
<h3 className={styles.subTitle}>Active Canvas Sessions</h3>
|
||||
<p className={styles.muted}>Query canvas history, fork/merge, and revision management available here.</p>
|
||||
<button className="btn-ghost">Open Canvas Manager</button>
|
||||
<p className={styles.muted}>Query canvas history, fork/merge, and revision management from the Oracle page.</p>
|
||||
<button className="btn-ghost" onClick={() => window.location.assign('/oracle')}>Open Oracle Workspace</button>
|
||||
</div>
|
||||
<div className={styles.adminSection}>
|
||||
<h3 className={styles.subTitle}>Schema Catalog</h3>
|
||||
@@ -177,21 +147,18 @@ function OracleAdminPanel() {
|
||||
<button className="btn-ghost">Run Data Health Check →</button>
|
||||
</div>
|
||||
<div className={styles.adminSection}>
|
||||
<h3 className={styles.subTitle}>MCP Tools</h3>
|
||||
<button className="btn-ghost">View Registered Tools →</button>
|
||||
<h3 className={styles.subTitle}>Component Codebook</h3>
|
||||
<p className={styles.muted}>Project Velocity JSON schemas are loaded from the backend Oracle codebook assets.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Comms Config ──────────────────────────────────────────────
|
||||
function CommsConfigPanel() {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<h2 className={styles.panelTitle}>Comms Configuration</h2>
|
||||
<p className={styles.panelSubtitle}>
|
||||
WhatsApp provider setup. Never visible to sales brokers.
|
||||
</p>
|
||||
<p className={styles.panelSubtitle}>WhatsApp provider setup. Never visible to broker-role users.</p>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.formLabel}>Provider</label>
|
||||
<select className={styles.formSelect}>
|
||||
@@ -212,9 +179,7 @@ function CommsConfigPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Users & Roles ─────────────────────────────────────────────
|
||||
function UsersPanel() {
|
||||
const roles = ['ADMIN', 'SALES_DIRECTOR', 'SALES_BROKER'];
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<h2 className={styles.panelTitle}>Users & Roles</h2>
|
||||
@@ -241,18 +206,17 @@ function UsersPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Model Hydration ───────────────────────────────────────────
|
||||
function ModelHydrationPanel() {
|
||||
const models = [
|
||||
{ name: 'Wan 2.2', size: '15 GB', status: 'synced', path: '/opt/models/comfy/wan2.2' },
|
||||
{ name: 'Qwen-Image 2512', size: '20 GB', status: 'synced', path: '/opt/models/comfy/qwen-image-2512' },
|
||||
{ name: 'Qwen3.6 35B A3B', size: '70 GB', status: 'synced', path: '/opt/models/llm/qwen3.6-35b-a3b' },
|
||||
{ name: 'Wan / Dream Weaver media stack', size: 'GPU NVMe', status: 'synced', path: '/opt/dlami/nvme/models/comfy' },
|
||||
{ name: 'Qwen3.6 35B A3B', size: 'GPU NVMe', status: 'synced', path: '/opt/dlami/nvme/models/Qwen-Qwen3.6-35B-A3B-FP8' },
|
||||
{ name: 'Oracle component codebook', size: 'local', status: 'loaded', path: 'core/oracle/oracle/oracle_runtime_codebook_merged.json' },
|
||||
];
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<h2 className={styles.panelTitle}>Model Hydration</h2>
|
||||
<p className={styles.panelSubtitle}>NVMe-backed model store. Re-sync from S3 via s5cmd.</p>
|
||||
{models.map(m => (
|
||||
<p className={styles.panelSubtitle}>NVMe-backed model store and backend component assets.</p>
|
||||
{models.map((m) => (
|
||||
<div key={m.name} className={`${styles.modelRow} glass-card`}>
|
||||
<div>
|
||||
<span className={styles.modelName}>{m.name}</span>
|
||||
@@ -260,29 +224,20 @@ function ModelHydrationPanel() {
|
||||
</div>
|
||||
<div className={styles.modelRight}>
|
||||
<span className={styles.modelSize}>{m.size}</span>
|
||||
<span style={{ color: 'var(--color-green)', fontSize: 'var(--text-xs)' }}>
|
||||
● {m.status}
|
||||
</span>
|
||||
<button className="btn-ghost">Re-sync</button>
|
||||
<span style={{ color: 'var(--color-green)', fontSize: 'var(--text-xs)' }}>{m.status}</span>
|
||||
<button className="btn-ghost">Inspect</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn-ghost" style={{ marginTop: 'var(--space-4)' }}>
|
||||
Run Full Hydration
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Meta Integration ──────────────────────────────────────────
|
||||
function MetaIntegrationPanel() {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<h2 className={styles.panelTitle}>Meta Business Integration</h2>
|
||||
<p className={styles.panelSubtitle}>
|
||||
OAuth, Ad Account, and Lookalike Audience configuration.
|
||||
Never visible to broker-role users.
|
||||
</p>
|
||||
<p className={styles.panelSubtitle}>OAuth, Ad Account, and Lookalike Audience configuration.</p>
|
||||
<div className={styles.metaStatus}>
|
||||
<span className={styles.statusDot} style={{ background: 'var(--color-amber)' }} />
|
||||
<span>OAuth not connected</span>
|
||||
|
||||
@@ -10,3 +10,6 @@
|
||||
.clearBtn { background: none; border: none; color: var(--color-text-tertiary); cursor: pointer; font-size: 14px; padding: var(--space-1); border-radius: var(--radius-sm); }
|
||||
.clearBtn:hover { color: var(--color-text-primary); }
|
||||
.placeholderCard { height: 120px; border-radius: var(--radius-xl); }
|
||||
.resultActions { display: flex; justify-content: flex-end; }
|
||||
.openOracleBtn { border: 1px solid rgba(124,58,237,0.34); border-radius: 999px; background: rgba(124,58,237,0.12); color: var(--color-text-primary); padding: var(--space-2) var(--space-4); cursor: pointer; font-weight: var(--font-semibold); box-shadow: 0 0 20px rgba(124,58,237,0.12); }
|
||||
.openOracleBtn:hover { border-color: rgba(124,58,237,0.62); background: rgba(124,58,237,0.20); }
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/shared/lib/apiClient';
|
||||
import { unwrapArray, unwrapObject } from '@/shared/lib/apiShape';
|
||||
import { OracleResultCard } from './OracleResultCard';
|
||||
import styles from './OracleBar.module.css';
|
||||
|
||||
type QueryState = 'idle' | 'thinking' | 'result';
|
||||
type OracleEnvelope = { status?: string; data?: unknown; detail?: string; message?: string; error?: string };
|
||||
type OracleRow = Record<string, unknown>;
|
||||
|
||||
function normalizeOracleResult(payload: unknown) {
|
||||
const root = unwrapObject<Record<string, unknown>>(payload) ?? {};
|
||||
const rows = unwrapArray<OracleRow>(root, ['rows', 'dataRows']);
|
||||
const components = unwrapArray<Record<string, unknown>>(root, ['components']);
|
||||
const firstComponent = components[0];
|
||||
const componentRows = rows.length > 0 ? rows : unwrapArray<OracleRow>(firstComponent, ['rows', 'dataRows']);
|
||||
const columns = Array.isArray(root.columns)
|
||||
? root.columns.filter((item): item is string => typeof item === 'string')
|
||||
: componentRows[0]
|
||||
? Object.keys(componentRows[0])
|
||||
: [];
|
||||
|
||||
return {
|
||||
...root,
|
||||
title: root.title ?? firstComponent?.title,
|
||||
summary: root.summary ?? firstComponent?.summary,
|
||||
rows: componentRows,
|
||||
columns,
|
||||
rowCount: typeof root.rowCount === 'number' ? root.rowCount : componentRows.length,
|
||||
componentType: root.componentType ?? firstComponent?.type ?? firstComponent?.componentType,
|
||||
sourceTables: Array.isArray(root.sourceTables) ? root.sourceTables : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OracleBar
|
||||
@@ -25,7 +53,74 @@ const SUGGESTIONS = [
|
||||
'What is the average QD score by project?',
|
||||
];
|
||||
|
||||
const NUMBER_WORDS: Record<string, number> = {
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10,
|
||||
eleven: 11,
|
||||
twelve: 12,
|
||||
fifteen: 15,
|
||||
twenty: 20,
|
||||
thirty: 30,
|
||||
fifty: 50,
|
||||
};
|
||||
|
||||
const ORACLE_CHAT_STORAGE_KEY = 'velocity-oracle-chat-sessions-v1';
|
||||
|
||||
function createChatSessionFromCommand(prompt: string, result: any) {
|
||||
const now = new Date().toISOString();
|
||||
const title = prompt.trim().length > 58 ? `${prompt.trim().slice(0, 55)}...` : prompt.trim();
|
||||
const session = {
|
||||
id: `oracle_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: title || 'Oracle Chat',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
turns: [{
|
||||
id: `turn_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
prompt,
|
||||
createdAt: now,
|
||||
status: result?.title === 'Oracle query failed' ? 'failed' : 'done',
|
||||
result,
|
||||
error: result?.title === 'Oracle query failed' ? result?.summary : undefined,
|
||||
}],
|
||||
};
|
||||
|
||||
try {
|
||||
const current = JSON.parse(localStorage.getItem(ORACLE_CHAT_STORAGE_KEY) ?? '[]');
|
||||
const sessions = Array.isArray(current) ? current : [];
|
||||
localStorage.setItem(ORACLE_CHAT_STORAGE_KEY, JSON.stringify([session, ...sessions].slice(0, 30)));
|
||||
} catch {
|
||||
localStorage.setItem(ORACLE_CHAT_STORAGE_KEY, JSON.stringify([session]));
|
||||
}
|
||||
}
|
||||
|
||||
function deriveRowLimit(prompt: string): number {
|
||||
const lowered = prompt.toLowerCase();
|
||||
const numericMatch = lowered.match(/\b(?:top|last|first|lowest|highest|best|worst)\s+(\d{1,3})\b|\b(\d{1,3})\s+(?:clients|leads|properties|projects|records|rows)\b/);
|
||||
const numericLimit = Number(numericMatch?.[1] ?? numericMatch?.[2]);
|
||||
if (Number.isFinite(numericLimit) && numericLimit > 0) {
|
||||
return Math.min(100, Math.max(1, numericLimit));
|
||||
}
|
||||
|
||||
for (const [word, value] of Object.entries(NUMBER_WORDS)) {
|
||||
const phrase = new RegExp(`\\b(?:top|last|first|lowest|highest|best|worst)\\s+${word}\\b|\\b${word}\\s+(?:clients|leads|properties|projects|records|rows)\\b`);
|
||||
if (phrase.test(lowered)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return 25;
|
||||
}
|
||||
|
||||
export function OracleBar() {
|
||||
const navigate = useNavigate();
|
||||
const [queryState, setQueryState] = useState<QueryState>('idle');
|
||||
const [query, setQuery] = useState('');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
@@ -45,25 +140,25 @@ export function OracleBar() {
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/oracle/query', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: query, context: { surface: 'command_pillar' } }),
|
||||
const payload = await api.post<OracleEnvelope>('/oracle/query', {
|
||||
prompt: query,
|
||||
row_limit: deriveRowLimit(query),
|
||||
context: { surface: 'command_pillar' },
|
||||
});
|
||||
const payload = await resp.json().catch(() => ({})) as OracleEnvelope;
|
||||
if (!resp.ok) {
|
||||
throw new Error(payload.detail || payload.message || payload.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
setResult(payload.data ?? payload);
|
||||
const normalized = normalizeOracleResult(payload.data ?? payload);
|
||||
setResult(normalized);
|
||||
createChatSessionFromCommand(query, normalized);
|
||||
setQueryState('result');
|
||||
} catch (error) {
|
||||
setResult({
|
||||
const failed = {
|
||||
title: 'Oracle query failed',
|
||||
summary: error instanceof Error ? error.message : 'Unable to run Oracle query.',
|
||||
rows: [],
|
||||
columns: [],
|
||||
warnings: ['Check the Oracle API route and backend logs.'],
|
||||
});
|
||||
};
|
||||
setResult(failed);
|
||||
createChatSessionFromCommand(query, failed);
|
||||
setQueryState('result');
|
||||
}
|
||||
}, [query, queryState]);
|
||||
@@ -173,7 +268,14 @@ export function OracleBar() {
|
||||
|
||||
{/* ── Result card ──────────────────────────────────── */}
|
||||
{queryState === 'result' && result && (
|
||||
<OracleResultCard result={result} query={query} />
|
||||
<>
|
||||
<div className={styles.resultActions}>
|
||||
<button type="button" className={styles.openOracleBtn} onClick={() => navigate('/oracle')}>
|
||||
Open full Oracle chat
|
||||
</button>
|
||||
</div>
|
||||
<OracleResultCard result={result} query={query} />
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
337
webos/src/pillars/command/OracleChatConsole.module.css
Normal file
337
webos/src/pillars/command/OracleChatConsole.module.css
Normal file
@@ -0,0 +1,337 @@
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
min-height: 540px;
|
||||
border: var(--glass-border);
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 20% 0%, rgba(124, 58, 237, 0.16), transparent 34%),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.025));
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.34), 0 0 44px rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
border-right: var(--glass-border);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--color-violet-light);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-bold);
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
.caption {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.railButton,
|
||||
.sessionButton,
|
||||
.searchInput,
|
||||
.shareButton {
|
||||
width: 100%;
|
||||
border: var(--glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.railButton,
|
||||
.shareButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-semibold);
|
||||
transition: border-color var(--duration-fast), box-shadow var(--duration-fast), color var(--duration-fast), background var(--duration-fast);
|
||||
}
|
||||
|
||||
.railButton:hover,
|
||||
.shareButton:hover {
|
||||
border-color: rgba(124, 58, 237, 0.48);
|
||||
background: rgba(124, 58, 237, 0.13);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: 0 0 26px rgba(124, 58, 237, 0.16);
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
color: white;
|
||||
border-color: rgba(124, 58, 237, 0.55);
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.85), rgba(59, 130, 246, 0.55));
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
min-height: 42px;
|
||||
padding: 0 var(--space-4);
|
||||
outline: none;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: rgba(124, 58, 237, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.16);
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-bold);
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sessionButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sessionButton:hover,
|
||||
.sessionActive {
|
||||
border-color: rgba(59, 130, 246, 0.42);
|
||||
background: rgba(59, 130, 246, 0.11);
|
||||
box-shadow: 0 0 28px rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
|
||||
.sessionGlyph {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(124, 58, 237, 0.35);
|
||||
background: rgba(124, 58, 237, 0.16);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
.sessionMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sessionTitle {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
.sessionSub {
|
||||
margin-top: 3px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.emptySessions {
|
||||
padding: var(--space-4);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.main {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-bottom: var(--glass-border);
|
||||
}
|
||||
|
||||
.topbarTitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
.topbarSub {
|
||||
margin: 3px 0 0;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.statusPill {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.24);
|
||||
background: rgba(16, 185, 129, 0.09);
|
||||
color: #86efac;
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-bold);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-6);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.emptyCanvas {
|
||||
margin: auto;
|
||||
max-width: 430px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto var(--space-4);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(124, 58, 237, 0.22);
|
||||
background: rgba(124, 58, 237, 0.10);
|
||||
box-shadow: 0 0 34px rgba(124, 58, 237, 0.16);
|
||||
color: var(--color-violet-light);
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.emptyCanvas h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.turn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.userBubble {
|
||||
align-self: flex-end;
|
||||
max-width: min(780px, 88%);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: 20px 20px 6px 20px;
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.28), rgba(59, 130, 246, 0.16));
|
||||
border: 1px solid rgba(124, 58, 237, 0.28);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.thinkingCard {
|
||||
min-height: 120px;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.composer {
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-top: var(--glass-border);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.composerInner {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid rgba(124, 58, 237, 0.28);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
box-shadow: 0 0 34px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
|
||||
.composerInput {
|
||||
flex: 1;
|
||||
min-height: 38px;
|
||||
max-height: 130px;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.composerInput::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--color-violet), #2563eb);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 0 26px rgba(124, 58, 237, 0.36);
|
||||
}
|
||||
|
||||
.sendButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rail {
|
||||
border-right: 0;
|
||||
border-bottom: var(--glass-border);
|
||||
}
|
||||
|
||||
.sessions {
|
||||
max-height: 220px;
|
||||
}
|
||||
}
|
||||
341
webos/src/pillars/command/OracleChatConsole.tsx
Normal file
341
webos/src/pillars/command/OracleChatConsole.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { api } from '../../shared/lib/apiClient';
|
||||
import { unwrapArray, unwrapObject } from '../../shared/lib/apiShape';
|
||||
import { OracleResultCard } from './OracleResultCard';
|
||||
import styles from './OracleChatConsole.module.css';
|
||||
|
||||
type OracleRow = Record<string, unknown>;
|
||||
|
||||
interface OracleResult {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
columns?: string[];
|
||||
rows?: OracleRow[];
|
||||
rowCount?: number;
|
||||
componentType?: string;
|
||||
warnings?: string[];
|
||||
sourceTables?: string[];
|
||||
}
|
||||
|
||||
interface OracleTurn {
|
||||
id: string;
|
||||
prompt: string;
|
||||
createdAt: string;
|
||||
status: 'thinking' | 'done' | 'failed';
|
||||
result?: OracleResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OracleSession {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
turns: OracleTurn[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'velocity-oracle-chat-sessions-v1';
|
||||
|
||||
function makeId(prefix: string) {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function createSession(title = 'New Oracle Chat'): OracleSession {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: makeId('oracle'),
|
||||
title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
turns: [],
|
||||
};
|
||||
}
|
||||
|
||||
function deriveTitle(prompt: string) {
|
||||
const clean = prompt.trim().replace(/\s+/g, ' ');
|
||||
return clean.length > 58 ? `${clean.slice(0, 55)}...` : clean || 'Oracle Chat';
|
||||
}
|
||||
|
||||
function normalizeResult(raw: unknown, prompt: string): OracleResult {
|
||||
const body = unwrapObject<Record<string, unknown>>(raw) ?? {};
|
||||
const data = unwrapObject<Record<string, unknown>>(body.data ?? body.result ?? body.payload ?? body) ?? {};
|
||||
const components = unwrapArray<Record<string, unknown>>(data.components ?? data.componentPlan ?? data.canvasComponents);
|
||||
const firstComponent = unwrapObject<Record<string, unknown>>(components[0]) ?? {};
|
||||
const rows = unwrapArray<OracleRow>(data.rows ?? data.dataRows ?? firstComponent.dataRows ?? firstComponent.rows);
|
||||
const columns = unwrapArray<unknown>(data.columns ?? firstComponent.columns);
|
||||
const sourceTables = unwrapArray<unknown>(data.sourceTables ?? data.source_tables ?? firstComponent.sourceTables);
|
||||
const warnings = unwrapArray<unknown>(data.warnings ?? firstComponent.warnings);
|
||||
|
||||
return {
|
||||
title: String(data.title ?? firstComponent.title ?? `Oracle result for "${prompt}"`),
|
||||
summary: String(data.summary ?? firstComponent.summary ?? data.sqlSummary ?? ''),
|
||||
rows,
|
||||
columns: columns.length ? columns.map(String) : rows[0] ? Object.keys(rows[0]) : [],
|
||||
rowCount: Number(data.rowCount ?? data.row_count ?? rows.length),
|
||||
componentType: String(data.componentType ?? data.component_type ?? firstComponent.type ?? firstComponent.componentType ?? 'table'),
|
||||
warnings: warnings.map(String),
|
||||
sourceTables: sourceTables.map(String),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveRowLimit(prompt: string) {
|
||||
const numeric = prompt.match(/\b(?:top|last|first|lowest|highest)?\s*(\d{1,3})\b/i);
|
||||
if (numeric) return Math.min(Math.max(Number(numeric[1]), 1), 100);
|
||||
const words: Record<string, number> = {
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10,
|
||||
};
|
||||
const word = prompt.toLowerCase().match(/\b(one|two|three|four|five|six|seven|eight|nine|ten)\b/);
|
||||
return word ? words[word[1]] : 10;
|
||||
}
|
||||
|
||||
function relativeTime(value: string) {
|
||||
const delta = Date.now() - new Date(value).getTime();
|
||||
const minutes = Math.max(1, Math.floor(delta / 60000));
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
function loadSessions(): OracleSession[] {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
|
||||
return Array.isArray(parsed) && parsed.length ? parsed : [createSession('Oracle Main Canvas')];
|
||||
} catch {
|
||||
return [createSession('Oracle Main Canvas')];
|
||||
}
|
||||
}
|
||||
|
||||
export default function OracleChatConsole() {
|
||||
const [sessions, setSessions] = useState<OracleSession[]>(loadSessions);
|
||||
const [activeId, setActiveId] = useState(() => sessions[0]?.id ?? '');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [shareNote, setShareNote] = useState('');
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((session) => session.id === activeId) ?? sessions[0],
|
||||
[activeId, sessions],
|
||||
);
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
const sorted = [...sessions].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
if (!term) return sorted;
|
||||
return sorted.filter((session) =>
|
||||
session.title.toLowerCase().includes(term)
|
||||
|| session.turns.some((turn) => turn.prompt.toLowerCase().includes(term)),
|
||||
);
|
||||
}, [search, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions.slice(0, 30)));
|
||||
}, [sessions]);
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
const session = createSession();
|
||||
setSessions((current) => [session, ...current]);
|
||||
setActiveId(session.id);
|
||||
setPrompt('');
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}, []);
|
||||
|
||||
const submitPrompt = useCallback(async () => {
|
||||
const clean = prompt.trim();
|
||||
if (!clean || !activeSession) return;
|
||||
|
||||
const turnId = makeId('turn');
|
||||
const now = new Date().toISOString();
|
||||
setPrompt('');
|
||||
setShareNote('');
|
||||
|
||||
setSessions((current) => current.map((session) => {
|
||||
if (session.id !== activeSession.id) return session;
|
||||
const title = session.turns.length === 0 && session.title === 'New Oracle Chat'
|
||||
? deriveTitle(clean)
|
||||
: session.title;
|
||||
return {
|
||||
...session,
|
||||
title,
|
||||
updatedAt: now,
|
||||
turns: [
|
||||
...session.turns,
|
||||
{ id: turnId, prompt: clean, createdAt: now, status: 'thinking' },
|
||||
],
|
||||
};
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await api.post('/oracle/query', {
|
||||
prompt: clean,
|
||||
row_limit: deriveRowLimit(clean),
|
||||
context: {
|
||||
surface: 'velocity_os_oracle_chat',
|
||||
session_id: activeSession.id,
|
||||
},
|
||||
});
|
||||
const result = normalizeResult(response, clean);
|
||||
setSessions((current) => current.map((session) => {
|
||||
if (session.id !== activeSession.id) return session;
|
||||
return {
|
||||
...session,
|
||||
updatedAt: new Date().toISOString(),
|
||||
turns: session.turns.map((turn) => (
|
||||
turn.id === turnId ? { ...turn, status: 'done', result } : turn
|
||||
)),
|
||||
};
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Oracle request failed';
|
||||
setSessions((current) => current.map((session) => {
|
||||
if (session.id !== activeSession.id) return session;
|
||||
return {
|
||||
...session,
|
||||
updatedAt: new Date().toISOString(),
|
||||
turns: session.turns.map((turn) => (
|
||||
turn.id === turnId ? { ...turn, status: 'failed', error: message } : turn
|
||||
)),
|
||||
};
|
||||
}));
|
||||
}
|
||||
}, [activeSession, prompt]);
|
||||
|
||||
const shareSession = useCallback(async () => {
|
||||
if (!activeSession) return;
|
||||
const payload = JSON.stringify(activeSession, null, 2);
|
||||
try {
|
||||
await navigator.clipboard.writeText(payload);
|
||||
setShareNote('Chat copied for sharing');
|
||||
} catch {
|
||||
setShareNote('Share export prepared in browser memory');
|
||||
}
|
||||
}, [activeSession]);
|
||||
|
||||
return (
|
||||
<section className={styles.shell} aria-label="Oracle chat canvas">
|
||||
<aside className={styles.rail}>
|
||||
<div>
|
||||
<p className={styles.eyebrow}>Oracle Canvas</p>
|
||||
<h2 className={styles.title}>Ask, save, share</h2>
|
||||
<p className={styles.caption}>Every search becomes a chat tab with persistent visual results.</p>
|
||||
</div>
|
||||
|
||||
<button className={`${styles.railButton} ${styles.primaryButton}`} type="button" onClick={startNewChat}>
|
||||
+ New Chat
|
||||
</button>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search chats..."
|
||||
/>
|
||||
|
||||
<p className={styles.sectionLabel}>Recent</p>
|
||||
<div className={styles.sessions}>
|
||||
{filteredSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
className={`${styles.sessionButton} ${session.id === activeSession?.id ? styles.sessionActive : ''}`}
|
||||
onClick={() => setActiveId(session.id)}
|
||||
>
|
||||
<span className={styles.sessionGlyph}>{session.title.slice(0, 1).toUpperCase()}</span>
|
||||
<span className={styles.sessionMeta}>
|
||||
<span className={styles.sessionTitle}>{session.title}</span>
|
||||
<span className={styles.sessionSub}>{session.turns.length} prompts · {relativeTime(session.updatedAt)}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{!filteredSessions.length && <div className={styles.emptySessions}>No matching chats.</div>}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className={styles.topbar}>
|
||||
<div>
|
||||
<h1 className={styles.topbarTitle}>{activeSession?.title ?? 'Oracle Canvas'}</h1>
|
||||
<p className={styles.topbarSub}>Natural CRM intelligence over live Velocity data</p>
|
||||
</div>
|
||||
<div>
|
||||
<button className={styles.shareButton} type="button" onClick={shareSession}>Share / Export</button>
|
||||
{shareNote && <p className={styles.notice}>{shareNote}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.thread}>
|
||||
{!activeSession?.turns.length && (
|
||||
<div className={styles.emptyCanvas}>
|
||||
<div className={styles.emptyIcon}>✦</div>
|
||||
<h3>Start with a business question</h3>
|
||||
<p>Example: “Which clients have not been contacted in 7 days but have high QD?”</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSession?.turns.map((turn) => (
|
||||
<motion.article
|
||||
key={turn.id}
|
||||
className={styles.turn}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className={styles.userBubble}>{turn.prompt}</div>
|
||||
{turn.status === 'thinking' && <div className={`${styles.thinkingCard} glass-card skeleton-shimmer`} />}
|
||||
{turn.status === 'failed' && (
|
||||
<OracleResultCard
|
||||
query={turn.prompt}
|
||||
result={{
|
||||
title: 'Oracle request failed',
|
||||
summary: turn.error,
|
||||
rows: [],
|
||||
warnings: turn.error ? [turn.error] : [],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{turn.status === 'done' && turn.result && (
|
||||
<OracleResultCard result={turn.result} query={turn.prompt} />
|
||||
)}
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={styles.composer}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitPrompt();
|
||||
}}
|
||||
>
|
||||
<div className={styles.composerInner}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={styles.composerInput}
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void submitPrompt();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask Oracle anything about clients, properties, QD, calls, visits, campaigns..."
|
||||
/>
|
||||
<button className={styles.sendButton} type="submit" disabled={!prompt.trim()}>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
14
webos/src/pillars/command/OracleWorkspacePage.module.css
Normal file
14
webos/src/pillars/command/OracleWorkspacePage.module.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.root {
|
||||
min-height: 100%;
|
||||
padding: var(--space-8);
|
||||
}
|
||||
|
||||
.shellWrap {
|
||||
min-height: calc(100vh - 96px);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.root {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
12
webos/src/pillars/command/OracleWorkspacePage.tsx
Normal file
12
webos/src/pillars/command/OracleWorkspacePage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import OracleChatConsole from './OracleChatConsole';
|
||||
import styles from './OracleWorkspacePage.module.css';
|
||||
|
||||
export default function OracleWorkspacePage() {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.shellWrap}>
|
||||
<OracleChatConsole />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,11 @@
|
||||
.liveContent { display: flex; flex-direction: column; gap: var(--space-5); flex: 1; }
|
||||
.twoCol { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-5); }
|
||||
/* Silhouette */
|
||||
.vizPanel { display: flex; align-items: center; justify-content: center; min-height: 240px; border-radius: var(--radius-xl); }
|
||||
.vizPanel { display: flex; flex-direction: column; align-items: stretch; justify-content: center; gap: var(--space-4); min-height: 320px; border-radius: var(--radius-xl); padding: var(--space-4); }
|
||||
.cameraFrame { flex: 1; min-height: 240px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-lg); overflow: hidden; background: radial-gradient(circle at 50% 30%, rgba(124,58,237,0.18), transparent 42%), rgba(0,0,0,0.28); border: var(--glass-border); }
|
||||
.cameraVideo { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.cameraActions { display: flex; align-items: center; gap: var(--space-3); justify-content: space-between; flex-wrap: wrap; }
|
||||
.cameraError { font-size: var(--text-xs); color: var(--color-amber); max-width: 70%; }
|
||||
.silhouetteWrap { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); }
|
||||
.silhouette { width: 120px; height: 180px; opacity: 0.7; }
|
||||
.zoneLabel { font-size: var(--text-xs); color: var(--color-text-tertiary); }
|
||||
|
||||
@@ -29,7 +29,10 @@ export default function ShowroomMode() {
|
||||
const navigate = useNavigate();
|
||||
const [phase, setPhase] = useState<ShowroomPhase>('live');
|
||||
const [elapsed, setElapsed] = useState(0); // seconds
|
||||
const [cameraStream, setCameraStream] = useState<MediaStream | null>(null);
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const {
|
||||
isShowroomActive,
|
||||
@@ -45,8 +48,15 @@ export default function ShowroomMode() {
|
||||
timerRef.current = setInterval(() => setElapsed(s => s + 1), 1000);
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
cameraStream?.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
}, []);
|
||||
}, [cameraStream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current && cameraStream) {
|
||||
videoRef.current.srcObject = cameraStream;
|
||||
}
|
||||
}, [cameraStream]);
|
||||
|
||||
// End session → summary
|
||||
const handleEndSession = () => {
|
||||
@@ -57,9 +67,28 @@ export default function ShowroomMode() {
|
||||
|
||||
const handleExitShowroom = () => {
|
||||
clearPendingAlert();
|
||||
cameraStream?.getTracks().forEach((track) => track.stop());
|
||||
navigate('/pipeline');
|
||||
};
|
||||
|
||||
const handleStartCamera = async () => {
|
||||
try {
|
||||
setCameraError(null);
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
audio: false,
|
||||
});
|
||||
setCameraStream(stream);
|
||||
} catch (error) {
|
||||
setCameraError(error instanceof Error ? error.message : 'Camera permission was denied.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopCamera = () => {
|
||||
cameraStream?.getTracks().forEach((track) => track.stop());
|
||||
setCameraStream(null);
|
||||
};
|
||||
|
||||
const formatTime = (s: number) =>
|
||||
`${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
|
||||
|
||||
@@ -96,7 +125,19 @@ export default function ShowroomMode() {
|
||||
<div className={styles.twoCol}>
|
||||
{/* Left: Anonymized silhouette visualization */}
|
||||
<div className={`${styles.vizPanel} glass`}>
|
||||
<SilhouetteViz zone={session?.currentZone} />
|
||||
<div className={styles.cameraFrame}>
|
||||
{cameraStream ? (
|
||||
<video ref={videoRef} className={styles.cameraVideo} autoPlay playsInline muted />
|
||||
) : (
|
||||
<SilhouetteViz zone={session?.currentZone} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.cameraActions}>
|
||||
<button className="btn-ghost" onClick={cameraStream ? handleStopCamera : () => void handleStartCamera()}>
|
||||
{cameraStream ? 'Stop Camera' : 'Start Camera'}
|
||||
</button>
|
||||
{cameraError && <span className={styles.cameraError}>{cameraError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: QD Engagement */}
|
||||
|
||||
@@ -35,9 +35,14 @@ export default function Client360() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('conversations');
|
||||
|
||||
const { client, isLoading, error } = useClient360(personId!);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab('conversations');
|
||||
}, [personId]);
|
||||
|
||||
const backToPipeline = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['kanban'] });
|
||||
navigate('/pipeline', { replace: true });
|
||||
navigate('/pipeline');
|
||||
};
|
||||
|
||||
if (isLoading) return <Client360Skeleton />;
|
||||
@@ -168,7 +173,7 @@ export default function Client360() {
|
||||
<div className={styles.tabContent}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
key={`${personId}:${activeTab}`}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
|
||||
@@ -13,7 +13,8 @@ export function useClient360(personId: string) {
|
||||
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
|
||||
return mapClient360(normalizeSnapshot(payload));
|
||||
},
|
||||
staleTime: 30_000,
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
enabled: !!personId,
|
||||
});
|
||||
return { client: query.data, isLoading: query.isLoading, error: query.error };
|
||||
@@ -30,7 +31,8 @@ export function useConversations(personId: string) {
|
||||
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
|
||||
return mapConversationEvents(normalizeSnapshot(payload));
|
||||
},
|
||||
staleTime: 10_000,
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
enabled: !!personId,
|
||||
});
|
||||
|
||||
@@ -51,7 +53,8 @@ export function useClientProperties(personId: string) {
|
||||
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
|
||||
return mapPropertyInterests(normalizeSnapshot(payload));
|
||||
},
|
||||
staleTime: 60_000,
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
enabled: !!personId,
|
||||
});
|
||||
return { properties: query.data ?? [], isLoading: query.isLoading };
|
||||
@@ -68,7 +71,8 @@ export function useClientTasks(personId: string) {
|
||||
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
|
||||
return mapTasks(normalizeSnapshot(payload));
|
||||
},
|
||||
staleTime: 30_000,
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
enabled: !!personId,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ export function useKanban() {
|
||||
queryKey: ['kanban'],
|
||||
queryFn: async () => {
|
||||
const payload = await api.get<unknown>('/crm/pipeline/kanban?limit=250&offset=0');
|
||||
return unwrapArray<RawKanbanStage>(payload).map(normalizeStage);
|
||||
return unwrapArray<RawKanbanStage>(payload, ['stages', 'kanban', 'columns', 'board']).map(normalizeStage);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
staleTime: 0,
|
||||
refetchInterval: 60_000,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: true,
|
||||
@@ -51,30 +51,49 @@ interface RawKanbanStage {
|
||||
stage?: string;
|
||||
status?: string;
|
||||
label?: string;
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
leads?: unknown;
|
||||
items?: unknown;
|
||||
data?: unknown;
|
||||
records?: unknown;
|
||||
rows?: unknown;
|
||||
}
|
||||
|
||||
function normalizeStage(stage: RawKanbanStage): KanbanStage {
|
||||
const id = String(stage.id ?? stage.stage ?? stage.status ?? 'new');
|
||||
const leads = unwrapArray<KanbanLead>(stage.leads ?? stage.items ?? stage.data).map(normalizeLead);
|
||||
const leads = unwrapArray<RawKanbanLead>(stage, ['leads', 'items', 'data', 'records', 'rows']).map(normalizeLead);
|
||||
return {
|
||||
id,
|
||||
label: String(stage.label ?? id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())),
|
||||
label: String(stage.label ?? stage.name ?? id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())),
|
||||
emoji: String(stage.emoji ?? id.slice(0, 1).toUpperCase()),
|
||||
leads,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLead(lead: Partial<KanbanLead>): KanbanLead {
|
||||
interface RawKanbanLead extends Partial<KanbanLead> {
|
||||
person_id?: string;
|
||||
lead_id?: string;
|
||||
client_id?: string;
|
||||
full_name?: string;
|
||||
client_name?: string;
|
||||
project_name?: string;
|
||||
projects?: string;
|
||||
qd_score?: number | string;
|
||||
score?: number | string;
|
||||
last_contact_relative?: string;
|
||||
last_contact_channel?: string;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
function normalizeLead(lead: RawKanbanLead): KanbanLead {
|
||||
return {
|
||||
...lead,
|
||||
id: String(lead.id),
|
||||
name: String(lead.name ?? 'Unnamed Client'),
|
||||
qdScore: Number.isFinite(Number(lead.qdScore)) ? Number(lead.qdScore) : 0,
|
||||
lastContactRelative: String(lead.lastContactRelative ?? 'No contact yet'),
|
||||
lastContactChannel: String(lead.lastContactChannel ?? 'crm'),
|
||||
id: String(lead.id ?? lead.person_id ?? lead.lead_id ?? lead.client_id ?? crypto.randomUUID()),
|
||||
name: String(lead.name ?? lead.full_name ?? lead.client_name ?? 'Unnamed Client'),
|
||||
location: lead.location ?? lead.project_name ?? lead.projects,
|
||||
qdScore: Number.isFinite(Number(lead.qdScore ?? lead.qd_score ?? lead.score)) ? Number(lead.qdScore ?? lead.qd_score ?? lead.score) : 0,
|
||||
lastContactRelative: String(lead.lastContactRelative ?? lead.last_contact_relative ?? 'No contact yet'),
|
||||
lastContactChannel: String(lead.lastContactChannel ?? lead.last_contact_channel ?? lead.channel ?? 'crm'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,8 +19,9 @@ export function AuthenticatedShell() {
|
||||
// Determine spatial direction for pillar transitions
|
||||
const getPillarIndex = (path: string): number => {
|
||||
if (path.startsWith('/command')) return 0;
|
||||
if (path.startsWith('/pipeline')) return 1;
|
||||
if (path.startsWith('/studio')) return 2;
|
||||
if (path.startsWith('/oracle')) return 1;
|
||||
if (path.startsWith('/pipeline')) return 2;
|
||||
if (path.startsWith('/studio')) return 3;
|
||||
return -1;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ const PILLARS = [
|
||||
sublabel: 'Morning Briefing',
|
||||
icon: CommandIcon,
|
||||
},
|
||||
{
|
||||
path: '/oracle',
|
||||
label: 'Oracle',
|
||||
sublabel: 'AI Canvas',
|
||||
icon: OracleIcon,
|
||||
},
|
||||
{
|
||||
path: '/pipeline',
|
||||
label: 'Pipeline',
|
||||
@@ -117,6 +123,27 @@ function CommandIcon({ active }: { active: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function OracleIcon({ active }: { active: boolean }) {
|
||||
const c = active ? 'var(--color-violet-light)' : 'currentColor';
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M10 2.4l1.45 4.3 4.15 1.5-4.15 1.5L10 14l-1.45-4.3-4.15-1.5 4.15-1.5L10 2.4Z"
|
||||
fill={active ? 'var(--color-violet)' : 'transparent'}
|
||||
stroke={c}
|
||||
strokeWidth="1.35"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.4 12.5l.55 1.55 1.55.55-1.55.55-.55 1.55-.55-1.55-1.55-.55 1.55-.55.55-1.55Z"
|
||||
stroke={c}
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineIcon({ active }: { active: boolean }) {
|
||||
const c = active ? 'var(--color-violet-light)' : 'currentColor';
|
||||
return (
|
||||
|
||||
@@ -18,6 +18,14 @@ export function unwrapArray<T>(payload: unknown, keys: string[] = []): T[] {
|
||||
if (Array.isArray(candidate)) return candidate as T[];
|
||||
}
|
||||
|
||||
for (const key of ['data', 'result', 'payload', 'body']) {
|
||||
const candidate = payload[key];
|
||||
if (isRecord(candidate)) {
|
||||
const nested = unwrapArray<T>(candidate, keys);
|
||||
if (nested.length > 0) return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
|
||||
const ZUSTAND_AUTH_KEY = 'velocity-auth';
|
||||
|
||||
function getPersistedAuthToken(): string | null {
|
||||
const raw = window.localStorage.getItem(ZUSTAND_AUTH_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { state?: { token?: unknown } };
|
||||
return typeof parsed.state?.token === 'string' && parsed.state.token.length > 0 ? parsed.state.token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getVelocityToken(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return window.localStorage.getItem(VELOCITY_TOKEN_KEY);
|
||||
return window.localStorage.getItem(VELOCITY_TOKEN_KEY) ?? getPersistedAuthToken();
|
||||
}
|
||||
|
||||
export function setVelocityToken(token: string) {
|
||||
|
||||
@@ -50,6 +50,13 @@ export default defineConfig({
|
||||
outDir: 'dist',
|
||||
sourcemap: false, // no source maps in production build
|
||||
minify: 'esbuild',
|
||||
modulePreload: {
|
||||
resolveDependencies(_url, deps) {
|
||||
// Three.js is only needed after entering Studio/property 3D routes.
|
||||
// Do not preload the heavy R3F chunk on the Command/Pipeline landing path.
|
||||
return deps.filter((dep) => !dep.includes('vendor-three'));
|
||||
},
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Content-hash filenames for immutable caching
|
||||
|
||||
Reference in New Issue
Block a user