Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for project search with AI agent #116

Merged
merged 8 commits into from
Jun 20, 2024
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"deploy": "yarn lint && yarn test && yarn build && npx vercel"
},
"dependencies": {
"@aws-sdk/client-bedrock-agent-runtime": "^3.598.0",
"@aws-sdk/client-dynamodb": "^3.598.0",
"@aws-sdk/lib-dynamodb": "^3.598.0",
"@emotion/cache": "^11.11.0",
Expand Down
2 changes: 1 addition & 1 deletion src/app/api
Submodule api updated from c0729d to 937023
157 changes: 157 additions & 0 deletions src/app/find-project/FindProjectForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
'use client';

import React, { useState, useCallback, createRef } from 'react';

import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Alert from '@mui/material/Alert';
import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import CircularProgress from '@mui/material/CircularProgress';
import SendRoundedIcon from '@mui/icons-material/SendRounded';

import ReCAPTCHA from 'react-google-recaptcha';
import { RECAPTCHA_SITE_KEY } from '@/constants/constants';

import invokeAgent from './invokeAgent';
import Skeleton from '@mui/material/Skeleton';
import { Grid, List, ListItem, ListItemText } from '@mui/material';

const enum FormState {
NotSubmitted,
Submitted,
Error,
Answered,
}

export default function FindProjectForm({ host }: { host: string }) {
const [formState, setFormState] = useState(FormState.NotSubmitted);
const [errorMessage, setErrorMessage] = useState('');
const [prompt, setPrompt] = useState('');
const [previousPrompt, setPreviousPrompt] = useState('');
const [answer, setAnswer] = useState('');
const [projectList, setProjectList] = useState<Array<string>>([]);
const recaptchaRef = createRef<ReCAPTCHA>();

const submitCallback = useCallback((): void => {
if (!recaptchaRef.current) {
return;
}

recaptchaRef.current.reset();
setPreviousPrompt(prompt);
setPrompt('');
setAnswer('');
setErrorMessage('');
setFormState(FormState.Submitted);
recaptchaRef.current.execute();
}, [prompt, recaptchaRef]);

const recaptchaHandler = useCallback(
(token: string | null): void => {
if (formState !== FormState.Submitted) {
// Ignore spurious recaptcha responses
return;
}
if (!token) {
setErrorMessage('Captcha error');
setFormState(FormState.Error);
return;
}

invokeAgent(host, previousPrompt, token)
.then((answer: string) => {
const sentences = answer.split('- ').map((sentence) => sentence.trim());
setAnswer(sentences[0]);
setProjectList(sentences.slice(1));
setFormState(FormState.Answered);
})
.catch((err) => {
setErrorMessage(`${err}`);
setFormState(FormState.Error);
});
},
[formState, host, previousPrompt]
);

const handleChange = useCallback((event: any) => {
const { value } = event.target;
setPrompt(value);
}, []);

const waitingForAnswer = formState === FormState.Submitted;
const sendDisabled = waitingForAnswer || prompt.length === 0;

return (
<>
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey={RECAPTCHA_SITE_KEY}
onChange={recaptchaHandler}
/>
<Grid container spacing={2}>
<Grid item xs={10}>
<TextField
aria-label="Request to find projects"
name="prompt"
placeholder="Environment related projects in Python"
disabled={waitingForAnswer}
onChange={handleChange}
onKeyDown={(event) => {
if (event.key === 'Enter' && !sendDisabled) {
event.preventDefault();
submitCallback();
}
}}
value={prompt}
sx={{ width: '100%' }}
inputProps={{ maxLength: 100 }}
/>
</Grid>
<Grid item xs={2}>
<IconButton
aria-label="send request"
disabled={sendDisabled}
onClick={submitCallback}
style={{ height: 50, width: 50 }}
>
{waitingForAnswer ? <CircularProgress size={25} /> : <SendRoundedIcon />}
</IconButton>
</Grid>
</Grid>
<br />
<br />
{formState !== FormState.NotSubmitted && (
<Card style={{ marginRight: '20%' }} elevation={2}>
<CardContent>{previousPrompt}</CardContent>
</Card>
)}
<br />
<Card style={{ marginLeft: '20%', backgroundColor: '#EEFFEE' }} elevation={2}>
{formState === FormState.Submitted && (
<CardContent>
<Skeleton />
<Skeleton width="75%" />
<Skeleton width="60%" />
</CardContent>
)}
{formState === FormState.Answered && (
<CardContent>
{answer.replace('\n', '')}
<List>
{projectList.map((project, index) => (
<ListItem key={index}>
<ListItemText primary={project} />
</ListItem>
))}
</List>
</CardContent>
)}
{formState === FormState.Error && formState === FormState.Error && (
<Alert severity="error">{errorMessage}</Alert>
)}
</Card>
</>
);
}
32 changes: 32 additions & 0 deletions src/app/find-project/invokeAgent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

export default async function sendPrompt(
host: string,
prompt: string,
recaptcha: string
) {
try {
const endpoint = `${host}/api/invoke-agent`;
const data = { prompt, recaptcha };
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});

if (!res.ok) {
const errData = await res.json();
const { reason } = errData;
throw new Error(`Failed to invoke agent: ${reason}`);
}

const { answer } = await res.json();
return answer;
} catch (err: any) {
// eslint-disable-next-line no-console
console.log(`Error invoking agent: ${err}`);
throw new Error(`Failed to invoke agent`);
}
}
41 changes: 41 additions & 0 deletions src/app/find-project/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 'use server';

