mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-12 01:08:28 +00:00
fix: onboarding
This commit is contained in:
parent
ba53605572
commit
3329f4751f
9
.gitignore
vendored
9
.gitignore
vendored
@ -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
|
||||
@ -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">
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 (
|
||||
|
||||
164
apps/webapp/app/components/onboarding/onboarding-question.tsx
Normal file
164
apps/webapp/app/components/onboarding/onboarding-question.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
562
apps/webapp/app/components/onboarding/onboarding-utils.ts
Normal file
562
apps/webapp/app/components/onboarding/onboarding-utils.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
));
|
||||
|
||||
@ -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(),
|
||||
|
||||
148
apps/webapp/app/lib/storage.server.ts
Normal file
148
apps/webapp/app/lib/storage.server.ts
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
|
||||
36
apps/webapp/app/routes/api.v1.storage.$uuid.tsx
Normal file
36
apps/webapp/app/routes/api.v1.storage.$uuid.tsx
Normal 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 };
|
||||
83
apps/webapp/app/routes/api.v1.storage.tsx
Normal file
83
apps/webapp/app/routes/api.v1.storage.tsx
Normal 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 };
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
139
apps/webapp/app/services/onboarding.server.ts
Normal file
139
apps/webapp/app/services/onboarding.server.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
108
apps/webapp/app/trigger/extension/utils.ts
Normal file
108
apps/webapp/app/trigger/extension/utils.ts
Normal 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;
|
||||
}
|
||||
@ -14,6 +14,10 @@ export function dashboardPath() {
|
||||
return `/home/dashboard`;
|
||||
}
|
||||
|
||||
export function activityPath() {
|
||||
return `/home/logs`;
|
||||
}
|
||||
|
||||
export function conversationPath() {
|
||||
return `/home/conversation`;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
@ -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
721
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user