From e4e147d35b3f637055c15134029a2d5941706dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Gonz=C3=A1lez=20Marcos?= Date: Mon, 19 Jan 2026 14:00:02 +0100 Subject: [PATCH 1/2] feat: update HARVEY with SPHERE integration --- frontend/.env.example | 3 +- .../harvey/components/ContextManager.tsx | 104 ++--- .../harvey/components/ContextManagerItem.tsx | 113 +++++ .../harvey/components/ControlPanel.tsx | 119 +++-- .../modules/harvey/components/PricingList.tsx | 44 ++ .../components/PricingVersionLoader.tsx | 14 + .../harvey/components/PricingVersions.tsx | 101 ++++ .../harvey/components/SearchPricings.tsx | 67 +++ .../modules/harvey/context/pricingContext.ts | 4 + .../modules/harvey/hooks/usePricingContext.ts | 13 + .../src/modules/harvey/hooks/usePricings.ts | 58 +++ .../src/modules/harvey/hooks/useVersion.ts | 38 ++ .../harvey/pages/pricing-assistant/index.tsx | 438 +++++++++--------- frontend/src/modules/harvey/sphere.ts | 54 +++ frontend/src/modules/harvey/types/types.ts | 85 +++- frontend/src/modules/harvey/utils.ts | 153 ++++++ 16 files changed, 1074 insertions(+), 334 deletions(-) create mode 100644 frontend/src/modules/harvey/components/ContextManagerItem.tsx create mode 100644 frontend/src/modules/harvey/components/PricingList.tsx create mode 100644 frontend/src/modules/harvey/components/PricingVersionLoader.tsx create mode 100644 frontend/src/modules/harvey/components/PricingVersions.tsx create mode 100644 frontend/src/modules/harvey/components/SearchPricings.tsx create mode 100644 frontend/src/modules/harvey/context/pricingContext.ts create mode 100644 frontend/src/modules/harvey/hooks/usePricingContext.ts create mode 100644 frontend/src/modules/harvey/hooks/usePricings.ts create mode 100644 frontend/src/modules/harvey/hooks/useVersion.ts create mode 100644 frontend/src/modules/harvey/sphere.ts create mode 100644 frontend/src/modules/harvey/utils.ts diff --git a/frontend/.env.example b/frontend/.env.example index 3a7f4a6..41c059a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,4 @@ VITE_API_URL=/api VITE_ASSETS_URL=/static -VITE_SECRET_KEY=secret \ No newline at end of file +VITE_SECRET_KEY=secret +VITE_HARVEY_URL=/harvey-api \ No newline at end of file diff --git a/frontend/src/modules/harvey/components/ContextManager.tsx b/frontend/src/modules/harvey/components/ContextManager.tsx index 9ef9984..cf1f5f0 100644 --- a/frontend/src/modules/harvey/components/ContextManager.tsx +++ b/frontend/src/modules/harvey/components/ContextManager.tsx @@ -1,41 +1,25 @@ import { useMemo, useState } from 'react'; -import { - Box, - Typography, - List, - ListItem, - Chip, - Button, - TextField, - IconButton, - Paper, - Alert -} from '@mui/material'; -import { grey, primary } from '../../core/theme/palette'; +import { Box, Typography, List, Chip, Button, TextField, Paper, Alert } from '@mui/material'; +import { grey } from '../../core/theme/palette'; -import type { ContextItemInput, PricingContextItem } from '../types/types'; +import type { ContextInputType, PricingContextItem, UrlContextItemInput } from '../types/types'; +import ContextManagerItem from './ContextManagerItem'; interface Props { items: PricingContextItem[]; detectedUrls: string[]; - onAdd: (input: ContextItemInput) => void; + onAdd: (input: ContextInputType) => void; onRemove: (id: string) => void; onClear: () => void; } -const ORIGIN_LABEL: Record = { - user: 'Manual', - detected: 'Detected', - preset: 'Preset', - agent: 'Agent' -}; - function ContextManager({ items, detectedUrls, onAdd, onRemove, onClear }: Props) { const [urlInput, setUrlInput] = useState(''); const [error, setError] = useState(null); const availableDetected = useMemo( - () => detectedUrls.filter((url) => !items.some((item) => item.kind === 'url' && item.value === url)), + () => + detectedUrls.filter(url => !items.some(item => item.kind === 'url' && item.value === url)), [detectedUrls, items] ); @@ -48,7 +32,15 @@ function ContextManager({ items, detectedUrls, onAdd, onRemove, onClear }: Props try { const normalized = new URL(trimmed).href; - onAdd({ kind: 'url', label: normalized, value: normalized, origin: 'user' }); + const urlItem: UrlContextItemInput = { + kind: 'url', + url: normalized, + label: normalized, + value: normalized, + origin: 'user', + transform: 'not-started', + }; + onAdd(urlItem); setUrlInput(''); setError(null); } catch { @@ -58,27 +50,30 @@ function ContextManager({ items, detectedUrls, onAdd, onRemove, onClear }: Props return ( - + - + Pricing Context Add URLs or YAML exports to ground H.A.R.V.E.Y.'s answers. - All pricings detected or added via URL will be modeled automatically; this process can take up to 30–60 minutes. + All pricings detected or added via URL will be modeled automatically; this process can + take up to 30-60 minutes. {items.length} selected - {items.length > 0 ? ( + {items.length > 0 && ( - ) : null} + )} @@ -89,46 +84,33 @@ function ContextManager({ items, detectedUrls, onAdd, onRemove, onClear }: Props ) : ( - {items.map((item) => ( - - - - {item.label} - - - {item.kind === 'url' ? 'URL' : 'YAML'} · {ORIGIN_LABEL[item.origin]} - - - - + {items.map(item => ( + ))} )} - {availableDetected.length > 0 ? ( + {availableDetected.length > 0 && ( Detected in question - {availableDetected.map((url) => ( + {availableDetected.map(url => ( onAdd({ kind: 'url', label: url, value: url, origin: 'detected' })} + onClick={() => + onAdd({ + kind: 'url', + url: url, + label: url, + value: url, + transform: 'not-started', + origin: 'detected', + }) + } color="primary" variant="outlined" size="small" @@ -136,7 +118,7 @@ function ContextManager({ items, detectedUrls, onAdd, onRemove, onClear }: Props ))} - ) : null} + )} { + onChange={event => { setUrlInput(event.target.value); setError(null); }} - onKeyDown={(event) => { + onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); handleAddUrl(); @@ -161,11 +143,11 @@ function ContextManager({ items, detectedUrls, onAdd, onRemove, onClear }: Props Add URL - {error ? ( + {error && ( {error} - ) : null} + )} ); } diff --git a/frontend/src/modules/harvey/components/ContextManagerItem.tsx b/frontend/src/modules/harvey/components/ContextManagerItem.tsx new file mode 100644 index 0000000..612f686 --- /dev/null +++ b/frontend/src/modules/harvey/components/ContextManagerItem.tsx @@ -0,0 +1,113 @@ +import { Alert, Button, CircularProgress, ListItem, Stack, Typography } from '@mui/material'; +import { PricingContextItem } from '../types/types'; +import { Box } from '@mui/system'; +import { grey } from '@mui/material/colors'; +import { OpenInNew } from '@mui/icons-material'; + +const HARVEY_API_BASE_URL = import.meta.env.VITE_HARVEY_URL ?? 'http://localhost:8086'; + +interface ContextManagerItemProps { + item: PricingContextItem; + onRemove: (id: string) => void; +} + +function computeOriginLabel(pricingContextItem: PricingContextItem): string { + switch (pricingContextItem.origin) { + case 'user': + return 'Manual'; + case 'detected': + return 'Detected'; + case 'preset': + return 'Preset'; + case 'agent': + return 'Agent'; + case 'sphere': + return 'SPHERE'; + default: + return ''; + } +} + +function computeContextItemMetadata(pricingContextItem: PricingContextItem): string { + let res = `${pricingContextItem.kind.toUpperCase()} · ${computeOriginLabel(pricingContextItem)} `; + switch (pricingContextItem.origin) { + case 'agent': + case 'detected': + case 'preset': + case 'user': { + return res; + } + case 'sphere': { + res += `· ${pricingContextItem.owner} · ${pricingContextItem.version}`; + return res; + } + default: + return ''; + } +} + +function ContextManagerItem({ item, onRemove }: ContextManagerItemProps) { + const formatSphereEditorLink = (url: string) => `/editor?pricingUrl=${url}`; + + const formatEditorLink = (): string => { + switch (item.origin) { + case 'preset': + case 'user': + case 'detected': + case 'agent': + return formatSphereEditorLink(`https:/${HARVEY_API_BASE_URL}/static/${item.id}.yaml`); + case 'sphere': + return formatSphereEditorLink(item.yamlPath); + default: + return '#'; + } + }; + + const isSphereEditorLinkEnabled = + item.kind === 'yaml' || (item.kind === 'url' && item.transform === 'done'); + + return ( + + + + {item.label} + + + {computeContextItemMetadata(item)} + + {item.kind === 'url' && item.transform === 'not-started' && ( + URL waiting to be processed by A-MINT... + )} + + + + {isSphereEditorLinkEnabled && ( + + )} + {item.kind === 'url' && item.transform === 'pending' && } + + + ); +} + +export default ContextManagerItem; diff --git a/frontend/src/modules/harvey/components/ControlPanel.tsx b/frontend/src/modules/harvey/components/ControlPanel.tsx index 2ea9af6..e2db4dc 100644 --- a/frontend/src/modules/harvey/components/ControlPanel.tsx +++ b/frontend/src/modules/harvey/components/ControlPanel.tsx @@ -1,8 +1,23 @@ -import { ChangeEvent, FormEvent } from 'react'; -import { Box, TextField, Button } from '@mui/material'; +import { ChangeEvent, FormEvent, useState } from 'react'; +import { + Box, + TextField, + Button, + Dialog, + Typography, + DialogTitle, + DialogActions, + DialogContent, + Stack, + Divider, + IconButton, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; import ContextManager from './ContextManager'; -import type { ContextItemInput, PricingContextItem } from '../types/types'; +import type { ContextInputType, PricingContextItem } from '../types/types'; +import SearchPricings from './SearchPricings'; +import { grey } from '@mui/material/colors'; interface Props { question: string; @@ -13,8 +28,9 @@ interface Props { onQuestionChange: (value: string) => void; onSubmit: (event: FormEvent) => void; onFileSelect: (files: FileList | null) => void; - onContextAdd: (input: ContextItemInput) => void; + onContextAdd: (input: ContextInputType) => void; onContextRemove: (id: string) => void; + onSphereContextRemove: (sphereId: string) => void; onContextClear: () => void; } @@ -29,14 +45,24 @@ function ControlPanel({ onFileSelect, onContextAdd, onContextRemove, - onContextClear + onSphereContextRemove, + onContextClear, }: Props) { + const [showPricingModal, setPricingModal] = useState(false); + const handleQuestionChange = (event: ChangeEvent) => { onQuestionChange(event.target.value); }; + const handleOpenModal = () => setPricingModal(true); + const handleCloseModal = () => setPricingModal(false); + return ( - + - - - + + Add Pricing Context + + + }> + + + + Upload pricing YAML (optional) + + + Uploaded YAMLs appear in the pricing context above so you can remove them at any time. + + + + + + + Add SPHERE iPricing (optional) + + + Add iPricings with our SPHERE integration (our iPricing repository). + + + You can further customize the search if you type a pricing name in the search bar. + + + + + + + + + Search Pricings + + + + + + - diff --git a/frontend/src/modules/harvey/components/PricingList.tsx b/frontend/src/modules/harvey/components/PricingList.tsx new file mode 100644 index 0000000..57555d6 --- /dev/null +++ b/frontend/src/modules/harvey/components/PricingList.tsx @@ -0,0 +1,44 @@ +import { Box, Typography } from '@mui/material'; +import { PricingSearchResultItem } from '../sphere'; +import { SphereContextItemInput } from '../types/types'; +import PricingVersions from './PricingVersions'; +import { grey } from '@mui/material/colors'; + +interface PricingListProps { + pricings: PricingSearchResultItem[]; + onContextAdd: (input: SphereContextItemInput) => void; + onContextRemove: (id: string) => void; +} + +function PricingsList({ pricings, onContextAdd, onContextRemove }: PricingListProps) { + const generateKey = (pricing: PricingSearchResultItem) => + `${pricing.owner}-${pricing.name}-${pricing.version}-${pricing.collectionName ?? 'nocollection'}`; + + if (pricings.length === 0) { + return No pricings found; + } + + return ( + + {pricings.map(item => ( + + + + {item.collectionName ? item.collectionName + '/' + item.name : item.name} + + Owned by: {item.owner} + + + + ))} + + ); +} + +export default PricingsList; diff --git a/frontend/src/modules/harvey/components/PricingVersionLoader.tsx b/frontend/src/modules/harvey/components/PricingVersionLoader.tsx new file mode 100644 index 0000000..dfc6dc1 --- /dev/null +++ b/frontend/src/modules/harvey/components/PricingVersionLoader.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from '@mui/material'; + +function PricingVersionLoader() { + return ( + <> + + + + + + ); +} + +export default PricingVersionLoader; diff --git a/frontend/src/modules/harvey/components/PricingVersions.tsx b/frontend/src/modules/harvey/components/PricingVersions.tsx new file mode 100644 index 0000000..acdac9e --- /dev/null +++ b/frontend/src/modules/harvey/components/PricingVersions.tsx @@ -0,0 +1,101 @@ +import { Box, Button, Chip, Divider, Typography } from '@mui/material'; +import { usePricingContext } from '../hooks/usePricingContext'; +import { usePricingVersions } from '../hooks/useVersion'; +import { fetchPricingYaml } from '../sphere'; +import { SphereContextItemInput } from '../types/types'; +import PricingVersionLoader from './PricingVersionLoader'; + +interface PricingVersionProps { + owner: string; + name: string; + collectionName?: string | null; + onContextAdd: (input: SphereContextItemInput) => void; + onContextRemove: (id: string) => void; +} + +function PricingVersions({ + owner, + name, + collectionName, + onContextAdd, + onContextRemove, +}: PricingVersionProps) { + const { loading, error, versions } = usePricingVersions(owner, name, collectionName); + const pricingContextItems = usePricingContext(); + + const isVersionIncludedInContext = (yamlPath: string) => + pricingContextItems.filter( + item => item.origin && item.origin === 'sphere' && item.yamlPath === yamlPath + ).length > 0; + + const totalVersions = versions?.versions.length; + + if (error) { + return
Something went wrong...
; + } + + if (loading) { + return ; + } + + const calculateLabel = (name: string, collectionName?: string | null) => + `${collectionName ? collectionName + '/' : ''}${name}`; + + const calculateTotalVersionLabel = () => { + const res = 'version'; + if (totalVersions === 1) { + return res; + } + return res + 's'; + }; + + const handleAddSpherePricing = async (sphereId: string, yamlUrl: string, version: string) => { + const yamlFile = await fetchPricingYaml(yamlUrl); + onContextAdd({ + sphereId: sphereId, + kind: 'yaml', + label: calculateLabel(name, collectionName), + value: yamlFile, + origin: 'sphere', + owner: owner, + yamlPath: yamlUrl, + pricingName: name, + version: version, + collection: collectionName ?? null, + }); + }; + + return ( + <> + {totalVersions && ( + + )} + + {versions?.versions.map(item => ( + + {item.version} + {!isVersionIncludedInContext(item.yaml) ? ( + + ) : ( + + )} + + ))} + + + + ); +} + +export default PricingVersions; diff --git a/frontend/src/modules/harvey/components/SearchPricings.tsx b/frontend/src/modules/harvey/components/SearchPricings.tsx new file mode 100644 index 0000000..ba323c7 --- /dev/null +++ b/frontend/src/modules/harvey/components/SearchPricings.tsx @@ -0,0 +1,67 @@ +import { usePricings } from '../hooks/usePricings'; +import { Box, Skeleton, Pagination, TextField, Typography, Chip } from '@mui/material'; +import { useState } from 'react'; +import { SphereContextItemInput } from '../types/types'; +import PricingsList from './PricingList'; + +interface SearchPricingsProps { + onContextAdd: (input: SphereContextItemInput) => void; + onContextRemove: (id: string) => void; +} + +function SearchPricings({ onContextAdd, onContextRemove }: SearchPricingsProps) { + const [search, setSearch] = useState(''); + const [offset, setOffset] = useState(0); + const limit = 10; + const { loading, error, pricings: result } = usePricings(search, offset, limit); + + const currentPage = offset / limit + 1; + const totalPages = Math.ceil(result.total / limit); + + function handleSeachChange(event: React.ChangeEvent) { + setSearch(event.currentTarget.value); + setOffset(0); + } + + const handlePaginationChange = (_: React.ChangeEvent, page: number) => + setOffset((page - 1) * limit); + + if (error) { + return Something went wrong...; + } + + const hasResults = result.pricings.length > 0; + + return ( + + + {!loading ? ( + <> + + + + + ) : ( + + )} + + + ); +} + +export default SearchPricings; diff --git a/frontend/src/modules/harvey/context/pricingContext.ts b/frontend/src/modules/harvey/context/pricingContext.ts new file mode 100644 index 0000000..bfae79a --- /dev/null +++ b/frontend/src/modules/harvey/context/pricingContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import { PricingContextItem } from "../types/types"; + +export const PricingContext = createContext(null) \ No newline at end of file diff --git a/frontend/src/modules/harvey/hooks/usePricingContext.ts b/frontend/src/modules/harvey/hooks/usePricingContext.ts new file mode 100644 index 0000000..8340bdc --- /dev/null +++ b/frontend/src/modules/harvey/hooks/usePricingContext.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; +import { PricingContext } from "../context/pricingContext"; + + +export function usePricingContext() { + const pricingContext = useContext(PricingContext) + + if (!pricingContext) { + throw new Error("usePricingContext must be used within PricingContext.Provider") + } + + return pricingContext +} \ No newline at end of file diff --git a/frontend/src/modules/harvey/hooks/usePricings.ts b/frontend/src/modules/harvey/hooks/usePricings.ts new file mode 100644 index 0000000..929eac4 --- /dev/null +++ b/frontend/src/modules/harvey/hooks/usePricings.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; +import { usePricingsApi } from "../../pricing/api/pricingsApi"; + +export interface PricingSearchResult { + total: number; + pricings: PricingSearchResultItem[]; +} + +export interface PricingSearchResultItem { + name: string; + owner: string; + version: string; + extractionDate: string; + currency: string; + analytycs: { + numberOfFeatures: number; + numberOfPlans: number; + numberOfAddOns: number; + configurationSpaceSize: number; + minSubscriptionPrice: number; + maxSubscriptionPrice: number; + }; + collectionName?: string | null; +} + +export function usePricings( + search: string, + offset: number = 0, + limit: number = 10 +) { + const [pricings, setPricings] = useState( + {pricings: [], total: 0} + ); + const [loading, setLoading] = useState(false) + const [error, setError] = useState(undefined); + const { getPricings} = usePricingsApi() + + useEffect(() => { + const makeRequest = async () => { + try { + setLoading(true) + const data = await getPricings({offset, limit, search}); + if ("error" in data) { + setError(new Error(data.error)); + } else { + setPricings(data); + } + } catch (error) { + setError(error as Error); + } finally { + setLoading(false) + } + }; + makeRequest(); + }, [search, offset]); + + return { loading, error, pricings }; +} diff --git a/frontend/src/modules/harvey/hooks/useVersion.ts b/frontend/src/modules/harvey/hooks/useVersion.ts new file mode 100644 index 0000000..f074301 --- /dev/null +++ b/frontend/src/modules/harvey/hooks/useVersion.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import { PricingVersionsResult } from "../sphere"; +import { usePricingsApi } from "../../pricing/api/pricingsApi"; + +export function usePricingVersions( + owner: string, + name: string, + collectionName?: string | null +) { + const [versions, setVersions] = useState( + undefined + ); + const [loading, setLoading] = useState(false) + const [error, setError] = useState(undefined); + const { getPricingByName } = usePricingsApi() + + useEffect(() => { + const makeRequest = async () => { + try { + setLoading(true) + const data = await getPricingByName(name, owner, collectionName ?? null); + if ("error" in data) { + data.error; + setError(Error(data.error)); + } else { + setVersions(data); + } + } catch (error) { + setError(error as Error); + } finally { + setLoading(false) + } + }; + makeRequest(); + }, []); + + return { loading, error, versions }; +} diff --git a/frontend/src/modules/harvey/pages/pricing-assistant/index.tsx b/frontend/src/modules/harvey/pages/pricing-assistant/index.tsx index 119318d..f9fbb1a 100644 --- a/frontend/src/modules/harvey/pages/pricing-assistant/index.tsx +++ b/frontend/src/modules/harvey/pages/pricing-assistant/index.tsx @@ -7,84 +7,50 @@ import ChatTranscript from '../../components/ChatTranscript'; import ControlPanel from '../../components/ControlPanel'; import type { ChatMessage, - ContextItemInput, + ChatRequest, + ContextInputType, + NotificationUrlEvent, PricingContextItem, - PromptPreset -} from '../../types/types' + PricingContextUrlWithId, + PromptPreset, +} from '../../types/types'; import { PROMPT_PRESETS } from '../../prompts'; - -const API_BASE_URL = import.meta.env.VITE_HARVEY_URL ?? 'http://localhost:8086'; - -const extractPricingUrls = (text: string): string[] => { - const matches = text.match(/https?:\/\/[^\s)]+/gi) ?? []; - const urls: string[] = []; - - matches.forEach((raw) => { - const candidate = raw.replace(/[),.;]+$/, ''); - try { - const url = new URL(candidate); - if (!urls.includes(url.href)) { - urls.push(url.href); - } - } catch (error) { - console.warn('Detected invalid pricing URL candidate', candidate, error); - } - }); - - return urls; -}; - -const isHttpUrl = (value: string): boolean => /^https?:\/\//i.test(value); - -const extractHttpReferences = (payload: unknown): string[] => { - const results = new Set(); - const visited = new Set(); - - const visit = (value: unknown) => { - if (value === null || value === undefined) { - return; - } - if (typeof value === 'string') { - if (isHttpUrl(value)) { - results.add(value); - } - return; - } - if (typeof value !== 'object') { - return; - } - if (visited.has(value)) { - return; - } - visited.add(value); - - if (Array.isArray(value)) { - value.forEach(visit); - return; - } - - Object.values(value).forEach(visit); - }; - - visit(payload); - return Array.from(results); -}; +import { PricingContext } from '../../context/pricingContext'; +import { + chatWithAgent, + createContextBodyPayload, + deleteYamlPricing, + diffPricingContextWithDetectedUrls, + extractHttpReferences, + extractPricingUrls, + uploadYamlPricing, +} from '../../utils'; + +const HARVEY_API_BASE_URL = import.meta.env.VITE_HARVEY_URL ?? 'http://localhost:8086'; function PricingAssistantPage() { const [messages, setMessages] = useState([]); const [question, setQuestion] = useState(''); const [contextItems, setContextItems] = useState([]); const [isLoading, setIsLoading] = useState(false); - const [theme, setTheme] = useState<'light' | 'dark'>(() => { - if (typeof window === 'undefined') { - return 'light'; - } - const stored = window.localStorage.getItem('pricing-theme'); - if (stored === 'light' || stored === 'dark') { - return stored; - } - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - }); + + useEffect(() => { + const eventSource = new EventSource(`${HARVEY_API_BASE_URL}/events`); + + eventSource.onopen = () => console.log('Connection established'); + + eventSource.addEventListener('url_transform', (event: MessageEvent) => { + const notification: NotificationUrlEvent = JSON.parse(event.data); + setContextItems(previous => + previous.map(item => + item.kind === 'url' && item.id === notification.id + ? { ...item, transform: 'done', value: notification.yaml_content } + : item + ) + ); + }); + return () => eventSource.close(); + }, []); const detectedPricingUrls = useMemo(() => extractPricingUrls(question), [question]); @@ -93,53 +59,81 @@ function PricingAssistantPage() { return isLoading || !hasQuestion; }, [question, isLoading]); - useEffect(() => { - document.documentElement.dataset.theme = theme; - if (typeof window !== 'undefined') { - window.localStorage.setItem('pricing-theme', theme); + const createPricingContextItems = (contextInputItems: ContextInputType[]): PricingContextItem[] => + contextInputItems + .map(item => ({ + ...item, + value: item.value.trim(), + id: crypto.randomUUID(), + })) + .filter( + item => + !contextItems.some( + stateItem => stateItem.kind === item.kind && stateItem.value === item.value + ) + ); + + const addContextItems = (inputs: ContextInputType[]) => { + if (inputs.length === 0) { + return null; } - }, [theme]); - const addContextItems = (inputs: ContextItemInput[]) => { - setContextItems((previous) => { - if (inputs.length === 0) { - return previous; - } + const newPricingContextItems: PricingContextItem[] = createPricingContextItems(inputs); - const next = [...previous]; - inputs.forEach((input) => { - const trimmedValue = input.value.trim(); - if (!trimmedValue) { - return; - } + const uploadPromises = newPricingContextItems + .filter( + item => + item.kind === 'yaml' && + item.origin && + (item.origin === 'user' || item.origin === 'preset') + ) + .map(item => uploadYamlPricing(`${item.id}.yaml`, item.value)); - const exists = next.some((item) => item.kind === input.kind && item.value === trimmedValue); - if (exists) { - return; - } + if (uploadPromises.length > 0) { + Promise.all(uploadPromises).catch(err => console.error('Upload failed', err)); + } - next.push({ - id: crypto.randomUUID(), - kind: input.kind, - label: input.label?.trim() || trimmedValue, - value: trimmedValue, - origin: input.origin ?? 'user' - }); - }); - return next; - }); + setContextItems(previous => [...previous, ...newPricingContextItems]); + + return newPricingContextItems; }; - const addContextItem = (input: ContextItemInput) => { + const addContextItem = (input: ContextInputType) => { addContextItems([input]); }; const removeContextItem = (id: string) => { - setContextItems((previous) => previous.filter((item) => item.id !== id)); + const deletePromises = contextItems + .filter( + item => + item.id === id && + item.kind === 'yaml' && + item.origin && + (item.origin === 'user' || item.origin === 'preset') + ) + .map(item => deleteYamlPricing(`${item.id}.yaml`)); + if (deletePromises.length > 0) { + Promise.all(deletePromises); + } + setContextItems(previous => previous.filter(item => item.id !== id)); + }; + + const removeSphereContextItem = (sphereId: string) => { + setContextItems(previous => + previous.filter(item => item.origin && item.origin === 'sphere' && item.sphereId !== sphereId) + ); }; const clearContext = () => { setContextItems([]); + const storedYamls = contextItems + .filter( + item => + (item.kind === 'yaml' && item.origin && item.origin !== 'sphere') || + (item.kind === 'url' && item.transform === 'done') + ) + .map(item => deleteYamlPricing(`${item.id}.yaml`)); + Promise.all(storedYamls).catch(() => console.error('Failed to delete yamls')); }; const handleFilesSelected = (files: FileList | null) => { @@ -148,21 +142,15 @@ function PricingAssistantPage() { } const fileArray = Array.from(files); - Promise.all( - fileArray.map((file) => - file - .text() - .then((content) => ({ name: file.name, content })) - ) - ) - .then((results) => { - const inputs: ContextItemInput[] = results - .filter((result) => Boolean(result.content.trim())) - .map((result) => ({ + Promise.all(fileArray.map(file => file.text().then(content => ({ name: file.name, content })))) + .then(results => { + const inputs: ContextInputType[] = results + .filter(result => Boolean(result.content.trim())) + .map(result => ({ kind: 'yaml', label: result.name, value: result.content, - origin: 'user' + origin: 'user', })); if (inputs.length > 0) { @@ -170,27 +158,27 @@ function PricingAssistantPage() { } if (inputs.length !== results.length) { - setMessages((prev) => [ + setMessages(prev => [ ...prev, { id: crypto.randomUUID(), role: 'assistant', content: 'One or more uploaded files were empty and were skipped.', - createdAt: new Date().toISOString() - } + createdAt: new Date().toISOString(), + }, ]); } }) - .catch((error) => { + .catch(error => { console.error('Failed to read YAML file', error); - setMessages((prev) => [ + setMessages(prev => [ ...prev, { id: crypto.randomUUID(), role: 'assistant', content: 'Could not read the uploaded file. Please try again.', - createdAt: new Date().toISOString() - } + createdAt: new Date().toISOString(), + }, ]); }); }; @@ -199,11 +187,11 @@ function PricingAssistantPage() { setQuestion(preset.question); if (preset.context.length > 0) { addContextItems( - preset.context.map((entry) => ({ + preset.context.map(entry => ({ kind: entry.kind, label: entry.label, value: entry.value, - origin: entry.origin ?? 'preset' + origin: 'preset', })) ); } @@ -216,6 +204,12 @@ function PricingAssistantPage() { setIsLoading(false); }; + const getUrlItems = () => + contextItems.filter(item => item.kind === 'url').map(item => ({ id: item.id, url: item.url })); + + const getUniqueYamlFiles = () => + Array.from(new Set(contextItems.filter(item => item.kind === 'yaml').map(item => item.value))); + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); if (isSubmitDisabled) return; @@ -223,76 +217,42 @@ function PricingAssistantPage() { const trimmedQuestion = question.trim(); if (!trimmedQuestion) return; - const contextUrls = contextItems.filter((item) => item.kind === 'url').map((item) => item.value); - const contextYamls = contextItems.filter((item) => item.kind === 'yaml').map((item) => item.value); - - const combinedUrlSet = new Set(contextUrls); - detectedPricingUrls.forEach((url) => combinedUrlSet.add(url)); - const combinedUrls = Array.from(combinedUrlSet); - const dedupedYamls = Array.from(new Set(contextYamls)); - - const newlyDetected = detectedPricingUrls.filter( - (url) => !contextUrls.includes(url) - ); + const newlyDetected = diffPricingContextWithDetectedUrls(contextItems, detectedPricingUrls); + let newUrls: PricingContextUrlWithId[] = []; if (newlyDetected.length > 0) { - addContextItems( - newlyDetected.map((url) => ({ + const newItems = addContextItems( + newlyDetected.map(url => ({ kind: 'url', + url: url, label: url, value: url, - origin: 'detected' + origin: 'detected', + transform: 'pending', })) ); - } - - const body: Record = { - question: trimmedQuestion - }; - - if (combinedUrls.length === 1) { - body.pricing_url = combinedUrls[0]; - } else if (combinedUrls.length > 1) { - body.pricing_urls = combinedUrls; - } - - if (dedupedYamls.length === 1) { - body.pricing_yaml = dedupedYamls[0]; - } else if (dedupedYamls.length > 1) { - body.pricing_yamls = dedupedYamls; + newUrls = newItems + ? newItems.filter(item => item.kind === 'url').map(item => ({ id: item.id, url: item.url })) + : []; } const userMessage: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: trimmedQuestion, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), }; - setMessages((prev) => [...prev, userMessage]); + setMessages(prev => [...prev, userMessage]); setIsLoading(true); + setContextItems(prev => + prev.map(item => (item.kind === 'url' ? { ...item, transform: 'pending' } : item)) + ); try { - const response = await fetch(`${API_BASE_URL}/chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }); - - if (!response.ok) { - let message = `API returned ${response.status}`; - try { - const detail = await response.json(); - if (typeof detail?.detail === 'string') { - message = detail.detail; - } - } catch (parseError) { - console.error('Failed to parse error response', parseError); - } - throw new Error(message); - } - - const data = await response.json(); + const requestBody: ChatRequest = { + question: trimmedQuestion, + ...createContextBodyPayload([...getUrlItems(), ...newUrls], getUniqueYamlFiles()), + }; + const data = await chatWithAgent(requestBody); const assistantMessage: ChatMessage = { id: crypto.randomUUID(), @@ -301,21 +261,27 @@ function PricingAssistantPage() { createdAt: new Date().toISOString(), metadata: { plan: data.plan ?? undefined, - result: data.result ?? undefined - } + result: data.result ?? undefined, + }, }; - setMessages((prev) => [...prev, assistantMessage]); + setMessages(prev => [...prev, assistantMessage]); const planReferences = extractHttpReferences(data?.plan); const resultReferences = extractHttpReferences(data?.result); const agentDiscoveredUrls = [...planReferences, ...resultReferences]; - if (agentDiscoveredUrls.length > 0) { + const newAgentDiscovered = diffPricingContextWithDetectedUrls( + contextItems, + agentDiscoveredUrls + ); + if (newAgentDiscovered.length > 0) { addContextItems( - agentDiscoveredUrls.map((url) => ({ + newAgentDiscovered.map(url => ({ kind: 'url', + url: url, label: url, value: url, - origin: 'agent' + origin: 'agent', + transform: 'not-started', })) ); } @@ -324,9 +290,9 @@ function PricingAssistantPage() { id: crypto.randomUUID(), role: 'assistant', content: `Error: ${(error as Error).message}`, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), }; - setMessages((prev) => [...prev, assistantMessage]); + setMessages(prev => [...prev, assistantMessage]); } finally { setIsLoading(false); setQuestion(''); @@ -334,56 +300,66 @@ function PricingAssistantPage() { }; return ( - - - - - - H.A.R.V.E.Y. Pricing Assistant - - - Ask about optimal subscriptions and pricing insights using the Holistic Agent for Reasoning on Value and Economic analYsis (HARVEY). - - - - + + + + + + + H.A.R.V.E.Y. Pricing Assistant + + + Ask about optimal subscriptions and pricing insights using the Holistic Agent for + Reasoning on Value and Economic analYsis (HARVEY). + + + + + - - - - - + + + + + + + - - - - + - - + + ); } diff --git a/frontend/src/modules/harvey/sphere.ts b/frontend/src/modules/harvey/sphere.ts new file mode 100644 index 0000000..469bde2 --- /dev/null +++ b/frontend/src/modules/harvey/sphere.ts @@ -0,0 +1,54 @@ +export interface PricingSearchResult { + total: number; + pricings: PricingSearchResultItem[]; +} + +export interface PricingSearchResultItem { + name: string; + owner: string; + version: string; + extractionDate: string; + currency: string; + analytycs: { + numberOfFeatures: number; + numberOfPlans: number; + numberOfAddOns: number; + configurationSpaceSize: number; + minSubscriptionPrice: number; + maxSubscriptionPrice: number; + }; + collectionName?: string | null; +} + +export interface SphereError { + error: string; +} + +export async function fetchPricingYaml(url: string) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Response status: ${response.status}`); + } + return await response.text(); +} + +export interface PricingVersionsResult { + name: string; + collectionName: string | null; + versions: PricingVersion[]; +} + +export interface PricingVersion { + id: string; + version: string; + private: boolean; + collectionName: string | null; + extractionDate: string; + url: string; + yaml: string; + analytics: object; + owner: { + id: string; + username: string; + }; +} diff --git a/frontend/src/modules/harvey/types/types.ts b/frontend/src/modules/harvey/types/types.ts index c1cc9bc..3df5973 100644 --- a/frontend/src/modules/harvey/types/types.ts +++ b/frontend/src/modules/harvey/types/types.ts @@ -11,23 +11,49 @@ export interface ChatMessage { }; } -export interface PricingContextItem { +export type Kinds = 'url' | 'yaml'; +export type Origins = 'user' | 'detected' | 'preset' | 'agent'; + +export type PricingContextItem = YamlContextItem | UrlContextItem; + +export type YamlContextItem = YamlContextItemInput & { id: string; - kind: 'url' | 'yaml'; +}; + +export interface UrlContextItem extends UrlContextItemInput { + id: string; +} + +export type ContextInputType = YamlContextItemInput | UrlContextItemInput; +export type YamlContextItemInput = BaseYamlContextItemInput | SphereContextItemInput; + +export interface BaseYamlContextItemInput { + kind: 'yaml'; label: string; value: string; - origin: 'user' | 'detected' | 'preset' | 'agent'; + origin?: Origins; } -export interface ContextItemInput { - kind: PricingContextItem['kind']; +export interface UrlContextItemInput { + kind: 'url'; label: string; + url: string; value: string; - origin?: PricingContextItem['origin']; + origin?: Origins; + transform: 'not-started' | 'pending' | 'done'; } -export interface PromptPresetContext extends Omit { - origin?: PricingContextItem['origin']; +export interface SphereContextItemInput { + sphereId: string; + kind: 'yaml'; + label: string; + value: string; + origin: 'sphere'; + owner: string; + collection: string | null; + pricingName: string; + version: string; + yamlPath: string; } export interface PromptPreset { @@ -35,5 +61,46 @@ export interface PromptPreset { label: string; description: string; question: string; - context: PromptPresetContext[]; + context: YamlContextItemInput[]; } + +export interface NotificationUrlEvent { + id: string; + pricing_url: string; + yaml_content: string; +} + +export type ChatRequest = { + question: string; +} & PricingContextPayload; + +export interface PricingContextUrlWithId { + id: string; + url: string; +} + +export type PricingContextPayload = + | { + pricing_url: PricingContextUrlWithId; + pricing_urls?: never; + pricing_yaml: string; + pricing_yamls?: never; + } + | { + pricing_url: PricingContextUrlWithId; + pricing_urls?: never; + pricing_yaml?: never; + pricing_yamls: string[]; + } + | { + pricing_url?: never; + pricing_urls: PricingContextUrlWithId[]; + pricing_yaml: string; + pricing_yamls?: never; + } + | { + pricing_url?: never; + pricing_urls: PricingContextUrlWithId[]; + pricing_yaml?: never; + pricing_yamls: string[]; + }; diff --git a/frontend/src/modules/harvey/utils.ts b/frontend/src/modules/harvey/utils.ts new file mode 100644 index 0000000..d378f2b --- /dev/null +++ b/frontend/src/modules/harvey/utils.ts @@ -0,0 +1,153 @@ +import { + ChatRequest, + PricingContextItem, + PricingContextPayload, + PricingContextUrlWithId, +} from "./types/types"; + +const HARVEY_API_BASE_URL = + import.meta.env.VITE_HARVEY_URL ?? "http://localhost:8086"; + +export function extractPricingUrls(text: string): string[] { + const matches = text.match(/https?:\/\/[^\s)]+/gi) ?? []; + const urls: string[] = []; + + matches.forEach((raw) => { + const candidate = raw.replace(/[),.;]+$/, ""); + try { + const url = new URL(candidate); + if (!urls.includes(url.href)) { + urls.push(url.href); + } + } catch (error) { + console.warn("Detected invalid pricing URL candidate", candidate, error); + } + }); + + return urls; +} + +function isHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +export function extractHttpReferences(payload: unknown): string[] { + const results = new Set(); + const visited = new Set(); + + const visit = (value: unknown) => { + if (value === null || value === undefined) { + return; + } + if (typeof value === "string") { + if (isHttpUrl(value)) { + results.add(value); + } + return; + } + if (typeof value !== "object") { + return; + } + if (visited.has(value)) { + return; + } + visited.add(value); + + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + + Object.values(value).forEach(visit); + }; + + visit(payload); + return Array.from(results); +} + +export async function uploadYamlPricing( + filename: string, + content: string +): Promise { + const form = new FormData(); + form.append( + "file", + new File([content], filename, { type: "application/yaml" }) + ); + const response = await fetch(HARVEY_API_BASE_URL + "/upload", { + method: "POST", + body: form, + }); + if (!response.ok) { + throw new Error(`Upload failed for ${filename}`); + } + + const json = await response.json(); + return json.filename; +} + +export async function deleteYamlPricing(filename: string): Promise { + const response = await fetch(HARVEY_API_BASE_URL + "/pricing/" + filename, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error(`Cannot delete item ${filename}`); + } +} + +export function diffPricingContextWithDetectedUrls( + pricingContext: PricingContextItem[], + detectedPricingUrls: string[] +): string[] { + const contextUrls = pricingContext + .filter((item) => item.kind === "url") + .map((item) => item.value); + return detectedPricingUrls.filter( + (detectedUrl) => !contextUrls.includes(detectedUrl) + ); +} + +export const createContextBodyPayload = ( + urls: PricingContextUrlWithId[], + yamls: string[] +): PricingContextPayload => { + const payload = {} as PricingContextPayload; + if (urls.length === 1) { + payload.pricing_url = urls[0]; + } else if (urls.length > 1) { + payload.pricing_urls = urls; + } + + if (yamls.length === 1) { + payload.pricing_yaml = yamls[0]; + } else if (yamls.length > 1) { + payload.pricing_yamls = yamls; + } + + return payload; +}; + +export async function chatWithAgent(body: ChatRequest) { + const response = await fetch(`${HARVEY_API_BASE_URL}/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + let message = `API returned ${response.status}`; + try { + const detail = await response.json(); + if (typeof detail?.detail === "string") { + message = detail.detail; + } + } catch (parseError) { + console.error("Failed to parse error response", parseError); + } + throw new Error(message); + } + + return await response.json(); +} From 8617eb1d88799121a5284005b8f2d469b228d7ad Mon Sep 17 00:00:00 2001 From: Alex-GF Date: Fri, 23 Jan 2026 10:34:30 +0100 Subject: [PATCH 2/2] feat: added headers to support SSE --- nginx/production/nginx.conf | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/nginx/production/nginx.conf b/nginx/production/nginx.conf index 3fd12a9..31bdb4f 100644 --- a/nginx/production/nginx.conf +++ b/nginx/production/nginx.conf @@ -27,6 +27,36 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + + location /harvey-api/events { + # 1. Reescritura: Transforma "/harvey-api/events" en "/events" + rewrite ^/harvey-api/(.*)$ /$1 break; + + # 2. Proxy Pass: Se envía a la raíz del servicio, pero con la URI modificada arriba + proxy_pass http://harvey:8086; + proxy_redirect off; + + # 3. CRUCIAL PARA SSE: Desactivar el buffering + # Si no pones esto, Nginx esperará a llenar un bloque de datos antes de enviarlo, + # rompiendo el tiempo real. + proxy_buffering off; + proxy_cache off; + + # 4. Conexión Persistente + proxy_http_version 1.1; + proxy_set_header Connection ""; # Asegura que se use keep-alive + + # 5. Timeouts + # Nota: Si el stream está inactivo (sin enviar datos) por más de 500s, + # Nginx cortará la conexión. Asegúrate que tu backend envíe "heartbeats" o pings. + proxy_connect_timeout 500; + proxy_read_timeout 500; + + # 6. Cabeceras estándar + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } location /harvey-api/ { include fastcgi_params;