feat: New Chat, Search Chat and Master Slave DB Architecture for CRM and Oracle Canvas
This commit is contained in:
@@ -1,23 +1,32 @@
|
|||||||
'use client';
|
'use client';
|
||||||
/**
|
|
||||||
* Oracle Page — Orchestration Host (v2 Refactor)
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
* Implements the vertical JSON canvas architecture from the Oracle spec §10–§13.
|
|
||||||
*
|
|
||||||
* Architecture:
|
|
||||||
* BranchBar — page identity, branch, revision, quick actions (top)
|
|
||||||
* CanvasViewport — virtualized component canvas (center, flex-1)
|
|
||||||
* PromptRail — durable execution history (right sidebar, collapsible)
|
|
||||||
* PromptBar — floating bottom prompt input (preserved premium glass treatment)
|
|
||||||
* ShareModal — fork-based sharing (overlay)
|
|
||||||
* RollbackConfirmModal — revision history + rollback (overlay)
|
|
||||||
* MergeReviewDrawer — diff/conflict reviewer (right drawer overlay)
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Send, Mic, Kanban, Users, Phone, MapPinned, CalendarClock, ChevronDown, History, BarChart2, Database } from 'lucide-react';
|
import {
|
||||||
|
Send,
|
||||||
|
Mic,
|
||||||
|
Kanban,
|
||||||
|
Users,
|
||||||
|
Phone,
|
||||||
|
MapPinned,
|
||||||
|
CalendarClock,
|
||||||
|
ChevronDown,
|
||||||
|
History,
|
||||||
|
BarChart2,
|
||||||
|
Database,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
MessageSquarePlus,
|
||||||
|
Sparkles,
|
||||||
|
PanelLeft,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import type { CanvasPageRevision, MergeRequest, UserProfile } from '@/oracle/types/canvas';
|
import type { CanvasPage, CanvasPageRevision, MergeRequest, UserProfile } from '@/oracle/types/canvas';
|
||||||
import type { ComponentRenderContext } from '@/oracle/components/ComponentRegistry';
|
import type { ComponentRenderContext } from '@/oracle/components/ComponentRegistry';
|
||||||
import { useOraclePage } from '@/oracle/hooks/useOraclePage';
|
import { useOraclePage } from '@/oracle/hooks/useOraclePage';
|
||||||
import { useOracleExecution } from '@/oracle/hooks/useOracleExecution';
|
import { useOracleExecution } from '@/oracle/hooks/useOracleExecution';
|
||||||
@@ -28,19 +37,29 @@ import { PromptRail } from '@/oracle/components/PromptRail';
|
|||||||
import { ShareModal } from '@/oracle/components/ShareModal';
|
import { ShareModal } from '@/oracle/components/ShareModal';
|
||||||
import { RollbackConfirmModal } from '@/oracle/components/RollbackConfirmModal';
|
import { RollbackConfirmModal } from '@/oracle/components/RollbackConfirmModal';
|
||||||
import { MergeReviewDrawer } from '@/oracle/components/review/MergeReviewDrawer';
|
import { MergeReviewDrawer } from '@/oracle/components/review/MergeReviewDrawer';
|
||||||
import { createFork, fetchMe, listRevisions, reviewMergeRequest, rollbackPage } from '@/oracle/lib/oracleApiClient';
|
import {
|
||||||
|
createCanvasPage,
|
||||||
|
createFork,
|
||||||
|
deleteCanvasPage,
|
||||||
|
fetchMe,
|
||||||
|
listCanvasPages,
|
||||||
|
listRevisions,
|
||||||
|
renameCanvasPage,
|
||||||
|
reviewMergeRequest,
|
||||||
|
rollbackPage,
|
||||||
|
} from '@/oracle/lib/oracleApiClient';
|
||||||
|
|
||||||
const PROMPT_MODES: Array<{ view: string; label: string; samplePrompt: string; icon: React.ComponentType<{ className?: string }> }> = [
|
type OracleSubtab = 'canvas' | 'client-data';
|
||||||
|
|
||||||
|
const PROMPT_MODES: Array<{ view: string; label: string; samplePrompt: string; icon: LucideIcon }> = [
|
||||||
{ view: 'pipeline', label: 'Pipeline', samplePrompt: 'Show me a pipeline view by stage for Q2 2026.', icon: Kanban },
|
{ view: 'pipeline', label: 'Pipeline', samplePrompt: 'Show me a pipeline view by stage for Q2 2026.', icon: Kanban },
|
||||||
{ view: 'team_performance', label: 'Team Performance', samplePrompt: 'What is the performance of the sales team this month?', icon: Users },
|
{ view: 'team_performance', label: 'Team Performance', samplePrompt: 'What is the performance of the sales team this month?', icon: Users },
|
||||||
{ view: 'account_timeline', label: 'Account Timeline', samplePrompt: "Find all activities for Apex Innovations this quarter.", icon: Phone },
|
{ view: 'account_timeline', label: 'Account Timeline', samplePrompt: 'Find all activities for Apex Innovations this quarter.', icon: Phone },
|
||||||
{ view: 'lead_map', label: 'Geographic Map', samplePrompt: 'Show me a map of all whale leads in Dubai Marina.', icon: MapPinned },
|
{ view: 'lead_map', label: 'Geographic Map', samplePrompt: 'Show me a map of all whale leads in Dubai Marina.', icon: MapPinned },
|
||||||
{ view: 'calendar_tasks', label: 'Calendar & Tasks', samplePrompt: 'Schedule follow-ups with the top 3 leads with no contact in 72h.', icon: CalendarClock },
|
{ view: 'calendar_tasks', label: 'Calendar & Tasks', samplePrompt: 'Schedule follow-ups with the top 3 leads with no contact in 72h.', icon: CalendarClock },
|
||||||
{ view: 'kpi', label: 'KPI Summary', samplePrompt: 'Give me a KPI summary of total pipeline value today.', icon: BarChart2 },
|
{ view: 'kpi', label: 'KPI Summary', samplePrompt: 'Give me a KPI summary of total pipeline value today.', icon: BarChart2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Render context ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const BASE_CTX: ComponentRenderContext = {
|
const BASE_CTX: ComponentRenderContext = {
|
||||||
tenantId: '',
|
tenantId: '',
|
||||||
actorRole: 'sales_director',
|
actorRole: 'sales_director',
|
||||||
@@ -48,7 +67,33 @@ const BASE_CTX: ComponentRenderContext = {
|
|||||||
density: 'comfortable',
|
density: 'comfortable',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Oracle Page ───────────────────────────────────────────────────────────────
|
function formatRelativeTime(value: string): string {
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
if (Number.isNaN(timestamp)) return 'just now';
|
||||||
|
const deltaMs = Date.now() - timestamp;
|
||||||
|
const minutes = Math.floor(deltaMs / 60000);
|
||||||
|
if (minutes < 1) return 'just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
if (weeks < 4) return `${weeks}w ago`;
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
return `${months}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveChatTitle(prompt: string): string {
|
||||||
|
const compact = prompt.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!compact) return 'Untitled Canvas';
|
||||||
|
return compact.length > 64 ? `${compact.slice(0, 61)}...` : compact;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUntitledPage(title?: string | null): boolean {
|
||||||
|
const normalized = (title ?? '').trim().toLowerCase();
|
||||||
|
return !normalized || normalized === 'untitled canvas' || normalized === 'oracle canvas';
|
||||||
|
}
|
||||||
|
|
||||||
export default function OraclePage() {
|
export default function OraclePage() {
|
||||||
const [me, setMe] = useState<UserProfile | null>(null);
|
const [me, setMe] = useState<UserProfile | null>(null);
|
||||||
@@ -56,28 +101,49 @@ export default function OraclePage() {
|
|||||||
const [revisions, setRevisions] = useState<CanvasPageRevision[]>([]);
|
const [revisions, setRevisions] = useState<CanvasPageRevision[]>([]);
|
||||||
const [revisionsLoading, setRevisionsLoading] = useState(false);
|
const [revisionsLoading, setRevisionsLoading] = useState(false);
|
||||||
|
|
||||||
// Page state & WebSocket
|
const [canvasPages, setCanvasPages] = useState<CanvasPage[]>([]);
|
||||||
const { page, isLoading, error: pageError, isConnected, applyRevision, refresh } = useOraclePage(me?.defaultPageId ?? null);
|
const [pagesLoading, setPagesLoading] = useState(false);
|
||||||
|
const [pagesError, setPagesError] = useState<string | null>(null);
|
||||||
|
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [pageMenuOpen, setPageMenuOpen] = useState<string | null>(null);
|
||||||
|
const [renamePageId, setRenamePageId] = useState<string | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
|
||||||
// Prompt execution
|
const { page, isLoading, error: pageError, isConnected, applyRevision, refresh } = useOraclePage(selectedPageId);
|
||||||
const { history, inFlight, lastError, submit } = useOracleExecution();
|
const { history, inFlight, lastError, submit, resetHistory } = useOracleExecution();
|
||||||
|
|
||||||
// UI state
|
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [selectedMode, setSelectedMode] = useState(PROMPT_MODES[0]);
|
const [selectedMode, setSelectedMode] = useState(PROMPT_MODES[0]);
|
||||||
const [viewDropOpen, setViewDropOpen] = useState(false);
|
const [viewDropOpen, setViewDropOpen] = useState(false);
|
||||||
const [listening, setListening] = useState(false);
|
const [listening, setListening] = useState(false);
|
||||||
const [railOpen, setRailOpen] = useState(false);
|
const [railOpen, setRailOpen] = useState(false);
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
const [activeSubtab, setActiveSubtab] = useState<'canvas' | 'client-data'>('canvas');
|
const [activeSubtab, setActiveSubtab] = useState<OracleSubtab>('canvas');
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
// Overlay state
|
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||||
const [mergeReviewOpen, setMergeReviewOpen] = useState(false);
|
const [mergeReviewOpen, setMergeReviewOpen] = useState(false);
|
||||||
const [activeMergeRequest, setActiveMergeRequest] = useState<MergeRequest | null>(null);
|
const [activeMergeRequest, setActiveMergeRequest] = useState<MergeRequest | null>(null);
|
||||||
|
|
||||||
const promptRef = useRef<HTMLInputElement>(null);
|
const promptRef = useRef<HTMLInputElement>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const loadCanvasSessions = useCallback(async () => {
|
||||||
|
if (!me) return;
|
||||||
|
setPagesLoading(true);
|
||||||
|
setPagesError(null);
|
||||||
|
try {
|
||||||
|
const pages = await listCanvasPages();
|
||||||
|
setCanvasPages(pages);
|
||||||
|
} catch (err) {
|
||||||
|
setPagesError(err instanceof Error ? err.message : 'Failed to load Oracle chats');
|
||||||
|
} finally {
|
||||||
|
setPagesLoading(false);
|
||||||
|
}
|
||||||
|
}, [me]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchMe()
|
void fetchMe()
|
||||||
@@ -90,12 +156,51 @@ export default function OraclePage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Prompt submission ───────────────────────────────────────────────────────
|
useEffect(() => {
|
||||||
|
if (!me) return;
|
||||||
|
if (!selectedPageId && me.defaultPageId) setSelectedPageId(me.defaultPageId);
|
||||||
|
void loadCanvasSessions();
|
||||||
|
}, [me, selectedPageId, loadCanvasSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPageId && canvasPages.length > 0) {
|
||||||
|
setSelectedPageId(canvasPages[0].pageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedPageId && canvasPages.length > 0 && !canvasPages.some((item) => item.pageId === selectedPageId)) {
|
||||||
|
setSelectedPageId(canvasPages[0].pageId);
|
||||||
|
}
|
||||||
|
}, [canvasPages, selectedPageId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetHistory();
|
||||||
|
setSelectedComponentId(null);
|
||||||
|
}, [selectedPageId, resetHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchOpen) return;
|
||||||
|
const timeout = setTimeout(() => searchRef.current?.focus(), 40);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [searchOpen]);
|
||||||
|
|
||||||
|
const filteredPages = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
const pages = [...canvasPages].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
||||||
|
if (!query) return pages;
|
||||||
|
return pages.filter((item) => {
|
||||||
|
const haystack = [item.title, item.branchName, item.pageType].join(' ').toLowerCase();
|
||||||
|
return haystack.includes(query);
|
||||||
|
});
|
||||||
|
}, [canvasPages, searchQuery]);
|
||||||
|
|
||||||
|
const recentPages = useMemo(() => filteredPages.slice(0, 4), [filteredPages]);
|
||||||
|
|
||||||
const submitPrompt = useCallback(async () => {
|
const submitPrompt = useCallback(async () => {
|
||||||
const clean = prompt.trim();
|
const clean = prompt.trim();
|
||||||
if (!clean || inFlight || !page || !me) return;
|
if (!clean || inFlight || !page || !me) return;
|
||||||
|
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
|
setActiveSubtab('canvas');
|
||||||
|
|
||||||
await submit({
|
await submit({
|
||||||
pageId: page.pageId,
|
pageId: page.pageId,
|
||||||
@@ -104,39 +209,63 @@ export default function OraclePage() {
|
|||||||
tenantId: me.tenantId,
|
tenantId: me.tenantId,
|
||||||
actorId: me.userId,
|
actorId: me.userId,
|
||||||
placementMode: me.canvasPreferences.defaultPlacementMode,
|
placementMode: me.canvasPreferences.defaultPlacementMode,
|
||||||
conversationContext: history.map((e) => [
|
conversationContext: history.flatMap((entry) => [
|
||||||
{ role: 'user' as const, content: e.execution.prompt },
|
{ role: 'user' as const, content: entry.execution.prompt },
|
||||||
{ role: 'assistant' as const, content: e.execution.summary ?? '' },
|
{ role: 'assistant' as const, content: entry.execution.summary ?? '' },
|
||||||
]).flat(),
|
]),
|
||||||
onExecutionCommitted: ({ headRevision, components }) => {
|
onExecutionCommitted: ({ headRevision, components }) => {
|
||||||
applyRevision(headRevision, components);
|
applyRevision(headRevision, components);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [prompt, inFlight, submit, page, history, me, applyRevision]);
|
|
||||||
|
|
||||||
// ── Mic handler ─────────────────────────────────────────────────────────────
|
if (page && isUntitledPage(page.title)) {
|
||||||
|
try {
|
||||||
|
await renameCanvasPage(page.pageId, deriveChatTitle(clean));
|
||||||
|
} catch {
|
||||||
|
// Keep the page usable even if title sync fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([refresh(), loadCanvasSessions()]);
|
||||||
|
}, [prompt, inFlight, page, me, submit, history, applyRevision, refresh, loadCanvasSessions]);
|
||||||
|
|
||||||
const handleMic = useCallback(() => {
|
const handleMic = useCallback(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const browserWindow = window as Window & {
|
||||||
const w = window as any;
|
SpeechRecognition?: new () => {
|
||||||
const SR = w.SpeechRecognition ?? w.webkitSpeechRecognition;
|
lang: string;
|
||||||
|
interimResults: boolean;
|
||||||
|
onstart: (() => void) | null;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
onerror: (() => void) | null;
|
||||||
|
onresult: ((event: { results?: ArrayLike<ArrayLike<{ transcript?: string }>> }) => void) | null;
|
||||||
|
start: () => void;
|
||||||
|
};
|
||||||
|
webkitSpeechRecognition?: new () => {
|
||||||
|
lang: string;
|
||||||
|
interimResults: boolean;
|
||||||
|
onstart: (() => void) | null;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
onerror: (() => void) | null;
|
||||||
|
onresult: ((event: { results?: ArrayLike<ArrayLike<{ transcript?: string }>> }) => void) | null;
|
||||||
|
start: () => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const SR = browserWindow.SpeechRecognition ?? browserWindow.webkitSpeechRecognition;
|
||||||
if (!SR) return;
|
if (!SR) return;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const recognition = new SR() as any;
|
const recognition = new SR();
|
||||||
recognition.lang = 'en-US';
|
recognition.lang = 'en-US';
|
||||||
recognition.interimResults = false;
|
recognition.interimResults = false;
|
||||||
recognition.onstart = () => setListening(true);
|
recognition.onstart = () => setListening(true);
|
||||||
recognition.onend = () => setListening(false);
|
recognition.onend = () => setListening(false);
|
||||||
recognition.onerror = () => setListening(false);
|
recognition.onerror = () => setListening(false);
|
||||||
recognition.onresult = (event: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => {
|
recognition.onresult = (event: { results?: ArrayLike<ArrayLike<{ transcript?: string }>> }) => {
|
||||||
const result = event.results[0][0];
|
const transcript = event.results?.[0]?.[0]?.transcript;
|
||||||
if (result) setPrompt(result.transcript);
|
if (transcript) setPrompt(transcript);
|
||||||
};
|
};
|
||||||
recognition.start();
|
recognition.start();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Share handler ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const handleShare = useCallback(async (params: { recipientUserId: string; visibility: 'private' | 'team'; message: string; sourceRevision: number }) => {
|
const handleShare = useCallback(async (params: { recipientUserId: string; visibility: 'private' | 'team'; message: string; sourceRevision: number }) => {
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
await createFork(page.pageId, {
|
await createFork(page.pageId, {
|
||||||
@@ -147,14 +276,12 @@ export default function OraclePage() {
|
|||||||
});
|
});
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
// ── Rollback handler ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const handleRollback = useCallback(async (targetRevision: number) => {
|
const handleRollback = useCallback(async (targetRevision: number) => {
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
const result = await rollbackPage(page.pageId, targetRevision, `cli_rollback_${Date.now()}`);
|
const result = await rollbackPage(page.pageId, targetRevision, `cli_rollback_${Date.now()}`);
|
||||||
applyRevision(result.headRevision, result.components);
|
applyRevision(result.headRevision, result.components);
|
||||||
await refresh();
|
await Promise.all([refresh(), loadCanvasSessions()]);
|
||||||
}, [page, applyRevision, refresh]);
|
}, [page, applyRevision, refresh, loadCanvasSessions]);
|
||||||
|
|
||||||
const handleOpenRollback = useCallback(() => {
|
const handleOpenRollback = useCallback(() => {
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
@@ -178,10 +305,49 @@ export default function OraclePage() {
|
|||||||
setMergeReviewOpen(false);
|
setMergeReviewOpen(false);
|
||||||
}, [activeMergeRequest]);
|
}, [activeMergeRequest]);
|
||||||
|
|
||||||
// ── Components to render ────────────────────────────────────────────────────
|
const handleCreateChat = useCallback(async () => {
|
||||||
|
setPageMenuOpen(null);
|
||||||
|
setRenamePageId(null);
|
||||||
|
setRenameValue('');
|
||||||
|
setActiveSubtab('canvas');
|
||||||
|
const created = await createCanvasPage('Untitled Canvas');
|
||||||
|
setSelectedPageId(created.pageId);
|
||||||
|
setSearchOpen(false);
|
||||||
|
await loadCanvasSessions();
|
||||||
|
}, [loadCanvasSessions]);
|
||||||
|
|
||||||
|
const handleRenameStart = useCallback((canvasPage: CanvasPage) => {
|
||||||
|
setPageMenuOpen(null);
|
||||||
|
setRenamePageId(canvasPage.pageId);
|
||||||
|
setRenameValue(canvasPage.title);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRenameCommit = useCallback(async () => {
|
||||||
|
if (!renamePageId) return;
|
||||||
|
const title = renameValue.trim() || 'Untitled Canvas';
|
||||||
|
await renameCanvasPage(renamePageId, title);
|
||||||
|
setRenamePageId(null);
|
||||||
|
setRenameValue('');
|
||||||
|
await Promise.all([refresh(), loadCanvasSessions()]);
|
||||||
|
}, [renamePageId, renameValue, refresh, loadCanvasSessions]);
|
||||||
|
|
||||||
|
const handleDeletePage = useCallback(async (pageId: string) => {
|
||||||
|
setPageMenuOpen(null);
|
||||||
|
const remaining = canvasPages.filter((item) => item.pageId !== pageId);
|
||||||
|
await deleteCanvasPage(pageId);
|
||||||
|
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
const created = await createCanvasPage('Untitled Canvas');
|
||||||
|
setSelectedPageId(created.pageId);
|
||||||
|
} else if (selectedPageId === pageId) {
|
||||||
|
setSelectedPageId(remaining[0].pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadCanvasSessions();
|
||||||
|
}, [canvasPages, selectedPageId, loadCanvasSessions]);
|
||||||
|
|
||||||
const components = page?.components ?? [];
|
const components = page?.components ?? [];
|
||||||
const combinedError = profileError ?? pageError ?? null;
|
const combinedError = profileError ?? pageError ?? pagesError ?? null;
|
||||||
const renderCtx: ComponentRenderContext = {
|
const renderCtx: ComponentRenderContext = {
|
||||||
...BASE_CTX,
|
...BASE_CTX,
|
||||||
tenantId: me?.tenantId ?? '',
|
tenantId: me?.tenantId ?? '',
|
||||||
@@ -190,17 +356,13 @@ export default function OraclePage() {
|
|||||||
density: me?.canvasPreferences.defaultDensity ?? 'comfortable',
|
density: me?.canvasPreferences.defaultDensity ?? 'comfortable',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="relative flex flex-col rounded-3xl border border-white/10 bg-zinc-950 text-zinc-100 overflow-hidden"
|
className="relative flex flex-col overflow-hidden rounded-3xl border border-white/10 bg-zinc-950 text-zinc-100"
|
||||||
style={{ height: 'calc(100vh - 80px)' }}
|
style={{ height: 'calc(100vh - 80px)' }}
|
||||||
>
|
>
|
||||||
{/* Ambient background glow */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 rounded-3xl bg-[radial-gradient(circle_at_25%_0%,rgba(14,116,144,0.12),transparent_38%),radial-gradient(circle_at_75%_100%,rgba(59,130,246,0.08),transparent_35%)]" />
|
<div className="pointer-events-none absolute inset-0 rounded-3xl bg-[radial-gradient(circle_at_25%_0%,rgba(14,116,144,0.12),transparent_38%),radial-gradient(circle_at_75%_100%,rgba(59,130,246,0.08),transparent_35%)]" />
|
||||||
|
|
||||||
{/* Loading veil */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -212,16 +374,15 @@ export default function OraclePage() {
|
|||||||
transition={{ duration: 0.4 }}
|
transition={{ duration: 0.4 }}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-10 h-10 rounded-2xl bg-blue-500/15 border border-blue-500/25 flex items-center justify-center mx-auto mb-3">
|
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-2xl border border-blue-500/25 bg-blue-500/15">
|
||||||
<BarChart2 className="w-5 h-5 text-blue-400 animate-pulse" />
|
<BarChart2 className="h-5 w-5 animate-pulse text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-zinc-400">Loading Oracle canvas…</p>
|
<p className="text-sm text-zinc-400">Loading Oracle canvas...</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* ── BranchBar ──────────────────────────────────────────────────────── */}
|
|
||||||
<BranchBar
|
<BranchBar
|
||||||
page={page}
|
page={page}
|
||||||
inFlightExecution={inFlight}
|
inFlightExecution={inFlight}
|
||||||
@@ -254,11 +415,10 @@ export default function OraclePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── AI Insight strip ───────────────────────────────────────────────── */}
|
|
||||||
{activeSubtab === 'canvas' && page && history.length > 0 && history[history.length - 1].execution.summary && (
|
{activeSubtab === 'canvas' && page && history.length > 0 && history[history.length - 1].execution.summary && (
|
||||||
<div className="relative z-10 px-4 pt-2 pb-1.5 flex-shrink-0">
|
<div className="relative z-10 flex-shrink-0 px-4 pb-1.5 pt-2">
|
||||||
<div
|
<div
|
||||||
className="flex items-stretch rounded-xl overflow-hidden"
|
className="flex items-stretch overflow-hidden rounded-xl"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, rgba(23,37,84,0.50) 0%, rgba(10,12,20,0.88) 100%)',
|
background: 'linear-gradient(135deg, rgba(23,37,84,0.50) 0%, rgba(10,12,20,0.88) 100%)',
|
||||||
border: '1px solid rgba(59,130,246,0.18)',
|
border: '1px solid rgba(59,130,246,0.18)',
|
||||||
@@ -266,9 +426,9 @@ export default function OraclePage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-[3px] flex-shrink-0" style={{ background: 'linear-gradient(180deg, #93c5fd 0%, #3b82f6 55%, rgba(59,130,246,0.2) 100%)' }} />
|
<div className="w-[3px] flex-shrink-0" style={{ background: 'linear-gradient(180deg, #93c5fd 0%, #3b82f6 55%, rgba(59,130,246,0.2) 100%)' }} />
|
||||||
<div className="flex items-center px-4 py-2.5 min-w-0 gap-3">
|
<div className="flex min-w-0 items-center gap-3 px-4 py-2.5">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-blue-400 flex-shrink-0">Oracle</p>
|
<p className="flex-shrink-0 text-[10px] font-semibold uppercase tracking-widest text-blue-400">Oracle</p>
|
||||||
<p className="text-xs text-zinc-300 leading-snug truncate">
|
<p className="truncate text-xs leading-snug text-zinc-300">
|
||||||
{history[history.length - 1].execution.summary}
|
{history[history.length - 1].execution.summary}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,13 +436,173 @@ export default function OraclePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Main content area: canvas + rail ───────────────────────────────── */}
|
<div className="relative z-10 flex min-h-0 flex-1 overflow-hidden">
|
||||||
<div className="relative z-10 flex-1 flex overflow-hidden min-h-0">
|
|
||||||
{activeSubtab === 'client-data' ? (
|
{activeSubtab === 'client-data' ? (
|
||||||
<ClientDataLens />
|
<ClientDataLens />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Canvas viewport */}
|
<aside
|
||||||
|
className={`border-r border-white/6 bg-black/20 transition-all duration-200 ${
|
||||||
|
sidebarCollapsed ? 'w-[84px]' : 'w-[320px]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between px-4 py-4">
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-cyan-300">Oracle Canvas</p>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">Natural chat sessions over live CRM state.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.03] p-2 text-zinc-400 transition hover:text-zinc-100"
|
||||||
|
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCreateChat()}
|
||||||
|
className="mb-2 flex w-full items-center gap-2 rounded-2xl border border-blue-400/25 bg-blue-500/12 px-3 py-3 text-left text-sm font-semibold text-blue-100 transition hover:bg-blue-500/18"
|
||||||
|
>
|
||||||
|
<MessageSquarePlus className="h-4 w-4 flex-shrink-0" />
|
||||||
|
{!sidebarCollapsed && <span>New Chat</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-2xl border border-white/8 bg-white/[0.03] px-3 py-3 text-left text-sm text-zinc-300 transition hover:bg-white/[0.05]"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4 flex-shrink-0" />
|
||||||
|
{!sidebarCollapsed && <span>Search</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-3 border-t border-white/6" />
|
||||||
|
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<div className="px-4 pb-2 pt-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-zinc-500">Recent</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
|
||||||
|
{pagesLoading && canvasPages.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-sm text-zinc-500">Loading chats...</div>
|
||||||
|
) : filteredPages.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-sm text-zinc-500">No chats found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{filteredPages.map((canvasPage) => {
|
||||||
|
const active = canvasPage.pageId === selectedPageId;
|
||||||
|
const renaming = renamePageId === canvasPage.pageId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={canvasPage.pageId}
|
||||||
|
className={`group relative rounded-2xl border transition ${
|
||||||
|
active ? 'border-blue-400/25 bg-blue-500/10' : 'border-transparent bg-transparent hover:bg-white/[0.04]'
|
||||||
|
}`}
|
||||||
|
style={active
|
||||||
|
? {
|
||||||
|
boxShadow: '0 0 0 1px rgba(96,165,250,0.18), 0 0 28px rgba(59,130,246,0.24), inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||||
|
}
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPageId(canvasPage.pageId);
|
||||||
|
setActiveSubtab('canvas');
|
||||||
|
setPageMenuOpen(null);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-start gap-3 px-3 py-3 text-left"
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border border-blue-400/20 bg-blue-500/10 text-xs font-semibold text-blue-100">
|
||||||
|
{(canvasPage.title || 'U').slice(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{renaming ? (
|
||||||
|
<Input
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(event) => setRenameValue(event.target.value)}
|
||||||
|
onBlur={() => void handleRenameCommit()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleRenameCommit();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setRenamePageId(null);
|
||||||
|
setRenameValue('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-8 border-white/10 bg-white/[0.04] text-sm text-zinc-100"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="truncate text-sm font-medium text-zinc-100">{canvasPage.title}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-zinc-500">
|
||||||
|
{canvasPage.branchName} · {formatRelativeTime(canvasPage.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!sidebarCollapsed && !renaming && (
|
||||||
|
<div className="absolute right-2 top-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setPageMenuOpen((prev) => (prev === canvasPage.pageId ? null : canvasPage.pageId));
|
||||||
|
}}
|
||||||
|
className="rounded-lg p-1.5 text-zinc-500 opacity-0 transition hover:bg-white/[0.06] hover:text-zinc-100 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{pageMenuOpen === canvasPage.pageId && (
|
||||||
|
<div className="absolute right-0 top-9 z-30 w-40 overflow-hidden rounded-xl border border-white/10 bg-zinc-950/96 shadow-2xl backdrop-blur-xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRenameStart(canvasPage)}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-zinc-200 transition hover:bg-white/[0.05]"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDeletePage(canvasPage.pageId)}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-red-300 transition hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 overflow-hidden">
|
||||||
<CanvasViewport
|
<CanvasViewport
|
||||||
components={components}
|
components={components}
|
||||||
ctx={renderCtx}
|
ctx={renderCtx}
|
||||||
@@ -291,23 +611,22 @@ export default function OraclePage() {
|
|||||||
onSelectComponent={setSelectedComponentId}
|
onSelectComponent={setSelectedComponentId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Prompt Rail */}
|
|
||||||
<PromptRail
|
<PromptRail
|
||||||
history={history}
|
history={history}
|
||||||
inFlight={inFlight}
|
inFlight={inFlight}
|
||||||
isOpen={railOpen}
|
isOpen={railOpen}
|
||||||
onToggle={() => setRailOpen((p) => !p)}
|
onToggle={() => setRailOpen((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Floating Prompt Bar ─────────────────────────────────────────────── */}
|
{activeSubtab === 'canvas' && (
|
||||||
{activeSubtab === 'canvas' && <div className="absolute inset-x-0 bottom-8 z-30 flex justify-center px-4">
|
<div className="absolute inset-x-0 bottom-8 z-30 flex justify-center px-4">
|
||||||
<div className="relative w-full max-w-3xl">
|
<div className="relative w-full max-w-3xl">
|
||||||
{/* Blue glow */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none"
|
className="pointer-events-none absolute"
|
||||||
style={{
|
style={{
|
||||||
inset: '-28px',
|
inset: '-28px',
|
||||||
background: 'radial-gradient(ellipse 50% 80% at 8% 50%, rgba(59,130,246,0.28) 0%, transparent 70%), radial-gradient(ellipse 50% 80% at 92% 50%, rgba(99,102,241,0.32) 0%, transparent 70%)',
|
background: 'radial-gradient(ellipse 50% 80% at 8% 50%, rgba(59,130,246,0.28) 0%, transparent 70%), radial-gradient(ellipse 50% 80% at 92% 50%, rgba(99,102,241,0.32) 0%, transparent 70%)',
|
||||||
@@ -317,7 +636,6 @@ export default function OraclePage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Container */}
|
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl"
|
className="rounded-2xl"
|
||||||
style={{
|
style={{
|
||||||
@@ -328,7 +646,6 @@ export default function OraclePage() {
|
|||||||
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.6)',
|
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.6)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Error banner */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{(combinedError || lastError) && (
|
{(combinedError || lastError) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -337,53 +654,49 @@ export default function OraclePage() {
|
|||||||
exit={{ height: 0 }}
|
exit={{ height: 0 }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="px-4 pt-3 pb-1">
|
<div className="px-4 pb-1 pt-3">
|
||||||
<p className="text-xs text-red-400">{combinedError ?? lastError}</p>
|
<p className="text-xs text-red-400">{combinedError ?? lastError}</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Input */}
|
<div className="px-4 pb-1 pt-3.5">
|
||||||
<div className="px-4 pt-3.5 pb-1">
|
|
||||||
<Input
|
<Input
|
||||||
ref={promptRef}
|
ref={promptRef}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(event) => {
|
||||||
if (e.key === 'Enter') { e.preventDefault(); void submitPrompt(); }
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void submitPrompt();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Ask Oracle anything — build your canvas with a prompt…"
|
placeholder="Ask Oracle anything — build your canvas with a prompt..."
|
||||||
className="border-0 bg-transparent text-[15px] text-zinc-100 placeholder:text-zinc-600 focus-visible:ring-0 px-0 h-auto py-0"
|
className="h-auto border-0 bg-transparent px-0 py-0 text-[15px] text-zinc-100 placeholder:text-zinc-600 focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-center justify-between px-3 pb-3 pt-2">
|
<div className="flex items-center justify-between px-3 pb-3 pt-2">
|
||||||
{/* Left: view dropdown + rail toggle */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* View mode dropdown */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="oracle-view-mode-trigger"
|
id="oracle-view-mode-trigger"
|
||||||
onClick={() => setViewDropOpen((p) => !p)}
|
onClick={() => setViewDropOpen((prev) => !prev)}
|
||||||
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium transition-all"
|
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium transition-all"
|
||||||
style={{ background: 'rgba(59,130,246,0.15)', color: '#93c5fd', border: '1px solid rgba(59,130,246,0.3)' }}
|
style={{ background: 'rgba(59,130,246,0.15)', color: '#93c5fd', border: '1px solid rgba(59,130,246,0.3)' }}
|
||||||
>
|
>
|
||||||
<selectedMode.icon className="h-3 w-3" />
|
<selectedMode.icon className="h-3 w-3" />
|
||||||
<span>{selectedMode.label}</span>
|
<span>{selectedMode.label}</span>
|
||||||
<ChevronDown
|
<ChevronDown className="h-3 w-3 transition-transform" style={{ transform: viewDropOpen ? 'rotate(180deg)' : 'rotate(0deg)' }} />
|
||||||
className="h-3 w-3 transition-transform"
|
|
||||||
style={{ transform: viewDropOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{viewDropOpen && (
|
{viewDropOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
id="oracle-view-dropdown"
|
id="oracle-view-dropdown"
|
||||||
className="absolute left-0 bottom-full mb-2 z-[999] rounded-xl min-w-[220px] py-1"
|
className="absolute bottom-full left-0 z-[999] mb-2 min-w-[220px] rounded-xl py-1"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgb(12, 13, 20)',
|
background: 'rgb(12, 13, 20)',
|
||||||
border: '1px solid rgba(255,255,255,0.12)',
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
@@ -400,19 +713,16 @@ export default function OraclePage() {
|
|||||||
<button
|
<button
|
||||||
key={mode.view}
|
key={mode.view}
|
||||||
type="button"
|
type="button"
|
||||||
id={`oracle-mode-${mode.view}`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedMode(mode);
|
setSelectedMode(mode);
|
||||||
setPrompt(mode.samplePrompt);
|
setPrompt(mode.samplePrompt);
|
||||||
setViewDropOpen(false);
|
setViewDropOpen(false);
|
||||||
}}
|
}}
|
||||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left"
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm"
|
||||||
style={{
|
style={{
|
||||||
color: isActive ? '#93c5fd' : '#e4e4e7',
|
color: isActive ? '#93c5fd' : '#e4e4e7',
|
||||||
background: isActive ? 'rgba(59,130,246,0.12)' : 'transparent',
|
background: isActive ? 'rgba(59,130,246,0.12)' : 'transparent',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.05)'; }}
|
|
||||||
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
|
||||||
>
|
>
|
||||||
<mode.icon className="h-4 w-4 flex-shrink-0" />
|
<mode.icon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>{mode.label}</span>
|
<span>{mode.label}</span>
|
||||||
@@ -423,28 +733,23 @@ export default function OraclePage() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{viewDropOpen && (
|
{viewDropOpen && <div className="fixed inset-0 z-[998]" onClick={() => setViewDropOpen(false)} />}
|
||||||
<div className="fixed inset-0 z-[998]" onClick={() => setViewDropOpen(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rail toggle */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="oracle-rail-toggle"
|
id="oracle-rail-toggle"
|
||||||
onClick={() => setRailOpen((p) => !p)}
|
onClick={() => setRailOpen((prev) => !prev)}
|
||||||
className="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs transition-all"
|
className="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs transition-all"
|
||||||
style={railOpen
|
style={railOpen
|
||||||
? { background: 'rgba(59,130,246,0.15)', color: '#93c5fd', border: '1px solid rgba(59,130,246,0.3)' }
|
? { background: 'rgba(59,130,246,0.15)', color: '#93c5fd', border: '1px solid rgba(59,130,246,0.3)' }
|
||||||
: { background: 'rgba(255,255,255,0.06)', color: '#71717a', border: '1px solid rgba(255,255,255,0.09)' }
|
: { background: 'rgba(255,255,255,0.06)', color: '#71717a', border: '1px solid rgba(255,255,255,0.09)' }}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<History className="h-3 w-3" />
|
<History className="h-3 w-3" />
|
||||||
<span className="hidden sm:inline">History</span>
|
<span className="hidden sm:inline">History</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: mic + send */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<motion.button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -453,21 +758,20 @@ export default function OraclePage() {
|
|||||||
className="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs"
|
className="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs"
|
||||||
style={listening
|
style={listening
|
||||||
? { background: 'rgba(239,68,68,0.18)', color: '#fca5a5', border: '1px solid rgba(239,68,68,0.35)' }
|
? { background: 'rgba(239,68,68,0.18)', color: '#fca5a5', border: '1px solid rgba(239,68,68,0.35)' }
|
||||||
: { background: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.45)', border: '1px solid rgba(255,255,255,0.09)' }
|
: { background: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.45)', border: '1px solid rgba(255,255,255,0.09)' }}
|
||||||
}
|
|
||||||
animate={listening ? { scale: [1, 1.05, 1] } : { scale: 1 }}
|
animate={listening ? { scale: [1, 1.05, 1] } : { scale: 1 }}
|
||||||
transition={listening ? { repeat: Infinity, duration: 0.9 } : {}}
|
transition={listening ? { repeat: Infinity, duration: 0.9 } : {}}
|
||||||
>
|
>
|
||||||
<Mic className="h-3.5 w-3.5" />
|
<Mic className="h-3.5 w-3.5" />
|
||||||
<span className="hidden sm:inline">{listening ? 'Listening…' : 'Voice'}</span>
|
<span className="hidden sm:inline">{listening ? 'Listening...' : 'Voice'}</span>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
id="oracle-send-button"
|
id="oracle-send-button"
|
||||||
onClick={() => void submitPrompt()}
|
onClick={() => void submitPrompt()}
|
||||||
disabled={!!inFlight || !prompt.trim()}
|
disabled={!!inFlight || !prompt.trim() || !page}
|
||||||
className="h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0 disabled:opacity-40"
|
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full disabled:opacity-40"
|
||||||
style={{ background: 'hsl(217 91% 60%)', boxShadow: '0 0 18px hsl(217 91% 60% / 0.5)' }}
|
style={{ background: 'hsl(217 91% 60%)', boxShadow: '0 0 18px hsl(217 91% 60% / 0.5)' }}
|
||||||
whileHover={{ scale: 1.08 }}
|
whileHover={{ scale: 1.08 }}
|
||||||
whileTap={{ scale: 0.91 }}
|
whileTap={{ scale: 0.91 }}
|
||||||
@@ -478,9 +782,92 @@ export default function OraclePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{searchOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setSearchOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 z-50 flex justify-center px-4 pt-24">
|
||||||
|
<motion.div
|
||||||
|
className="w-[min(680px,calc(100%-2rem))] overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/96 shadow-2xl"
|
||||||
|
style={{
|
||||||
|
maxHeight: '320px',
|
||||||
|
boxShadow: '0 0 0 1px rgba(255,255,255,0.05), 0 18px 80px rgba(0,0,0,0.62), 0 0 42px rgba(59,130,246,0.12)',
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: 14, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.97 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/8 px-5 py-4">
|
||||||
|
<Search className="h-5 w-5 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search chats"
|
||||||
|
className="flex-1 bg-transparent text-base text-zinc-100 outline-none placeholder:text-zinc-600"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setSearchOpen(false)} className="rounded-xl p-2 text-zinc-500 transition hover:bg-white/[0.05] hover:text-zinc-100">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[248px] overflow-y-auto px-5 py-3">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-zinc-500">Recent</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCreateChat()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-blue-400/25 bg-blue-500/10 px-3 py-1.5 text-xs font-semibold text-blue-100 transition hover:bg-blue-500/16"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(recentPages.length > 0 ? recentPages : filteredPages.slice(0, 4)).map((canvasPage) => (
|
||||||
|
<button
|
||||||
|
key={canvasPage.pageId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPageId(canvasPage.pageId);
|
||||||
|
setSearchOpen(false);
|
||||||
|
setActiveSubtab('canvas');
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-3 text-left transition hover:bg-white/[0.05]"
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full border border-cyan-400/20 bg-cyan-500/10 text-cyan-100">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-zinc-100">{canvasPage.title}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-zinc-500">{formatRelativeTime(canvasPage.updatedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredPages.length === 0 && (
|
||||||
|
<div className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-4 text-center text-sm text-zinc-500">
|
||||||
|
No matching chats.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* ── Overlays ────────────────────────────────────────────────────────── */}
|
|
||||||
<ShareModal
|
<ShareModal
|
||||||
page={page}
|
page={page}
|
||||||
isOpen={shareOpen}
|
isOpen={shareOpen}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface OracleExecutionState {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
resetHistory: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOracleExecution(): OracleExecutionState {
|
export function useOracleExecution(): OracleExecutionState {
|
||||||
@@ -126,5 +127,12 @@ export function useOracleExecution(): OracleExecutionState {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { history, inFlight, lastError, submit, clearError: () => setLastError(null) };
|
return {
|
||||||
|
history,
|
||||||
|
inFlight,
|
||||||
|
lastError,
|
||||||
|
submit,
|
||||||
|
clearError: () => setLastError(null),
|
||||||
|
resetHistory: () => setHistory([]),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,32 @@ export async function fetchCanvasPage(pageId: string): Promise<CanvasPage> {
|
|||||||
return apiFetch<CanvasPage>(`/canvas-pages/${pageId}`);
|
return apiFetch<CanvasPage>(`/canvas-pages/${pageId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listCanvasPages(search?: string): Promise<CanvasPage[]> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (search?.trim()) qs.set('search', search.trim());
|
||||||
|
return apiFetch<CanvasPage[]>(`/canvas-pages${qs.toString() ? `?${qs.toString()}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCanvasPage(title = 'Untitled Canvas'): Promise<CanvasPage> {
|
||||||
|
return apiFetch<CanvasPage>('/canvas-pages', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameCanvasPage(pageId: string, title: string): Promise<CanvasPage> {
|
||||||
|
return apiFetch<CanvasPage>(`/canvas-pages/${pageId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCanvasPage(pageId: string): Promise<{ pageId: string; deleted: boolean }> {
|
||||||
|
return apiFetch<{ pageId: string; deleted: boolean }>(`/canvas-pages/${pageId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitPrompt(
|
export async function submitPrompt(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
payload: PromptSubmitRequest,
|
payload: PromptSubmitRequest,
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ def _json_array(value: Any) -> list[Any]:
|
|||||||
def _json_safe(value: Any) -> Any:
|
def _json_safe(value: Any) -> Any:
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
return value.isoformat()
|
return value.isoformat()
|
||||||
|
if isinstance(value, uuid.UUID):
|
||||||
|
return str(value)
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {str(key): _json_safe(val) for key, val in value.items()}
|
return {str(key): _json_safe(val) for key, val in value.items()}
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
@@ -173,6 +175,54 @@ def _deserialize_page_row(row: Any, components: list[dict[str, Any]]) -> dict[st
|
|||||||
|
|
||||||
|
|
||||||
class CanvasService:
|
class CanvasService:
|
||||||
|
async def list_pages(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
owner_id: str,
|
||||||
|
search: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ensure_ready()
|
||||||
|
safe_limit = max(1, min(limit, 100))
|
||||||
|
search_term = (search or "").strip().lower()
|
||||||
|
if _is_demo():
|
||||||
|
candidates = [
|
||||||
|
page
|
||||||
|
for page in _DEMO_PAGES.values()
|
||||||
|
if page["tenantId"] == tenant_id and page["ownerId"] == owner_id
|
||||||
|
]
|
||||||
|
if search_term:
|
||||||
|
candidates = [page for page in candidates if search_term in page.get("title", "").lower()]
|
||||||
|
candidates.sort(key=lambda page: page.get("updatedAt", ""), reverse=True)
|
||||||
|
return [{**page, "components": deepcopy(_DEMO_COMPONENTS.get(page["pageId"], []))} for page in candidates[:safe_limit]]
|
||||||
|
|
||||||
|
assert asyncpg is not None
|
||||||
|
conn = await asyncpg.connect(_DB_URL)
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM oracle_canvas_pages
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND owner_id = $2
|
||||||
|
AND ($3 = '' OR lower(title) LIKE '%' || $3 || '%')
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT $4
|
||||||
|
""",
|
||||||
|
tenant_id,
|
||||||
|
owner_id,
|
||||||
|
search_term,
|
||||||
|
safe_limit,
|
||||||
|
)
|
||||||
|
pages: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
components = await self._pg_fetch_components(conn, _stringify(row["page_id"]), tenant_id)
|
||||||
|
pages.append(_deserialize_page_row(row, components))
|
||||||
|
return pages
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
async def create_page(
|
async def create_page(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -310,6 +360,80 @@ class CanvasService:
|
|||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
async def update_page_title(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
page_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
owner_id: str,
|
||||||
|
title: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_ensure_ready()
|
||||||
|
clean_title = (title or "").strip() or "Untitled Canvas"
|
||||||
|
if _is_demo():
|
||||||
|
page = _DEMO_PAGES.get(page_id)
|
||||||
|
if not page or page["tenantId"] != tenant_id or page["ownerId"] != owner_id:
|
||||||
|
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
|
||||||
|
page["title"] = clean_title
|
||||||
|
page["updatedAt"] = _now()
|
||||||
|
return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page_id, []))}
|
||||||
|
|
||||||
|
assert asyncpg is not None
|
||||||
|
conn = await asyncpg.connect(_DB_URL)
|
||||||
|
try:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
UPDATE oracle_canvas_pages
|
||||||
|
SET title = $4, updated_at = NOW()
|
||||||
|
WHERE page_id = $1::uuid AND tenant_id = $2 AND owner_id = $3
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
page_id,
|
||||||
|
tenant_id,
|
||||||
|
owner_id,
|
||||||
|
clean_title,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
|
||||||
|
components = await self._pg_fetch_components(conn, page_id, tenant_id)
|
||||||
|
return _deserialize_page_row(row, components)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
async def delete_page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
page_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
owner_id: str,
|
||||||
|
) -> None:
|
||||||
|
_ensure_ready()
|
||||||
|
if _is_demo():
|
||||||
|
page = _DEMO_PAGES.get(page_id)
|
||||||
|
if not page or page["tenantId"] != tenant_id or page["ownerId"] != owner_id:
|
||||||
|
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
|
||||||
|
del _DEMO_PAGES[page_id]
|
||||||
|
_DEMO_COMPONENTS.pop(page_id, None)
|
||||||
|
_DEMO_REVISIONS.pop(page_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
assert asyncpg is not None
|
||||||
|
conn = await asyncpg.connect(_DB_URL)
|
||||||
|
try:
|
||||||
|
result = await conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM oracle_canvas_pages
|
||||||
|
WHERE page_id = $1::uuid AND tenant_id = $2 AND owner_id = $3
|
||||||
|
""",
|
||||||
|
page_id,
|
||||||
|
tenant_id,
|
||||||
|
owner_id,
|
||||||
|
)
|
||||||
|
if result.endswith("0"):
|
||||||
|
raise ValueError(f"Page {page_id} not found for tenant {tenant_id}")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
async def commit_revision(
|
async def commit_revision(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Natural DB-first Oracle agent.
|
Natural DB-first Oracle agent.
|
||||||
|
|
||||||
The LLM can plan arbitrary analytical SELECT statements over the Velocity CRM,
|
The LLM can plan arbitrary analytical SELECT statements over the full public
|
||||||
intel, inventory, and read-model tables. The executor enforces a read-only SQL
|
Velocity app schema. The executor enforces only a read-only SQL contract and a
|
||||||
contract and a UI row cap; write paths stay behind typed API endpoints.
|
UI row cap; write paths stay behind typed API endpoints.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -25,25 +25,12 @@ except Exception: # pragma: no cover
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MAX_ROW_CAP = 500
|
|
||||||
|
|
||||||
ALLOWED_TABLES = {
|
|
||||||
"crm_people", "crm_leads", "crm_accounts", "crm_households", "crm_relationships",
|
|
||||||
"crm_opportunities", "crm_property_interests", "crm_stage_history",
|
|
||||||
"intel_interactions", "intel_messages", "intel_calls", "intel_transcripts",
|
|
||||||
"intel_emails", "intel_email_threads", "intel_whatsapp_threads", "intel_visits",
|
|
||||||
"intel_reminders", "intel_qd_scores", "intel_qd_timeseries",
|
|
||||||
"intel_extracted_facts", "intel_call_objections", "intel_cctv_links",
|
|
||||||
"intel_perception_events", "intel_vehicle_events",
|
|
||||||
"inventory_projects", "inventory_units",
|
|
||||||
"read_last_contacted", "read_next_best_action",
|
|
||||||
}
|
|
||||||
|
|
||||||
DESTRUCTIVE_SQL = re.compile(
|
DESTRUCTIVE_SQL = re.compile(
|
||||||
r"\b(insert|update|delete|drop|alter|truncate|copy|create|grant|revoke|call|execute|do|merge)\b",
|
r"\b(insert|update|delete|drop|alter|truncate|copy|create|grant|revoke|call|execute|do|merge)\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
TABLE_REF_RE = re.compile(r"\b(?:from|join)\s+([a-zA-Z_][\w.]*)(?:\s|$)", re.IGNORECASE)
|
TABLE_REF_RE = re.compile(r"\b(?:from|join)\s+([a-zA-Z_][\w.]*)(?:\s|$)", re.IGNORECASE)
|
||||||
|
CTE_NAME_RE = re.compile(r"\b(?:with|,)\s*([a-zA-Z_][\w]*)\s+as\s*\(", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _json_safe(value: Any) -> Any:
|
def _json_safe(value: Any) -> Any:
|
||||||
@@ -61,6 +48,9 @@ def _json_safe(value: Any) -> Any:
|
|||||||
def db_ready() -> bool:
|
def db_ready() -> bool:
|
||||||
if asyncpg is None:
|
if asyncpg is None:
|
||||||
return False
|
return False
|
||||||
|
read_database_url = os.getenv("ORACLE_READ_DATABASE_URL", "")
|
||||||
|
if read_database_url and not read_database_url.startswith("PLACEHOLDER"):
|
||||||
|
return True
|
||||||
database_url = os.getenv("DATABASE_URL", "")
|
database_url = os.getenv("DATABASE_URL", "")
|
||||||
return bool(database_url and not database_url.startswith("PLACEHOLDER")) or all(
|
return bool(database_url and not database_url.startswith("PLACEHOLDER")) or all(
|
||||||
os.getenv(name) for name in ("VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD")
|
os.getenv(name) for name in ("VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD")
|
||||||
@@ -70,6 +60,17 @@ def db_ready() -> bool:
|
|||||||
async def connect_db() -> Any:
|
async def connect_db() -> Any:
|
||||||
if asyncpg is None:
|
if asyncpg is None:
|
||||||
raise RuntimeError("asyncpg is not installed.")
|
raise RuntimeError("asyncpg is not installed.")
|
||||||
|
read_database_url = os.getenv("ORACLE_READ_DATABASE_URL", "")
|
||||||
|
if read_database_url and not read_database_url.startswith("PLACEHOLDER"):
|
||||||
|
return await asyncpg.connect(read_database_url)
|
||||||
|
if all(os.getenv(name) for name in ("VELOCITY_DB_READ_NAME", "VELOCITY_DB_READ_USER", "VELOCITY_DB_READ_PASSWORD")):
|
||||||
|
return await asyncpg.connect(
|
||||||
|
host=os.getenv("VELOCITY_DB_READ_HOST", os.getenv("VELOCITY_DB_HOST", "127.0.0.1")),
|
||||||
|
port=int(os.getenv("VELOCITY_DB_READ_PORT", os.getenv("VELOCITY_DB_PORT", "5432"))),
|
||||||
|
database=os.environ["VELOCITY_DB_READ_NAME"],
|
||||||
|
user=os.environ["VELOCITY_DB_READ_USER"],
|
||||||
|
password=os.environ["VELOCITY_DB_READ_PASSWORD"],
|
||||||
|
)
|
||||||
database_url = os.getenv("DATABASE_URL", "")
|
database_url = os.getenv("DATABASE_URL", "")
|
||||||
if database_url and not database_url.startswith("PLACEHOLDER"):
|
if database_url and not database_url.startswith("PLACEHOLDER"):
|
||||||
return await asyncpg.connect(database_url)
|
return await asyncpg.connect(database_url)
|
||||||
@@ -124,13 +125,6 @@ def sanitize_sql(sql: str, row_limit: int) -> tuple[str, list[str], list[str]]:
|
|||||||
continue
|
continue
|
||||||
if table and table not in tables:
|
if table and table not in tables:
|
||||||
tables.append(table)
|
tables.append(table)
|
||||||
blocked = [table for table in tables if table not in ALLOWED_TABLES]
|
|
||||||
if blocked:
|
|
||||||
raise ValueError(f"Oracle SQL agent blocked unknown tables: {', '.join(blocked)}")
|
|
||||||
capped = max(1, min(int(row_limit or 100), MAX_ROW_CAP))
|
|
||||||
if not re.search(r"\blimit\s+\d+\b", clean, re.IGNORECASE):
|
|
||||||
clean = f"SELECT * FROM ({clean}) oracle_limited_rows LIMIT {capped}"
|
|
||||||
warnings.append(f"Applied UI row cap LIMIT {capped}.")
|
|
||||||
return clean, tables, warnings
|
return clean, tables, warnings
|
||||||
|
|
||||||
|
|
||||||
@@ -151,6 +145,18 @@ def infer_component_type(prompt: str, columns: list[str], rows: list[dict[str, A
|
|||||||
return "table"
|
return "table"
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_property_rollup_prompt(prompt: str) -> bool:
|
||||||
|
lower = prompt.lower()
|
||||||
|
property_terms = ("property", "properties", "project", "projects")
|
||||||
|
aggregate_terms = ("top", "most", "majority", "highest", "popular", "common")
|
||||||
|
interest_terms = ("interest", "interested", "liked", "preference", "preferences")
|
||||||
|
return (
|
||||||
|
any(term in lower for term in property_terms)
|
||||||
|
and any(term in lower for term in aggregate_terms)
|
||||||
|
and any(term in lower for term in interest_terms)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def title_from_prompt(prompt: str) -> str:
|
def title_from_prompt(prompt: str) -> str:
|
||||||
words = re.sub(r"\s+", " ", prompt.strip()).strip(" ?.!")
|
words = re.sub(r"\s+", " ", prompt.strip()).strip(" ?.!")
|
||||||
return words[:1].upper() + words[1:80] if words else "Oracle Query Result"
|
return words[:1].upper() + words[1:80] if words else "Oracle Query Result"
|
||||||
@@ -164,19 +170,27 @@ class NaturalDbAgent:
|
|||||||
return {"tables": [], "available": False}
|
return {"tables": [], "available": False}
|
||||||
conn = await connect_db()
|
conn = await connect_db()
|
||||||
try:
|
try:
|
||||||
|
table_names = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
public_tables = [row["table_name"] for row in table_names]
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.is_nullable
|
SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.is_nullable
|
||||||
FROM information_schema.columns c
|
FROM information_schema.columns c
|
||||||
WHERE c.table_schema = 'public' AND c.table_name = ANY($1::text[])
|
WHERE c.table_schema = 'public'
|
||||||
ORDER BY c.table_name, c.ordinal_position
|
ORDER BY c.table_name, c.ordinal_position
|
||||||
""",
|
"""
|
||||||
sorted(ALLOWED_TABLES),
|
|
||||||
)
|
)
|
||||||
counts = {}
|
counts = {}
|
||||||
for table in sorted(ALLOWED_TABLES):
|
for table in public_tables:
|
||||||
exists = await conn.fetchval("SELECT to_regclass($1)", f"public.{table}")
|
exists = await conn.fetchval("SELECT to_regclass($1)", f"public.{table}")
|
||||||
counts[table] = None if not exists else int(await conn.fetchval(f"SELECT COUNT(*) FROM {table}"))
|
counts[table] = None if not exists else int(await conn.fetchval(f'SELECT COUNT(*) FROM "{table}"'))
|
||||||
tables: dict[str, dict[str, Any]] = {}
|
tables: dict[str, dict[str, Any]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
entry = tables.setdefault(row["table_name"], {"columns": [], "rowCount": counts.get(row["table_name"])})
|
entry = tables.setdefault(row["table_name"], {"columns": [], "rowCount": counts.get(row["table_name"])})
|
||||||
@@ -186,7 +200,7 @@ class NaturalDbAgent:
|
|||||||
"udtName": row["udt_name"],
|
"udtName": row["udt_name"],
|
||||||
"nullable": row["is_nullable"] == "YES",
|
"nullable": row["is_nullable"] == "YES",
|
||||||
})
|
})
|
||||||
return {"available": True, "tables": tables, "allowedTables": sorted(ALLOWED_TABLES)}
|
return {"available": True, "tables": tables, "allowedTables": public_tables}
|
||||||
finally:
|
finally:
|
||||||
if own_conn:
|
if own_conn:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
@@ -210,7 +224,7 @@ class NaturalDbAgent:
|
|||||||
"read_next_best_action": 250,
|
"read_next_best_action": 250,
|
||||||
}
|
}
|
||||||
tables = catalog.get("tables", {})
|
tables = catalog.get("tables", {})
|
||||||
counts = {table: (tables.get(table) or {}).get("rowCount") for table in sorted(ALLOWED_TABLES)}
|
counts = {table: (meta or {}).get("rowCount") for table, meta in sorted(tables.items())}
|
||||||
return {
|
return {
|
||||||
"counts": counts,
|
"counts": counts,
|
||||||
"expectedSyntheticV2Counts": expected,
|
"expectedSyntheticV2Counts": expected,
|
||||||
@@ -238,27 +252,12 @@ class NaturalDbAgent:
|
|||||||
async def _run_plan(self, conn: Any, prompt: str, plan: dict[str, Any], row_limit: int) -> NaturalQueryResult:
|
async def _run_plan(self, conn: Any, prompt: str, plan: dict[str, Any], row_limit: int) -> NaturalQueryResult:
|
||||||
raw_sql = str(plan.get("sql") or "").strip()
|
raw_sql = str(plan.get("sql") or "").strip()
|
||||||
if not raw_sql:
|
if not raw_sql:
|
||||||
raw_sql = self._fallback_sql(prompt, row_limit)
|
raise RuntimeError("Natural SQL planner returned no SQL.")
|
||||||
sql, tables, warnings = sanitize_sql(raw_sql, row_limit)
|
sql, tables, warnings = sanitize_sql(raw_sql, row_limit)
|
||||||
try:
|
try:
|
||||||
records = await conn.fetch(sql)
|
records = await conn.fetch(sql)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
retry = await self._repair_sql(prompt, raw_sql, str(exc), row_limit)
|
raise RuntimeError(f"Natural SQL execution failed: {exc}") from exc
|
||||||
sql, tables, retry_warnings = sanitize_sql(retry, row_limit)
|
|
||||||
warnings.extend(retry_warnings)
|
|
||||||
warnings.append(f"Initial SQL repaired after database error: {exc}")
|
|
||||||
records = await conn.fetch(sql)
|
|
||||||
if not records:
|
|
||||||
retry_sql = self._zero_row_retry_sql(prompt, row_limit, raw_sql)
|
|
||||||
if retry_sql and retry_sql.strip() != raw_sql.strip():
|
|
||||||
retry_clean, retry_tables, retry_warnings = sanitize_sql(retry_sql, row_limit)
|
|
||||||
retry_records = await conn.fetch(retry_clean)
|
|
||||||
if retry_records:
|
|
||||||
sql = retry_clean
|
|
||||||
tables = retry_tables
|
|
||||||
records = retry_records
|
|
||||||
warnings.extend(retry_warnings)
|
|
||||||
warnings.append("Initial SQL returned zero rows; Oracle retried with a broader CRM read query.")
|
|
||||||
rows = [_json_safe(dict(record)) for record in records]
|
rows = [_json_safe(dict(record)) for record in records]
|
||||||
columns = list(rows[0].keys()) if rows else []
|
columns = list(rows[0].keys()) if rows else []
|
||||||
component_type = infer_component_type(prompt, columns, rows)
|
component_type = infer_component_type(prompt, columns, rows)
|
||||||
@@ -276,17 +275,29 @@ class NaturalDbAgent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _plan_sql(self, prompt: str, catalog: dict[str, Any], row_limit: int) -> dict[str, Any]:
|
async def _plan_sql(self, prompt: str, catalog: dict[str, Any], row_limit: int) -> dict[str, Any]:
|
||||||
fallback = {"sql": self._fallback_sql(prompt, row_limit), "title": title_from_prompt(prompt), "rationale": "Deterministic SQL planner fallback."}
|
|
||||||
try:
|
try:
|
||||||
providers = runtime_llm_service._provider_catalog()
|
providers = runtime_llm_service._provider_catalog()
|
||||||
except Exception:
|
except Exception:
|
||||||
providers = {}
|
providers = {}
|
||||||
if not providers:
|
if not providers:
|
||||||
return fallback
|
raise RuntimeError("No runtime LLM providers are configured for Oracle natural planning.")
|
||||||
schema_brief = json.dumps(catalog.get("tables", {}), default=str)[:16000]
|
schema_brief = json.dumps(catalog.get("tables", {}), default=str)[:16000]
|
||||||
|
semantic_rules = """
|
||||||
|
Velocity SQL semantics:
|
||||||
|
- QD score means intel_qd_scores.current_value. Do not use crm_people.engagement_score, crm_leads.engagement_score, or intel_interactions.engagement_score as QD.
|
||||||
|
- For project/property scoped prompts such as "in Atri Surya Toron", "interested in", "for project", or "for property", use crm_property_interests as the primary scoping table.
|
||||||
|
- Prefer crm_property_interests.project_name for textual project matching. inventory_projects is optional for enrichment, not the primary client-to-project relationship.
|
||||||
|
- For client lists scoped to a project, join crm_people to crm_property_interests on person_id and filter project_name case-insensitively.
|
||||||
|
- For lowest/highest/best/worst QD prompts, sort on intel_qd_scores.current_value ASC/DESC as requested.
|
||||||
|
- Respect the user-requested cardinality exactly when possible. If the prompt says five/top 5/lowest 5, return LIMIT 5.
|
||||||
|
- When listing clients, include person identity fields from crm_people such as person_id, full_name, primary_phone, and primary_email.
|
||||||
|
- When aggregating top properties/projects, group by crm_property_interests.project_name and count DISTINCT person_id.
|
||||||
|
- You may use any table in the public schema that is relevant to the question.
|
||||||
|
- Use only read-only PostgreSQL SELECT/CTE queries.
|
||||||
|
"""
|
||||||
system = (
|
system = (
|
||||||
"You are Oracle's read-only PostgreSQL planner. Generate one useful SELECT or WITH query "
|
"You are Oracle's read-only PostgreSQL planner. Generate one useful SELECT or WITH query "
|
||||||
"for the user's CRM question. Use only the provided schema. Return JSON with sql, title, rationale. "
|
"for the user's CRM question. You have access to the full public schema. Return JSON with sql, title, rationale. "
|
||||||
"Never generate INSERT, UPDATE, DELETE, DDL, COPY, or permission statements."
|
"Never generate INSERT, UPDATE, DELETE, DDL, COPY, or permission statements."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -294,7 +305,16 @@ class NaturalDbAgent:
|
|||||||
provider_id="sglang",
|
provider_id="sglang",
|
||||||
model=None,
|
model=None,
|
||||||
system_prompt=system,
|
system_prompt=system,
|
||||||
messages=[{"role": "user", "content": f"Schema:\n{schema_brief}\n\nQuestion:\n{prompt}\n\nRow cap: {row_limit}"}],
|
messages=[{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Schema:\n{schema_brief}\n\n"
|
||||||
|
f"Semantic rules:\n{semantic_rules}\n\n"
|
||||||
|
f"Question:\n{prompt}\n\n"
|
||||||
|
f"Row cap: {row_limit}\n\n"
|
||||||
|
"Return strict JSON with keys: sql, title, rationale."
|
||||||
|
),
|
||||||
|
}],
|
||||||
temperature=0.05,
|
temperature=0.05,
|
||||||
response_format="json",
|
response_format="json",
|
||||||
metadata={"agent": "oracle_natural_db_agent"},
|
metadata={"agent": "oracle_natural_db_agent"},
|
||||||
@@ -307,162 +327,7 @@ class NaturalDbAgent:
|
|||||||
if isinstance(parsed, dict) and parsed.get("sql"):
|
if isinstance(parsed, dict) and parsed.get("sql"):
|
||||||
return parsed
|
return parsed
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Natural DB planner LLM failed, using fallback: %s", exc)
|
raise RuntimeError(f"Natural DB planner LLM failed: {exc}") from exc
|
||||||
return fallback
|
raise RuntimeError("Natural DB planner returned no valid SQL.")
|
||||||
|
|
||||||
async def _repair_sql(self, prompt: str, failed_sql: str, error: str, row_limit: int) -> str:
|
|
||||||
# Keep retry operationally deterministic if model is unavailable.
|
|
||||||
if "read_last_contacted" in failed_sql and "does not exist" in error.lower():
|
|
||||||
return self._base_last_contacted_sql(row_limit)
|
|
||||||
if "read_next_best_action" in failed_sql and "does not exist" in error.lower():
|
|
||||||
return self._base_last_contacted_sql(row_limit)
|
|
||||||
return self._fallback_sql(prompt, row_limit)
|
|
||||||
|
|
||||||
def _zero_row_retry_sql(self, prompt: str, row_limit: int, previous_sql: str) -> str | None:
|
|
||||||
lower = prompt.lower()
|
|
||||||
if any(term in lower for term in ("contact", "recent", "last", "call", "message", "email", "whatsapp", "follow")):
|
|
||||||
return self._base_last_contacted_sql(row_limit)
|
|
||||||
if any(term in lower for term in ("interest", "interested", "property", "project", "unit", "budget", "bhk")):
|
|
||||||
return self._base_property_interest_sql(row_limit)
|
|
||||||
if "from crm_people" not in previous_sql.lower():
|
|
||||||
return self._generic_clients_sql(row_limit)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _base_last_contacted_sql(self, row_limit: int) -> str:
|
|
||||||
limit = max(1, min(row_limit, MAX_ROW_CAP))
|
|
||||||
return f"""
|
|
||||||
WITH contact_events AS (
|
|
||||||
SELECT i.person_id, i.happened_at AS event_at, i.channel::text AS channel,
|
|
||||||
i.interaction_type AS event_type, i.summary AS summary, i.broker_name AS actor
|
|
||||||
FROM intel_interactions i
|
|
||||||
WHERE i.happened_at IS NOT NULL
|
|
||||||
UNION ALL
|
|
||||||
SELECT i.person_id, m.delivered_at, 'message', COALESCE(m.sender_role, 'message'), m.message_text, m.sender_name
|
|
||||||
FROM intel_messages m
|
|
||||||
JOIN intel_interactions i ON i.interaction_id = m.interaction_id
|
|
||||||
WHERE m.delivered_at IS NOT NULL
|
|
||||||
UNION ALL
|
|
||||||
SELECT i.person_id, e.sent_at, 'email', COALESCE(e.direction::text, 'email'), e.subject, e.from_address
|
|
||||||
FROM intel_emails e
|
|
||||||
JOIN intel_interactions i ON i.interaction_id = e.interaction_id
|
|
||||||
WHERE e.sent_at IS NOT NULL
|
|
||||||
UNION ALL
|
|
||||||
SELECT v.person_id, v.visited_at, 'site_visit', 'visit', v.outcome, v.hosted_by
|
|
||||||
FROM intel_visits v
|
|
||||||
WHERE v.visited_at IS NOT NULL
|
|
||||||
),
|
|
||||||
ranked AS (
|
|
||||||
SELECT *, row_number() OVER (PARTITION BY person_id ORDER BY event_at DESC) AS rn,
|
|
||||||
count(*) OVER (PARTITION BY person_id) AS interaction_count
|
|
||||||
FROM contact_events
|
|
||||||
)
|
|
||||||
SELECT p.person_id::text, p.full_name AS name, p.primary_phone AS phone,
|
|
||||||
p.primary_email AS email, r.event_at AS last_contacted_at,
|
|
||||||
r.channel AS last_contact_channel, r.event_type AS last_interaction_type,
|
|
||||||
r.summary AS last_contact_summary, r.actor AS last_contact_actor,
|
|
||||||
r.interaction_count::int,
|
|
||||||
q.current_value AS qd_score
|
|
||||||
FROM ranked r
|
|
||||||
JOIN crm_people p ON p.person_id = r.person_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT current_value FROM intel_qd_scores q
|
|
||||||
WHERE q.person_id = p.person_id
|
|
||||||
ORDER BY q.current_value DESC, q.computed_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
) q ON TRUE
|
|
||||||
WHERE r.rn = 1
|
|
||||||
ORDER BY r.event_at DESC
|
|
||||||
LIMIT {limit}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _base_property_interest_sql(self, row_limit: int) -> str:
|
|
||||||
limit = max(1, min(row_limit, MAX_ROW_CAP))
|
|
||||||
return f"""
|
|
||||||
SELECT p.person_id::text, p.full_name AS name, p.primary_phone AS phone, p.primary_email AS email,
|
|
||||||
COUNT(pi.interest_id)::int AS interest_count,
|
|
||||||
string_agg(DISTINCT COALESCE(pi.project_name, pr.project_name), ', ') AS projects,
|
|
||||||
string_agg(DISTINCT pi.configuration, ', ') AS configurations,
|
|
||||||
MIN(pi.budget_min) AS budget_min, MAX(pi.budget_max) AS budget_max,
|
|
||||||
MAX(pi.last_discussed_at) AS last_interest_at,
|
|
||||||
MAX(q.current_value) AS qd_score
|
|
||||||
FROM crm_people p
|
|
||||||
JOIN crm_property_interests pi ON pi.person_id = p.person_id
|
|
||||||
LEFT JOIN inventory_projects pr ON pr.project_id = pi.project_id
|
|
||||||
LEFT JOIN intel_qd_scores q ON q.person_id = p.person_id
|
|
||||||
GROUP BY p.person_id, p.full_name, p.primary_phone, p.primary_email
|
|
||||||
HAVING COUNT(pi.interest_id) > 0
|
|
||||||
ORDER BY interest_count DESC, qd_score DESC NULLS LAST, last_interest_at DESC NULLS LAST
|
|
||||||
LIMIT {limit}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _generic_clients_sql(self, row_limit: int) -> str:
|
|
||||||
limit = max(1, min(row_limit, MAX_ROW_CAP))
|
|
||||||
return f"""
|
|
||||||
SELECT p.person_id::text, p.full_name AS name, p.primary_email AS email, p.primary_phone AS phone,
|
|
||||||
p.buyer_type, l.status::text AS lead_status, l.budget_band, l.urgency,
|
|
||||||
q.current_value AS qd_score
|
|
||||||
FROM crm_people p
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT * FROM crm_leads l WHERE l.person_id = p.person_id ORDER BY l.updated_at DESC LIMIT 1
|
|
||||||
) l ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT current_value FROM intel_qd_scores q
|
|
||||||
WHERE q.person_id = p.person_id
|
|
||||||
ORDER BY q.current_value DESC, q.computed_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
) q ON TRUE
|
|
||||||
ORDER BY qd_score DESC NULLS LAST, p.full_name ASC
|
|
||||||
LIMIT {limit}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _fallback_sql(self, prompt: str, row_limit: int) -> str:
|
|
||||||
lower = prompt.lower()
|
|
||||||
limit = max(1, min(row_limit, MAX_ROW_CAP))
|
|
||||||
if "objection" in lower:
|
|
||||||
return f"""
|
|
||||||
SELECT p.person_id::text, p.full_name AS name, co.objection_type, co.category, co.severity,
|
|
||||||
co.client_quote, co.agent_response, co.extracted_at
|
|
||||||
FROM intel_call_objections co
|
|
||||||
JOIN intel_calls c ON c.call_id = co.call_id
|
|
||||||
JOIN intel_interactions i ON i.interaction_id = c.interaction_id
|
|
||||||
JOIN crm_people p ON p.person_id = i.person_id
|
|
||||||
ORDER BY co.extracted_at DESC
|
|
||||||
LIMIT {limit}
|
|
||||||
"""
|
|
||||||
if "whatsapp" in lower or "message" in lower or "conversation" in lower:
|
|
||||||
return f"""
|
|
||||||
SELECT p.person_id::text, p.full_name AS name, 'whatsapp' AS type,
|
|
||||||
m.message_text AS summary, m.sender_role AS actor, m.delivered_at AS date
|
|
||||||
FROM intel_messages m
|
|
||||||
JOIN intel_interactions i ON i.interaction_id = m.interaction_id
|
|
||||||
JOIN crm_people p ON p.person_id = i.person_id
|
|
||||||
WHERE lower(m.message_text) LIKE '%' || lower(split_part($${prompt}$$, ' ', 1)) || '%'
|
|
||||||
OR i.channel = 'whatsapp'
|
|
||||||
ORDER BY m.delivered_at DESC
|
|
||||||
LIMIT {limit}
|
|
||||||
"""
|
|
||||||
if "contact" in lower or "recent" in lower or "last" in lower:
|
|
||||||
return f"""
|
|
||||||
SELECT p.person_id::text, p.full_name AS name, p.primary_phone AS phone,
|
|
||||||
lc.last_contact_at AS last_contacted_at, lc.last_channel AS last_contact_channel,
|
|
||||||
lc.last_interaction_type, lc.days_since_contact, lc.total_interactions AS interaction_count,
|
|
||||||
nba.recommended_action AS next_action, q.current_value AS qd_score
|
|
||||||
FROM crm_people p
|
|
||||||
LEFT JOIN read_last_contacted lc ON lc.person_id = p.person_id
|
|
||||||
LEFT JOIN read_next_best_action nba ON nba.person_id = p.person_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT current_value FROM intel_qd_scores q
|
|
||||||
WHERE q.person_id = p.person_id
|
|
||||||
ORDER BY q.current_value DESC, q.computed_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
) q ON TRUE
|
|
||||||
WHERE lc.last_contact_at IS NOT NULL
|
|
||||||
ORDER BY lc.last_contact_at DESC
|
|
||||||
LIMIT {limit}
|
|
||||||
"""
|
|
||||||
if "4 bhk" in lower or "budget" in lower or "interest" in lower or "property" in lower or "client" in lower:
|
|
||||||
return self._base_property_interest_sql(limit)
|
|
||||||
return self._generic_clients_sql(limit)
|
|
||||||
|
|
||||||
|
|
||||||
natural_db_agent = NaturalDbAgent()
|
natural_db_agent = NaturalDbAgent()
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ def _coerce_datetime(value: datetime | str | None) -> datetime | None:
|
|||||||
def _json_safe(value: Any) -> Any:
|
def _json_safe(value: Any) -> Any:
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
return value.isoformat()
|
return value.isoformat()
|
||||||
|
if isinstance(value, uuid.UUID):
|
||||||
|
return str(value)
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {str(key): _json_safe(val) for key, val in value.items()}
|
return {str(key): _json_safe(val) for key, val in value.items()}
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
@@ -130,6 +132,49 @@ def _build_demo_retrieval_plan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_chart_axes(rows: list[dict[str, Any]], columns: list[str]) -> tuple[str | None, str | None]:
|
||||||
|
if not rows or not columns:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
sample = rows[0]
|
||||||
|
string_columns = [
|
||||||
|
column for column in columns
|
||||||
|
if isinstance(sample.get(column), str) and sample.get(column) not in (None, "")
|
||||||
|
]
|
||||||
|
numeric_columns = [
|
||||||
|
column for column in columns
|
||||||
|
if isinstance(sample.get(column), (int, float))
|
||||||
|
]
|
||||||
|
|
||||||
|
preferred_dimension_keys = (
|
||||||
|
"property_name",
|
||||||
|
"project_name",
|
||||||
|
"projects",
|
||||||
|
"name",
|
||||||
|
"category",
|
||||||
|
"label",
|
||||||
|
)
|
||||||
|
preferred_measure_keys = (
|
||||||
|
"interested_clients",
|
||||||
|
"interest_count",
|
||||||
|
"total_interest_events",
|
||||||
|
"count",
|
||||||
|
"value",
|
||||||
|
"avg_qd_score",
|
||||||
|
"qd_score",
|
||||||
|
)
|
||||||
|
|
||||||
|
x_axis = next((key for key in preferred_dimension_keys if key in string_columns), None)
|
||||||
|
if x_axis is None and string_columns:
|
||||||
|
x_axis = string_columns[0]
|
||||||
|
|
||||||
|
y_axis = next((key for key in preferred_measure_keys if key in numeric_columns), None)
|
||||||
|
if y_axis is None and numeric_columns:
|
||||||
|
y_axis = numeric_columns[0]
|
||||||
|
|
||||||
|
return x_axis, y_axis
|
||||||
|
|
||||||
|
|
||||||
_DATASET_MAP: dict[str, str] = {
|
_DATASET_MAP: dict[str, str] = {
|
||||||
"pipeline_board": "crm_opportunity_pipeline",
|
"pipeline_board": "crm_opportunity_pipeline",
|
||||||
"bar_chart": "oracle_property_interest_rollup",
|
"bar_chart": "oracle_property_interest_rollup",
|
||||||
@@ -168,12 +213,44 @@ def _component_plan_type_from_codebook(example: CodebookExample) -> str:
|
|||||||
|
|
||||||
def _parse_prompt_row_limit(prompt: str, actor_role: str) -> int:
|
def _parse_prompt_row_limit(prompt: str, actor_role: str) -> int:
|
||||||
default_limit = 50 if actor_role in ("senior_broker", "junior_broker") else 200
|
default_limit = 50 if actor_role in ("senior_broker", "junior_broker") else 200
|
||||||
match = re.search(r"\b(?:top|last|latest|recent|first|show|name of the last)\s+(\d{1,4})\b", prompt.lower())
|
lowered = prompt.lower()
|
||||||
if not match:
|
match = re.search(r"\b(?:top|last|latest|recent|first|show|name of the last|which)\s+(\d{1,4})\b", lowered)
|
||||||
return default_limit
|
if match:
|
||||||
requested = max(1, int(match.group(1)))
|
requested = max(1, int(match.group(1)))
|
||||||
return min(requested, default_limit)
|
return min(requested, default_limit)
|
||||||
|
|
||||||
|
word_to_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,
|
||||||
|
"thirteen": 13,
|
||||||
|
"fourteen": 14,
|
||||||
|
"fifteen": 15,
|
||||||
|
"sixteen": 16,
|
||||||
|
"seventeen": 17,
|
||||||
|
"eighteen": 18,
|
||||||
|
"nineteen": 19,
|
||||||
|
"twenty": 20,
|
||||||
|
}
|
||||||
|
word_match = re.search(
|
||||||
|
r"\b(?:top|last|latest|recent|first|show|name of the last|which)\s+"
|
||||||
|
r"(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty)\b",
|
||||||
|
lowered,
|
||||||
|
)
|
||||||
|
if not word_match:
|
||||||
|
return default_limit
|
||||||
|
requested = word_to_number[word_match.group(1)]
|
||||||
|
return min(requested, default_limit)
|
||||||
|
|
||||||
|
|
||||||
def _prompt_data_intent(prompt: str) -> str | None:
|
def _prompt_data_intent(prompt: str) -> str | None:
|
||||||
lowered = prompt.lower()
|
lowered = prompt.lower()
|
||||||
@@ -416,17 +493,20 @@ class PromptOrchestrator:
|
|||||||
next_order_base = self._next_order_base(existing_comps)
|
next_order_base = self._next_order_base(existing_comps)
|
||||||
section_id = f"sec_prompt_generated_{execution_id.replace('-', '')[:12]}"
|
section_id = f"sec_prompt_generated_{execution_id.replace('-', '')[:12]}"
|
||||||
|
|
||||||
natural_result = None
|
|
||||||
try:
|
try:
|
||||||
natural_result = await natural_db_agent.execute_prompt(
|
natural_result = await natural_db_agent.execute_prompt(
|
||||||
prompt,
|
prompt,
|
||||||
row_limit=_parse_prompt_row_limit(prompt, actor_role),
|
row_limit=_parse_prompt_row_limit(prompt, actor_role),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("ORCH natural DB agent unavailable, falling back to component planner: %s", exc)
|
logger.warning("ORCH natural DB agent failed with no fallback enabled: %s", exc)
|
||||||
warnings.append(f"Natural DB agent unavailable ({exc}); using component planner fallback.")
|
execution["status"] = "failed"
|
||||||
|
execution["summary"] = f"Oracle planner failed: {exc}"
|
||||||
|
execution["completedAt"] = _now()
|
||||||
|
execution["warnings"] = warnings + [f"No fallback enabled. Natural planner failure: {exc}"]
|
||||||
|
await self._persist_execution(execution)
|
||||||
|
return execution
|
||||||
|
|
||||||
if natural_result is not None:
|
|
||||||
execution["status"] = "executing"
|
execution["status"] = "executing"
|
||||||
execution["retrievalPlan"] = {
|
execution["retrievalPlan"] = {
|
||||||
"planId": str(uuid.uuid4()),
|
"planId": str(uuid.uuid4()),
|
||||||
@@ -718,6 +798,27 @@ class PromptOrchestrator:
|
|||||||
mapped_type = self._map_type(ctype)
|
mapped_type = self._map_type(ctype)
|
||||||
dataset = "oracle_natural_sql"
|
dataset = "oracle_natural_sql"
|
||||||
component_id = str(uuid.uuid4())
|
component_id = str(uuid.uuid4())
|
||||||
|
x_axis, y_axis = _infer_chart_axes(rows, columns)
|
||||||
|
bindings = self._default_bindings(ctype)
|
||||||
|
viz_params = {
|
||||||
|
**self._default_viz_params(ctype, dataset, rows),
|
||||||
|
"columns": columns,
|
||||||
|
"sqlSummary": result.get("summary"),
|
||||||
|
"sourceTables": result.get("sourceTables", []),
|
||||||
|
"rowCount": result.get("rowCount", len(rows)),
|
||||||
|
}
|
||||||
|
if ctype == "bar_chart":
|
||||||
|
if x_axis:
|
||||||
|
viz_params["xAxis"] = x_axis
|
||||||
|
bindings["dimensions"] = [x_axis]
|
||||||
|
if y_axis:
|
||||||
|
viz_params["yAxis"] = y_axis
|
||||||
|
bindings["measures"] = [y_axis]
|
||||||
|
elif ctype == "line_chart":
|
||||||
|
if x_axis:
|
||||||
|
bindings["dimensions"] = [x_axis]
|
||||||
|
if y_axis:
|
||||||
|
bindings["measures"] = [y_axis]
|
||||||
comp: dict[str, Any] = {
|
comp: dict[str, Any] = {
|
||||||
"componentId": component_id,
|
"componentId": component_id,
|
||||||
"type": mapped_type,
|
"type": mapped_type,
|
||||||
@@ -735,14 +836,8 @@ class PromptOrchestrator:
|
|||||||
"privacyTier": "standard",
|
"privacyTier": "standard",
|
||||||
"cachePolicy": {"mode": "revision_scoped"},
|
"cachePolicy": {"mode": "revision_scoped"},
|
||||||
},
|
},
|
||||||
"visualizationParameters": {
|
"visualizationParameters": viz_params,
|
||||||
**self._default_viz_params(ctype, dataset, rows),
|
"dataBindings": bindings,
|
||||||
"columns": columns,
|
|
||||||
"sqlSummary": result.get("summary"),
|
|
||||||
"sourceTables": result.get("sourceTables", []),
|
|
||||||
"rowCount": result.get("rowCount", len(rows)),
|
|
||||||
},
|
|
||||||
"dataBindings": self._default_bindings(ctype),
|
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"lifecycleState": "active",
|
"lifecycleState": "active",
|
||||||
"provenance": {
|
"provenance": {
|
||||||
@@ -966,10 +1061,9 @@ class PromptOrchestrator:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _generate_summary(prompt: str, viz_plan: dict[str, Any]) -> str:
|
def _generate_summary(prompt: str, viz_plan: dict[str, Any]) -> str:
|
||||||
count = len(viz_plan.get("components", []))
|
count = len([component for component in viz_plan.get("components", []) if component.get("type") != "textCanvas"])
|
||||||
short_prompt = prompt[:60] + ("…" if len(prompt) > 60 else "")
|
short_prompt = prompt[:60] + ("…" if len(prompt) > 60 else "")
|
||||||
data_component_count = max(count - 1, 0)
|
return f'Generated {count} component{"s" if count != 1 else ""} for: "{short_prompt}"'
|
||||||
return f'Generated {data_component_count} component{"s" if data_component_count != 1 else ""} for: "{short_prompt}"'
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _error_component(
|
def _error_component(
|
||||||
|
|||||||
@@ -134,6 +134,14 @@ async def _ctx_from_request(request: Request, user: UserPrincipal) -> PolicyCont
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_page_id(request: Request, user: UserPrincipal, page_id: str) -> str:
|
||||||
|
normalized = (page_id or "").strip()
|
||||||
|
if normalized and normalized.lower() != "main":
|
||||||
|
return normalized
|
||||||
|
me = await _get_current_user_profile(request, user)
|
||||||
|
return str(me["defaultPageId"])
|
||||||
|
|
||||||
|
|
||||||
# ── Pydantic Models ───────────────────────────────────────────────────────────
|
# ── Pydantic Models ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class PromptSubmitRequest(BaseModel):
|
class PromptSubmitRequest(BaseModel):
|
||||||
@@ -184,6 +192,14 @@ class PersonaRenderRequest(BaseModel):
|
|||||||
variables: dict[str, Any] = Field(default_factory=dict)
|
variables: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PageCreateRequest(BaseModel):
|
||||||
|
title: str = Field(default="Untitled Canvas", max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class PageUpdateRequest(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/me", summary="Get current user profile")
|
@router.get("/me", summary="Get current user profile")
|
||||||
@@ -191,8 +207,41 @@ async def get_me(request: Request, user: UserPrincipal = Depends(get_current_use
|
|||||||
return _ok(await _get_current_user_profile(request, user))
|
return _ok(await _get_current_user_profile(request, user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/canvas-pages", summary="List canvas pages for current user")
|
||||||
|
async def list_canvas_pages(
|
||||||
|
request: Request,
|
||||||
|
search: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
user: UserPrincipal = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
ctx = await _ctx_from_request(request, user)
|
||||||
|
pages = await canvas_service.list_pages(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
owner_id=ctx.actor_id,
|
||||||
|
search=search,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return _ok(pages, meta={"count": len(pages)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/canvas-pages", summary="Create a new canvas page")
|
||||||
|
async def create_canvas_page(
|
||||||
|
payload: PageCreateRequest,
|
||||||
|
request: Request,
|
||||||
|
user: UserPrincipal = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
ctx = await _ctx_from_request(request, user)
|
||||||
|
page = await canvas_service.create_page(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
owner_id=ctx.actor_id,
|
||||||
|
title=payload.title.strip() or "Untitled Canvas",
|
||||||
|
)
|
||||||
|
return _ok(page)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/canvas-pages/{page_id}", summary="Get canvas page by ID")
|
@router.get("/canvas-pages/{page_id}", summary="Get canvas page by ID")
|
||||||
async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
|
async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
|
||||||
|
page_id = await _resolve_page_id(request, user, page_id)
|
||||||
ctx = await _ctx_from_request(request, user)
|
ctx = await _ctx_from_request(request, user)
|
||||||
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
||||||
if not page:
|
if not page:
|
||||||
@@ -200,6 +249,46 @@ async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal =
|
|||||||
return _ok(page)
|
return _ok(page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/canvas-pages/{page_id}", summary="Rename a canvas page")
|
||||||
|
async def rename_canvas_page(
|
||||||
|
page_id: str,
|
||||||
|
payload: PageUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
user: UserPrincipal = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
page_id = await _resolve_page_id(request, user, page_id)
|
||||||
|
ctx = await _ctx_from_request(request, user)
|
||||||
|
try:
|
||||||
|
page = await canvas_service.update_page_title(
|
||||||
|
page_id=page_id,
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
owner_id=ctx.actor_id,
|
||||||
|
title=payload.title,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return _ok(page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/canvas-pages/{page_id}", summary="Delete a canvas page")
|
||||||
|
async def delete_canvas_page(
|
||||||
|
page_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: UserPrincipal = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
page_id = await _resolve_page_id(request, user, page_id)
|
||||||
|
ctx = await _ctx_from_request(request, user)
|
||||||
|
try:
|
||||||
|
await canvas_service.delete_page(
|
||||||
|
page_id=page_id,
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
owner_id=ctx.actor_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return _ok({"pageId": page_id, "deleted": True})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/canvas-pages/{page_id}/prompts", summary="Submit a prompt to generate canvas components")
|
@router.post("/canvas-pages/{page_id}/prompts", summary="Submit a prompt to generate canvas components")
|
||||||
async def submit_prompt(
|
async def submit_prompt(
|
||||||
page_id: str,
|
page_id: str,
|
||||||
@@ -207,6 +296,7 @@ async def submit_prompt(
|
|||||||
request: Request,
|
request: Request,
|
||||||
user: UserPrincipal = Depends(get_current_user),
|
user: UserPrincipal = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
page_id = await _resolve_page_id(request, user, page_id)
|
||||||
ctx = await _ctx_from_request(request, user)
|
ctx = await _ctx_from_request(request, user)
|
||||||
execution = await prompt_orchestrator.execute(
|
execution = await prompt_orchestrator.execute(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
@@ -253,6 +343,7 @@ async def create_fork(
|
|||||||
request: Request,
|
request: Request,
|
||||||
user: UserPrincipal = Depends(get_current_user),
|
user: UserPrincipal = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
page_id = await _resolve_page_id(request, user, page_id)
|
||||||
ctx = await _ctx_from_request(request, user)
|
ctx = await _ctx_from_request(request, user)
|
||||||
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
||||||
if not page:
|
if not page:
|
||||||
@@ -277,6 +368,7 @@ async def rollback_canvas(
|
|||||||
request: Request,
|
request: Request,
|
||||||
user: UserPrincipal = Depends(get_current_user),
|
user: UserPrincipal = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
page_id = await _resolve_page_id(request, user, page_id)
|
||||||
ctx = await _ctx_from_request(request, user)
|
ctx = await _ctx_from_request(request, user)
|
||||||
result = await canvas_service.rollback(
|
result = await canvas_service.rollback(
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
@@ -295,6 +387,7 @@ async def rollback_canvas(
|
|||||||
|
|
||||||
@router.get("/canvas-pages/{page_id}/revisions", summary="List revision history for a canvas page")
|
@router.get("/canvas-pages/{page_id}/revisions", summary="List revision history for a canvas page")
|
||||||
async def list_revisions(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
|
async def list_revisions(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
|
||||||
|
page_id = await _resolve_page_id(request, user, page_id)
|
||||||
ctx = await _ctx_from_request(request, user)
|
ctx = await _ctx_from_request(request, user)
|
||||||
revisions = await canvas_service.list_revisions(page_id, ctx.tenant_id)
|
revisions = await canvas_service.list_revisions(page_id, ctx.tenant_id)
|
||||||
return _ok(revisions, meta={"count": len(revisions)})
|
return _ok(revisions, meta={"count": len(revisions)})
|
||||||
|
|||||||
Reference in New Issue
Block a user