fix: onboarding

This commit is contained in:
Harshith Mullapudi 2025-09-05 14:59:06 +05:30
parent ba53605572
commit 3329f4751f
24 changed files with 2307 additions and 359 deletions

9
.gitignore vendored
View File

@ -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

View File

@ -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}
/>
) : (
<div className="flex h-full items-center justify-center">

View File

@ -46,6 +46,8 @@ export interface GraphClusteringProps {
labelColorMap?: Map<string, number>;
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<string | null>(null);
const selectedEdgeRef = useRef<string | null>(null);
const selectedClusterRef = useRef<string | null>(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",

View File

@ -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 (

View File

@ -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<string | string[]>(
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 (
<div className="mx-auto w-full max-w-md">
<Card className="bg-background-2 w-full rounded-lg p-3 pt-1">
<CardHeader className="flex flex-col items-start px-0">
<div className="mb-2 flex w-full items-center justify-between">
<span className="text-muted-foreground text-sm">
Step {currentStep} of {totalSteps}
</span>
<div className="bg-grayAlpha-100 h-1.5 w-32 rounded-full">
<div
className="bg-primary h-1.5 rounded-full transition-all duration-300"
style={{ width: `${(currentStep / totalSteps) * 100}%` }}
/>
</div>
</div>
</CardHeader>
<CardContent className="text-base">
<div className="space-y-6">
<div>
<CardTitle className="mb-2 text-xl">{question.title}</CardTitle>
</div>
{question.type === "single-select" && question.options && (
<div className="space-y-3">
{question.options.map((option) => (
<Button
key={option.id}
type="button"
variant={
selectedValue === option.value ? "secondary" : "outline"
}
className="hover:bg-grayAlpha-100 h-auto w-full justify-start px-4 py-3 text-left font-normal"
onClick={() => handleSingleSelect(option.value)}
>
{option.label}
</Button>
))}
</div>
)}
{question.type === "multi-select" && question.options && (
<div className="space-y-3">
{question.options.map((option) => (
<div key={option.id} className="flex items-center space-x-2">
<Checkbox
id={option.id}
checked={
Array.isArray(selectedValue) &&
selectedValue.includes(option.value)
}
onCheckedChange={(checked) =>
handleMultiSelect(option.value, !!checked)
}
className="h-6 w-6 text-xl"
checkboxClassname="h-5 w-5 text-xl"
/>
<Label
htmlFor={option.id}
className="cursor-pointer text-base font-normal"
>
{option.label}
</Label>
</div>
))}
</div>
)}
<div className="flex justify-between pt-4">
{!isFirst && (
<Button
type="button"
variant="ghost"
size="xl"
onClick={onPrevious}
className="rounded-lg px-4 py-2"
>
Previous
</Button>
)}
<div className="flex-1" />
<Button
type="button"
variant="secondary"
size="xl"
onClick={onNext}
disabled={!isValid()}
className="rounded-lg px-4 py-2"
>
{isLast ? "Complete Profile" : "Continue"}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -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;
}

View File

@ -4,10 +4,15 @@ import React from "react";
import { cn } from "../../lib/utils";
interface CheckBoxProps
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> {
checkboxClassname?: string;
}
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
CheckBoxProps
>(({ className, checkboxClassname, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
@ -19,7 +24,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-white")}
>
<CheckIcon className="h-3 w-3" />
<CheckIcon className={cn("h-3 w-3", checkboxClassname)} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));

View File

@ -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(),

View File

@ -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<UploadFileResult> {
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<Response> {
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<string> {
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<string, FileMetadata>();
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);
}

View File

@ -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);
},

View File

@ -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 };

View File

@ -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 };

View File

@ -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,

View File

@ -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<typeof action>();
const { user } = useLoaderData<typeof loader>();
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<OnboardingAnswer[]>([]);
// 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<RawTriplet[]>(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 (
<LoginPageLayout>
<Card className="min-w-[400px] rounded-lg bg-transparent p-3 pt-1">
<CardHeader className="flex flex-col items-start px-0">
<CardTitle className="px-0 text-xl">Your Memory Link</CardTitle>
<CardDescription className="text-md">
Here's your personal memory API endpoint. Copy this URL to connect
with external tools (Claude, Cursor etc).
</CardDescription>
</CardHeader>
const handleNext = () => {
if (currentQuestion < ONBOARDING_QUESTIONS.length - 1) {
setCurrentQuestion(currentQuestion + 1);
} else {
// Submit all answers
submitAnswers();
}
};
<CardContent className="pt-2 text-base">
<div className="space-y-4">
<div className="space-y-3">
<div className="bg-grayAlpha-100 flex space-x-1 rounded-lg p-1">
{(["Claude", "Cursor", "Other"] as const).map((source) => (
<Button
key={source}
onClick={() => setSelectedSource(source)}
variant="ghost"
className={cn(
"flex-1 rounded-md px-3 py-1.5 transition-all",
selectedSource === source
? "bg-accent text-accent-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{source}
</Button>
))}
</div>
const handlePrevious = () => {
if (currentQuestion > 0) {
setCurrentQuestion(currentQuestion - 1);
}
};
<div className="bg-background-3 flex items-center rounded">
<Input
type="text"
id="memoryUrl"
value={memoryUrl}
readOnly
className="bg-background-3 block w-full text-base"
/>
<Button
type="button"
variant="link"
size="sm"
onClick={copyToClipboard}
className="px-3"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
const submitAnswers = async () => {
const formData = new FormData();
formData.append("answers", JSON.stringify(answers));
<Button
type="button"
variant="secondary"
size="xl"
className="w-full rounded-lg px-4 py-2"
onClick={() => navigate("/")}
>
Continue to Dashboard
</Button>
</div>
</CardContent>
</Card>
</LoginPageLayout>
);
}
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 (
<LoginPageLayout>
<Card className="bg-background-2 w-full max-w-[400px] rounded-lg p-3 pt-1">
<CardHeader className="flex flex-col items-start px-0"></CardHeader>
<CardContent className="text-base">
<form method="post" {...form.props}>
<div className="space-y-4 pl-1">
<CardTitle className="text-md mb-0 -ml-1 px-0 text-xl">
Tell me about you
</CardTitle>
<div>
<Textarea
id="aboutUser"
placeholder="I'm Steve Jobs, co-founder of Apple. I helped create the iPhone, iPad, and Mac. I'm passionate about design, technology, and making products that change the world. I spent much of my life in California, working on innovative devices and inspiring creativity. I enjoy simplicity, calligraphy, and thinking differently..."
name={fields.aboutUser.name}
className="block min-h-[120px] w-full bg-transparent px-0 text-base"
rows={10}
/>
{fields.aboutUser.error && (
<div className="text-sm text-red-500">
{fields.aboutUser.error}
</div>
)}
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="secondary"
size="xl"
className="rounded-lg px-4 py-2"
>
Continue
</Button>
</div>
<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 width={60} height={60} />
</div>
</form>
</CardContent>
</Card>
</LoginPageLayout>
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}
isFirst={currentQuestion === 0}
isLast={currentQuestion === ONBOARDING_QUESTIONS.length - 1}
currentStep={currentQuestion + 1}
totalSteps={ONBOARDING_QUESTIONS.length}
/>
)}
</div>
</div>
</div>
);
}

