core/apps/webapp/app/routes/onboarding.tsx
2025-10-10 10:03:19 +05:30

262 lines
8.1 KiB
TypeScript

import { z } from "zod";
import { useLoaderData, useSubmit } from "@remix-run/react";
import {
type ActionFunctionArgs,
json,
type LoaderFunctionArgs,
redirect,
} from "@remix-run/node";
import { requireUser, requireUserId } from "~/services/session.server";
import { updateUser } from "~/models/user.server";
import Logo from "~/components/logo/logo";
import { useState } from "react";
import { GraphVisualizationClient } from "~/components/graph/graph-client";
import OnboardingQuestionComponent from "~/components/onboarding/onboarding-question";
import {
ONBOARDING_QUESTIONS,
createInitialIdentityStatement,
createPreviewStatements,
createProgressiveEpisode,
type OnboardingAnswer,
} from "~/components/onboarding/onboarding-utils";
import { parse } from "@conform-to/zod";
import { type RawTriplet } from "~/components/graph/type";
import { addToQueue } from "~/lib/ingest.server";
import { EpisodeType } from "@core/types";
import { inboxPath } from "~/utils/pathBuilder";
const schema = z.object({
answers: z.string(),
});
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
if (user.onboardingComplete) {
return redirect(inboxPath());
}
return json({ user });
}
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();
const submission = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const { answers } = submission.value;
const parsedAnswers = JSON.parse(answers);
const user = await requireUser(request);
try {
const userName = user.displayName || user.email;
const episodeText = createProgressiveEpisode(userName, parsedAnswers);
// Update user's onboarding status
await updateUser({
id: userId,
onboardingComplete: true,
metadata: {
answers,
},
});
await addToQueue(
{
episodeBody: episodeText,
source: "Onboarding",
referenceTime: new Date().toISOString(),
type: EpisodeType.CONVERSATION,
},
userId,
);
return redirect("/home/inbox");
} catch (e: any) {
return json({ errors: { body: e.message } }, { status: 400 });
}
}
export default function Onboarding() {
const { user } = useLoaderData<typeof loader>();
const submit = useSubmit();
const [loading, setLoading] = useState(false); // Add loading state
const [currentQuestion, setCurrentQuestion] = useState(0);
const [answers, setAnswers] = useState<OnboardingAnswer[]>([]);
// Initialize with default identity statement converted to triplets
const getInitialTriplets = () => {
const displayName = user.displayName || user.email || "User";
const identityStatement = createInitialIdentityStatement(displayName);
// Convert identity statement to triplet format for visualization
return [
// Statement -> Subject relationship
{
sourceNode: identityStatement.statementNode,
edge: identityStatement.edges.hasSubject,
targetNode: identityStatement.subjectNode,
},
// Statement -> Predicate relationship
{
sourceNode: identityStatement.statementNode,
edge: identityStatement.edges.hasPredicate,
targetNode: identityStatement.predicateNode,
},
// Statement -> Object relationship
{
sourceNode: identityStatement.statementNode,
edge: identityStatement.edges.hasObject,
targetNode: identityStatement.objectNode,
},
];
};
const [generatedTriplets, setGeneratedTriplets] =
useState<RawTriplet[]>(getInitialTriplets);
const handleAnswer = async (answer: OnboardingAnswer) => {
// Update answers array
const newAnswers = [...answers];
const existingIndex = newAnswers.findIndex(
(a) => a.questionId === answer.questionId,
);
if (existingIndex >= 0) {
newAnswers[existingIndex] = answer;
} else {
newAnswers.push(answer);
}
setAnswers(newAnswers);
// Generate reified statements with episode hierarchy for visualization (client-side preview)
try {
const userName = user.displayName || user.email;
// Create episode and statements using the reified knowledge graph structure
const { statements } = createPreviewStatements(userName, newAnswers);
// Convert episode-statement hierarchy to triplet format for visualization
const episodeTriplets = convertEpisodeToTriplets(statements);
// Update with identity + episode-based statements
setGeneratedTriplets([...getInitialTriplets(), ...episodeTriplets]);
} catch (error) {
console.error("Error generating preview statements:", error);
}
};
const handleNext = () => {
if (currentQuestion < ONBOARDING_QUESTIONS.length - 1) {
setCurrentQuestion(currentQuestion + 1);
} else {
setLoading(true);
// Submit all answers
submitAnswers();
}
};
const handlePrevious = () => {
if (currentQuestion > 0) {
setCurrentQuestion(currentQuestion - 1);
}
};
const submitAnswers = async () => {
try {
const formData = new FormData();
formData.append("answers", JSON.stringify(answers));
submit(formData, {
method: "POST",
});
} catch (e) {}
};
// Convert episode and statements structure to triplets for visualization
const convertEpisodeToTriplets = (statements: any[]): any[] => {
const triplets: any[] = [];
// Add the episode node itself
// Episode will be connected to statements via HAS_PROVENANCE edges
for (const statement of statements) {
// Statement -> Subject relationship
triplets.push({
sourceNode: statement.statementNode,
edge: statement.edges.hasSubject,
targetNode: statement.subjectNode,
});
// Statement -> Predicate relationship
triplets.push({
sourceNode: statement.statementNode,
edge: statement.edges.hasPredicate,
targetNode: statement.predicateNode,
});
// Statement -> Object relationship
triplets.push({
sourceNode: statement.statementNode,
edge: statement.edges.hasObject,
targetNode: statement.objectNode,
});
}
return triplets;
};
// These helper functions are no longer needed as they're moved to onboarding-utils
// Keeping them for potential backward compatibility
const currentQuestionData = ONBOARDING_QUESTIONS[currentQuestion];
const currentAnswer = answers.find(
(a) => a.questionId === currentQuestionData?.id,
);
return (
<div className="grid h-[100vh] w-[100vw] grid-cols-1 overflow-hidden xl:grid-cols-3">
<div className="bg-grayAlpha-100 relative col-span-2 hidden xl:block">
<GraphVisualizationClient
triplets={generatedTriplets || []}
clusters={[]}
selectedClusterId={undefined}
onClusterSelect={() => {}}
className="h-full w-full"
singleClusterView
forOnboarding
/>
</div>
<div className="col-span-1 flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<a href="#" className="flex items-center gap-2 font-medium">
<div className="flex size-8 items-center justify-center rounded-md">
<Logo size={60} />
</div>
C.O.R.E.
</a>
</div>
<div className="flex flex-1 items-center justify-center">
{currentQuestionData && (
<OnboardingQuestionComponent
question={currentQuestionData}
answer={currentAnswer?.value}
onAnswer={handleAnswer}
onNext={handleNext}
onPrevious={handlePrevious}
loading={loading}
isFirst={currentQuestion === 0}
isLast={currentQuestion === ONBOARDING_QUESTIONS.length - 1}
currentStep={currentQuestion + 1}
totalSteps={ONBOARDING_QUESTIONS.length}
/>
)}
</div>
</div>
</div>
);
}