Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
VITE_API_URL=/api
VITE_ASSETS_URL=/static
VITE_SECRET_KEY=secret
VITE_SECRET_KEY=secret
VITE_HARVEY_URL=/harvey-api
104 changes: 43 additions & 61 deletions frontend/src/modules/harvey/components/ContextManager.tsx
Original file line number Diff line number Diff line change
@@ -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<PricingContextItem['origin'], string> = {
user: 'Manual',
detected: 'Detected',
preset: 'Preset',
agent: 'Agent'
};

function ContextManager({ items, detectedUrls, onAdd, onRemove, onClear }: Props) {
const [urlInput, setUrlInput] = useState('');
const [error, setError] = useState<string | null>(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]
);

Expand All @@ -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 {
Expand All @@ -58,27 +50,30 @@ function ContextManager({ items, detectedUrls, onAdd, onRemove, onClear }: Props

return (
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}
>
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Typography variant="h6" component="h2" sx={{ fontWeight: 600 }}>
Pricing Context
</Typography>
<Typography variant="body2" sx={{ color: grey[600] }}>
Add URLs or YAML exports to ground H.A.R.V.E.Y.'s answers.
</Typography>
<Alert severity="info" sx={{ mt: 1 }}>
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.
</Alert>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: grey[600] }}>
{items.length} selected
</Typography>
{items.length > 0 ? (
{items.length > 0 && (
<Button size="small" onClick={onClear} color="error">
Clear all
</Button>
) : null}
)}
</Box>
</Box>

Expand All @@ -89,66 +84,53 @@ function ContextManager({ items, detectedUrls, onAdd, onRemove, onClear }: Props
</Typography>
) : (
<List sx={{ py: 0 }}>
{items.map((item) => (
<ListItem
key={item.id}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 1,
px: 0,
borderBottom: `1px solid ${grey[200]}`
}}
>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.label}
</Typography>
<Typography variant="caption" sx={{ color: grey[600] }}>
{item.kind === 'url' ? 'URL' : 'YAML'} · {ORIGIN_LABEL[item.origin]}
</Typography>
</Box>
<Button size="small" onClick={() => onRemove(item.id)} color="error">
Remove
</Button>
</ListItem>
{items.map(item => (
<ContextManagerItem key={item.id} item={item} onRemove={onRemove} />
))}
</List>
)}
</Box>

{availableDetected.length > 0 ? (
{availableDetected.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
Detected in question
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{availableDetected.map((url) => (
{availableDetected.map(url => (
<Chip
key={url}
label={`Add ${url}`}
onClick={() => 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"
/>
))}
</Box>
</Box>
) : null}
)}

<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
type="url"
name="context-url"
value={urlInput}
placeholder="https://example.com/pricing"
onChange={(event) => {
onChange={event => {
setUrlInput(event.target.value);
setError(null);
}}
onKeyDown={(event) => {
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddUrl();
Expand All @@ -161,11 +143,11 @@ function ContextManager({ items, detectedUrls, onAdd, onRemove, onClear }: Props
Add URL
</Button>
</Box>
{error ? (
{error && (
<Alert severity="error" sx={{ mt: 1 }}>
{error}
</Alert>
) : null}
)}
</Paper>
);
}
Expand Down
113 changes: 113 additions & 0 deletions frontend/src/modules/harvey/components/ContextManagerItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ListItem
key={item.id}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 1,
px: 0,
borderBottom: `1px solid ${grey[200]}`,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.label}
</Typography>
<Typography variant="caption" sx={{ color: grey[600] }}>
{computeContextItemMetadata(item)}
</Typography>
{item.kind === 'url' && item.transform === 'not-started' && (
<Alert severity="info">URL waiting to be processed by A-MINT...</Alert>
)}
</Box>
<Stack direction="row" spacing={2}>
<Button size="small" onClick={() => onRemove(item.id)} color="error">
Remove
</Button>
{isSphereEditorLinkEnabled && (
<Button
size="small"
variant="text"
target="_blank"
href={formatEditorLink()}
startIcon={<OpenInNew />}
>
Open in editor
</Button>
)}
{item.kind === 'url' && item.transform === 'pending' && <CircularProgress size="30px" />}
</Stack>
</ListItem>
);
}

export default ContextManagerItem;
Loading
Loading