View File

@ -19,6 +19,7 @@ const posthogProxy = async (request: Request) => {
headers.set("host", hostname);
const response = await fetch(newUrl, {
duplex: "half",
method: request.method,
headers,
body: request.body,

View File

@ -379,10 +379,10 @@ export async function invalidateStatement({
RETURN statement
`;
const params = {
statementId,
const params = {
statementId,
invalidAt,
...(invalidatedBy && { invalidatedBy })
...(invalidatedBy && { invalidatedBy }),
};
const result = await runQuery(query, params);

View File

@ -0,0 +1,139 @@
import type {
Triple,
EntityNode,
EpisodicNode,
StatementNode,
} from "@core/types";
import { getEmbedding } from "~/lib/model.server";
import {
createProgressiveEpisode,
type OnboardingAnswer,
} from "~/components/onboarding/onboarding-utils";
import crypto from "crypto";
// Server-side helper functions with embeddings
async function createEntityWithEmbeddings(
name: string,
type: string,
userId: string,
space?: string,
): Promise<EntityNode> {
return {
uuid: crypto.randomUUID(),
name,
type,
attributes: {},
nameEmbedding: await getEmbedding(name),
typeEmbedding: await getEmbedding(type),
createdAt: new Date(),
userId,
space,
};
}
async function createEpisodeWithEmbeddings(
content: string,
userId: string,
space?: string,
): Promise<EpisodicNode> {
return {
uuid: crypto.randomUUID(),
content,
originalContent: content,
contentEmbedding: await getEmbedding(content),
metadata: { source: "onboarding" },
source: "onboarding",
createdAt: new Date(),
validAt: new Date(),
labels: ["onboarding"],
userId,
space,
};
}
async function createStatementWithEmbeddings(
fact: string,
userId: string,
space?: string,
): Promise<StatementNode> {
return {
uuid: crypto.randomUUID(),
fact,
factEmbedding: await getEmbedding(fact),
createdAt: new Date(),
validAt: new Date(),
invalidAt: null,
attributes: {},
userId,
space,
};
}
// Helper function to map question types to statement templates
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 with embeddings (server-side only)
export async function createOnboardingEpisodeWithEmbeddings(
username: string,
answers: OnboardingAnswer[],
userId: string,
space?: string,
): Promise<EpisodicNode> {
// Generate progressive episode content
const episodeContent = createProgressiveEpisode(username, answers);
// Create the main onboarding episode with embeddings
const episode: EpisodicNode = {
uuid: crypto.randomUUID(),
content: episodeContent,
originalContent: episodeContent,
contentEmbedding: await getEmbedding(episodeContent),
source: "onboarding",
metadata: {
completedAt: new Date().toISOString(),
questionCount: answers.length,
answersData: answers,
},
createdAt: new Date(),
validAt: new Date(),
labels: ["onboarding", "user-profile"],
userId,
space,
sessionId: crypto.randomUUID(),
};
return episode;
}

View File

@ -3,6 +3,7 @@ import { type CoreMessage } from "ai";
import * as cheerio from "cheerio";
import { z } from "zod";
import { makeModelCall } from "~/lib/model.server";
import { summarizeImage, extractImageUrls } from "./utils";
export type PageType = "text" | "video";
@ -10,15 +11,19 @@ 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),
apiKey: z.string().optional(),
});
interface ContentExtractionResult {
pageType: PageType;
title: string;
content: string;
images: string[];
metadata: {
url: string;
wordCount: number;
imageCount: number;
};
supported: boolean;
}
@ -51,11 +56,13 @@ function isVideoPage(url: string, $: cheerio.CheerioAPI): boolean {
}
/**
* Extract all text content from any webpage
* Extract all text content and images from any webpage
*/
function extractTextContent(
$: cheerio.CheerioAPI,
url: string,
html: string,
parseImages: boolean = false,
): ContentExtractionResult {
// Extract title from multiple possible locations
const title =
@ -105,27 +112,68 @@ function extractTextContent(
// Clean up whitespace and normalize text
content = content.replace(/\s+/g, " ").trim();
// Extract images if requested
const images = parseImages ? extractImageUrls(html) : [];
const wordCount = content
.split(/\s+/)
.filter((word) => word.length > 0).length;
const supported = !isVideo && content.length > 50;
const supported = !isVideo && (content.length > 50 || images.length > 0);
return {
pageType,
title: title.trim(),
content: content.slice(0, 10000), // Limit content size for processing
images,
metadata: {
url,
wordCount,
imageCount: images.length,
},
supported,
};
}
/**
* Generate summary using LLM
* Process images and get their summaries
*/
async function generateSummary(title: string, content: string) {
async function processImages(
images: string[],
apiKey?: string,
): Promise<string[]> {
if (images.length === 0) return [];
const imageSummaries: string[] = [];
for (const imageUrl of images) {
try {
const summary = await summarizeImage(imageUrl, apiKey);
imageSummaries.push(`[Image Description]: ${summary}`);
} catch (error) {
console.error(`Error processing image ${imageUrl}:`, error);
imageSummaries.push(
`[Image Description]: Unable to analyze image at ${imageUrl}`,
);
}
}
return imageSummaries;
}
/**
* Generate summary using LLM with optional image descriptions
*/
async function generateSummary(
title: string,
content: string,
imageSummaries: string[] = [],
) {
// Combine content with image descriptions
const contentWithImages =
imageSummaries.length > 0
? `${content}\n\n${imageSummaries.join("\n\n")}`
: content;
const messages: CoreMessage[] = [
{
role: "system",
@ -137,15 +185,17 @@ Create a clear, informative summary that captures the key points and main ideas
- Maintain the original context and meaning
- Be useful for someone who wants to quickly understand the content
- Format the summary in clean HTML using appropriate tags like <h1>, <h2>, <p>, <ul>, <li> to structure the information
- When image descriptions are provided, integrate them naturally into the summary context
- Replace image references with their detailed descriptions
IMPORTANT: Return ONLY the HTML content without any markdown code blocks or formatting. Do not wrap the response in \`\`\`html or any other markdown syntax. Return the raw HTML directly.
Extract the essential information while preserving important details, facts, or insights.`,
Extract the essential information while preserving important details, facts, or insights. If image descriptions are included, weave them seamlessly into the narrative.`,
},
{
role: "user",
content: `Title: ${title}
Content: ${content}
Content: ${contentWithImages}
Please provide a concise summary of this content in HTML format.`,
},
@ -167,7 +217,12 @@ export const extensionSummary = task({
const $ = cheerio.load(body.html);
// Extract content from any webpage
const extraction = extractTextContent($, body.url);
const extraction = extractTextContent(
$,
body.url,
body.html,
body.parseImages,
);
// Override title if provided
if (body.title) {
@ -175,22 +230,33 @@ export const extensionSummary = task({
}
let summary = "";
let imageSummaries: string[] = [];
if (extraction.supported && extraction.content.length > 0) {
// Generate summary for text content
const response = (await generateSummary(
extraction.title,
extraction.content,
)) as any;
const stream = await metadata.stream("messages", response.textStream);
let finalText: string = "";
for await (const chunk of stream) {
finalText = finalText + chunk;
if (extraction.supported) {
// Process images if requested and available
if (body.parseImages && extraction.images.length > 0) {
imageSummaries = await processImages(extraction.images, body.apiKey);
}
summary = finalText;
// Generate summary for text content with image descriptions
if (extraction.content.length > 0 || imageSummaries.length > 0) {
const response = (await generateSummary(
extraction.title,
extraction.content,
imageSummaries,
)) as any;
const stream = await metadata.stream("messages", response.textStream);
let finalText: string = "";
for await (const chunk of stream) {
finalText = finalText + chunk;
}
summary = finalText;
} else {
summary = "Unable to extract sufficient content for summarization.";
}
} else {
// Handle unsupported content types
if (extraction.pageType === "video") {
@ -208,6 +274,8 @@ export const extensionSummary = task({
title: extraction.title,
summary,
content: extraction.content.slice(0, 1000), // Return first 1000 chars of content
images: extraction.images,
imageSummaries: imageSummaries.length > 0 ? imageSummaries : undefined,
supported: extraction.supported,
metadata: extraction.metadata,
};
@ -223,10 +291,12 @@ export const extensionSummary = task({
title: body.title || "Error",
summary: "Unable to process this page content.",
content: "",
images: [],
supported: false,
metadata: {
url: body.url,
wordCount: 0,
imageCount: 0,
},
};
}

View File

@ -0,0 +1,108 @@
import { type DataContent, type CoreMessage } from "ai";
import axios from "axios";
import { makeModelCall } from "~/lib/model.server";
/**
* Summarizes an image by sending it to the model for analysis
* Focuses on describing Figma designs, personal photos, emotions, tone, location, premise,
* and design/art language when applicable
*/
export async function summarizeImage(
imageUrl: string,
apiKey?: string,
): Promise<string> {
const response = await axios.get(imageUrl, {
responseType: "arraybuffer",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const messages: CoreMessage[] = [
{
role: "system",
content: `You are a helpful assistant that analyzes images and provides detailed descriptions. When describing images, focus on:
For Figma designs and UI/UX content:
- Design language, visual hierarchy, and layout patterns
- Color palette, typography, and spacing
- User interface elements and interactions
- Design system components and patterns
- Overall design approach and style
For personal photos and general images:
- Setting, location, and environment details
- Emotions, mood, and atmosphere
- People's expressions, body language, and interactions
- Lighting, composition, and visual tone
- Objects, activities, and context
- Time of day or season if apparent
For art and creative content:
- Artistic style, medium, and technique
- Color theory, composition, and visual elements
- Artistic movement or influence
- Emotional impact and artistic intent
- Cultural or historical context if relevant
Provide a comprehensive, detailed description that captures both the visual elements and the underlying meaning or purpose of the image. Be specific and descriptive while maintaining clarity.`,
},
{
role: "user",
content: [
{
type: "text",
text: "Please analyze this image and provide a detailed description following the guidelines above.",
},
{
type: "image",
image: response.data as DataContent,
},
],
},
];
try {
const response = await makeModelCall(
false, // Don't stream for image analysis
messages,
() => {}, // Empty onFinish callback
{ temperature: 0.7 },
);
return response as string;
} catch (error) {
console.error("Error summarizing image:", error);
return "Unable to analyze image content.";
}
}
/**
* Extracts image URLs from HTML content and limits to first 5 images
*/
export function extractImageUrls(html: string): string[] {
// Match img tags with src attributes
const imgRegex = /<img[^>]+src\s*=\s*["']([^"']+)["'][^>]*>/gi;
const imageUrls: string[] = [];
let match;
while ((match = imgRegex.exec(html)) !== null && imageUrls.length < 5) {
const src = match[1];
// Filter out common non-content images
if (
!src.includes("favicon") &&
!src.includes("logo") &&
!src.includes("icon") &&
!src.includes("avatar") &&
!src.endsWith(".svg") && // Often logos/icons
!src.includes("tracking") &&
!src.includes("analytics") &&
src.startsWith("http") // Only external URLs
) {
imageUrls.push(src);
}
}
return imageUrls;
}

View File

@ -14,6 +14,10 @@ export function dashboardPath() {
return `/home/dashboard`;
}
export function activityPath() {
return `/home/logs`;
}
export function conversationPath() {
return `/home/conversation`;
}

View File

@ -17,6 +17,8 @@
"@ai-sdk/anthropic": "^1.2.12",
"@ai-sdk/google": "^1.2.22",
"@ai-sdk/openai": "^1.3.21",
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",
"@anthropic-ai/sdk": "^0.60.0",
"@coji/remix-auth-google": "^4.2.0",
"@conform-to/react": "^0.6.1",

View File

@ -1,111 +0,0 @@
import { createRequestHandler } from "@remix-run/express";
import compression from "compression";
import express from "express";
import morgan from "morgan";
// import { handleMCPRequest, handleSessionRequest } from "~/services/mcp.server";
// import { authenticateHybridRequest } from "~/services/routeBuilders/apiBuilder.server";
let viteDevServer;
let remixHandler;
async function init() {
if (process.env.NODE_ENV !== "production") {
const vite = await import("vite");
viteDevServer = await vite.createServer({
server: { middlewareMode: true },
});
}
const build = viteDevServer
? () => viteDevServer.ssrLoadModule("virtual:remix/server-build")
: await import("./build/server/index.js");
const module = viteDevServer
? (await build()).entry.module
: build.entry?.module;
remixHandler = createRequestHandler({ build });
const app = express();
app.use(compression());
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable("x-powered-by");
// handle asset requests
if (viteDevServer) {
app.use(viteDevServer.middlewares);
}
else {
// Vite fingerprints its assets so we can cache forever.
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
}
// Everything else (like favicon.ico) is cached for an hour. You may want to be
// more aggressive with this caching.
app.use(express.static("build/client", { maxAge: "1h" }));
app.use(morgan("tiny"));
app.get("/api/v1/mcp", async (req, res) => {
const authenticationResult = await module.authenticateHybridRequest(req, {
allowJWT: true,
});
if (!authenticationResult) {
res.status(401).json({ error: "Authentication required" });
return;
}
await module.handleSessionRequest(req, res, authenticationResult.userId);
});
app.post("/api/v1/mcp", async (req, res) => {
const authenticationResult = await module.authenticateHybridRequest(req, {
allowJWT: true,
});
if (!authenticationResult) {
res.status(401).json({ error: "Authentication required" });
return;
}
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", async () => {
try {
const parsedBody = JSON.parse(body);
const queryParams = req.query; // Get query parameters from the request
await module.handleMCPRequest(req, res, parsedBody, authenticationResult, queryParams);
}
catch (error) {
res.status(400).json({ error: "Invalid JSON" });
}
});
});
app.delete("/api/v1/mcp", async (req, res) => {
const authenticationResult = await module.authenticateHybridRequest(req, {
allowJWT: true,
});
if (!authenticationResult) {
res.status(401).json({ error: "Authentication required" });
return;
}
await module.handleSessionRequest(req, res, authenticationResult.userId);
});
app.options("/api/v1/mcp", (_, res) => {
res.json({});
});
app.get("/.well-known/oauth-authorization-server", (req, res) => {
res.json({
issuer: process.env.APP_ORIGIN,
authorization_endpoint: `${process.env.APP_ORIGIN}/oauth/authorize`,
token_endpoint: `${process.env.APP_ORIGIN}/oauth/token`,
registration_endpoint: `${process.env.APP_ORIGIN}/oauth/register`,
scopes_supported: ["mcp"],
response_types_supported: ["code"],
grant_types_supported: [
"authorization_code",
"refresh_token",
"client_credentials",
],
code_challenge_methods_supported: ["S256", "plain"],
token_endpoint_auth_methods_supported: [
"client_secret_basic",
"none",
"client_secret_post",
],
});
});
// handle SSR requests
app.all("*", remixHandler);
const port = process.env.REMIX_APP_PORT || 3000;
app.listen(port, () => console.log(`Express server listening at http://localhost:${port}`));
}
init().catch(console.error);

View File

@ -98,6 +98,7 @@ export enum EpisodeTypeEnum {
export const EpisodeType = {
CONVERSATION: "CONVERSATION",
DOCUMENT: "DOCUMENT",
IMAGE: "IMAGE",
};
export type EpisodeType = (typeof EpisodeType)[keyof typeof EpisodeType];

721
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff