Add a fact checker feature with updated styling (#835)

- Add an experimental feature used for fact-checking falsifiable statements with customizable models. See attached screenshot for example. Once you input a statement that needs to be fact-checked, Khoj goes on a research spree to verify or refute it.
- Integrate frontend libraries for [Tailwind](https://tailwindcss.com/) and [ShadCN](https://ui.shadcn.com/) for easier UI development. Update corresponding styling for some existing UI components. 
- Add component for model selection 
- Add backend support for sharing arbitrary packets of data that will be consumed by specific front-end views in shareable scenarios
This commit is contained in:
sabaimran
2024-06-27 06:15:38 -07:00
committed by GitHub
parent 3b7a9358c3
commit 870d9ecdbf
35 changed files with 3294 additions and 223 deletions

View File

@@ -1,7 +1,6 @@
div.titleBar {
padding: 16px 0;
text-align: center;
font-size: larger;
text-align: left;
}
.agentPersonality p {
@@ -33,7 +32,6 @@ div.agent img {
div.agent a {
text-decoration: none;
color: var(--main-text-color);
}
div#agentsHeader {
@@ -56,23 +54,20 @@ div.agentInfo button {
padding: 4px;
border: none;
border-radius: 8px;
background-color: var(--summer-sun);
font: inherit;
color: var(--main-text-color);
cursor: pointer;
transition: background-color 0.3s;
}
div#agentsHeader a:hover,
div.agentInfo button:hover {
background-color: var(--primary-hover);
box-shadow: 0 0 10px var(--primary-hover);
}
div.agent {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 20px;
gap: 4px;
align-items: center;
padding: 20px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
@@ -81,9 +76,6 @@ div.agent {
}
div.agentModal {
padding: 20px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
border-radius: 8px;
background: linear-gradient(18.48deg,rgba(252, 213, 87, 0.25) 2.76%,rgba(197, 0, 0, 0) 17.23%),linear-gradient(200.6deg,rgba(244, 229, 68, 0.25) 4.13%,rgba(230, 26, 26, 0) 20.54%);
}
@@ -129,16 +121,17 @@ svg.newConvoButton {
}
div.agentModalContainer {
position: absolute;
position: fixed; /* Changed from absolute to fixed */
top: 0;
left: 0;
width: 100%;
margin: auto;
height: 100%;
height: 100%; /* This ensures it covers the viewport height */
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(1,1,1,0.5);
z-index: 1000; /* Ensure it's above other content */
overflow-y: auto; /* Allows scrolling within the modal if needed */
}
div.agentModal {
@@ -161,26 +154,28 @@ div.agentModalActions button {
padding: 8px;
border: none;
border-radius: 8px;
background-color: var(--summer-sun);
color: var(--main-text-color);
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
div.agentModalActions button:hover {
background-color: var(--primary-hover);
box-shadow: 0 0 10px var(--primary-hover);
box-shadow: 0 0 10px var(hsla(--background));
}
@media only screen and (max-width: 700px) {
div.agentList {
width: 90%;
padding: 0;
margin-right: auto;
margin-left: auto;
grid-template-columns: 1fr;
}
div.agentModal {
width: 90%;
}
}
.loader {
width: 48px;
@@ -201,7 +196,6 @@ div.agentModalActions button:hover {
width: 48px;
height: 48px;
border-radius: 50%;
border-left: 4px solid var(--summer-sun);
border-bottom: 4px solid transparent;
animation: rotation 0.5s linear infinite reverse;
}

View File

@@ -9,6 +9,7 @@ import useSWR from 'swr';
import { useEffect, useState } from 'react';
import { useAuthenticatedData, UserProfile } from '../common/auth';
import { Button } from '@/components/ui/button';
export interface AgentData {
@@ -27,7 +28,6 @@ async function openChat(slug: string, userData: UserProfile | null) {
}
const response = await fetch(`/api/chat/sessions?agent_slug=${slug}`, { method: "POST" });
// const data = await response.json();
if (response.status == 200) {
window.location.href = `/chat`;
} else if(response.status == 403 || response.status == 401) {
@@ -74,7 +74,7 @@ function AgentModal(props: AgentModalProps) {
<h2>{props.data.name}</h2>
</div>
<div className={styles.agentModalActions}>
<button onClick={() => {
<Button className='bg-transparent hover:bg-yellow-500' onClick={() => {
navigator.clipboard.writeText(`${window.location.host}/agents?agent=${props.data.slug}`);
setCopiedToClipboard(true);
}}>
@@ -91,21 +91,23 @@ function AgentModal(props: AgentModalProps) {
width={24}
height={24} />
}
</button>
<button onClick={() => props.setShowModal(false)}>
</Button>
<Button className='bg-transparent hover:bg-yellow-500' onClick={() => {
props.setShowModal(false);
}}>
<Image
src="Close.svg"
alt="Close"
width={24}
height={24} />
</button>
</Button>
</div>
</div>
<p>{props.data.personality}</p>
<div className={styles.agentInfo}>
<button onClick={() => openChat(props.data.slug, props.userData)}>
<Button className='bg-yellow-400 hover:bg-yellow-500' onClick={() => openChat(props.data.slug, props.userData)}>
Chat
</button>
</Button>
</div>
</div>
</div>
@@ -140,19 +142,23 @@ function AgentCard(props: AgentCardProps) {
</div>
</Link>
<div className={styles.agentInfo}>
<button className={styles.infoButton} onClick={() => setShowModal(true)}>
<button className={styles.infoButton} onClick={() => {
setShowModal(true);
} }>
<h2>{props.data.name}</h2>
</button>
</div>
<div className={styles.agentInfo}>
<button onClick={() => openChat(props.data.slug, userData)}>
<Button
className='bg-yellow-400 hover:bg-yellow-500'
onClick={() => openChat(props.data.slug, userData)}>
<Image
src="send.svg"
alt="Chat"
width={40}
height={40}
/>
</button>
</Button>
</div>
<div className={styles.agentPersonality}>
<button className={styles.infoButton} onClick={() => setShowModal(true)}>
@@ -170,7 +176,7 @@ export default function Agents() {
if (error) {
return (
<main className={styles.main}>
<div className={styles.titleBar}>
<div className={`${styles.titleBar} text-5xl`}>
Talk to a Specialized Agent
</div>
<div className={styles.agentList}>
@@ -183,7 +189,7 @@ export default function Agents() {
if (!data) {
return (
<main className={styles.main}>
<div className={styles.titleBar}>
<div className={`${styles.titleBar} text-5xl`}>
Talk to a Specialized Agent
</div>
<div className={styles.agentList}>
@@ -195,7 +201,7 @@ export default function Agents() {
return (
<main className={styles.main}>
<div className={styles.titleBar}>
<div className={`${styles.titleBar} text-5xl`}>
Talk to a Specialized Agent
</div>
<div className={styles.agentList}>

View File

@@ -58,6 +58,7 @@ function Loading() {
function handleChatInput(e: React.FormEvent<HTMLInputElement>) {
const target = e.target as HTMLInputElement;
console.log(target.value);
}
export default function Chat() {
@@ -71,6 +72,7 @@ export default function Chat() {
setLoading(false);
// Render chat options, if any
if (data) {
console.log(data);
setChatOptionsData(data);
}
})

View File

@@ -40,6 +40,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
setLoading(false);
// Render chat options, if any
if (chatData) {
console.log(chatData);
setData(chatData.response);
}
})

View File

@@ -1,11 +1,6 @@
div.chatMessageContainer {
display: flex;
flex-direction: column;
margin: 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
border: 1px solid black;
/* max-width: 80%; */
}
div.you {
@@ -81,5 +76,5 @@ button.codeCopyButton:hover {
div.feedbackButtons img,
button.copyButton img {
width: auto;
width: 24px;
}

View File

@@ -0,0 +1,23 @@
select.modelPicker {
font-size: small;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
border: none;
border-width: 1px;
display: flex;
align-items: center;
height: 2.5rem;
justify-content: space-between;
border-radius: calc(0.5rem - 2px);
}
select.modelPicker:after {
grid-area: select;
justify-self: end;
}
div.modelPicker {
margin-top: 8px;
}

View File

@@ -0,0 +1,152 @@
import { useAuthenticatedData } from '@/app/common/auth';
import React, { useEffect } from 'react';
import useSWR from 'swr';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import styles from './modelPicker.module.css';
export interface Model {
id: number;
chat_model: string;
}
// Custom fetcher function to fetch options
const fetchOptionsRequest = async (url: string) => {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return response.json();
};
export const useOptionsRequest = (url: string) => {
const { data, error } = useSWR<Model[]>(url, fetchOptionsRequest);
return {
data,
isLoading: !error && !data,
isError: error,
};
};
const fetchSelectedModel = async (url: string) => {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return response.json();
}
export const useSelectedModel = (url: string) => {
const { data, error } = useSWR<Model>(url, fetchSelectedModel);
return {
data,
isLoading: !error && !data,
isError: error,
}
}
interface ModelPickerProps {
disabled?: boolean;
setModelUsed?: (model: Model) => void;
initialModel?: Model;
}
export const ModelPicker: React.FC<any> = (props: ModelPickerProps) => {
const { data: models } = useOptionsRequest('/api/config/data/conversation/model/options');
const { data: selectedModel } = useSelectedModel('/api/config/data/conversation/model');
const [openLoginDialog, setOpenLoginDialog] = React.useState(false);
let userData = useAuthenticatedData();
useEffect(() => {
if (props.setModelUsed && selectedModel) {
props.setModelUsed(selectedModel);
}
}, [selectedModel]);
if (!models) {
return <div>Loading...</div>;
}
function onSelect(model: Model) {
if (!userData) {
setOpenLoginDialog(true);
return;
}
if (props.setModelUsed) {
props.setModelUsed(model);
}
fetch('/api/config/data/conversation/model' + '?id=' + String(model.id), { method: 'POST', body: JSON.stringify(model) })
.then((response) => {
if (!response.ok) {
throw new Error('Failed to select model');
}
})
.catch((error) => {
console.error('Failed to select model', error);
});
}
function isSelected(model: Model) {
if (props.initialModel) {
return model.id === props.initialModel.id;
}
return selectedModel?.id === model.id;
}
return (
<div className={styles.modelPicker}>
<select className={styles.modelPicker} onChange={(e) => {
const selectedModelId = Number(e.target.value);
const selectedModel = models.find((model) => model.id === selectedModelId);
if (selectedModel) {
onSelect(selectedModel);
} else {
console.error('Selected model not found', e.target.value);
}
}} disabled={props.disabled}>
{models?.map((model) => (
<option key={model.id} value={model.id} selected={isSelected(model)}>
{model.chat_model}
</option>
))}
</select>
<AlertDialog open={openLoginDialog} onOpenChange={setOpenLoginDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>You must be logged in to configure your model.</AlertDialogTitle>
<AlertDialogDescription>Once you create an account with Khoj, you can configure your model and use a whole suite of other features. Check out our <a href="https://docs.khoj.dev/">documentation</a> to learn more.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
window.location.href = window.location.origin + '/login';
}}>
Sign in
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -1,5 +1,4 @@
menu.menu a {
color: var(--main-text-color);
text-decoration: none;
font-size: medium;
font-weight: normal;
@@ -51,7 +50,6 @@ div.settingsMenuProfile img {
}
div.settingsMenu {
color: var(--main-text-color);
padding: 0 4px;
border-radius: 4px;
display: flex;

View File

@@ -2,7 +2,6 @@ div.panel {
padding: 1rem;
border-radius: 1rem;
background-color: var(--calm-blue);
color: var(--main-text-color);
max-height: 80vh;
overflow-y: auto;
max-width: auto;

View File

@@ -0,0 +1,63 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface ShareLinkProps {
buttonTitle: string;
title: string;
description: string;
url: string;
onShare: () => void;
}
function copyToClipboard(text: string) {
const clipboard = navigator.clipboard;
if (!clipboard) {
return;
}
clipboard.writeText(text);
}
export default function ShareLink(props: ShareLinkProps) {
return (
<Dialog>
<DialogTrigger
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
onClick={props.onShare}>
{props.buttonTitle}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
<DialogDescription>
{props.description}
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2">
<div className="grid flex-1 gap-2">
<Label htmlFor="link" className="sr-only">
Link
</Label>
<Input
id="link"
defaultValue={props.url}
readOnly
/>
</div>
<Button type="submit" size="sm" className="px-3" onClick={() => copyToClipboard(props.url)}>
<span>Copy</span>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,161 @@
input.factVerification {
width: 100%;
display: block;
padding: 12px 20px;
margin: 8px 0;
border: none;
box-sizing: border-box;
border-radius: 4px;
text-align: left;
margin: auto;
margin-top: 8px;
margin-bottom: 8px;
font-size: large;
}
input.factVerification:focus {
outline: none;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
}
div.responseText {
margin: 0;
padding: 0;
border-radius: 8px;
}
div.response {
margin-bottom: 12px;
}
a.titleLink {
color: #333;
font-weight: bold;
}
a.subLinks {
color: #333;
text-decoration: none;
font-weight: small;
border-radius: 4px;
font-size: small;
}
div.subLinks {
display: flex;
flex-direction: row;
gap: 8px;
flex-wrap: wrap;
}
div.reference {
padding: 12px;
margin: 8px;
border-radius: 8px;
}
footer.footer {
width: 100%;
background: transparent;
text-align: left;
}
div.reportActions {
display: flex;
flex-direction: row;
gap: 8px;
justify-content: space-between;
margin-top: 8px;
}
button.factCheckButton {
border: none;
cursor: pointer;
width: 100%;
border-radius: 4px;
margin: 8px;
padding-left: 1rem;
padding-right: 1rem;
line-height: 1.25rem;
}
button.factCheckButton:hover {
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
}
div.spinner {
margin: 20px;
width: 40px;
height: 40px;
position: relative;
text-align: center;
-webkit-animation: sk-rotate 2.0s infinite linear;
animation: sk-rotate 2.0s infinite linear;
}
div.inputFields {
width: 100%;
display: grid;
grid-template-columns: 1fr auto;
grid-gap: 8px;
}
/* Loading Animation */
div.dot1,
div.dot2 {
width: 60%;
height: 60%;
display: inline-block;
position: absolute;
top: 0;
border-radius: 100%;
-webkit-animation: sk-bounce 2.0s infinite ease-in-out;
animation: sk-bounce 2.0s infinite ease-in-out;
}
div.dot2 {
top: auto;
bottom: 0;
-webkit-animation-delay: -1.0s;
animation-delay: -1.0s;
}
@-webkit-keyframes sk-rotate {
100% {
-webkit-transform: rotate(360deg)
}
}
@keyframes sk-rotate {
100% {
transform: rotate(360deg);
-webkit-transform: rotate(360deg)
}
}
@-webkit-keyframes sk-bounce {
0%,
100% {
-webkit-transform: scale(0.0)
}
50% {
-webkit-transform: scale(1.0)
}
}
@keyframes sk-bounce {
0%,
100% {
transform: scale(0.0);
-webkit-transform: scale(0.0);
}
50% {
transform: scale(1.0);
-webkit-transform: scale(1.0);
}
}

View File

@@ -0,0 +1,10 @@
.factCheckerLayout {
max-width: 70vw;
margin: auto;
}
@media screen and (max-width: 700px) {
.factCheckerLayout {
max-width: 90vw;
}
}

View File

@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import NavMenu from '../components/navMenu/navMenu';
import styles from './factCheckerLayout.module.css';
export const metadata: Metadata = {
title: "Khoj AI - Fact Checker",
description: "Use the Fact Checker with Khoj AI for verifying statements. It can research the internet for you, either refuting or confirming the statement using fresh data.",
icons: {
icon: '/static/favicon.ico',
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className={styles.factCheckerLayout}>
<NavMenu selected="none" />
{children}
</div>
);
}

View File

@@ -0,0 +1,576 @@
'use client'
import styles from './factChecker.module.css';
import { useAuthenticatedData } from '@/app/common/auth';
import { useState, useEffect } from 'react';
import ChatMessage, { Context, OnlineContextData, WebPage } from '../components/chatMessage/chatMessage';
import { ModelPicker, Model } from '../components/modelPicker/modelPicker';
import ShareLink from '../components/shareLink/shareLink';
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import Link from 'next/link';
const chatURL = "/api/chat";
const verificationPrecursor = "Limit your search to reputable sources. Search the internet for relevant supporting or refuting information. Do not reference my notes. Refuse to answer any queries that are not falsifiable by informing me that you will not answer the question. You're not permitted to ask follow-up questions, so do the best with what you have. Respond with **TRUE** or **FALSE** or **INCONCLUSIVE**, then provide your justification. Fact Check:"
const LoadingSpinner = () => (
<div className={styles.loading}>
<div className={styles.loadingVerification}>
Researching...
<div className={styles.spinner}>
<div className={`${styles.dot1} bg-blue-300`}></div>
<div className={`${styles.dot2} bg-blue-300`}></div>
</div>
</div>
</div>
);
interface SupplementReferences {
additionalLink: string;
response: string;
linkTitle: string;
}
interface ResponseWithReferences {
context?: Context[];
online?: {
[key: string]: OnlineContextData
}
response?: string;
}
function handleCompiledReferences(chunk: string, currentResponse: string) {
const rawReference = chunk.split("### compiled references:")[1];
const rawResponse = chunk.split("### compiled references:")[0];
let references: ResponseWithReferences = {};
// Set the initial response
references.response = currentResponse + rawResponse;
const rawReferenceAsJson = JSON.parse(rawReference);
if (rawReferenceAsJson instanceof Array) {
references.context = rawReferenceAsJson;
} else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) {
references.online = rawReferenceAsJson;
}
return references;
}
async function verifyStatement(
message: string,
conversationId: string,
setIsLoading: (loading: boolean) => void,
setInitialResponse: (response: string) => void,
setInitialReferences: (references: ResponseWithReferences) => void) {
setIsLoading(true);
// Send a message to the chat server to verify the fact
let verificationMessage = `${verificationPrecursor} ${message}`;
const apiURL = `${chatURL}?q=${encodeURIComponent(verificationMessage)}&client=web&stream=true&conversation_id=${conversationId}`;
try {
const response = await fetch(apiURL);
if (!response.body) throw new Error("No response body found");
const reader = response.body?.getReader();
let decoder = new TextDecoder();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
let chunk = decoder.decode(value, { stream: true });
if (chunk.includes("### compiled references:")) {
const references = handleCompiledReferences(chunk, result);
if (references.response) {
result = references.response;
setInitialResponse(references.response);
setInitialReferences(references);
}
} else {
result += chunk;
setInitialResponse(result);
}
}
} catch (error) {
console.error("Error verifying statement: ", error);
} finally {
setIsLoading(false);
}
}
async function spawnNewConversation(setConversationID: (conversationID: string) => void) {
let createURL = `/api/chat/sessions?client=web`;
const response = await fetch(createURL, { method: "POST" });
const data = await response.json();
setConversationID(data.conversation_id);
}
interface ReferenceVerificationProps {
message: string;
additionalLink: string;
conversationId: string;
linkTitle: string;
setChildReferencesCallback: (additionalLink: string, response: string, linkTitle: string) => void;
prefilledResponse?: string;
}
function ReferenceVerification(props: ReferenceVerificationProps) {
const [initialResponse, setInitialResponse] = useState("");
const [isLoading, setIsLoading] = useState(true);
const verificationStatement = `${props.message}. Use this link for reference: ${props.additionalLink}`;
useEffect(() => {
if (props.prefilledResponse) {
setInitialResponse(props.prefilledResponse);
setIsLoading(false);
} else {
verifyStatement(verificationStatement, props.conversationId, setIsLoading, setInitialResponse, () => {});
}
}, [verificationStatement, props.conversationId, props.prefilledResponse]);
useEffect(() => {
if (initialResponse === "") return;
if (props.prefilledResponse) return;
if (!isLoading) {
// Only set the child references when it's done loading and if the initial response is not prefilled (i.e. it was fetched from the server)
props.setChildReferencesCallback(props.additionalLink, initialResponse, props.linkTitle);
}
}, [initialResponse, isLoading, props]);
return (
<div>
{isLoading &&
<LoadingSpinner />
}
<ChatMessage chatMessage={
{
automationId: "",
by: "AI",
intent: {},
message: initialResponse,
context: [],
created: (new Date()).toISOString(),
onlineContext: {}
}
} setReferencePanelData={() => {}} setShowReferencePanel={() => {}} />
</div>
)
}
interface SupplementalReferenceProps {
onlineData?: OnlineContextData;
officialFactToVerify: string;
conversationId: string;
additionalLink: string;
setChildReferencesCallback: (additionalLink: string, response: string, linkTitle: string) => void;
prefilledResponse?: string;
linkTitle?: string;
}
function SupplementalReference(props: SupplementalReferenceProps) {
const linkTitle = props.linkTitle || props.onlineData?.organic?.[0]?.title || "Reference";
const linkAsWebpage = { link: props.additionalLink } as WebPage;
return (
<Card className={`mt-2 mb-4`}>
<CardHeader>
<a className={styles.titleLink} href={props.additionalLink} target="_blank" rel="noreferrer">
{linkTitle}
</a>
<WebPageLink {...linkAsWebpage} />
</CardHeader>
<CardContent>
<ReferenceVerification
additionalLink={props.additionalLink}
message={props.officialFactToVerify}
linkTitle={linkTitle}
conversationId={props.conversationId}
setChildReferencesCallback={props.setChildReferencesCallback}
prefilledResponse={props.prefilledResponse} />
</CardContent>
</Card>
);
}
const WebPageLink = (webpage: WebPage) => {
const webpageDomain = new URL(webpage.link).hostname;
return (
<div className={styles.subLinks}>
<a className={`${styles.subLinks} bg-blue-200 px-2`} href={webpage.link} target="_blank" rel="noreferrer">
{webpageDomain}
</a>
</div>
)
}
export default function FactChecker() {
const [factToVerify, setFactToVerify] = useState("");
const [officialFactToVerify, setOfficialFactToVerify] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [initialResponse, setInitialResponse] = useState("");
const [clickedVerify, setClickedVerify] = useState(false);
const [initialReferences, setInitialReferences] = useState<ResponseWithReferences>();
const [childReferences, setChildReferences] = useState<SupplementReferences[]>();
const [modelUsed, setModelUsed] = useState<Model>();
const [conversationID, setConversationID] = useState("");
const [runId, setRunId] = useState("");
const [loadedFromStorage, setLoadedFromStorage] = useState(false);
const [initialModel, setInitialModel] = useState<Model>();
function setChildReferencesCallback(additionalLink: string, response: string, linkTitle: string) {
const newReferences = childReferences || [];
const exists = newReferences.find((reference) => reference.additionalLink === additionalLink);
if (exists) return;
newReferences.push({ additionalLink, response, linkTitle });
setChildReferences(newReferences);
}
let userData = useAuthenticatedData();
function storeData() {
const data = {
factToVerify,
response: initialResponse,
references: initialReferences,
childReferences,
runId,
modelUsed,
};
fetch(`/api/chat/store/factchecker`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"runId": runId,
"storeData": data
}),
});
}
useEffect(() => {
if (factToVerify) {
document.title = `AI Fact Check: ${factToVerify}`;
} else {
document.title = 'AI Fact Checker';
}
}, [factToVerify]);
useEffect(() => {
const storedFact = localStorage.getItem('factToVerify');
if (storedFact) {
setFactToVerify(storedFact);
}
// Get query params from the URL
const urlParams = new URLSearchParams(window.location.search);
const factToVerifyParam = urlParams.get('factToVerify');
if (factToVerifyParam) {
setFactToVerify(factToVerifyParam);
}
const runIdParam = urlParams.get('runId');
if (runIdParam) {
setRunId(runIdParam);
// Define an async function to fetch data
const fetchData = async () => {
const storedDataURL = `/api/chat/store/factchecker?runId=${runIdParam}`;
try {
const response = await fetch(storedDataURL);
if (response.status !== 200) {
throw new Error("Failed to fetch stored data");
}
const storedData = JSON.parse(await response.json());
if (storedData) {
setOfficialFactToVerify(storedData.factToVerify);
setInitialResponse(storedData.response);
setInitialReferences(storedData.references);
setChildReferences(storedData.childReferences);
setInitialModel(storedData.modelUsed);
}
setLoadedFromStorage(true);
} catch (error) {
console.error("Error fetching stored data: ", error);
}
};
// Call the async function
fetchData();
}
}, []);
function onClickVerify() {
if (clickedVerify) return;
// Perform validation checks on the fact to verify
if (!factToVerify) {
alert("Please enter a fact to verify.");
return;
}
setClickedVerify(true);
if (!userData) {
let currentURL = window.location.href;
window.location.href = `/login?next=${currentURL}`;
}
setInitialReferences(undefined);
setInitialResponse("");
spawnNewConversation(setConversationID);
// Set the runId to a random 12-digit alphanumeric string
const newRunId = [...Array(16)].map(() => Math.random().toString(36)[2]).join('');
setRunId(newRunId);
window.history.pushState({}, document.title, window.location.pathname + `?runId=${newRunId}`);
setOfficialFactToVerify(factToVerify);
setClickedVerify(false);
}
useEffect(() => {
if (!conversationID) return;
verifyStatement(officialFactToVerify, conversationID, setIsLoading, setInitialResponse, setInitialReferences);
}, [conversationID, officialFactToVerify]);
// Store factToVerify in localStorage whenever it changes
useEffect(() => {
localStorage.setItem('factToVerify', factToVerify);
}, [factToVerify]);
// Update the meta tags for the description and og:description
useEffect(() => {
let metaTag = document.querySelector('meta[name="description"]');
if (metaTag) {
metaTag.setAttribute('content', initialResponse);
}
let metaOgTag = document.querySelector('meta[property="og:description"]');
if (!metaOgTag) {
metaOgTag = document.createElement('meta');
metaOgTag.setAttribute('property', 'og:description');
document.getElementsByTagName('head')[0].appendChild(metaOgTag);
}
metaOgTag.setAttribute('content', initialResponse);
}, [initialResponse]);
const renderReferences = (conversationId: string, initialReferences: ResponseWithReferences, officialFactToVerify: string, loadedFromStorage: boolean, childReferences?: SupplementReferences[]) => {
if (loadedFromStorage && childReferences) {
return renderSupplementalReferences(childReferences);
}
const seenLinks = new Set();
// Any links that are present in webpages should not be searched again
Object.entries(initialReferences.online || {}).map(([key, onlineData], index) => {
const webpages = onlineData?.webpages || [];
// Webpage can be a list or a single object
if (webpages instanceof Array) {
for (let i = 0; i < webpages.length; i++) {
const webpage = webpages[i];
const additionalLink = webpage.link || '';
if (seenLinks.has(additionalLink)) {
return null;
}
seenLinks.add(additionalLink);
}
} else {
let singleWebpage = webpages as WebPage;
const additionalLink = singleWebpage.link || '';
if (seenLinks.has(additionalLink)) {
return null;
}
seenLinks.add(additionalLink);
}
});
return Object.entries(initialReferences.online || {}).map(([key, onlineData], index) => {
let additionalLink = '';
// Loop through organic links until we find one that hasn't been searched
for (let i = 0; i < onlineData?.organic?.length; i++) {
const webpage = onlineData?.organic?.[i];
additionalLink = webpage.link || '';
if (!seenLinks.has(additionalLink)) {
break;
}
}
seenLinks.add(additionalLink);
if (additionalLink === '') return null;
return (
<SupplementalReference
key={index}
onlineData={onlineData}
officialFactToVerify={officialFactToVerify}
conversationId={conversationId}
additionalLink={additionalLink}
setChildReferencesCallback={setChildReferencesCallback} />
);
}).filter(Boolean);
};
const renderSupplementalReferences = (references: SupplementReferences[]) => {
return references.map((reference, index) => {
return (
<SupplementalReference
key={index}
additionalLink={reference.additionalLink}
officialFactToVerify={officialFactToVerify}
conversationId={conversationID}
linkTitle={reference.linkTitle}
setChildReferencesCallback={setChildReferencesCallback}
prefilledResponse={reference.response} />
)
});
}
const renderWebpages = (webpages: WebPage[] | WebPage) => {
if (webpages instanceof Array) {
return webpages.map((webpage, index) => {
return WebPageLink(webpage);
});
} else {
return WebPageLink(webpages);
}
};
function constructShareUrl() {
const url = new URL(window.location.href);
url.searchParams.set('runId', runId);
return url.href;
}
return (
<div className={styles.factCheckerContainer}>
<h1 className={`${styles.response} font-large outline-slate-800 dark:outline-slate-200`}>
AI Fact Checker
</h1>
<footer className={`${styles.footer} mt-4`}>
This is an experimental AI tool. It may make mistakes.
</footer>
{
initialResponse && initialReferences && childReferences
?
<div className={styles.reportActions}>
<Button asChild variant='secondary'>
<Link href="/factchecker" target="_blank" rel="noopener noreferrer">
Try Another
</Link>
</Button>
<ShareLink
buttonTitle='Share report'
title="AI Fact Checking Report"
description="Share this fact checking report with others. Anyone who has this link will be able to view the report."
url={constructShareUrl()}
onShare={loadedFromStorage ? () => {} : storeData} />
</div>
: <div className={styles.newReportActions}>
<div className={`${styles.inputFields} mt-4`}>
<Input
type="text"
maxLength={200}
placeholder="Enter a falsifiable statement to verify"
disabled={isLoading}
onChange={(e) => setFactToVerify(e.target.value)}
value={factToVerify}
onKeyDown={(e) => {
if (e.key === "Enter") {
onClickVerify();
}
}}
onFocus={(e) => e.target.placeholder = ""}
onBlur={(e) => e.target.placeholder = "Enter a falsifiable statement to verify"} />
<Button disabled={clickedVerify} onClick={() => onClickVerify()}>Verify</Button>
</div>
<h3 className={`mt-4 mb-4`}>
Try with a particular model. You must be <a href="/config" className="font-medium text-blue-600 dark:text-blue-500 hover:underline">subscribed</a> to configure the model.
</h3>
</div>
}
<ModelPicker disabled={isLoading || loadedFromStorage} setModelUsed={setModelUsed} initialModel={initialModel} />
{isLoading && <div className={styles.loading}>
<LoadingSpinner />
</div>}
{
initialResponse &&
<Card className={`mt-4`}>
<CardHeader>
<CardTitle>{officialFactToVerify}</CardTitle>
</CardHeader>
<CardContent>
<div className={styles.responseText}>
<ChatMessage chatMessage={
{
automationId: "",
by: "AI",
intent: {},
message: initialResponse,
context: [],
created: (new Date()).toISOString(),
onlineContext: {}
}
} setReferencePanelData={() => {}} setShowReferencePanel={() => {}} />
</div>
</CardContent>
<CardFooter>
{
initialReferences && initialReferences.online && Object.keys(initialReferences.online).length > 0 && (
<div className={styles.subLinks}>
{
Object.entries(initialReferences.online).map(([key, onlineData], index) => {
const webpages = onlineData?.webpages || [];
return renderWebpages(webpages);
})
}
</div>
)}
</CardFooter>
</Card>
}
{
initialReferences &&
<div className={styles.referenceContainer}>
<h2 className="mt-4 mb-4">Supplements</h2>
<div className={styles.references}>
{ initialReferences.online !== undefined &&
renderReferences(conversationID, initialReferences, officialFactToVerify, loadedFromStorage, childReferences)}
</div>
</div>
}
</div>
)
}

View File

@@ -1,158 +1,80 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Arabic:wght@100..900&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');
:root {
--max-width: 1100px;
--border-radius: 12px;
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
"Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 209.1 100% 40.8%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 209.1 100% 40.8%;
--radius: 0.5rem;
--font-family: "Noto Sans", "Noto Sans Arabic", sans-serif !important;
--primary-hover: #fee285;
}
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
--primary-glow: conic-gradient(
from 180deg at 50% 50%,
#16abff33 0deg,
#0885ff33 55deg,
#54d6ff33 120deg,
#0071ff33 160deg,
transparent 360deg
);
--secondary-glow: radial-gradient(
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0)
);
--tile-start-rgb: 239, 245, 249;
--tile-end-rgb: 228, 232, 233;
--tile-border: conic-gradient(
#00000080,
#00000040,
#00000030,
#00000020,
#00000010,
#00000010,
#00000080
);
--callout-rgb: 238, 240, 241;
--callout-border-rgb: 172, 175, 176;
--card-rgb: 180, 185, 188;
--card-border-rgb: 131, 134, 135;
--primary: #f9f5de;
--primary-hover: #fee285;
--primary-focus: rgba(255, 179, 0, 0.125);
--primary-inverse: rgba(0, 0, 0, 0.75);
--background-color: #f5f4f3;
--frosted-background-color: rgba(245, 244, 243, 0.75);
--main-text-color: #475569;
--summer-sun: #fcc50b;
--water: #44b9da;
--leaf: #7b990a;
--flower: #ffaeae;
--font-family: "Noto Sans", "Noto Sans Arabic", sans-serif !important;
--calm-blue: #E4D9D9;
--calmer-blue: #e4cdcd;
--calm-green: #d0f5d6;
--intense-green: #1C2841;
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 263.4 70% 50.4%;
--font-family: "Noto Sans", "Noto Sans Arabic", sans-serif !important;
}
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
@layer base {
* {
@apply border-border;
}
--primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
--secondary-glow: linear-gradient(
to bottom right,
rgba(1, 65, 255, 0),
rgba(1, 65, 255, 0),
rgba(1, 65, 255, 0.3)
);
body {
@apply bg-background text-foreground;
}
--tile-start-rgb: 2, 13, 46;
--tile-end-rgb: 2, 5, 19;
--tile-border: conic-gradient(
#ffffff80,
#ffffff40,
#ffffff30,
#ffffff20,
#ffffff10,
#ffffff10,
#ffffff80
);
h1 {
@apply text-4xl font-bold;
}
--callout-rgb: 20, 20, 20;
--callout-border-rgb: 108, 108, 108;
--card-rgb: 100, 100, 100;
--card-border-rgb: 200, 200, 200;
body {
font-family: var(--font-family);
}
--primary: #f9f5de;
--primary-hover: #fee285;
--primary-focus: rgba(255, 179, 0, 0.125);
--primary-inverse: rgba(0, 0, 0, 0.75);
--background-color: #f5f4f3;
--frosted-background-color: rgba(245, 244, 243, 0.75);
--main-text-color: #475569;
--summer-sun: #fcc50b;
--water: #44b9da;
--leaf: #7b990a;
--flower: #ffaeae;
--font-family: "Noto Sans", "Noto Sans Arabic", sans-serif !important;
}
pre code.hljs {
white-space: preserve;
}
a {
text-decoration: underline;
}
}
* {
box-sizing: border-box;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
font-family: var(--font-family);
}
input,
div,
button,
p,
textarea {
font-family: var(--font-family);
}
body {
color: var(--main-text-color);
background-color: var(--background-color);
}
a {
color: inherit;
text-decoration: none;
}
p {
margin: 0;
}
pre code.hljs {
white-space: preserve;
}
body {
margin: 0;
}
button:hover {
cursor: pointer;
}
/* Uncomment when ready for dark-mode */
/* @media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
} */

View File

@@ -2,7 +2,7 @@ import styles from "./page.module.css";
export default function Home() {
return (
<main className={styles.main}>
<main className={`${styles.main} text-3xl font-bold underline`}>
Hi, Khoj here.
</main>
);