diff --git a/.gitignore b/.gitignore index 2d7dabe..0a7d866 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,11 @@ registry/ CLAUDE.md .claude - +.clinerules/byterover-rules.md +.kilocode/rules/byterover-rules.md +.roo/rules/byterover-rules.md +.windsurf/rules/byterover-rules.md +.cursor/rules/byterover-rules.mdc +.kiro/steering/byterover-rules.md +.qoder/rules/byterover-rules.md +.augment/rules/byterover-rules.md \ No newline at end of file diff --git a/apps/webapp/app/components/graph/graph-clustering-visualization.tsx b/apps/webapp/app/components/graph/graph-clustering-visualization.tsx index 020fc60..17ac19f 100644 --- a/apps/webapp/app/components/graph/graph-clustering-visualization.tsx +++ b/apps/webapp/app/components/graph/graph-clustering-visualization.tsx @@ -24,6 +24,7 @@ export interface GraphClusteringVisualizationProps { selectedClusterId?: string | null; onClusterSelect?: (clusterId: string) => void; singleClusterView?: boolean; + forOnboarding?: boolean; } export const GraphClusteringVisualization = forwardRef< @@ -41,6 +42,7 @@ export const GraphClusteringVisualization = forwardRef< selectedClusterId, onClusterSelect, singleClusterView, + forOnboarding, }, ref, ) => { @@ -265,6 +267,7 @@ export const GraphClusteringVisualization = forwardRef< labelColorMap={sharedLabelColorMap} showClusterLabels={!selectedClusterId} // Show cluster labels when not filtering enableClusterColors={true} // Always enable cluster colors + forOnboarding={forOnboarding} /> ) : (
diff --git a/apps/webapp/app/components/graph/graph-clustering.tsx b/apps/webapp/app/components/graph/graph-clustering.tsx index cb9dafb..5232975 100644 --- a/apps/webapp/app/components/graph/graph-clustering.tsx +++ b/apps/webapp/app/components/graph/graph-clustering.tsx @@ -46,6 +46,8 @@ export interface GraphClusteringProps { labelColorMap?: Map; showClusterLabels?: boolean; enableClusterColors?: boolean; + // Change this later + forOnboarding?: boolean; } export interface GraphClusteringRef { @@ -88,6 +90,7 @@ export const GraphClustering = forwardRef< labelColorMap: externalLabelColorMap, showClusterLabels = true, enableClusterColors = true, + forOnboarding, }, ref, ) => { @@ -101,6 +104,7 @@ export const GraphClustering = forwardRef< const selectedNodeRef = useRef(null); const selectedEdgeRef = useRef(null); const selectedClusterRef = useRef(null); + const size = forOnboarding ? 16 : 4; // Create cluster color mapping const clusterColorMap = useMemo(() => { @@ -236,7 +240,7 @@ export const GraphClustering = forwardRef< ? triplet.source.value.split(/\s+/).slice(0, 4).join(" ") + (triplet.source.value.split(/\s+/).length > 4 ? " ..." : "") : "", - size: isStatementNode ? 4 : 2, // Statement nodes slightly larger + size: isStatementNode ? size : size / 2, // Statement nodes slightly larger color: nodeColor, x: width, y: height, @@ -260,7 +264,7 @@ export const GraphClustering = forwardRef< ? triplet.target.value.split(/\s+/).slice(0, 4).join(" ") + (triplet.target.value.split(/\s+/).length > 4 ? " ..." : "") : "", - size: isStatementNode ? 4 : 2, // Statement nodes slightly larger + size: isStatementNode ? size : size / 2, // Statement nodes slightly larger color: nodeColor, x: width, y: height, @@ -329,7 +333,7 @@ export const GraphClustering = forwardRef< graph.setNodeAttribute(node, "highlighted", false); graph.setNodeAttribute(node, "color", originalColor); - graph.setNodeAttribute(node, "size", isStatementNode ? 4 : 2); + graph.setNodeAttribute(node, "size", isStatementNode ? size : size / 2); graph.setNodeAttribute(node, "zIndex", 1); }); graph.forEachEdge((edge) => { @@ -519,7 +523,7 @@ export const GraphClustering = forwardRef< return { scalingRatio: Math.round(scalingRatio * 10) / 10, gravity: Math.round(gravity * 10) / 10, - duration: Math.round(durationSeconds * 100) / 100, // in seconds + duration: forOnboarding ? 1 : Math.round(durationSeconds * 100) / 100, // in seconds }; }, []); @@ -661,7 +665,11 @@ export const GraphClustering = forwardRef< }); layout.start(); - setTimeout(() => layout.stop(), (optimalParams.duration ?? 2) * 1000); + if (!forOnboarding) { + setTimeout(() => layout.stop(), (optimalParams.duration ?? 2) * 1000); + } else { + setTimeout(() => layout.stop(), 500); + } } // Create Sigma instance @@ -673,7 +681,9 @@ export const GraphClustering = forwardRef< edgeProgramClasses: { "edges-fast": EdgeLineProgram, }, - renderLabels: false, + renderLabels: true, + labelRenderedSizeThreshold: 15, // labels appear when node size >= 10px + enableEdgeEvents: true, minCameraRatio: 0.01, defaultDrawNodeHover: drawHover, @@ -693,12 +703,6 @@ export const GraphClustering = forwardRef< }, 100); } - // Update cluster labels after any camera movement - sigma.getCamera().on("updated", () => { - if (showClusterLabels) { - } - }); - // Drag and drop implementation (same as original) let draggedNode: string | null = null; let isDragging = false; @@ -841,8 +845,8 @@ export const GraphClustering = forwardRef< ref={containerRef} className="" style={{ - width: `${width}px`, - height: `${height}px`, + width: forOnboarding ? "100%" : `${width}px`, + height: forOnboarding ? "100%" : `${height}px`, borderRadius: "8px", cursor: "grab", fontSize: "12px", diff --git a/apps/webapp/app/components/layout/login-page-layout.tsx b/apps/webapp/app/components/layout/login-page-layout.tsx index b752bdb..71782a9 100644 --- a/apps/webapp/app/components/layout/login-page-layout.tsx +++ b/apps/webapp/app/components/layout/login-page-layout.tsx @@ -1,7 +1,4 @@ -import { Button } from "../ui"; import Logo from "../logo/logo"; -import { Theme, useTheme } from "remix-themes"; -import { GalleryVerticalEnd } from "lucide-react"; export function LoginPageLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/webapp/app/components/onboarding/onboarding-question.tsx b/apps/webapp/app/components/onboarding/onboarding-question.tsx new file mode 100644 index 0000000..c8c2f13 --- /dev/null +++ b/apps/webapp/app/components/onboarding/onboarding-question.tsx @@ -0,0 +1,164 @@ +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui"; +import { Checkbox } from "~/components/ui/checkbox"; +import { Label } from "~/components/ui/label"; +import type { OnboardingQuestion, OnboardingAnswer } from "./onboarding-utils"; + +interface OnboardingQuestionProps { + question: OnboardingQuestion; + answer?: string | string[]; + onAnswer: (answer: OnboardingAnswer) => void; + onNext: () => void; + onPrevious?: () => void; + isFirst: boolean; + isLast: boolean; + currentStep: number; + totalSteps: number; +} + +export default function OnboardingQuestionComponent({ + question, + answer, + onAnswer, + onNext, + onPrevious, + isFirst, + isLast, + currentStep, + totalSteps, +}: OnboardingQuestionProps) { + const [selectedValue, setSelectedValue] = useState( + answer || (question.type === "multi-select" ? [] : ""), + ); + + // Sync local state when answer prop changes (e.g., when navigating between steps) + useEffect(() => { + setSelectedValue(answer || (question.type === "multi-select" ? [] : "")); + }, [answer, question.type]); + + const handleSingleSelect = (value: string) => { + setSelectedValue(value); + onAnswer({ questionId: question.id, value }); + }; + + const handleMultiSelect = (optionValue: string, checked: boolean) => { + const currentValues = Array.isArray(selectedValue) ? selectedValue : []; + const newValues = checked + ? [...currentValues, optionValue] + : currentValues.filter((v) => v !== optionValue); + + setSelectedValue(newValues); + onAnswer({ questionId: question.id, value: newValues }); + }; + + const isValid = () => { + if (!question.required) return true; + + if (question.type === "multi-select") { + return Array.isArray(selectedValue) && selectedValue.length > 0; + } + + return selectedValue && selectedValue !== ""; + }; + + return ( +
+ + +
+ + Step {currentStep} of {totalSteps} + +
+
+
+
+ + + +
+
+ {question.title} +
+ + {question.type === "single-select" && question.options && ( +
+ {question.options.map((option) => ( + + ))} +
+ )} + + {question.type === "multi-select" && question.options && ( +
+ {question.options.map((option) => ( +
+ + handleMultiSelect(option.value, !!checked) + } + className="h-6 w-6 text-xl" + checkboxClassname="h-5 w-5 text-xl" + /> + +
+ ))} +
+ )} + +
+ {!isFirst && ( + + )} + +
+ + +
+
+ + +
+ ); +} diff --git a/apps/webapp/app/components/onboarding/onboarding-utils.ts b/apps/webapp/app/components/onboarding/onboarding-utils.ts new file mode 100644 index 0000000..f20a969 --- /dev/null +++ b/apps/webapp/app/components/onboarding/onboarding-utils.ts @@ -0,0 +1,562 @@ +import type { + Triple, + EntityNode, + EpisodicNode, + StatementNode, +} from "@core/types"; +import crypto from "crypto"; + +export interface OnboardingQuestion { + id: string; + title: string; + description?: string; + type: "single-select" | "multi-select" | "text"; + options?: OnboardingOption[]; + placeholder?: string; + required?: boolean; +} + +export interface OnboardingOption { + id: string; + label: string; + value: string; +} + +export interface OnboardingAnswer { + questionId: string; + value: string | string[]; +} + +// Onboarding questions in order +export const ONBOARDING_QUESTIONS: OnboardingQuestion[] = [ + { + id: "role", + title: "What best describes you?", + description: 'Role / identity → anchors the "user" node', + type: "single-select", + options: [ + { id: "developer", label: "Developer", value: "Developer" }, + { id: "designer", label: "Designer", value: "Designer" }, + { + id: "product-manager", + label: "Product Manager", + value: "Product Manager", + }, + { + id: "engineering-manager", + label: "Engineering Manager", + value: "Engineering Manager", + }, + { + id: "founder", + label: "Founder / Executive", + value: "Founder / Executive", + }, + { id: "other", label: "Other", value: "Other" }, + ], + required: true, + }, + { + id: "goal", + title: "What's your primary goal with CORE?", + description: 'Motivation → drives the "objective" branch of graph', + type: "single-select", + options: [ + { + id: "personal-memory", + label: "Build a personal memory system", + value: "Build a personal memory system", + }, + { + id: "team-knowledge", + label: "Manage team/project knowledge", + value: "Manage team/project knowledge", + }, + { + id: "automate-workflows", + label: "Automate workflows across tools", + value: "Automate workflows across tools", + }, + { + id: "ai-assistant", + label: "Power an AI assistant / agent with context", + value: "Power an AI assistant / agent with context", + }, + { + id: "explore-graphs", + label: "Explore / learn about reified graphs", + value: "Explore / learn about reified graphs", + }, + ], + required: true, + }, + { + id: "tools", + title: "Which tools or data sources do you care about most?", + description: "Context → lets you connect integration nodes live", + type: "multi-select", + options: [ + { id: "github", label: "GitHub", value: "GitHub" }, + { id: "slack", label: "Slack", value: "Slack" }, + { id: "notion", label: "Notion", value: "Notion" }, + { id: "obsidian", label: "Obsidian", value: "Obsidian" }, + { id: "gmail", label: "Gmail", value: "Gmail" }, + { id: "linear", label: "Linear", value: "Linear" }, + { + id: "figma", + label: "Figma", + value: "Figma", + }, + ], + required: true, + }, +]; + +// Helper function to create entity nodes (client-side, no embeddings) +function createEntity( + name: string, + type: string, + userId: string, + space?: string, +): EntityNode { + return { + uuid: crypto.randomUUID(), + name, + type, + attributes: {}, + nameEmbedding: [], // Empty placeholder for client-side preview + typeEmbedding: [], // Empty placeholder for client-side preview + createdAt: new Date(), + userId, + space, + }; +} + +// Helper function to create episodic node (client-side, no embeddings) +function createEpisode( + content: string, + userId: string, + space?: string, +): EpisodicNode { + return { + uuid: crypto.randomUUID(), + content, + originalContent: content, + contentEmbedding: [], // Empty placeholder for client-side preview + metadata: { source: "onboarding" }, + source: "onboarding", + createdAt: new Date(), + validAt: new Date(), + labels: ["onboarding"], + userId, + space, + }; +} + +// Helper function to create statement node (client-side, no embeddings) +function createStatement( + fact: string, + userId: string, + space?: string, +): StatementNode { + return { + uuid: crypto.randomUUID(), + fact, + factEmbedding: [], // Empty placeholder for client-side preview + createdAt: new Date(), + validAt: new Date(), + invalidAt: null, + attributes: {}, + userId, + space, + }; +} + +// Create triplet from onboarding answer using reified knowledge graph structure (client-side, no embeddings) +export function createOnboardingTriplet( + username: string, + questionId: string, + answer: string | string[], + userId: string, + space?: string, +): Triple[] { + const triplets: Triple[] = []; + + // Convert array answers to individual triplets + const answers = Array.isArray(answer) ? answer : [answer]; + + for (const singleAnswer of answers) { + // Get the statement mapping for this question type + const { predicateType, objectType, factTemplate } = + getStatementMapping(questionId); + + // Create the statement fact (e.g., "Manoj uses GitHub") + const fact = factTemplate(username, singleAnswer); + + // Create entities following CORE's reified structure (client-side preview only) + const subject = createEntity(username, "Person", userId, space); + const predicate = createEntity( + predicateType.toLowerCase().replace("_", " "), // "uses tool" instead of "USES_TOOL" + "Predicate", // Use "Predicate" type instead of "Relationship" + userId, + space, + ); + const object = createEntity(singleAnswer, objectType, userId, space); + + // Create statement node as first-class object (client-side preview only) + const statement = createStatement(fact, userId, space); + + // Create provenance episode (client-side preview only) + const provenance = createEpisode( + `Onboarding question: ${questionId} - Answer: ${singleAnswer}`, + userId, + space, + ); + + // Create the reified triple structure (no embeddings for client preview) + triplets.push({ + statement, + subject, + predicate, + object, + provenance, + }); + } + + return triplets; +} + +// Create initial identity statement for preview using reified knowledge graph structure +export function createInitialIdentityStatement(displayName: string): any { + const timestamp = Date.now(); + const now = new Date().toISOString(); + + // Create the identity statement: "I'm [DisplayName]" using reified structure + const fact = `I'm ${displayName}`; + + return { + // Statement node (center) + statementNode: { + uuid: `identity-statement-${timestamp}`, + name: fact, + labels: ["Statement"], + attributes: { + nodeType: "Statement", + type: "Statement", + fact: fact, + source: "onboarding", + validAt: now, + }, + createdAt: now, + }, + // Subject entity ("I") + subjectNode: { + uuid: `pronoun-${timestamp}`, + name: "I", + labels: ["Entity"], + attributes: { + nodeType: "Entity", + type: "Pronoun", + source: "onboarding", + }, + createdAt: now, + }, + // Predicate entity ("am") + predicateNode: { + uuid: `predicate-identity-${timestamp}`, + name: "am", + labels: ["Entity"], + attributes: { + nodeType: "Entity", + type: "Predicate", + source: "onboarding", + }, + createdAt: now, + }, + // Object entity (DisplayName) + objectNode: { + uuid: `user-${timestamp}`, + name: displayName, + labels: ["Entity"], + attributes: { + nodeType: "Entity", + type: "Person", + source: "onboarding", + }, + createdAt: now, + }, + // Edges connecting statement to subject, predicate, object + edges: { + hasSubject: { + uuid: `identity-has-subject-${timestamp}`, + type: "HAS_SUBJECT", + source_node_uuid: `identity-statement-${timestamp}`, + target_node_uuid: `pronoun-${timestamp}`, + createdAt: now, + }, + hasPredicate: { + uuid: `identity-has-predicate-${timestamp}`, + type: "HAS_PREDICATE", + source_node_uuid: `identity-statement-${timestamp}`, + target_node_uuid: `predicate-identity-${timestamp}`, + createdAt: now, + }, + hasObject: { + uuid: `identity-has-object-${timestamp}`, + type: "HAS_OBJECT", + source_node_uuid: `identity-statement-${timestamp}`, + target_node_uuid: `user-${timestamp}`, + createdAt: now, + }, + }, + }; +} + +// Create progressive episode content as user answers questions +export function createProgressiveEpisode( + username: string, + answers: OnboardingAnswer[], +): string { + // Start with identity + let episodeContent = `I'm ${username}.`; + + // Build episode progressively based on answers + for (const answer of answers) { + const values = Array.isArray(answer.value) ? answer.value : [answer.value]; + + switch (answer.questionId) { + case "role": + episodeContent += ` I'm a ${values[0]}.`; + break; + + case "goal": + episodeContent += ` My primary goal with CORE is to ${values[0].toLowerCase()}.`; + break; + + case "tools": + if (values.length === 1) { + episodeContent += ` I use ${values[0]}.`; + } else if (values.length === 2) { + episodeContent += ` I use ${values[0]} and ${values[1]}.`; + } else { + // Create a copy to avoid mutating the original array + const toolsCopy = [...values]; + const lastTool = toolsCopy.pop(); + episodeContent += ` I use ${toolsCopy.join(", ")}, and ${lastTool}.`; + } + break; + } + } + + return episodeContent; +} + +// Create preview statements for real-time visualization (reified structure) +// Including episode hierarchy: Episode → Statements → Entities +export function createPreviewStatements( + username: string, + answers: OnboardingAnswer[], +): { episode: any; statements: any[] } { + const allStatements: any[] = []; + const now = new Date().toISOString(); + const baseTimestamp = Date.now(); + + // Create the cumulative episode content + const episodeContent = createProgressiveEpisode(username, answers); + + // Create episode node that contains all statements + const episode = { + uuid: `onboarding-episode-${baseTimestamp}`, + name: username, + content: episodeContent, + labels: ["Episode"], + attributes: { + nodeType: "Episode", + type: "Episode", + source: "onboarding", + content: episodeContent, + validAt: now, + }, + createdAt: now, + }; + + // Create user entity that will be the subject of all statements + const userEntityId = `user-${baseTimestamp}`; + + for (let i = 0; i < answers.length; i++) { + const answer = answers[i]; + const values = Array.isArray(answer.value) ? answer.value : [answer.value]; + + for (let j = 0; j < values.length; j++) { + const value = values[j]; + const uniqueId = `${baseTimestamp}-${i}-${j}`; + + // Get the relationship mapping for this question + const { predicateType, objectType, factTemplate } = getStatementMapping( + answer.questionId, + ); + + // Create the statement fact (e.g., "Manoj uses GitHub") + const fact = factTemplate(username, value); + + // Create statement visualization as a reified structure + const statement = { + // Statement node (center) + statementNode: { + uuid: `statement-${uniqueId}`, + name: fact, + labels: ["Statement"], + attributes: { + nodeType: "Statement", + type: "Statement", + fact: fact, + source: "onboarding", + validAt: now, + }, + createdAt: now, + }, + // Subject entity (user) + subjectNode: { + uuid: userEntityId, + name: username, + labels: ["Entity"], + attributes: { + nodeType: "Entity", + type: "Person", + source: "onboarding", + }, + createdAt: now, + }, + // Predicate entity (relationship type) + predicateNode: { + uuid: `predicate-${predicateType}-${uniqueId}`, + name: predicateType.toLowerCase().replace("_", " "), + labels: ["Entity"], + attributes: { + nodeType: "Entity", + type: "Predicate", + source: "onboarding", + }, + createdAt: now, + }, + // Object entity (the thing being related to) + objectNode: { + uuid: `object-${uniqueId}`, + name: value, + labels: ["Entity"], + attributes: { + nodeType: "Entity", + type: objectType, + source: "onboarding", + }, + createdAt: now, + }, + // Edges connecting statement to subject, predicate, object + edges: { + hasSubject: { + uuid: `has-subject-${uniqueId}`, + type: "HAS_SUBJECT", + source_node_uuid: `statement-${uniqueId}`, + target_node_uuid: userEntityId, + createdAt: now, + }, + hasPredicate: { + uuid: `has-predicate-${uniqueId}`, + type: "HAS_PREDICATE", + source_node_uuid: `statement-${uniqueId}`, + target_node_uuid: `predicate-${predicateType}-${uniqueId}`, + createdAt: now, + }, + hasObject: { + uuid: `has-object-${uniqueId}`, + type: "HAS_OBJECT", + source_node_uuid: `statement-${uniqueId}`, + target_node_uuid: `object-${uniqueId}`, + createdAt: now, + }, + // Provenance connection: Episode → Statement + hasProvenance: { + uuid: `provenance-${uniqueId}`, + type: "HAS_PROVENANCE", + source_node_uuid: `statement-${uniqueId}`, + target_node_uuid: episode.uuid, + createdAt: now, + }, + }, + }; + + allStatements.push(statement); + } + } + + return { episode, statements: allStatements }; +} + +// Helper function to map question types to statement templates with natural English phrasing +function getStatementMapping(questionId: string): { + predicateType: string; + objectType: string; + factTemplate: (subject: string, object: string) => string; +} { + switch (questionId) { + case "role": + return { + predicateType: "IS_A", + objectType: "Role", + factTemplate: (subject, object) => + `${subject} is a ${object.toLowerCase()}`, + }; + case "goal": + return { + predicateType: "WANTS_TO", + objectType: "Goal", + factTemplate: (subject, object) => + `${subject} wants to ${object.toLowerCase()}`, + }; + case "tools": + return { + predicateType: "USES", + objectType: "Tool", + factTemplate: (subject, object) => `${subject} uses ${object}`, + }; + default: + return { + predicateType: "HAS", + objectType: "Attribute", + factTemplate: (subject, object) => `${subject} has ${object}`, + }; + } +} + +// Create main onboarding episode (client-side preview, no embeddings) +export function createOnboardingEpisode( + username: string, + answers: OnboardingAnswer[], + userId: string, + space?: string, +): EpisodicNode { + // Generate progressive episode content + const episodeContent = createProgressiveEpisode(username, answers); + + // Create the main onboarding episode for client preview + const episode: EpisodicNode = { + uuid: crypto.randomUUID(), + content: episodeContent, + originalContent: episodeContent, // Same as content for onboarding + contentEmbedding: [], // Empty placeholder for client-side preview + source: "onboarding", + metadata: { + completedAt: new Date().toISOString(), + questionCount: answers.length, + answersData: answers, // Store original answers for reference + }, + createdAt: new Date(), + validAt: new Date(), + labels: ["onboarding", "user-profile"], + userId, + space, + sessionId: crypto.randomUUID(), // Generate unique session for onboarding + }; + + return episode; +} diff --git a/apps/webapp/app/components/ui/checkbox.tsx b/apps/webapp/app/components/ui/checkbox.tsx index de74adf..64423e4 100644 --- a/apps/webapp/app/components/ui/checkbox.tsx +++ b/apps/webapp/app/components/ui/checkbox.tsx @@ -4,10 +4,15 @@ import React from "react"; import { cn } from "../../lib/utils"; +interface CheckBoxProps + extends React.ComponentPropsWithoutRef { + checkboxClassname?: string; +} + const Checkbox = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + CheckBoxProps +>(({ className, checkboxClassname, ...props }, ref) => ( - + )); diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index bcfe52d..c074a51 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -43,6 +43,11 @@ const EnvironmentSchema = z.object({ APP_ORIGIN: z.string().default("http://localhost:5173"), POSTHOG_PROJECT_KEY: z.string().default(""), + //storage + ACCESS_KEY_ID: z.string().optional(), + SECRET_ACCESS_KEY: z.string().optional(), + BUCKET: z.string().optional(), + // google auth AUTH_GOOGLE_CLIENT_ID: z.string().optional(), AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(), diff --git a/apps/webapp/app/lib/storage.server.ts b/apps/webapp/app/lib/storage.server.ts new file mode 100644 index 0000000..37206d1 --- /dev/null +++ b/apps/webapp/app/lib/storage.server.ts @@ -0,0 +1,148 @@ +import { env } from "~/env.server"; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + type GetObjectCommandInput, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +const s3Client = new S3Client({ + region: process.env.AWS_REGION || "us-east-1", + credentials: { + accessKeyId: env.ACCESS_KEY_ID || "", + secretAccessKey: env.SECRET_ACCESS_KEY || "", + }, +}); + +export interface UploadFileResult { + uuid: string; + url: string; +} + +export async function uploadFileToS3( + fileBuffer: Buffer, + fileName: string, + contentType: string, + userId: string, +): Promise { + if (!env.BUCKET) { + throw new Error("S3 bucket not configured"); + } + + const uuid = crypto.randomUUID(); + const key = `storage/${userId}/${uuid}`; + + const command = new PutObjectCommand({ + Bucket: env.BUCKET, + Key: key, + Body: fileBuffer, + ContentType: contentType, + }); + + await s3Client.send(command); + + // Store metadata for later retrieval + storeFileMetadata(uuid, fileName, contentType, userId); + + const frontendHost = env.APP_ORIGIN; + const url = `${frontendHost}/api/v1/storage/${uuid}`; + + return { uuid, url }; +} + +export async function getFileFromS3( + uuid: string, + userId: string, +): Promise { + if (!env.BUCKET) { + throw new Error("S3 bucket not configured"); + } + + const key = `storage/${userId}/${uuid}`; + + const command = new GetObjectCommand({ + Bucket: env.BUCKET, + Key: key, + }); + + try { + const response = await s3Client.send(command); + + if (!response.Body) { + throw new Error("File not found"); + } + + // Convert the response body to a stream + const stream = response.Body as ReadableStream; + + return new Response(stream, { + headers: { + "Content-Type": response.ContentType as string, + "Content-Length": response.ContentLength?.toString() || "", + "Cache-Control": "public, max-age=3600", + }, + }); + } catch (error) { + throw new Error(`Failed to retrieve file: ${error}`); + } +} + +export async function getSignedUrlForS3( + uuid: string, + userId: string, + expiresIn: number = 3600, +): Promise { + if (!env.BUCKET) { + throw new Error("S3 bucket not configured"); + } + + const key = `storage/${userId}/${uuid}`; + + const command: GetObjectCommandInput = { + Bucket: env.BUCKET, + Key: key, + }; + + try { + const signedUrl = await getSignedUrl( + s3Client, + new GetObjectCommand(command), + { expiresIn }, + ); + return signedUrl; + } catch (error) { + throw new Error(`Failed to generate signed URL: ${error}`); + } +} + +// Store file metadata for retrieval +interface FileMetadata { + uuid: string; + fileName: string; + contentType: string; + userId: string; + uploadedAt: Date; +} + +// Simple in-memory storage for file metadata (use database in production) +const fileMetadataStore = new Map(); + +export function storeFileMetadata( + uuid: string, + fileName: string, + contentType: string, + userId: string, +) { + fileMetadataStore.set(uuid, { + uuid, + fileName, + contentType, + userId, + uploadedAt: new Date(), + }); +} + +export function getFileMetadata(uuid: string): FileMetadata | undefined { + return fileMetadataStore.get(uuid); +} diff --git a/apps/webapp/app/routes/api.v1.extension-summary.tsx b/apps/webapp/app/routes/api.v1.extension-summary.tsx index 624ba98..c2c2c17 100644 --- a/apps/webapp/app/routes/api.v1.extension-summary.tsx +++ b/apps/webapp/app/routes/api.v1.extension-summary.tsx @@ -7,6 +7,7 @@ export const ExtensionSummaryBodyRequest = z.object({ html: z.string().min(1, "HTML content is required"), url: z.string().url("Valid URL is required"), title: z.string().optional(), + parseImages: z.boolean().default(false), }); const { action, loader } = createActionApiRoute( @@ -18,8 +19,11 @@ const { action, loader } = createActionApiRoute( }, corsStrategy: "all", }, - async ({ body }) => { - const response = await extensionSummary.trigger(body); + async ({ body, authentication }) => { + const response = await extensionSummary.trigger({ + ...body, + apiKey: authentication.apiKey, + }); return json(response); }, diff --git a/apps/webapp/app/routes/api.v1.storage.$uuid.tsx b/apps/webapp/app/routes/api.v1.storage.$uuid.tsx new file mode 100644 index 0000000..c8fe7b4 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.storage.$uuid.tsx @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { createHybridLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { getFileFromS3 } from "~/lib/storage.server"; + +const ParamsSchema = z.object({ + uuid: z.string().uuid("Invalid UUID format"), +}); + +const loader = createHybridLoaderApiRoute( + { + params: ParamsSchema, + corsStrategy: "all", + findResource: async (params) => { + // Return the UUID as the resource + return params?.uuid || null; + }, + }, + async ({ params, authentication }) => { + if (!params?.uuid) { + return new Response("UUID not provided", { status: 400 }); + } + + try { + const fileResponse = await getFileFromS3( + params.uuid, + authentication.userId, + ); + return fileResponse; + } catch (error) { + console.error("File retrieval error:", error); + return new Response("File not found", { status: 404 }); + } + }, +); + +export { loader }; diff --git a/apps/webapp/app/routes/api.v1.storage.tsx b/apps/webapp/app/routes/api.v1.storage.tsx new file mode 100644 index 0000000..1f0b187 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.storage.tsx @@ -0,0 +1,83 @@ +import { z } from "zod"; +import { json } from "@remix-run/node"; +import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { uploadFileToS3 } from "~/lib/storage.server"; + +const { action, loader } = createHybridActionApiRoute( + { + corsStrategy: "all", + allowJWT: true, + maxContentLength: 50 * 1024 * 1024, // 50MB limit + }, + async ({ request, authentication }) => { + let buffer: Buffer; + let fileName = "unnamed-file"; + let contentType = "application/octet-stream"; + + return json({ + success: true, + + url: "http://localhost:3033/api/v1/storage/69bd1e11-552b-4708-91b0-bad006f41ddb", + filename: fileName, + + contentType: contentType, + }); + + try { + const contentTypeHeader = request.headers.get("Content-Type") || ""; + + if (contentTypeHeader.includes("multipart/form-data")) { + const formData = await request.formData(); + const file = formData.get("File") as File; + + if (!file) { + return json({ error: "No file provided" }, { status: 400 }); + } + + if (file.size === 0) { + return json({ error: "File is empty" }, { status: 400 }); + } + + // Convert file to buffer + const arrayBuffer = await file.arrayBuffer(); + buffer = Buffer.from(arrayBuffer); + fileName = file.name; + contentType = file.type; + } else if (contentTypeHeader.includes("application/json")) { + const jsonBody = await request.json(); + const base64Data = jsonBody.base64Data; + fileName = jsonBody.fileName || fileName; + contentType = jsonBody.contentType || contentType; + + if (!base64Data) { + return json({ error: "No base64 data provided" }, { status: 400 }); + } + + buffer = Buffer.from(base64Data, "base64"); + } else { + return json({ error: "Unsupported content type" }, { status: 400 }); + } + + const result = await uploadFileToS3( + buffer, + fileName, + contentType, + authentication.userId, + ); + + return json({ + success: true, + uuid: result.uuid, + url: result.url, + filename: fileName, + size: buffer.length, + contentType: contentType, + }); + } catch (error) { + console.error("File upload error:", error); + return json({ error: "Failed to upload file" }, { status: 500 }); + } + }, +); + +export { action, loader }; diff --git a/apps/webapp/app/routes/confirm-basic-details.tsx b/apps/webapp/app/routes/confirm-basic-details.tsx index 44e82ea..0e5a7e3 100644 --- a/apps/webapp/app/routes/confirm-basic-details.tsx +++ b/apps/webapp/app/routes/confirm-basic-details.tsx @@ -4,6 +4,7 @@ import { type ActionFunctionArgs, json, type LoaderFunctionArgs, + redirect, } from "@remix-run/node"; import { useForm } from "@conform-to/react"; import { getFieldsetConstraint, parse } from "@conform-to/zod"; @@ -19,7 +20,7 @@ import { Button } from "~/components/ui"; import { Input } from "~/components/ui/input"; import { requireUser, requireUserId } from "~/services/session.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; -import { rootPath } from "~/utils/pathBuilder"; +import { onboardingPath, rootPath } from "~/utils/pathBuilder"; import { createWorkspace, getWorkspaceByUser } from "~/models/workspace.server"; import { typedjson } from "remix-typedjson"; @@ -63,6 +64,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const user = await requireUser(request); const workspace = await getWorkspaceByUser(user.id); + if (user.confirmedBasicDetails) { + return redirect(onboardingPath()); + } + return typedjson({ user, workspace, diff --git a/apps/webapp/app/routes/onboarding.tsx b/apps/webapp/app/routes/onboarding.tsx index 63ce074..203f2ca 100644 --- a/apps/webapp/app/routes/onboarding.tsx +++ b/apps/webapp/app/routes/onboarding.tsx @@ -1,65 +1,47 @@ import { z } from "zod"; -import { useLoaderData, useActionData, useNavigate } from "@remix-run/react"; +import { useLoaderData, useSubmit } from "@remix-run/react"; import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect, - createCookie, } from "@remix-run/node"; -import { useForm } from "@conform-to/react"; -import { getFieldsetConstraint, parse } from "@conform-to/zod"; -import { LoginPageLayout } from "~/components/layout/login-page-layout"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; -import { Button } from "~/components/ui"; -import { Textarea } from "~/components/ui/textarea"; -import { Input } from "~/components/ui/input"; -import { useState } from "react"; -import { requireUserId } from "~/services/session.server"; +import { requireUser, requireUserId } from "~/services/session.server"; import { updateUser } from "~/models/user.server"; -import { Copy, Check } from "lucide-react"; -import { addToQueue } from "~/lib/ingest.server"; -import { cn } from "~/lib/utils"; -import { EpisodeTypeEnum } from "@core/types"; +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"; -const ONBOARDING_STEP_COOKIE = "onboardingStep"; -const onboardingStepCookie = createCookie(ONBOARDING_STEP_COOKIE, { - path: "/", - httpOnly: true, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, // 1 week -}); +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 { activityPath } from "~/utils/pathBuilder"; const schema = z.object({ - aboutUser: z - .string() - .min( - 10, - "Please tell us a bit more about yourself (at least 10 characters)", - ) - .max(1000, "Please keep it under 1000 characters"), + answers: z.string(), }); export async function loader({ request }: LoaderFunctionArgs) { - await requireUserId(request); + const user = await requireUser(request); - // Read step from cookie - const cookieHeader = request.headers.get("Cookie"); - const cookie = (await onboardingStepCookie.parse(cookieHeader)) || {}; - const step = cookie.step || null; + if (user.onboardingComplete) { + return redirect(activityPath()); + } - return json({ step }); + return json({ user }); } export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request); - const formData = await request.formData(); const submission = parse(formData, { schema }); @@ -67,23 +49,13 @@ export async function action({ request }: ActionFunctionArgs) { return json(submission); } - const { aboutUser } = submission.value; + const { answers } = submission.value; + const parsedAnswers = JSON.parse(answers); + const user = await requireUser(request); try { - // Ingest memory via API call - const memoryResponse = await addToQueue( - { - source: "Core", - episodeBody: aboutUser, - referenceTime: new Date().toISOString(), - type: EpisodeTypeEnum.CONVERSATION, - }, - userId, - ); - - if (!memoryResponse.id) { - throw new Error("Failed to save memory"); - } + const userName = user.displayName || user.email; + const episodeText = createProgressiveEpisode(userName, parsedAnswers); // Update user's onboarding status await updateUser({ @@ -91,169 +63,191 @@ export async function action({ request }: ActionFunctionArgs) { onboardingComplete: true, }); - // Set step in cookie and redirect to GET (PRG pattern) - const cookie = await onboardingStepCookie.serialize({ - step: "memory-link", - }); - return redirect("/onboarding", { - headers: { - "Set-Cookie": cookie, + await addToQueue( + { + episodeBody: episodeText, + source: "Onboarding", + referenceTime: new Date().toISOString(), + type: EpisodeType.CONVERSATION, }, - }); + userId, + ); + + return redirect("/home/logs"); } catch (e: any) { return json({ errors: { body: e.message } }, { status: 400 }); } } export default function Onboarding() { - const loaderData = useLoaderData<{ step: string | null }>(); - const lastSubmission = useActionData(); + const { user } = useLoaderData(); + const submit = useSubmit(); - const navigate = useNavigate(); - const [copied, setCopied] = useState(false); - const [selectedSource, setSelectedSource] = useState< - "Claude" | "Cursor" | "Other" - >("Claude"); + const [currentQuestion, setCurrentQuestion] = useState(0); + const [answers, setAnswers] = useState([]); + // Initialize with default identity statement converted to triplets + const getInitialTriplets = () => { + const displayName = user.displayName || user.email || "User"; + const identityStatement = createInitialIdentityStatement(displayName); - const [form, fields] = useForm({ - lastSubmission: lastSubmission as any, - constraint: getFieldsetConstraint(schema), - onValidate({ formData }) { - return parse(formData, { schema }); - }, - }); - - const getMemoryUrl = (source: "Claude" | "Cursor" | "Other") => { - const baseUrl = "https://core.heysol.ai/api/v1/mcp"; - return `${baseUrl}?Source=${source}`; + // 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 memoryUrl = getMemoryUrl(selectedSource); + const [generatedTriplets, setGeneratedTriplets] = + useState(getInitialTriplets); - const copyToClipboard = async () => { + 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 { - await navigator.clipboard.writeText(memoryUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy:", err); + 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); } }; - // Show memory link step after successful submission (step persisted in cookie) - if (loaderData.step === "memory-link") { - return ( - - - - Your Memory Link - - Here's your personal memory API endpoint. Copy this URL to connect - with external tools (Claude, Cursor etc). - - + const handleNext = () => { + if (currentQuestion < ONBOARDING_QUESTIONS.length - 1) { + setCurrentQuestion(currentQuestion + 1); + } else { + // Submit all answers + submitAnswers(); + } + }; - -
-
-
- {(["Claude", "Cursor", "Other"] as const).map((source) => ( - - ))} -
+ const handlePrevious = () => { + if (currentQuestion > 0) { + setCurrentQuestion(currentQuestion - 1); + } + }; -
- - -
-
+ const submitAnswers = async () => { + const formData = new FormData(); + formData.append("answers", JSON.stringify(answers)); - -
-
-
-
- ); - } + submit(formData, { + method: "POST", + }); + }; + + // 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 ( - - - - - -
-
- - Tell me about you - -
-