import React, { Suspense } from 'react';
import { Metadata } from 'next';
import Link from 'next/link';

import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';

import getHost from '@/utils/getHost';

import FindProjectForm from './FindProjectForm';

export const metadata: Metadata = {
title: 'Ask and find your project!',
};

export default async function FindProject() {
return (
<>
<Typography variant="h1">Natural language project search</Typography>
<p>
This page lets you search for projects using natural language from the same list
of projects. For a more targeted search, please continue using the{' '}
<Link href="/">main search page</Link> and its category, language, and search
filters.
</p>
<Alert severity="info">
This feature is experimental, and the quality of results may vary. We would love
to hear your <Link href="/about">feedback</Link> if you have time!
</Alert>
<br />
<br />
<Typography variant="h2">What are you looking for?</Typography>
<br />
<Suspense>
<FindProjectForm host={getHost()} />
</Suspense>
</>
);
}
10 changes: 10 additions & 0 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import IconButton from '@mui/material/IconButton';
import Drawer from '@mui/material/Drawer';
import Box from '@mui/material/Box';
import MenuIcon from '@mui/icons-material/Menu';
import ScienceIcon from '@mui/icons-material/Science';
import Link from 'next/link';

import MobileMenu from './MobileMenu';
Expand Down Expand Up @@ -42,6 +43,15 @@ function HeaderLinks() {
>
About
</Button>
<Button
/* @ts-ignore: color type not properly recognized */
color="neutral"
component={Link}
href="/find-project"
startIcon={<ScienceIcon />}
>
Find projects!
</Button>
</>
);
}
Expand Down
78 changes: 44 additions & 34 deletions src/components/MobileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,46 +12,56 @@ import HomeIcon from '@mui/icons-material/Home';
import InfoIcon from '@mui/icons-material/Info';
import AddIcon from '@mui/icons-material/Add';
import RocketIcon from '@mui/icons-material/Rocket';
import ScienceIcon from '@mui/icons-material/Science';

export default function MobileMenu(): JSX.Element {
function MenuButton({
href,
text,
icon,
}: {
href: string;
text: string;
icon: JSX.Element;
}) {
const theme = useTheme();
const isLightMode = theme.palette.mode === 'light';
const color = isLightMode ? 'var(--black)' : 'var(--white)';
return (
<ListItem component={Link} href={href}>
<ListItemButton>
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={text} sx={{ color: color }} />
</ListItemButton>
</ListItem>
);
}

export default function MobileMenu(): JSX.Element {
const buttons = [
{ href: '/submit-project', text: 'Submit project', icon: <AddIcon /> },
{ text: 'divider' },
{ href: '/', text: 'Home', icon: <HomeIcon /> },
{ href: '/get-started', text: 'Getting started', icon: <RocketIcon /> },
{ href: '/about', text: 'About', icon: <InfoIcon /> },
{ href: '/find-project', text: 'Find projects!', icon: <ScienceIcon /> },
];
return (
<List className="mobile-menu">
<ListItem component={Link} href="/submit-project">
<ListItemButton>
<ListItemIcon>
<AddIcon />
</ListItemIcon>
<ListItemText primary="Submit project" sx={{ color: color }} />
</ListItemButton>
</ListItem>
<Divider />
<ListItem component={Link} href="/">
<ListItemButton>
<ListItemIcon>
<HomeIcon />
</ListItemIcon>
<ListItemText primary="Home" sx={{ color: color }} />
</ListItemButton>
</ListItem>
<ListItem component={Link} href="/get-started">
<ListItemButton>
<ListItemIcon>
<RocketIcon />
</ListItemIcon>
<ListItemText primary="Getting started" sx={{ color: color }} />
</ListItemButton>
</ListItem>
<ListItem component={Link} href="/about">
<ListItemButton>
<ListItemIcon>
<InfoIcon />
</ListItemIcon>
<ListItemText primary="About" sx={{ color: color }} />
</ListItemButton>
</ListItem>
{buttons.map((button, index) => {
if (button.text === 'divider') {
return <Divider key={index} />;
}
return (
<MenuButton
key={index}
// @ts-ignore: href will be present
href={button.href}
// @ts-ignore: icon will be present
icon={button.icon}
text={button.text}
/>
);
})}
</List>
);
}
Loading
Loading