- {[
- {
- step: 1,
- title: "Check in with how you feel",
- description: "Delight greets you and asks about your emotional state. Stressed, energized, or somewhere in between—your answer shapes what happens next.",
- },
- {
- step: 2,
- title: "Triage what matters next",
- description: "Based on your goals, recent work, and current energy, Delight suggests three meaningful priorities. You're not choosing from an endless list—just what's important now.",
- },
- {
- step: 3,
- title: "Pick a mission-sized task",
- description: "Select a mission that fits your bandwidth. The companion adjusts duration and difficulty. 10 minutes or 2 hours—you decide what feels right.",
- },
- {
- step: 4,
- title: "Work with gentle support",
- description: "Complete your mission while the companion stays present. If you drift, it checks in: intentional break, or pulled away? No judgment, just awareness.",
- },
- {
- step: 5,
- title: "Capture proof and watch your world change",
- description: "Mark the mission complete, optionally add notes or photos. Watch your streak grow, earn progress points, and see your constellation expand. The proof compounds over time.",
- },
- ].map((item) => (
-
-
-
-
- {item.title}
-
-
- {item.description}
-
-
-
- ))}
+
+
+
+
+
+ {/* 02 PSYCHOLOGY */}
+
+
+
+
+ {/* 03 MECHANICS */}
+
+
+
+
-
-
- {/* Technical Section */}
-
-
-
-
-
- Built for developers and technical buyers
-
-
- Modern architecture designed for cost efficiency and emotional intelligence
-
-
-
-
- {[
- { icon: Code2, label: "FastAPI + Next.js 15", desc: "Async-first, type-safe" },
- { icon: Database, label: "PostgreSQL + pgvector", desc: "Unified semantic memory" },
- { icon: Cpu, label: "LangGraph Multi-Agent", desc: "Stateful AI orchestration" },
- { icon: Brain, label: "Three-Tier Memory", desc: "Personal, project, task" },
- { icon: Zap, label: "Cost-Aware", desc: "<$0.10/user/day target" },
- { icon: Heart, label: "Privacy-First", desc: "Opt-in context tracking" },
- ].map((item) => (
-
-
-
{item.label}
-
{item.desc}
-
- ))}
-
-
+
+
+ {/* 04 SQUAD */}
+
+
+
+
+ {/* 05 SOCIAL / WORLD */}
+
+
+
+
+
+ {/* 06 GROWTH */}
+
+
+
+
-
+
- {/* Final CTA */}
-
-
-
-
- Ready to transform overwhelm into momentum?
-
-
- Join ambitious people who are building trust in themselves, one mission at a time.
-
-
-
- Join Waitlist
-
-
- Explore the future →
-
-
-
-
+ {/* 07 MANIFESTO */}
+
);
diff --git a/packages/frontend/src/app/(marketing)/waitlist/page.tsx b/packages/frontend/src/app/(marketing)/waitlist/page.tsx
index b0a2a6e..f3c75f7 100644
--- a/packages/frontend/src/app/(marketing)/waitlist/page.tsx
+++ b/packages/frontend/src/app/(marketing)/waitlist/page.tsx
@@ -1,263 +1,354 @@
-import Link from "next/link";
-import { CheckCircle2, Mail, Clock, Users } from "lucide-react";
+'use client';
+
+import React from 'react';
+import { motion } from 'framer-motion';
+import Link from 'next/link';
+import { ArrowRight, Sparkles, Users, Mail, CheckCircle2, Zap, Heart, Shield } from 'lucide-react';
export default function WaitlistPage() {
return (
-
- {/* Hero */}
-
-
-
-
- Join the Delight waitlist
+
+ {/* Hero Section */}
+
+
+
+
+
+
+
+
+ Join the Simulation
+
+
+
+ Enter
+ Early Access.
-
- Be among the first to experience an AI companion that truly
- understands your journey
+
+
+ Be among the first to experience an AI companion that
+ transforms ambition into narrative momentum.
-
+
- {/* Content */}
-
-
-
-
- {/* Left: Benefits */}
-
-
-
- What to expect
-
-
- When you join our waitlist, you're not just signing up
- for updates—you're expressing interest in helping shape
- a tool that could genuinely change how you approach your
- goals.
-
-
-
+ {/* Main Content */}
+
+
+
+ {/* Left: Benefits & Profile */}
+
+ {/* Benefits */}
+
+
+ What You'll Receive
+
-
+
-
-
- Early access to the beta
+
+ Early Beta Access
-
- Waitlist members get first access when we open up pilot
- cohorts. You'll be using Delight before the general
- public, helping us refine the experience.
+
+ Join pilot cohorts before public launch. Shape the product with your lived experience,
+ not abstract personas. Your journey informs our roadmap.
-
+
-
+
-
-
- Thoughtful, infrequent updates
+
+ Thoughtful Updates
-
- We'll send you meaningful progress updates when we
- hit major milestones. No daily spam. No sales pressure.
- Just honest communication about where we are.
+
+ No daily spam. No sales pressure. 1-2 meaningful emails per month when we hit milestones.
+ Honest communication about progress, setbacks, and learnings.
-
+
-
+
-
-
- Shape the product direction
+
+ Shape Direction
-
- Your feedback will directly influence features,
- priorities, and design decisions. We're building
- this for people like you—not for abstract personas.
+
+ Your feedback directly influences features, priorities, and design. The roadmap items
+ that ship will be the ones that resonate with early users like you.
-
-
-
-
-
- Who's the best fit for early access?
-
-
-
-
-
- Ambitious people who routinely set big goals but
- struggle with consistency
-
-
-
-
-
- Founders, students, or creators juggling multiple
- priorities
-
-
-
-
-
- People willing to give honest feedback, even when
- it's critical
-
-
-
-
-
- Anyone who believes productivity tools should understand
- emotion, not ignore it
-
-
-
+
- {/* Right: Form Embed */}
-
-
-
-
- Reserve your spot
-
-
- Takes less than a minute. We respect your inbox.
-
-
-
- {/* Google Form Embed */}
-
-
-
+ {/* Ideal User Profile */}
+
+
+ You're the ideal fit if...
+
+
+
+
+
+ You routinely set ambitious goals but struggle
+ with emotional friction and consistency
+
+
+
+
+
+ You're a founder, student, or creator juggling
+ multiple complex priorities simultaneously
+
+
+
+
+
+ You're willing to give honest, critical feedback —
+ even when it stings
+
+
+
+
+
+ You believe productivity tools should understand emotion ,
+ not ignore it
+
+
+
+
-
+ {/* Trust Indicators */}
+
- {/* FAQ */}
-
-
- Common questions
-
-
-
-
- When will the beta start?
+ {/* Right: Form Embed */}
+
+
+
+
+ Reserve Your Spot
-
- We're targeting early pilot cohorts in Q1 2026. The
- exact timing depends on core loop stability and memory
- system performance. Waitlist members will get at least 2
- weeks notice before their cohort starts.
+
+ Takes less than 60 seconds. We respect your inbox.
-
-
- How often will you email me?
-
-
- Very rarely. Expect 1-2 emails per month at most, and only
- when there's something meaningful to share— like beta
- access opening, major feature launches, or requests for
- specific feedback. You can unsubscribe anytime.
-
-
-
-
-
- Will early access be free?
-
-
- Yes. Pilot cohort members will have free access during the
- beta period. We're not asking you to pay for an
- unfinished product—we're asking you to invest time
- giving us feedback. That's valuable enough.
-
+ {/* Google Form Embed */}
+
+
-
-
- What if Delight doesn't work for me?
-
-
- That's important data. If the core loop doesn't
- resonate, if the narrative feels gimmicky, if the AI
- misunderstands you—we want to know. Critical feedback is
- more valuable than polite praise. Help us build something
- that actually works.
+
-
+
+
+
+
- {/* Bottom CTA */}
-
-
- Want to learn more before joining?
+ {/* FAQ Section */}
+
+
+
+ Common Questions
+
+
+
+
+ When will the beta start?
+
+
+ We're targeting early pilot cohorts in Q1 2026 .
+ Exact timing depends on core loop stability and memory system performance.
+ Waitlist members get 2+ weeks notice before their cohort starts.
-
-
- ← Explore the Product
-
-
- Read the Manifesto
-
-
- See the Future →
-
-
-
+
+
+
+
+ How often will you email me?
+
+
+ Very rarely. Expect 1-2 emails per month at most,
+ and only when there's something meaningful—beta access opening, major features, or
+ specific feedback requests. Unsubscribe anytime.
+
+
+
+
+
+ Will early access be free?
+
+
+ Yes. Pilot cohort members get free access during
+ the beta period. We're not asking you to pay for an unfinished product—we're asking
+ you to invest time giving feedback. That's valuable enough.
+
+
+
+
+
+ What if Delight doesn't work for me?
+
+
+ That's important data. If the core loop doesn't
+ resonate, if the narrative feels gimmicky, if the AI misunderstands you—we want to know.
+ Critical feedback is more valuable than polite praise.
+
+
+
+
+
+
+ {/* Bottom CTA */}
+
+
+
+
+ Want to learn more
+ before joining?
+
+
+ Explore the system, read the philosophy, or see what we're building next.
+
+
+
+
+
+ ← Explore the System
+
+
+ Read the Manifesto
+
+
+ See the Future
+
diff --git a/packages/frontend/src/app/(marketing)/why/page.tsx b/packages/frontend/src/app/(marketing)/why/page.tsx
index ba0257a..455c0e0 100644
--- a/packages/frontend/src/app/(marketing)/why/page.tsx
+++ b/packages/frontend/src/app/(marketing)/why/page.tsx
@@ -1,211 +1,274 @@
-import Link from "next/link";
+'use client';
+
+import React from 'react';
+import { motion } from 'framer-motion';
+import Link from 'next/link';
+import { ArrowRight, Sparkles, Shield, Zap, Heart } from 'lucide-react';
export default function WhyPage() {
return (
-
- {/* Hero */}
-
-
-
-
- Why we're building Delight
+
+ {/* Hero Section */}
+
+
+
+
+
+
+
+
+ The Manifesto
+
+
+
+ Why Delight
+ Exists.
-
- A manifesto for ambitious people who know what to do but can't seem to start
+
+
+ A declaration for ambitious people who know what to do
+ but can't seem to start.
-
+
- {/* Content */}
-
-
-
- {/* Section 1 */}
-
-
- Ambition is not the problem. Emotional friction is.
-
-
-
- You're ambitious. You set audacious goals. You know exactly what needs to be done.
- But after lunch, or after a break, starting feels impossible. Five minutes into focused work,
- you're checking another tab. Your to-do list sprawls across three tools, and the thought of
- prioritizing feels paralyzing.
-
-
- This isn't laziness. It's not a character flaw. It's emotional friction—the cognitive and
- affective resistance that emerges when stress, overwhelm, and context switching collide with
- complex goals. Your brain is protecting you from perceived threats. The problem is, your
- brain can't tell the difference between a difficult presentation and a physical danger.
-
-
- Traditional productivity tools don't address this. They focus on structure—lists, timers,
- Kanban boards—but ignore the emotional state that determines whether you can even engage
- with that structure. When you're overwhelmed, another task list isn't the answer.
- Understanding and acknowledgment is.
-
-
-
-
- {/* Pull Quote */}
-
- "The gap between knowing what to do and actually doing it is where most ambitious projects die."
-
-
- {/* Section 2 */}
-
-
- Why tools that ignore your state keep failing you
-
-
-
- DIY productivity systems—Notion templates, habit trackers, elaborate frameworks—demand constant
- maintenance exactly when you have the least bandwidth. They're built for the version of you
- who has energy to spare. When life gets chaotic, they collapse. You return three weeks later
- to a graveyard of abandoned boards and outdated goals.
-
-
- Generic AI assistants provide surface-level advice without understanding your unique context.
- They reset every conversation, forcing you to re-explain your situation repeatedly. They can't
- remember that three weeks ago you mentioned your fear of public speaking, or that last month
- you tried a similar approach and it didn't work.
-
-
- Professional coaching is effective but expensive—and unavailable during the exact moments of
- hesitation when you need support most. That 3pm slump where you're staring at your screen,
- knowing you should start the report but opening Twitter instead? Your coach isn't there.
- Your accountability buddy is in their own meeting. You're alone with your avoidance.
-
+ {/* Thesis Statements */}
+
+
+
+ {/* Statement 1 */}
+
+ 01
+
+
+ Ambition is not the problem.
+ Emotional friction is.
+
+
+
+ You're ambitious. You set audacious goals. You know exactly what needs to be done.
+ But after lunch, or after a break, starting feels impossible . Five minutes into focused work,
+ you're checking another tab.
+
+
+ This isn't laziness. It's not a character flaw. It's emotional friction —the cognitive and
+ affective resistance that emerges when stress, overwhelm, and context switching collide with
+ complex goals.
+
+
+ "Your brain can't tell the difference between a difficult presentation and a physical danger."
+
+
+ Traditional productivity tools don't address this. They focus on structure—lists, timers,
+ Kanban boards—but ignore the emotional state that determines whether you can even engage
+ with that structure.
+
+
-
-
- {/* Section 3 */}
-
-
- What it means to be a companion with memory
-
-
-
- Delight is designed to be what those other tools aren't: a companion that remembers. Not just
- your tasks, but your journey. Your values. Your fears. Your patterns. When you open Delight
- after a difficult week, it doesn't greet you with a generic "What can I do for you today?"
- It says: "You've been quiet. Last time we talked, you were stressed about the presentation.
- How did it go?"
-
-
- This memory system operates on three tiers. Personal memories capture long-term context:
- your career aspirations, your tendency to procrastinate on creative work, your preference for
- morning focus sessions. Project memories track each major goal's evolution: what you've tried,
- what worked, what obstacles emerged. Task memories ensure the AI understands specific mission
- details: that "finish design mockups" actually means three screens with interactive prototypes,
- not just static images.
-
-
- Over time, this creates a relationship that compounds in value. The companion doesn't just
- respond—it anticipates. It notices when you're slipping into old patterns. It remembers what
- helped you push through similar challenges before. It becomes more useful precisely when you
- need it most: when you're too overwhelmed to articulate what you need.
-
-
-
-
- {/* Pull Quote */}
-
- "Trust isn't built in a single conversation. It's built when someone remembers—and acts on—what you told them last time."
-
-
- {/* Section 4 */}
-
-
- Why we built a world instead of another list
-
-
-
- Productivity shouldn't feel like paperwork. For some people, tracking progress in a spreadsheet
- is satisfying. But for many ambitious people—especially those with ADHD tendencies or creative
- mindsets—lists feel lifeless. They don't inspire. They don't create meaning.
-
-
- This is why Delight includes a narrative layer. Your work unfolds in a living world that
- responds to your real-world progress. Complete missions to earn Essence (in-game currency),
- build relationships with AI characters, unlock new zones, and progress through story chapters.
- The narrative serves your real goals—"prepare for job interview" becomes "prove yourself to
- the Guild Council," making preparation feel like meaningful progression in a larger story.
-
-
- This isn't superficial gamification. Points and badges alone don't work—they feel hollow.
- But when your actual work drives a story that surprises you, when characters you've grown to
- care about acknowledge your effort, when the world visibly changes based on your consistency—
- that creates genuine motivation. It turns "I should work on this" into "I want to see what
- happens next."
-
+
+
+ {/* Statement 2 */}
+
+ 02
+
+
+ Tools that ignore your state
+ keep failing you.
+
+
+
+ DIY productivity systems demand constant maintenance exactly when you have the least bandwidth.
+ They're built for the version of you who has energy to spare. When life gets chaotic, they collapse .
+
+
+ Generic AI assistants provide surface-level advice without understanding your unique context.
+ They reset every conversation, forcing you to re-explain your situation repeatedly.
+
+
+ Professional coaching is effective but expensive—and unavailable during the exact moments of
+ hesitation when you need support most. That 3pm slump where you're staring at your screen?
+ Your coach isn't there.
+
+
-
-
- {/* Section 5 */}
-
-
- How we think about privacy, cost, and trust
-
-
-
- When you share your emotional state, your goals, and your struggles with a tool, you're
- extending enormous trust. We don't take that lightly.
-
-
- Privacy in Delight means transparency and control. Any context signals—like tab focus or
- activity patterns—are explicitly opt-in. You always know what we're tracking. You can review
- everything stored about you, revoke permissions anytime, and export your complete data on demand.
- Your emotional check-ins, goals, and progress belong to you. We're caretakers, not owners.
-
-
- Cost efficiency matters because it determines accessibility. We're targeting operational costs
- under $0.10 per user per day—using smart architecture choices like PostgreSQL with pgvector
- for unified storage, GPT-4o-mini for most interactions, and careful prompt engineering to
- minimize API calls. This isn't about maximizing profit margins. It's about building something
- that students, freelancers, and early-stage founders can actually afford.
-
-
- Trust is the only defensible moat for a companion. If we betray that trust—through dark patterns,
- surveillance, or exploitative pricing—we lose everything that makes Delight valuable. Your
- autonomy stays front and center. Always.
-
+
+
+ {/* Statement 3 */}
+
+ 03
+
+
+ A companion that
+ remembers.
+
+
+
+ Delight is designed to be what those other tools aren't: a companion that remembers. Not just
+ your tasks, but your journey. Your values. Your fears. Your patterns .
+
+
+ When you open Delight after a difficult week, it doesn't greet you with a generic "What can I do for you today?"
+ It says: "You've been quiet. Last time we talked, you were stressed about the presentation.
+ How did it go?"
+
+
+
+
+
Personal
+
Long-term context
+
+
+
Project
+
Goal evolution
+
+
+
Task
+
Mission details
+
+
+
+
+ "Trust isn't built in a single conversation. It's built when someone remembers what you told them last time."
+
+
-
-
- {/* Closing */}
-
-
-
- If this resonates—if you've felt the gap between ambition and execution, if you've wished for
- a tool that understands the emotional dimension of getting things done—you're exactly who we're
- building for.
-
-
- We're in early development. The core loop is taking shape. The memory system works. The narrative
- engine is generating personalized stories. But we need people willing to trust us with their goals
- and give honest feedback when something doesn't work.
-
+
+
+ {/* Statement 4 */}
+
+ 04
+
+
+ A world, not
+ another list.
+
+
+
+ Productivity shouldn't feel like paperwork. For many ambitious people—especially those with ADHD tendencies or creative
+ mindsets—lists feel lifeless . They don't inspire. They don't create meaning.
+
+
+ This is why Delight includes a narrative layer. Your work unfolds in a living world that
+ responds to your real-world progress. Complete missions, build relationships, unlock new zones .
+
+
+ This isn't superficial gamification. When your actual work drives a story that surprises you,
+ when characters you've grown to care about acknowledge your effort—that creates genuine motivation .
+ It turns "I should work on this" into "I want to see what happens next."
+
+
-
-
- Join the Waitlist
-
-
- ← Back to Product
-
+
+
+ {/* Statement 5 */}
+
+ 05
+
+
+ Privacy, cost,
+ and trust.
+
+
+
+ When you share your emotional state, your goals, and your struggles with a tool, you're
+ extending enormous trust . We don't take that lightly.
+
+
+
+
+
+
Privacy First
+
Opt-in tracking. Export anytime. You own your data.
+
+
+
+
Cost Efficient
+
<$0.10/user/day target. Accessible pricing.
+
+
+
+
Trust-Centered
+
Your autonomy stays front and center. Always.
+
+
+
+
+ Trust is the only defensible moat for a companion. If we betray that trust—through dark patterns,
+ surveillance, or exploitative pricing—we lose everything that makes Delight valuable.
+
+
-
+
-
+
+
+ {/* Closing CTA */}
+
+
+
+
+ If this resonates,
+ you're who we're building for.
+
+
+ We're in early development. The core loop is taking shape. The memory system works.
+ But we need people willing to trust us with their goals and give honest feedback.
+
+
+
+
+
+ Join the Waitlist
+
+
+ ← Explore the System
+
+
+
+
);
}
diff --git a/packages/frontend/src/app/dashboard/page.tsx b/packages/frontend/src/app/dashboard/page.tsx
index f8fb148..6204fd7 100644
--- a/packages/frontend/src/app/dashboard/page.tsx
+++ b/packages/frontend/src/app/dashboard/page.tsx
@@ -1,17 +1,14 @@
-import { UserButton } from "@clerk/nextjs";
-
// Force dynamic rendering to work with Clerk middleware (Next.js 15)
export const dynamic = "force-dynamic";
export default function DashboardPage() {
return (
-
+
@@ -44,6 +41,25 @@ export default function DashboardPage() {
+
+ {/* Link to experimental features */}
+
diff --git a/packages/frontend/src/app/experimental/page.tsx b/packages/frontend/src/app/experimental/page.tsx
new file mode 100644
index 0000000..e40ea2d
--- /dev/null
+++ b/packages/frontend/src/app/experimental/page.tsx
@@ -0,0 +1,303 @@
+"use client";
+
+import { useState } from "react";
+import { motion } from "framer-motion";
+import {
+ MessageSquare,
+ Brain,
+ BarChart3,
+ Settings,
+ Beaker,
+ Activity,
+ AlertTriangle,
+ Code,
+ Network,
+} from "lucide-react";
+import { ChatInterfaceWithSidebar } from "@/components/experimental/ChatInterfaceWithSidebar";
+import { MemoryVisualization } from "@/components/experimental/MemoryVisualization";
+import { MemoryGraph } from "@/components/experimental/MemoryGraph";
+import { AnalyticsDashboard } from "@/components/experimental/AnalyticsDashboard";
+import { ConfigurationPanel } from "@/components/experimental/ConfigurationPanel";
+import { UserSwitcher } from "@/components/experimental/UserSwitcher";
+import { useHealthCheck } from "@/lib/hooks/useExperimentalAPI";
+import { usePersistentUser } from "@/lib/hooks/usePersistentUser";
+
+// Force dynamic rendering
+export const dynamic = "force-dynamic";
+
+type TabType = "chat" | "memories" | "graph" | "analytics" | "config";
+
+export default function ExperimentalPage() {
+ const [activeTab, setActiveTab] = useState
("chat");
+ const { healthy, checking } = useHealthCheck();
+ const { userId, isLoading: userLoading } = usePersistentUser();
+
+ const handleUserChange = () => {
+ // Reload page to reinitialize with new user
+ window.location.reload();
+ };
+
+ return (
+
+ {/* Header with glassmorphism */}
+
+
+ {/* Tab Navigation */}
+
+
+
+ setActiveTab("chat")}
+ icon={ }
+ label="Chat"
+ />
+ setActiveTab("memories")}
+ icon={ }
+ label="Memories"
+ />
+ setActiveTab("graph")}
+ icon={ }
+ label="Graph"
+ />
+ setActiveTab("analytics")}
+ icon={ }
+ label="Analytics"
+ />
+ setActiveTab("config")}
+ icon={ }
+ label="Config"
+ />
+
+
+
+
+ {/* Main Content */}
+
+
+ {userLoading ? (
+
+
+ Loading user session...
+
+
+ ) : !userId ? (
+
+
+ Error: Could not initialize user session
+
+
+ ) : (
+
+ {activeTab === "chat" && (
+
+ )}
+ {activeTab === "memories" && (
+
+ )}
+ {activeTab === "graph" && (
+
+
+
+ )}
+ {activeTab === "analytics" && (
+
+ )}
+ {activeTab === "config" && }
+
+ )}
+
+
+
+ {/* Footer Info */}
+
+
+ );
+}
+
+// ============================================================================
+// Tab Button Component
+// ============================================================================
+
+function TabButton({
+ active,
+ onClick,
+ icon,
+ label,
+}: {
+ active: boolean;
+ onClick: () => void;
+ icon: React.ReactNode;
+ label: string;
+}) {
+ return (
+
+ {icon}
+ {label}
+ {active && (
+
+ )}
+
+ );
+}
diff --git a/packages/frontend/src/app/globals.css b/packages/frontend/src/app/globals.css
index 31f5c19..b06ef70 100644
--- a/packages/frontend/src/app/globals.css
+++ b/packages/frontend/src/app/globals.css
@@ -1,3 +1,7 @@
+/* Marketing Page Custom Font Imports - Must come first */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400;700&display=swap');
+
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -81,3 +85,88 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
}
+
+/* Marketing Page Custom Styles */
+
+/* Custom font utilities */
+.font-display {
+ font-family: 'Space Grotesk', sans-serif;
+}
+
+.font-signature {
+ font-family: 'Dancing Script', cursive;
+}
+
+/* Glass panel effect */
+.glass-panel {
+ background: rgba(20, 20, 20, 0.6);
+ backdrop-filter: blur(12px);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+/* 3D Perspective utilities */
+.perspective-1000 {
+ perspective: 1000px;
+}
+
+.preserve-3d {
+ transform-style: preserve-3d;
+}
+
+/* Custom animations */
+@keyframes pulse-slow {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes float {
+ 0%, 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-10px);
+ }
+}
+
+@keyframes spin-slow {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.animate-pulse-slow {
+ animation: pulse-slow 4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.animate-float {
+ animation: float 6s ease-in-out infinite;
+}
+
+.animate-spin-slow {
+ animation: spin-slow 20s linear infinite;
+}
+
+/* Custom scrollbar for marketing pages */
+::-webkit-scrollbar {
+ width: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: #0a0a0a;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #333;
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #555;
+}
diff --git a/packages/frontend/src/app/layout.tsx b/packages/frontend/src/app/layout.tsx
index 3cdeedc..1374113 100644
--- a/packages/frontend/src/app/layout.tsx
+++ b/packages/frontend/src/app/layout.tsx
@@ -1,8 +1,11 @@
import type { Metadata } from "next";
+// import { Inter } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import { MainNav } from "@/components/navigation/main-nav";
import "./globals.css";
+// const inter = Inter({ subsets: ["latin"] });
+
export const metadata: Metadata = {
title: "Delight - AI-Powered Self-Improvement Companion",
description:
@@ -12,6 +15,11 @@ export const metadata: Metadata = {
shortcut: "/favicon.ico",
apple: "/apple-touch-icon.png",
},
+ viewport: {
+ width: "device-width",
+ initialScale: 1,
+ maximumScale: 5, // Allow zoom for accessibility, but inputs are 16px+ to prevent auto-zoom
+ },
};
export default function RootLayout({
@@ -22,9 +30,9 @@ export default function RootLayout({
return (
-
+
- {children}
+ {children}
diff --git a/packages/frontend/src/components/experimental/AnalyticsDashboard.tsx b/packages/frontend/src/components/experimental/AnalyticsDashboard.tsx
new file mode 100644
index 0000000..410e673
--- /dev/null
+++ b/packages/frontend/src/components/experimental/AnalyticsDashboard.tsx
@@ -0,0 +1,341 @@
+/**
+ * Analytics Dashboard Component (Modernized)
+ *
+ * Dark theme analytics dashboard with:
+ * - Lucide React icons (no emojis)
+ * - Glass morphism effects
+ * - Memory statistics
+ * - Token usage and costs
+ * - Real-time updates
+ */
+
+"use client";
+
+import React from "react";
+import { motion } from "framer-motion";
+import {
+ Brain,
+ Target,
+ BarChart3,
+ DollarSign,
+ TrendingUp,
+ Database,
+ Zap,
+ Activity,
+ Loader2,
+ AlertTriangle,
+} from "lucide-react";
+import { useMemoryStats, useTokenUsage } from "@/lib/hooks/useExperimentalAPI";
+
+export function AnalyticsDashboard({ userId }: { userId: string }) {
+ const {
+ stats,
+ loading: statsLoading,
+ error: statsError,
+ } = useMemoryStats(userId, true, 10000);
+ const {
+ usage,
+ loading: usageLoading,
+ error: usageError,
+ } = useTokenUsage(24, userId, true, 10000);
+
+ const loading = statsLoading || usageLoading;
+ const error = statsError || usageError;
+
+ if (loading) {
+ return (
+
+
+
+ Loading analytics...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Error loading analytics
+
+
{error.message}
+
+ Make sure the experimental backend server is running on port 8001
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Analytics Dashboard
+
+
+ Memory stats and usage metrics
+
+
+
+
+
+ {/* Content */}
+
+ {/* Overview Cards */}
+
+ }
+ color="purple"
+ trend="+12%"
+ />
+ }
+ color="indigo"
+ />
+ }
+ color="cyan"
+ subtitle="Last 24h"
+ />
+ }
+ color="green"
+ subtitle="Last 24h"
+ />
+
+
+ {/* Memory Distribution */}
+
+ {/* By Type */}
+
+
+
+
+ Memory Distribution by Type
+
+
+ {stats?.by_type && Object.keys(stats.by_type).length > 0 ? (
+
+ {Object.entries(stats.by_type).map(([type, count], idx) => (
+
+
+
+ {count}
+
+
+ ))}
+
+ ) : (
+
No data available
+ )}
+
+
+ {/* By Category */}
+
+
+ {stats?.by_category && Object.keys(stats.by_category).length > 0 ? (
+
+ {Object.entries(stats.by_category)
+ .sort(([, a], [, b]) => (b as number) - (a as number))
+ .slice(0, 5)
+ .map(([category, count], idx) => (
+
+
+ {/* Progress bar */}
+
+
+
+
+ ))}
+
+ ) : (
+
No categories yet
+ )}
+
+
+
+ {/* Token Usage by Model */}
+ {usage?.by_model && Object.keys(usage.by_model).length > 0 && (
+
+
+
+
+ Token Usage by Model (Last 24 Hours)
+
+
+
+
+
+
+
+ Model
+
+
+ Tokens
+
+
+ Cost
+
+
+
+
+ {Object.entries(usage.by_model).map(([model, data], idx) => (
+
+
+ {model}
+
+
+ {data.tokens.toLocaleString()}
+
+
+ ${data.cost.toFixed(4)}
+
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Analytics Info */}
+
+
+
+
+
+ Real-Time Analytics Active
+
+
+ All data is now tracked in real-time from the database. Memory
+ stats and token usage are automatically updated with each
+ interaction.
+
+
+
+
+
+
+ );
+}
+
+// ============================================================================
+// Stat Card Component
+// ============================================================================
+
+function StatCard({
+ title,
+ value,
+ icon,
+ color,
+ trend,
+ subtitle,
+}: {
+ title: string;
+ value: string | number;
+ icon: React.ReactNode;
+ color: "purple" | "indigo" | "cyan" | "green";
+ trend?: string;
+ subtitle?: string;
+}) {
+ const colorClasses = {
+ purple: "from-purple-500 to-purple-600",
+ indigo: "from-indigo-500 to-indigo-600",
+ cyan: "from-cyan-500 to-cyan-600",
+ green: "from-green-500 to-green-600",
+ };
+
+ return (
+
+
+
+
{value}
+ {trend && (
+
+
+ {trend}
+
+ )}
+
+ {subtitle && {subtitle}
}
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/ChatInterface.tsx b/packages/frontend/src/components/experimental/ChatInterface.tsx
new file mode 100644
index 0000000..798bf96
--- /dev/null
+++ b/packages/frontend/src/components/experimental/ChatInterface.tsx
@@ -0,0 +1,613 @@
+/**
+ * Chat Interface Component (Modernized)
+ *
+ * Modern, minimalistic chat interface with:
+ * - Lucide React icons (no emojis)
+ * - Dark theme with glassmorphism
+ * - Framer Motion animations
+ * - Async memory processing indicator
+ * - Progress bars for relevance scores
+ */
+
+"use client";
+
+import React, { useState, useEffect, useRef } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ User,
+ Bot,
+ AlertCircle,
+ Send,
+ Brain,
+ Sparkles,
+ Loader2,
+ Database,
+ MessageSquarePlus,
+} from "lucide-react";
+import { SearchResult, Memory } from "@/lib/api/experimental-client";
+
+interface Message {
+ id: string;
+ role: "user" | "assistant" | "system";
+ content: string;
+ timestamp: Date;
+ memories_retrieved?: SearchResult[];
+ memories_created?: Memory[];
+ loading?: boolean;
+ processing_memories?: boolean; // For async memory processing
+}
+
+export function ChatInterface({ userId }: { userId: string }) {
+ const [messages, setMessages] = useState([
+ {
+ id: "0",
+ role: "system",
+ content:
+ "Welcome! I'm your AI companion with memory. Start chatting and I'll remember our conversations.",
+ timestamp: new Date(),
+ },
+ ]);
+ const [input, setInput] = useState("");
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [conversationId, setConversationId] = useState(null);
+ const [isLoadingConversation, setIsLoadingConversation] = useState(true);
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(null);
+
+ // Auto-scroll to bottom
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages]);
+
+ // Focus input on mount
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ // Load or create conversation on mount
+ useEffect(() => {
+ const loadConversation = async () => {
+ try {
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+
+ // Check if there's a conversation ID in localStorage for this user
+ const storageKey = `conversation_${userId}`;
+ const storedConversationId = localStorage.getItem(storageKey);
+
+ if (storedConversationId) {
+ // Try to load existing conversation
+ try {
+ const conversation = await experimentalAPI.getConversation(
+ storedConversationId
+ );
+
+ if (conversation.messages && conversation.messages.length > 0) {
+ // Load messages from database
+ const loadedMessages: Message[] = [
+ {
+ id: "0",
+ role: "system",
+ content: "Welcome back! Continuing your conversation...",
+ timestamp: new Date(),
+ },
+ ...conversation.messages.map((msg) => ({
+ id: msg.id,
+ role: msg.role,
+ content: msg.content,
+ timestamp: new Date(msg.created_at),
+ memories_retrieved: msg.metadata?.memories_retrieved,
+ memories_created: msg.metadata?.memories_created,
+ })),
+ ];
+
+ setMessages(loadedMessages);
+ setConversationId(conversation.id);
+ setIsLoadingConversation(false);
+ return;
+ } else {
+ // Conversation exists but has no messages, use it
+ setConversationId(conversation.id);
+ setIsLoadingConversation(false);
+ return;
+ }
+ } catch (error) {
+ console.warn(
+ "Failed to load conversation, creating new one:",
+ error
+ );
+ // Continue to create new conversation
+ }
+ }
+
+ // Create new conversation
+ const newConversation = await experimentalAPI.createConversation(
+ userId,
+ `Chat ${new Date().toLocaleDateString()}`
+ );
+ setConversationId(newConversation.id);
+ localStorage.setItem(storageKey, newConversation.id);
+ setIsLoadingConversation(false);
+ } catch (error) {
+ console.error("Failed to initialize conversation:", error);
+ setIsLoadingConversation(false);
+ }
+ };
+
+ loadConversation();
+ }, [userId]);
+
+ const handleSendMessage = async () => {
+ if (!input.trim() || isProcessing || !conversationId) return;
+
+ const userMessage: Message = {
+ id: Date.now().toString(),
+ role: "user",
+ content: input.trim(),
+ timestamp: new Date(),
+ };
+
+ setMessages((prev) => [...prev, userMessage]);
+ setInput("");
+ setIsProcessing(true);
+
+ // Add loading message
+ const loadingId = (Date.now() + 1).toString();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: loadingId,
+ role: "assistant",
+ content: "",
+ timestamp: new Date(),
+ loading: true,
+ },
+ ]);
+
+ try {
+ // Import the API client
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+
+ // Prepare conversation history
+ const conversationHistory = messages
+ .filter((m) => m.role !== "system")
+ .map((m) => ({
+ role: m.role,
+ content: m.content,
+ timestamp: m.timestamp.toISOString(),
+ }));
+
+ // Call the chat API with persistent user ID
+ const response = await experimentalAPI.sendChatMessage({
+ message: userMessage.content,
+ user_id: userId,
+ conversation_history: conversationHistory,
+ });
+
+ // Create assistant message from response
+ const assistantMessage: Message = {
+ id: (Date.now() + 2).toString(),
+ role: "assistant",
+ content: response.response,
+ timestamp: new Date(response.timestamp),
+ memories_retrieved: response.memories_retrieved.map((m) => ({
+ id: m.id,
+ content: m.content,
+ memory_type: m.memory_type,
+ score: m.score ?? 0,
+ metadata: { categories: m.categories || [] },
+ })) as SearchResult[],
+ memories_created: response.memories_created.map((m) => ({
+ id: m.id,
+ content: m.content,
+ memory_type: m.memory_type,
+ user_id: "current-user",
+ metadata: { categories: m.categories || [] },
+ created_at: new Date().toISOString(),
+ })),
+ };
+
+ setMessages((prev) =>
+ prev.filter((m) => m.id !== loadingId).concat(assistantMessage)
+ );
+
+ // Save both messages to database in the background
+ try {
+ // Save user message
+ await experimentalAPI.saveMessage(
+ conversationId,
+ userId,
+ "user",
+ userMessage.content
+ );
+
+ // Save assistant message with metadata
+ await experimentalAPI.saveMessage(
+ conversationId,
+ userId,
+ "assistant",
+ assistantMessage.content,
+ {
+ memories_retrieved: assistantMessage.memories_retrieved || [],
+ memories_created: assistantMessage.memories_created || [],
+ }
+ );
+ } catch (saveError) {
+ console.error("Failed to save messages to database:", saveError);
+ // Don't show error to user - messages are still in UI
+ }
+ } catch (error) {
+ console.error("Chat error:", error);
+
+ // Show error message
+ const errorMessage: Message = {
+ id: (Date.now() + 2).toString(),
+ role: "system",
+ content: `Error: ${
+ error instanceof Error
+ ? error.message
+ : "Failed to connect to backend"
+ }\n\nMake sure the experimental backend server is running on port 8001:\ncd packages/backend && poetry run python experiments/web/dashboard_server.py`,
+ timestamp: new Date(),
+ };
+
+ setMessages((prev) =>
+ prev.filter((m) => m.id !== loadingId).concat(errorMessage)
+ );
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSendMessage();
+ }
+ };
+
+ const handleNewChat = async () => {
+ if (
+ !confirm("Start a new conversation? Current conversation will be saved.")
+ )
+ return;
+
+ try {
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+
+ // Create new conversation
+ const newConversation = await experimentalAPI.createConversation(
+ userId,
+ `Chat ${new Date().toLocaleDateString()}`
+ );
+
+ // Update localStorage and state
+ const storageKey = `conversation_${userId}`;
+ localStorage.setItem(storageKey, newConversation.id);
+ setConversationId(newConversation.id);
+
+ // Reset messages
+ setMessages([
+ {
+ id: "0",
+ role: "system",
+ content:
+ "Welcome! I'm your AI companion with memory. Start chatting and I'll remember our conversations.",
+ timestamp: new Date(),
+ },
+ ]);
+ } catch (error) {
+ console.error("Failed to create new conversation:", error);
+ }
+ };
+
+ // Show loading state while conversation is being initialized
+ if (isLoadingConversation) {
+ return (
+
+
+
+
+
Loading conversation...
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header with glassmorphism */}
+
+
+
+
+
+
+
+
AI Companion
+
+ Powered by memory-augmented intelligence
+
+
+
+
+
+
+ New Chat
+
+
+ Session: {userId.slice(0, 8)}...
+
+
+
+
+
+ {/* Messages Area with custom scrollbar */}
+
+
+ {messages.map((message) => (
+
+ {/* Message Bubble */}
+
+
+
+ {/* Icon */}
+
+ {message.role === "user" ? (
+
+ ) : message.role === "system" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {message.loading ? (
+
+
+
+ Thinking...
+
+
+ ) : (
+
+ {message.content}
+
+ )}
+
+
+
+
+
+ {/* Retrieved Memories */}
+ {message.memories_retrieved &&
+ message.memories_retrieved.length > 0 && (
+
+
+
+
+ Context Retrieved ({message.memories_retrieved.length})
+
+
+
+ {message.memories_retrieved.map((memory, idx) => (
+
+
+ {/* Relevance Score with Progress Bar */}
+
+
+ {(memory.score * 100).toFixed(0)}%
+
+
+
+
+
+
+ {/* Memory Content */}
+
+
+ {memory.content}
+
+ {memory.metadata?.categories &&
+ memory.metadata.categories.length > 0 && (
+
+ {memory.metadata.categories
+ .slice(0, 3)
+ .map((cat: string, i: number) => (
+
+ {cat}
+
+ ))}
+
+ )}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Created Memories */}
+ {message.memories_created &&
+ message.memories_created.length > 0 && (
+
+
+
+
+ Knowledge Added ({message.memories_created.length})
+
+
+
+ {message.memories_created.map((memory, idx) => (
+
+
+
+
+ {memory.content}
+
+
+ {memory.metadata?.categories &&
+ memory.metadata.categories.length > 0 && (
+
+ {memory.metadata.categories
+ .slice(0, 3)
+ .map((cat: string, i: number) => (
+
+ {cat}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Async Memory Processing Indicator */}
+ {message.processing_memories && (
+
+
+
+ Processing memories in background...
+
+
+ )}
+
+ ))}
+
+
+
+
+ {/* Input Area */}
+
+
+ setInput(e.target.value)}
+ onKeyPress={handleKeyPress}
+ placeholder="Type your message..."
+ disabled={isProcessing}
+ className="flex-1 px-5 py-3 bg-slate-900/50 border border-slate-700/50 rounded-xl
+ text-slate-100 placeholder-slate-500
+ focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-transparent
+ disabled:opacity-50 disabled:cursor-not-allowed
+ transition-all"
+ />
+
+ {isProcessing ? (
+ <>
+
+ Sending...
+ >
+ ) : (
+ <>
+
+ Send
+ >
+ )}
+
+
+
+
+
+ I'll remember facts from our conversation and use them in
+ future responses
+
+
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/ChatInterfaceWithSidebar.tsx b/packages/frontend/src/components/experimental/ChatInterfaceWithSidebar.tsx
new file mode 100644
index 0000000..b1d3d3d
--- /dev/null
+++ b/packages/frontend/src/components/experimental/ChatInterfaceWithSidebar.tsx
@@ -0,0 +1,912 @@
+/**
+ * Chat Interface with Sidebar
+ *
+ * Comprehensive chat interface with:
+ * - Conversation list sidebar on the left
+ * - Real-time memory notifications on the right
+ * - Modern, futuristic, minimalistic design
+ * - No emojis, only Lucide icons
+ * - Warm glassmorphism effects
+ *
+ * FIXES APPLIED (2024):
+ * =====================
+ * 1. Dependency Loop Fix: Removed isCreatingConversation from useCallback deps
+ * - Prevents infinite re-renders and excessive API calls
+ * - Uses ref-based guard instead of state for initialization
+ *
+ * 2. Request Caching: Integrated API client caching
+ * - Clears cache after mutations (create/delete messages)
+ * - Prevents duplicate requests when components re-render
+ *
+ * 3. Mobile Optimizations: Fixed iOS zoom, scroll behavior, white background
+ * - Input font-size set to 16px to prevent iOS auto-zoom
+ * - Instant scroll on mobile for better UX
+ * - Proper backdrop blur to prevent white flashes
+ */
+
+"use client";
+
+import React, { useState, useEffect, useRef, useCallback } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ User,
+ Bot,
+ Send,
+ Brain,
+ Sparkles,
+ Loader2,
+ MessageSquarePlus,
+ Menu,
+ X,
+} from "lucide-react";
+import { SearchResult, Memory } from "@/lib/api/experimental-client";
+import { ConversationList } from "./ConversationList";
+import { MemoryNotifications } from "./MemoryNotifications";
+
+interface Message {
+ id: string;
+ role: "user" | "assistant" | "system";
+ content: string;
+ timestamp: Date;
+ memories_retrieved?: SearchResult[];
+ memories_created?: Memory[];
+ loading?: boolean;
+}
+
+export function ChatInterfaceWithSidebar({ userId }: { userId: string }) {
+ const [messages, setMessages] = useState([
+ {
+ id: "0",
+ role: "system",
+ content:
+ "Welcome to your AI-powered second brain. I remember everything we discuss.",
+ timestamp: new Date(),
+ },
+ ]);
+ const [input, setInput] = useState("");
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [isProcessingMemories, setIsProcessingMemories] = useState(false);
+ const [recentMemories, setRecentMemories] = useState([]);
+ const [conversationId, setConversationId] = useState(null);
+ const [refreshKey, setRefreshKey] = useState(0);
+ const [isCreatingConversation, setIsCreatingConversation] = useState(false);
+ const [showSidebar, setShowSidebar] = useState(false);
+ const [showMemoryPanel, setShowMemoryPanel] = useState(false);
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(null);
+ const hasInitialized = useRef(false); // Prevent duplicate initialization
+ const loadingConversationRef = useRef(null); // Track which conversation is being loaded
+
+ // Auto-scroll to bottom - use instant scroll on mobile for better UX
+ const scrollToBottom = useCallback(() => {
+ if (messagesEndRef.current) {
+ // Use instant scroll on mobile, smooth on desktop
+ const isMobile = window.innerWidth < 640;
+ messagesEndRef.current.scrollIntoView({
+ behavior: isMobile ? "auto" : "smooth",
+ block: "nearest",
+ });
+ }
+ }, []);
+
+ useEffect(() => {
+ // Use requestAnimationFrame to ensure DOM is updated before scrolling
+ requestAnimationFrame(() => {
+ scrollToBottom();
+ });
+ }, [messages, scrollToBottom]);
+
+ // Focus input on mount (but not on mobile to avoid keyboard issues)
+ useEffect(() => {
+ // Only auto-focus on desktop to avoid mobile keyboard issues
+ if (window.innerWidth >= 640) {
+ inputRef.current?.focus();
+ }
+ }, []);
+
+ /**
+ * FIXED: Dependency Loop Issue
+ * =============================
+ * Previously, loadOrCreateConversation depended on isCreatingConversation state,
+ * which caused the callback to be recreated every time the state changed,
+ * triggering the useEffect again → infinite loop of API calls.
+ *
+ * Solution: Use ref (hasInitialized) instead of state for guard, and remove
+ * isCreatingConversation from useCallback dependencies. This prevents the
+ * callback from being recreated unnecessarily.
+ */
+ const loadOrCreateConversation = useCallback(async () => {
+ // Guard against concurrent calls using ref instead of state
+ if (hasInitialized.current === false) {
+ console.log("⏳ Already creating/loading conversation, skipping...");
+ return;
+ }
+
+ // Mark as in progress
+ hasInitialized.current = false;
+
+ try {
+ setIsCreatingConversation(true);
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+
+ // Check if there's a conversation ID in localStorage for this user
+ const storageKey = `conversation_${userId}`;
+ const storedConversationId = localStorage.getItem(storageKey);
+
+ console.log(
+ `🔍 Checking for existing conversation: ${storedConversationId}`
+ );
+
+ if (storedConversationId) {
+ // Try to load existing conversation
+ try {
+ const conversation = await experimentalAPI.getConversation(
+ storedConversationId,
+ true // Use cache
+ );
+
+ console.log(
+ `✅ Loaded conversation ${conversation.id} with ${conversation.message_count} messages`
+ );
+
+ if (conversation.messages && conversation.messages.length > 0) {
+ // Load messages from database
+ const loadedMessages: Message[] = [
+ {
+ id: "0",
+ role: "system",
+ content: "Conversation restored. Ready to continue.",
+ timestamp: new Date(),
+ },
+ ...conversation.messages.map((msg) => ({
+ id: msg.id,
+ role: msg.role,
+ content: msg.content,
+ timestamp: new Date(msg.created_at),
+ memories_retrieved: msg.metadata?.memories_retrieved,
+ memories_created: msg.metadata?.memories_created,
+ })),
+ ];
+
+ setMessages(loadedMessages);
+ setConversationId(conversation.id);
+ hasInitialized.current = true; // Mark as complete
+ return;
+ }
+
+ // Conversation exists but has no messages - use it
+ setConversationId(conversation.id);
+ console.log(
+ `✅ Using existing empty conversation ${conversation.id}`
+ );
+ hasInitialized.current = true; // Mark as complete
+ return;
+ } catch (error) {
+ console.warn(
+ "⚠️ Failed to load stored conversation, will create new one:",
+ error
+ );
+ // Clear invalid conversation ID from storage
+ localStorage.removeItem(storageKey);
+ }
+ }
+
+ // Create new conversation only if we don't have one
+ console.log("➕ Creating new conversation...");
+ const newConversation = await experimentalAPI.createConversation(
+ userId,
+ `Chat ${new Date().toLocaleDateString()}`
+ );
+ setConversationId(newConversation.id);
+ localStorage.setItem(storageKey, newConversation.id);
+ // Clear cache and refresh conversation list
+ experimentalAPI.clearCache();
+ setRefreshKey((prev) => prev + 1); // Trigger conversation list refresh
+ console.log(`✅ Created new conversation ${newConversation.id}`);
+ hasInitialized.current = true; // Mark as complete
+ } catch (error) {
+ console.error("❌ Failed to initialize conversation:", error);
+ hasInitialized.current = true; // Reset on error so it can retry
+ } finally {
+ setIsCreatingConversation(false);
+ }
+ }, [userId]); // Remove isCreatingConversation from dependencies
+
+ // Load or create conversation on mount (with duplicate prevention)
+ /**
+ * FIXED: Removed loadOrCreateConversation from dependencies
+ * =========================================================
+ * Including the callback in dependencies caused re-runs when it was recreated.
+ * Since we use hasInitialized ref to prevent duplicates, we only need to
+ * depend on userId. The eslint-disable comment is intentional.
+ */
+ useEffect(() => {
+ // Only initialize once per userId
+ if (hasInitialized.current) {
+ loadOrCreateConversation();
+ }
+
+ // Cleanup on unmount or userId change
+ return () => {
+ hasInitialized.current = false;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [userId]); // Only depend on userId, not loadOrCreateConversation to avoid loops
+
+ const handleConversationSelect = async (newConversationId: string) => {
+ // Prevent loading the same conversation that's already loaded
+ if (conversationId === newConversationId) {
+ console.log(
+ `⏭️ Conversation ${newConversationId} already loaded, skipping...`
+ );
+ return;
+ }
+
+ // Prevent loading if we're already loading this conversation
+ if (loadingConversationRef.current === newConversationId) {
+ console.log(
+ `⏳ Conversation ${newConversationId} already loading, skipping...`
+ );
+ return;
+ }
+
+ try {
+ loadingConversationRef.current = newConversationId;
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ const conversation = await experimentalAPI.getConversation(
+ newConversationId,
+ true // Use cache
+ );
+
+ const loadedMessages: Message[] = [
+ {
+ id: "0",
+ role: "system",
+ content: "Conversation loaded.",
+ timestamp: new Date(),
+ },
+ ...(conversation.messages || []).map((msg) => ({
+ id: msg.id,
+ role: msg.role,
+ content: msg.content,
+ timestamp: new Date(msg.created_at),
+ memories_retrieved: msg.metadata?.memories_retrieved,
+ memories_created: msg.metadata?.memories_created,
+ })),
+ ];
+
+ setMessages(loadedMessages);
+ setConversationId(newConversationId);
+ localStorage.setItem(`conversation_${userId}`, newConversationId);
+ loadingConversationRef.current = null; // Clear loading flag
+ } catch (error) {
+ console.error("Failed to load conversation:", error);
+ loadingConversationRef.current = null; // Clear loading flag on error
+ }
+ };
+
+ // Poll for newly created memories (created in background)
+ const pollForNewMemories = async (userId: string) => {
+ let pollCount = 0;
+ const maxPolls = 6; // Poll for 30 seconds (6 polls * 5 seconds)
+ const pollInterval = 5000; // Poll every 5 seconds instead of 2
+ let lastMemoryIds = new Set(); // Track memory IDs instead of count
+
+ const poll = async () => {
+ try {
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ const memories = await experimentalAPI.getMemories({
+ user_id: userId,
+ limit: 20,
+ });
+
+ // Find truly new memories by comparing IDs
+ const newMemories = memories.filter((m) => !lastMemoryIds.has(m.id));
+
+ if (newMemories.length > 0) {
+ console.log(
+ `📥 Detected ${newMemories.length} new memories, adding incrementally...`
+ );
+
+ // Add memories one at a time with animation delay
+ for (let i = 0; i < newMemories.length; i++) {
+ const mem = newMemories[i];
+ setTimeout(() => {
+ setRecentMemories((prev) => {
+ // Avoid duplicates
+ if (prev.find((m) => m.id === mem.id)) return prev;
+ console.log(
+ `✨ Adding memory: ${mem.content.substring(0, 50)}...`
+ );
+ return [mem, ...prev]; // Add to beginning for newest-first display
+ });
+ }, i * 500); // 500ms delay between each memory for smooth animation
+ }
+
+ // Update tracking set
+ memories.forEach((m) => lastMemoryIds.add(m.id));
+
+ // Calculate notification duration based on number of memories
+ // Base: 5 seconds, +2 seconds per memory, max 15 seconds
+ const notificationDuration = Math.min(
+ 5000 + newMemories.length * 2000,
+ 15000
+ );
+
+ // Stop processing indicator after all memories are added
+ setTimeout(() => {
+ setIsProcessingMemories(false);
+ }, newMemories.length * 500);
+
+ // Hide notification after appropriate duration
+ setTimeout(() => {
+ setRecentMemories([]);
+ }, notificationDuration);
+
+ // Continue polling in case more memories are created
+ pollCount++;
+ if (pollCount < maxPolls) {
+ setTimeout(poll, pollInterval);
+ } else {
+ // Stop polling after max attempts
+ console.log("⏹️ Stopped polling for memories (timeout)");
+ setIsProcessingMemories(false);
+ }
+ } else {
+ // No new memories yet, continue polling
+ pollCount++;
+ if (pollCount < maxPolls) {
+ setTimeout(poll, pollInterval);
+ } else {
+ // Stop polling after max attempts
+ console.log("⏹️ Stopped polling for memories (timeout)");
+ setIsProcessingMemories(false);
+ }
+ }
+ } catch (error) {
+ console.error("Error polling for memories:", error);
+ setIsProcessingMemories(false);
+ }
+ };
+
+ // Get initial memory IDs
+ try {
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ const initialMemories = await experimentalAPI.getMemories({
+ user_id: userId,
+ limit: 20,
+ });
+ initialMemories.forEach((m) => lastMemoryIds.add(m.id));
+ console.log(`📊 Initial memory count: ${initialMemories.length}`);
+ } catch (error) {
+ console.error("Error getting initial memories:", error);
+ }
+
+ // Start polling after 2 seconds (give background task time to start)
+ setTimeout(poll, 2000);
+ };
+
+ const handleNewConversation = async () => {
+ // Guard against concurrent creation
+ if (isCreatingConversation) {
+ console.log("⏳ Already creating conversation, skipping...");
+ return;
+ }
+
+ try {
+ setIsCreatingConversation(true);
+ console.log("➕ Creating new conversation (manual)...");
+
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ const newConversation = await experimentalAPI.createConversation(
+ userId,
+ `Chat ${new Date().toLocaleDateString()} ${new Date().toLocaleTimeString()}`
+ );
+
+ console.log(`✅ Created new conversation ${newConversation.id}`);
+
+ setConversationId(newConversation.id);
+ localStorage.setItem(`conversation_${userId}`, newConversation.id);
+ // Clear cache and refresh conversation list
+ experimentalAPI.clearCache();
+ setMessages([
+ {
+ id: "0",
+ role: "system",
+ content: "New conversation started.",
+ timestamp: new Date(),
+ },
+ ]);
+ setRefreshKey((prev) => prev + 1); // Trigger conversation list refresh
+ } catch (error) {
+ console.error("❌ Failed to create conversation:", error);
+ alert("Failed to create new conversation. Please try again.");
+ } finally {
+ setIsCreatingConversation(false);
+ }
+ };
+
+ const handleSendMessage = async () => {
+ if (!input.trim() || !conversationId) return;
+
+ const userMessage: Message = {
+ id: `temp-${Date.now()}`,
+ role: "user",
+ content: input,
+ timestamp: new Date(),
+ };
+
+ const loadingMessage: Message = {
+ id: `loading-${Date.now()}`,
+ role: "assistant",
+ content: "",
+ timestamp: new Date(),
+ loading: true,
+ };
+
+ // Optimistically add messages immediately
+ setMessages((prev) => [...prev, userMessage, loadingMessage]);
+ const currentInput = input;
+ setInput("");
+ setIsProcessing(true);
+
+ // Scroll to bottom immediately after state update
+ // Use double RAF to ensure DOM is fully updated
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ scrollToBottom();
+ });
+ });
+
+ try {
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+
+ // Send message to backend
+ const response = await experimentalAPI.sendChatMessage({
+ message: currentInput, // Use the captured input value
+ user_id: userId,
+ conversation_history: messages
+ .filter(
+ (m) => m.role !== "system" && !m.loading && m.id !== userMessage.id
+ )
+ .map((m) => ({
+ role: m.role,
+ content: m.content,
+ timestamp: m.timestamp.toISOString(),
+ })),
+ });
+
+ // Update messages with response
+ setMessages((prev) =>
+ prev
+ .filter((m) => m.id !== loadingMessage.id)
+ .concat([
+ {
+ id: `assistant-${Date.now()}`,
+ role: "assistant",
+ content: response.response,
+ timestamp: new Date(response.timestamp),
+ memories_retrieved: response.memories_retrieved.map((m) => ({
+ id: m.id,
+ content: m.content,
+ memory_type: m.memory_type,
+ score: m.score || 0,
+ metadata: { categories: m.categories || [] },
+ })),
+ memories_created: response.memories_created.map((m) => ({
+ id: m.id,
+ content: m.content,
+ memory_type: m.memory_type,
+ user_id: "current-user",
+ metadata: { categories: m.categories || [] },
+ created_at: new Date().toISOString(),
+ })),
+ },
+ ])
+ );
+
+ // Scroll to bottom after response is added
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ scrollToBottom();
+ });
+ });
+
+ // Show memory processing notification
+ if (response.memories_created && response.memories_created.length > 0) {
+ console.log(
+ `📥 Received ${response.memories_created.length} memories in response, adding incrementally...`
+ );
+ setIsProcessingMemories(true);
+
+ // Add memories one at a time for transparency
+ response.memories_created.forEach((mem, index) => {
+ setTimeout(() => {
+ setRecentMemories((prev) => {
+ // Avoid duplicates
+ if (prev.find((m) => m.id === mem.id)) return prev;
+ console.log(
+ `✨ Adding memory ${index + 1}/${
+ response.memories_created.length
+ }: ${mem.content.substring(0, 50)}...`
+ );
+ const fullMemory = {
+ id: mem.id,
+ content: mem.content,
+ memory_type: mem.memory_type,
+ user_id: "current-user",
+ metadata: { categories: mem.categories || [] },
+ created_at: new Date().toISOString(),
+ };
+ return [fullMemory, ...prev]; // Add to beginning for newest-first display
+ });
+ }, index * 500); // 500ms delay between each memory
+ });
+
+ // Stop processing indicator after all memories are added
+ const addDuration = response.memories_created.length * 500;
+ setTimeout(() => {
+ setIsProcessingMemories(false);
+ }, addDuration);
+
+ // Calculate notification duration: base 5s + 2s per memory, max 15s
+ const notificationDuration = Math.min(
+ 5000 + response.memories_created.length * 2000,
+ 15000
+ );
+ setTimeout(() => {
+ setRecentMemories([]);
+ }, addDuration + notificationDuration);
+ } else {
+ // Memories are created in background - poll for them
+ console.log(
+ "📋 Memories being created in background, starting polling..."
+ );
+ setIsProcessingMemories(true);
+ pollForNewMemories(userId);
+ }
+
+ // Save both messages to database
+ try {
+ await experimentalAPI.saveMessage(
+ conversationId,
+ userId,
+ "user",
+ userMessage.content
+ );
+ await experimentalAPI.saveMessage(
+ conversationId,
+ userId,
+ "assistant",
+ response.response,
+ {
+ memories_retrieved: response.memories_retrieved.map((m) => ({
+ id: m.id,
+ content: m.content,
+ memory_type: m.memory_type,
+ score: m.score || 0,
+ metadata: { categories: m.categories || [] },
+ })),
+ memories_created: response.memories_created.map((m) => ({
+ id: m.id,
+ content: m.content,
+ memory_type: m.memory_type,
+ user_id: "current-user",
+ metadata: { categories: m.categories || [] },
+ created_at: new Date().toISOString(),
+ })),
+ }
+ );
+ // Clear cache for this conversation to ensure fresh data
+ experimentalAPI.clearCache(`/api/conversations/${conversationId}`);
+ } catch (saveError) {
+ console.error("Failed to save messages:", saveError);
+ }
+ } catch (error: any) {
+ console.error("Failed to send message:", error);
+
+ // Show error message
+ setMessages((prev) =>
+ prev
+ .filter((m) => m.id !== loadingMessage.id)
+ .concat([
+ {
+ id: `error-${Date.now()}`,
+ role: "system",
+ content: `Error: ${
+ error.message || "Failed to send message. Please try again."
+ }`,
+ timestamp: new Date(),
+ },
+ ])
+ );
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSendMessage();
+ }
+ };
+
+ return (
+
+ {/* Mobile Sidebar Overlay */}
+ {showSidebar && (
+
setShowSidebar(false)}
+ />
+ )}
+
+ {/* Left Sidebar - Conversation List */}
+
+
+ {
+ handleConversationSelect(id);
+ setShowSidebar(false);
+ }}
+ onNewConversation={() => {
+ handleNewConversation();
+ setShowSidebar(false);
+ }}
+ refreshTrigger={refreshKey}
+ />
+
+
+
+ {/* Main Chat Area */}
+
+ {/* Chat Header */}
+
+
+
+
setShowSidebar(!showSidebar)}
+ className="sm:hidden p-1.5 hover:bg-slate-800/50 rounded-lg transition-all"
+ >
+
+
+
+
+
+
+
+ AI Second Brain
+
+
+ Memory-augmented chat
+
+
+
+
+
+ setShowMemoryPanel(!showMemoryPanel)}
+ className="sm:hidden p-1.5 hover:bg-slate-800/50 rounded-lg transition-all relative"
+ >
+
+ {(isProcessingMemories || recentMemories.length > 0) && (
+
+ )}
+
+
+
+ New Chat
+ New
+
+
+
+
+
+ {/* Messages Area */}
+
+
+ {messages.map((message) => (
+
+ ))}
+
+
+
+
+ {/* Input Area */}
+
+
+ setInput(e.target.value)}
+ onKeyPress={handleKeyPress}
+ placeholder="Type your message..."
+ disabled={isProcessing}
+ // Prevent iOS zoom by ensuring font-size is at least 16px on mobile
+ className="flex-1 px-3 sm:px-4 py-2 sm:py-3 text-base sm:text-base bg-slate-800/50 border border-slate-700/50 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-purple-500/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontSize: "16px" }} // Force 16px to prevent iOS zoom
+ />
+
+ {isProcessing ? (
+ <>
+
+ Sending...
+ >
+ ) : (
+ <>
+
+ Send
+ >
+ )}
+
+
+
+
+
+ {/* Right Side - Memory Notifications (Desktop) */}
+
+ {(isProcessingMemories || recentMemories.length > 0) && (
+ <>
+ {/* Desktop */}
+
+
+
+ {/* Mobile - Overlay */}
+ {showMemoryPanel && (
+ <>
+ setShowMemoryPanel(false)}
+ />
+
+
+
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
+
+// ============================================================================
+// Chat Message Component
+// ============================================================================
+
+function ChatMessage({ message }: { message: Message }) {
+ const isUser = message.role === "user";
+ const isSystem = message.role === "system";
+
+ if (isSystem) {
+ return (
+
+
+ {message.content}
+
+
+ );
+ }
+
+ return (
+
+ {/* Avatar */}
+
+ {isUser ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Message Content */}
+
+ {message.loading ? (
+
+
+
+ ) : (
+ <>
+
+
+ {message.content}
+
+
+
+ {/* Memory Indicators */}
+ {!isUser &&
+ (message.memories_retrieved || message.memories_created) && (
+
+ {message.memories_retrieved &&
+ message.memories_retrieved.length > 0 && (
+
+
+
+ {message.memories_retrieved.length} recalled
+
+
+ )}
+ {message.memories_created &&
+ message.memories_created.length > 0 && (
+
+
+ {message.memories_created.length} saved
+
+ )}
+
+ )}
+ >
+ )}
+
+
+ {message.timestamp.toLocaleTimeString()}
+
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/CleanupPanel.tsx b/packages/frontend/src/components/experimental/CleanupPanel.tsx
new file mode 100644
index 0000000..b572b8d
--- /dev/null
+++ b/packages/frontend/src/components/experimental/CleanupPanel.tsx
@@ -0,0 +1,297 @@
+/**
+ * Memory Cleanup Panel
+ *
+ * Analyzes and cleans up problematic memories:
+ * - Questions, vague statements, duplicates, trivial memories
+ * - Shows analysis report before cleanup
+ * - Executes cleanup and refreshes memory list
+ */
+
+'use client';
+
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ AlertTriangle,
+ Trash2,
+ RefreshCw,
+ CheckCircle,
+ XCircle,
+ Loader2,
+} from 'lucide-react';
+
+interface CleanupPanelProps {
+ userId: string;
+ onCleanupComplete: () => void;
+}
+
+interface AnalysisReport {
+ total_memories: number;
+ issues_found: number;
+ breakdown: {
+ questions: number;
+ vague: number;
+ duplicates: number;
+ trivial: number;
+ no_embedding: number;
+ };
+ recommendations: string[];
+}
+
+export function CleanupPanel({ userId, onCleanupComplete }: CleanupPanelProps) {
+ const [analyzing, setAnalyzing] = useState(false);
+ const [cleaning, setCleaning] = useState(false);
+ const [analysis, setAnalysis] = useState(null);
+ const [cleanupResult, setCleanupResult] = useState<{deleted_count: number} | null>(null);
+ const [error, setError] = useState(null);
+
+ const runAnalysis = async () => {
+ try {
+ setAnalyzing(true);
+ setError(null);
+
+ const response = await fetch(`http://localhost:8001/api/cleanup/analyze?user_id=${userId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Analysis failed: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ setAnalysis(data);
+ } catch (err: any) {
+ setError(err.message || 'Failed to analyze memories');
+ } finally {
+ setAnalyzing(false);
+ }
+ };
+
+ const executeCleanup = async () => {
+ if (!confirm(`Delete ${analysis?.issues_found || 0} problematic memories? This cannot be undone.`)) {
+ return;
+ }
+
+ try {
+ setCleaning(true);
+ setError(null);
+
+ const response = await fetch('http://localhost:8001/api/cleanup/execute', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ user_id: userId,
+ delete_questions: true,
+ delete_vague: true,
+ delete_duplicates: true,
+ delete_trivial: false, // User might want to keep some
+ similarity_threshold: 0.85,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Cleanup failed: ${response.statusText}`);
+ }
+
+ const result = await response.json();
+ setCleanupResult(result);
+
+ // Refresh memory list
+ setTimeout(() => {
+ onCleanupComplete();
+ }, 1000);
+ } catch (err: any) {
+ setError(err.message || 'Failed to execute cleanup');
+ } finally {
+ setCleaning(false);
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
Memory Cleanup
+
+ Identify and remove problematic memories
+
+
+
+
+ {!analysis && (
+
+ {analyzing ? (
+ <>
+
+ Analyzing...
+ >
+ ) : (
+ <>
+
+ Analyze Memories
+ >
+ )}
+
+ )}
+
+
+ {/* Error */}
+ {error && (
+
+ )}
+
+ {/* Analysis Report */}
+
+ {analysis && (
+
+ {/* Summary Stats */}
+
+
+
Total Memories
+
{analysis.total_memories}
+
+
+
Issues Found
+
{analysis.issues_found}
+
+
+
Will Remain
+
+ {analysis.total_memories - analysis.issues_found}
+
+
+
+
Reduction
+
+ {((analysis.issues_found / analysis.total_memories) * 100).toFixed(0)}%
+
+
+
+
+ {/* Breakdown */}
+
+
Issues Breakdown
+
+ {analysis.breakdown.questions > 0 && (
+
+ ❓ Questions
+ {analysis.breakdown.questions}
+
+ )}
+ {analysis.breakdown.vague > 0 && (
+
+ 💭 Vague Statements
+ {analysis.breakdown.vague}
+
+ )}
+ {analysis.breakdown.duplicates > 0 && (
+
+ 🔄 Duplicates
+ {analysis.breakdown.duplicates}
+
+ )}
+ {analysis.breakdown.trivial > 0 && (
+
+ 🗑️ Trivial
+ {analysis.breakdown.trivial}
+
+ )}
+ {analysis.breakdown.no_embedding > 0 && (
+
+ 📊 No Embeddings
+ {analysis.breakdown.no_embedding}
+
+ )}
+
+
+
+ {/* Recommendations */}
+ {analysis.recommendations && analysis.recommendations.length > 0 && (
+
+
Recommendations
+
+ {analysis.recommendations.map((rec, i) => (
+
+ •
+ {rec}
+
+ ))}
+
+
+ )}
+
+ {/* Cleanup Result */}
+ {cleanupResult && (
+
+
+
+
Cleanup Complete!
+
+ Successfully deleted {cleanupResult.deleted_count} problematic memories
+
+
+
+ )}
+
+ {/* Actions */}
+ {!cleanupResult && analysis.issues_found > 0 && (
+
+
+ {cleaning ? (
+ <>
+
+ Cleaning...
+ >
+ ) : (
+ <>
+
+ Delete {analysis.issues_found} Problematic Memories
+ >
+ )}
+
+
+
+ Re-analyze
+
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/ConfigurationPanel.tsx b/packages/frontend/src/components/experimental/ConfigurationPanel.tsx
new file mode 100644
index 0000000..f265ff3
--- /dev/null
+++ b/packages/frontend/src/components/experimental/ConfigurationPanel.tsx
@@ -0,0 +1,511 @@
+/**
+ * Configuration Panel Component (Dark Mode)
+ *
+ * Modern dark-themed configuration interface for AI models and system parameters
+ */
+
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { motion } from "framer-motion";
+import {
+ Save,
+ Bot,
+ Search,
+ FileText,
+ CheckCircle2,
+ XCircle,
+ Loader2,
+ AlertCircle,
+ Settings2,
+} from "lucide-react";
+import { useConfig } from "@/lib/hooks/useExperimentalAPI";
+import { SystemConfig } from "@/lib/api/experimental-client";
+
+export function ConfigurationPanel() {
+ const { config, loading, error, saving, updateConfig } = useConfig();
+ const [localConfig, setLocalConfig] = useState(null);
+ const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">(
+ "idle"
+ );
+
+ useEffect(() => {
+ if (config) {
+ setLocalConfig(config);
+ }
+ }, [config]);
+
+ const handleSave = async () => {
+ if (!localConfig) return;
+
+ try {
+ await updateConfig(localConfig);
+ setSaveStatus("success");
+ setTimeout(() => setSaveStatus("idle"), 3000);
+ } catch (err) {
+ setSaveStatus("error");
+ setTimeout(() => setSaveStatus("idle"), 3000);
+ }
+ };
+
+ if (loading || !localConfig) {
+ return (
+
+
+
Loading configuration...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Error loading configuration
+
+
{error.message}
+
+ Make sure the experimental backend server is running on port 8001
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ System Configuration
+
+
+ Configure AI models and system parameters
+
+
+
+
+ {saving ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ <>
+
+ Save Changes
+ >
+ )}
+
+
+
+ {/* Save Status */}
+ {saveStatus !== "idle" && (
+
+ {saveStatus === "success" ? (
+ <>
+
+ Configuration saved successfully
+ >
+ ) : (
+ <>
+
+ Failed to save configuration
+ >
+ )}
+
+ )}
+
+
+ {/* Model Configuration */}
+
+
+
+
+ AI Models
+
+
+
+
+
+
+ Chat Model
+
+
+ setLocalConfig({
+ ...localConfig,
+ models: { ...localConfig.models, chat_model: e.target.value },
+ })
+ }
+ className="w-full px-4 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ >
+ GPT-4o Mini (Fast & Cheap)
+ GPT-4o (Balanced)
+ GPT-4 Turbo
+ GPT-3.5 Turbo
+
+
+ Used for general chat and fact extraction
+
+
+
+
+
+ Reasoning Model
+
+
+ setLocalConfig({
+ ...localConfig,
+ models: {
+ ...localConfig.models,
+ reasoning_model: e.target.value,
+ },
+ })
+ }
+ className="w-full px-4 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ >
+ O1 Preview (Best Reasoning)
+ O1 Mini
+ GPT-4o
+ GPT-4 Turbo
+
+
+ Used for complex tasks and planning
+
+
+
+
+
+ High-Quality Model
+
+
+ setLocalConfig({
+ ...localConfig,
+ models: {
+ ...localConfig.models,
+ expensive_model: e.target.value,
+ },
+ })
+ }
+ className="w-full px-4 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ >
+ GPT-4o
+ GPT-4 Turbo
+ O1 Preview
+
+
Used for premium outputs
+
+
+
+
+ Embedding Model
+
+
+ setLocalConfig({
+ ...localConfig,
+ models: {
+ ...localConfig.models,
+ embedding_model: e.target.value,
+ },
+ })
+ }
+ className="w-full px-4 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ >
+
+ Text Embedding 3 Small (Recommended)
+
+
+ Text Embedding 3 Large
+
+ Ada 002 (Legacy)
+
+
Used for semantic search
+
+
+
+
+ {/* Search Configuration */}
+
+
+
+
+ Search Parameters
+
+
+
+
+
+
+
+ Similarity Threshold
+
+
+ {localConfig.search.similarity_threshold.toFixed(2)}
+
+
+
+ setLocalConfig({
+ ...localConfig,
+ search: {
+ ...localConfig.search,
+ similarity_threshold: parseFloat(e.target.value),
+ },
+ })
+ }
+ className="w-full h-2 bg-slate-700/50 rounded-lg appearance-none cursor-pointer accent-purple-500"
+ />
+
+ Higher = more strict matching. Lower = more results.
+
+
+
+
+
+ Default Search Limit
+
+
+ setLocalConfig({
+ ...localConfig,
+ search: {
+ ...localConfig.search,
+ default_search_limit: parseInt(e.target.value),
+ },
+ })
+ }
+ className="w-full px-4 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ />
+
+
+
+
+
+ Vector Search Weight
+
+
+ {localConfig.search.hybrid_search_weight_vector.toFixed(2)}
+
+
+
+ setLocalConfig({
+ ...localConfig,
+ search: {
+ ...localConfig.search,
+ hybrid_search_weight_vector: parseFloat(e.target.value),
+ },
+ })
+ }
+ className="w-full h-2 bg-slate-700/50 rounded-lg appearance-none cursor-pointer accent-purple-500"
+ />
+
+ Weight for vector search vs keyword search
+
+
+
+
+
+ {/* Fact Extraction Configuration */}
+
+
+
+
+ Fact Extraction
+
+
+
+
+
+
+ Max Facts Per Message
+
+
+ setLocalConfig({
+ ...localConfig,
+ fact_extraction: {
+ ...localConfig.fact_extraction,
+ max_facts_per_message: parseInt(e.target.value),
+ },
+ })
+ }
+ className="w-full px-4 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ />
+
+
+
+
+ Minimum Fact Length (characters)
+
+
+ setLocalConfig({
+ ...localConfig,
+ fact_extraction: {
+ ...localConfig.fact_extraction,
+ min_fact_length: parseInt(e.target.value),
+ },
+ })
+ }
+ className="w-full px-4 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ />
+
+
+
+
+
+ Auto-Categorize Facts
+
+
+ setLocalConfig({
+ ...localConfig,
+ fact_extraction: {
+ ...localConfig.fact_extraction,
+ auto_categorize:
+ !localConfig.fact_extraction.auto_categorize,
+ },
+ })
+ }
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-purple-500/50 ${
+ localConfig.fact_extraction.auto_categorize
+ ? "bg-purple-600"
+ : "bg-slate-700"
+ }`}
+ >
+
+
+
+
+ Automatically assign categories using LLM
+
+
+
+
+
+ Max Categories Per Fact
+
+
+ setLocalConfig({
+ ...localConfig,
+ fact_extraction: {
+ ...localConfig.fact_extraction,
+ max_categories_per_fact: parseInt(e.target.value),
+ },
+ })
+ }
+ className="w-full px-4 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ />
+
+
+
+
+ {/* Info Banner */}
+
+
+
+
+
+ Configuration Notes
+
+
+ • Changes take effect immediately after saving
+ • Model names must match OpenAI API model identifiers
+
+ • Higher similarity thresholds return more precise but fewer
+ results
+
+ • Auto-categorization requires additional API calls
+
+
+
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/ConversationList.tsx b/packages/frontend/src/components/experimental/ConversationList.tsx
new file mode 100644
index 0000000..c860141
--- /dev/null
+++ b/packages/frontend/src/components/experimental/ConversationList.tsx
@@ -0,0 +1,256 @@
+/**
+ * Conversation List Sidebar
+ *
+ * Displays list of all conversations for the current user
+ * - Shows conversation titles with timestamps
+ * - Highlights active conversation
+ * - Delete conversations
+ * - Create new conversation
+ *
+ * FIXES APPLIED (2024):
+ * =====================
+ * 1. Memoization: Wrapped loadConversations in useCallback
+ * - Prevents function recreation on every render
+ * - Properly included in useEffect dependencies
+ *
+ * 2. Concurrent Request Prevention: Added isLoadingRef guard
+ * - Prevents multiple simultaneous API calls
+ * - Reduces backend load from rapid re-renders
+ *
+ * 3. Cache Management: Clears API cache after mutations
+ * - Ensures fresh data after delete operations
+ * - Prevents stale data from being displayed
+ */
+
+"use client";
+
+import React, { useState, useEffect, useCallback, useRef } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ MessageSquare,
+ Plus,
+ Trash2,
+ Clock,
+ ChevronRight,
+ Archive,
+} from "lucide-react";
+
+interface Conversation {
+ id: string;
+ title: string;
+ message_count: number;
+ is_archived: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+interface ConversationListProps {
+ userId: string;
+ currentConversationId: string | null;
+ onConversationSelect: (conversationId: string) => void;
+ onNewConversation: () => void;
+ refreshTrigger?: number; // Add refresh trigger
+}
+
+export function ConversationList({
+ userId,
+ currentConversationId,
+ onConversationSelect,
+ onNewConversation,
+ refreshTrigger,
+}: ConversationListProps) {
+ const [conversations, setConversations] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const isLoadingRef = useRef(false); // Prevent concurrent loads
+
+ const loadConversations = useCallback(async () => {
+ // Prevent concurrent requests
+ if (isLoadingRef.current) {
+ console.log("⏳ Conversation list already loading, skipping...");
+ return;
+ }
+
+ try {
+ isLoadingRef.current = true;
+ setLoading(true);
+ setError(null);
+
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ const data = await experimentalAPI.getConversations(userId, false);
+
+ setConversations(data);
+ } catch (err: any) {
+ console.error("Failed to load conversations:", err);
+ setError(err.message || "Failed to load conversations");
+ } finally {
+ setLoading(false);
+ isLoadingRef.current = false;
+ }
+ }, [userId]);
+
+ useEffect(() => {
+ loadConversations();
+ }, [userId, refreshTrigger, loadConversations]); // Include loadConversations in deps
+
+ const deleteConversation = async (conversationId: string) => {
+ if (!confirm("Delete this conversation? This cannot be undone.")) return;
+
+ const isDeletingCurrent = conversationId === currentConversationId;
+
+ try {
+ console.log(`🗑️ Deleting conversation ${conversationId}...`);
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ await experimentalAPI.deleteConversation(conversationId);
+
+ // Clear cache to ensure fresh data
+ experimentalAPI.clearCache();
+
+ console.log(`✅ Successfully deleted conversation ${conversationId}`);
+
+ // Remove from list immediately for instant feedback (dynamic update, no reload)
+ setConversations((prev) => prev.filter((c) => c.id !== conversationId));
+
+ // If deleted current conversation, just clear localStorage
+ // DON'T auto-create a new one - let user do it manually
+ if (isDeletingCurrent) {
+ console.log("📝 Deleted current conversation, clearing localStorage");
+ localStorage.removeItem(`conversation_${userId}`); // Clear old conversation ID
+ // Note: User will need to click "New Chat" button to start a new conversation
+ }
+ } catch (err: any) {
+ console.error("❌ Failed to delete conversation:", err);
+ alert(`Failed to delete conversation: ${err.message || "Unknown error"}`);
+ // Only reload on error to resync
+ await loadConversations();
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 1) return "Just now";
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Conversations
+
+
{conversations.length}
+
+
+
+
+ New Chat
+
+
+
+ {/* Conversation List */}
+
+ {loading ? (
+
+ ) : error ? (
+
+ ) : conversations.length === 0 ? (
+
+
+
No conversations yet
+
+ Start chatting to create your first conversation
+
+
+ ) : (
+
+
+ {conversations.map((conversation) => (
+
+ onConversationSelect(conversation.id)}
+ className={`w-full text-left p-3 rounded-lg transition-all ${
+ conversation.id === currentConversationId
+ ? "bg-purple-500/20 border border-purple-500/30"
+ : "hover:bg-slate-800/50 border border-transparent"
+ }`}
+ >
+
+
+
+ {conversation.title}
+
+
+
+ {conversation.message_count} messages
+
+ •
+
+
+ {formatDate(conversation.updated_at)}
+
+
+
+
+ {conversation.id === currentConversationId && (
+
+ )}
+
+
+
+ {/* Delete button on hover */}
+ {
+ e.stopPropagation();
+ deleteConversation(conversation.id);
+ }}
+ className="absolute top-3 right-3 p-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-md opacity-0 group-hover:opacity-100 transition-all z-10"
+ title="Delete conversation"
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/EditMemoryModal.tsx b/packages/frontend/src/components/experimental/EditMemoryModal.tsx
new file mode 100644
index 0000000..08edda4
--- /dev/null
+++ b/packages/frontend/src/components/experimental/EditMemoryModal.tsx
@@ -0,0 +1,169 @@
+/**
+ * Edit Memory Modal Component
+ *
+ * Modal dialog for editing memory content
+ * - Textarea for content editing
+ * - Save and cancel buttons
+ * - Dark theme glassmorphism design
+ */
+
+'use client';
+
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { X, Save, Loader2 } from 'lucide-react';
+import { Memory } from '@/lib/api/experimental-client';
+
+interface EditMemoryModalProps {
+ memory: Memory;
+ onClose: () => void;
+ onSave: (memoryId: string, content: string) => Promise;
+}
+
+export function EditMemoryModal({ memory, onClose, onSave }: EditMemoryModalProps) {
+ const [content, setContent] = useState(memory.content);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const handleSave = async () => {
+ if (!content.trim()) {
+ alert('Memory content cannot be empty');
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ await onSave(memory.id, content);
+ } catch (error) {
+ // Error already handled by parent
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose();
+ } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+ handleSave();
+ }
+ };
+
+ return (
+
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+ {/* Header */}
+
+
Edit Memory
+
+
+
+
+
+ {/* Content */}
+
+
+ {/* Memory Type Badge */}
+
+
+ Type:
+
+
+ {memory.memory_type}
+
+
+
+ {/* Content Editor */}
+
+
+ {/* Categories (if available) */}
+ {memory.metadata?.categories && memory.metadata.categories.length > 0 && (
+
+
+ Categories
+
+
+ {memory.metadata.categories.map((category: string, idx: number) => (
+
+ {category}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+ Cancel
+
+
+ {isSaving ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ <>
+
+ Save Changes
+ >
+ )}
+
+
+
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/KnowledgeGraph.tsx b/packages/frontend/src/components/experimental/KnowledgeGraph.tsx
new file mode 100644
index 0000000..2f4d6ee
--- /dev/null
+++ b/packages/frontend/src/components/experimental/KnowledgeGraph.tsx
@@ -0,0 +1,434 @@
+/**
+ * Knowledge Graph Component
+ *
+ * Interactive knowledge graph visualization using React Flow
+ * - Displays entities and relationships as a network
+ * - Node types: person, project, institution, technology, concept
+ * - Interactive: click to expand, drag to rearrange
+ * - Filter by relationship type
+ *
+ * NOTE: Requires installing reactflow:
+ * npm install reactflow
+ */
+
+'use client';
+
+import React, { useCallback, useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import {
+ User,
+ Building2,
+ Code2,
+ Lightbulb,
+ Folder,
+ Link as LinkIcon,
+ Maximize2,
+ Minimize2,
+ Filter,
+ RefreshCw,
+} from 'lucide-react';
+
+// Types for the knowledge graph
+interface GraphNode {
+ id: string;
+ type: 'person' | 'institution' | 'project' | 'technology' | 'concept';
+ label: string;
+ data: {
+ name: string;
+ attributes?: Record;
+ color?: string;
+ };
+ position: { x: number; y: number };
+}
+
+interface GraphEdge {
+ id: string;
+ source: string;
+ target: string;
+ label: string;
+ type: 'attended' | 'works_on' | 'uses' | 'related_to' | 'part_of';
+}
+
+// Mock data for demonstration
+const MOCK_NODES: GraphNode[] = [
+ {
+ id: '1',
+ type: 'person',
+ label: 'Jack',
+ data: { name: 'Jack', attributes: { role: 'Developer', location: 'SF' } },
+ position: { x: 250, y: 250 },
+ },
+ {
+ id: '2',
+ type: 'institution',
+ label: 'UCSB',
+ data: { name: 'UC Santa Barbara', color: '#3b82f6' },
+ position: { x: 100, y: 100 },
+ },
+ {
+ id: '3',
+ type: 'institution',
+ label: 'MIT',
+ data: { name: 'Massachusetts Institute of Technology', color: '#3b82f6' },
+ position: { x: 400, y: 100 },
+ },
+ {
+ id: '4',
+ type: 'project',
+ label: 'Delight',
+ data: { name: 'Delight AI App', color: '#8b5cf6' },
+ position: { x: 250, y: 400 },
+ },
+ {
+ id: '5',
+ type: 'technology',
+ label: 'Python',
+ data: { name: 'Python', color: '#f59e0b' },
+ position: { x: 100, y: 400 },
+ },
+ {
+ id: '6',
+ type: 'technology',
+ label: 'TypeScript',
+ data: { name: 'TypeScript', color: '#f59e0b' },
+ position: { x: 400, y: 400 },
+ },
+];
+
+const MOCK_EDGES: GraphEdge[] = [
+ { id: 'e1-2', source: '1', target: '2', label: 'Attended', type: 'attended' },
+ { id: 'e1-3', source: '1', target: '3', label: 'Attended', type: 'attended' },
+ { id: 'e1-4', source: '1', target: '4', label: 'Works On', type: 'works_on' },
+ { id: 'e1-5', source: '1', target: '5', label: 'Uses', type: 'uses' },
+ { id: 'e1-6', source: '1', target: '6', label: 'Uses', type: 'uses' },
+ { id: 'e4-5', source: '4', target: '5', label: 'Built With', type: 'uses' },
+ { id: 'e4-6', source: '4', target: '6', label: 'Built With', type: 'uses' },
+];
+
+export function KnowledgeGraph({ userId }: { userId?: string }) {
+ const [nodes, setNodes] = useState(MOCK_NODES);
+ const [edges, setEdges] = useState(MOCK_EDGES);
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [filter, setFilter] = useState('all');
+ const [isFullscreen, setIsFullscreen] = useState(false);
+
+ // Fetch real graph data from API
+ useEffect(() => {
+ const fetchGraphData = async () => {
+ try {
+ const { default: experimentalAPI } = await import('@/lib/api/experimental-client');
+ const data = await experimentalAPI.getMemoryGraph(userId, 100);
+
+ // Transform API data to graph nodes/edges
+ // This is a placeholder - adapt based on actual API response
+ if (data.nodes && data.edges) {
+ // Map API nodes to GraphNode format
+ const transformedNodes: GraphNode[] = data.nodes.map((n, idx) => ({
+ id: n.id,
+ type: 'concept', // Default type, determine from n.type
+ label: n.label || 'Unknown',
+ data: {
+ name: n.label || 'Unknown',
+ attributes: { categories: n.categories },
+ },
+ position: {
+ x: 250 + (idx % 5) * 150,
+ y: 250 + Math.floor(idx / 5) * 150,
+ },
+ }));
+
+ setNodes(transformedNodes);
+
+ // Map API edges
+ const transformedEdges: GraphEdge[] = data.edges.map((e) => ({
+ id: `e-${e.source}-${e.target}`,
+ source: e.source,
+ target: e.target,
+ label: e.type || 'related',
+ type: 'related_to',
+ }));
+
+ setEdges(transformedEdges);
+ }
+ } catch (error) {
+ console.error('Failed to fetch graph data:', error);
+ // Use mock data on error
+ }
+ };
+
+ // Uncomment to use real API data
+ // fetchGraphData();
+ }, [userId]);
+
+ const getNodeIcon = (type: GraphNode['type']) => {
+ switch (type) {
+ case 'person':
+ return ;
+ case 'institution':
+ return ;
+ case 'project':
+ return ;
+ case 'technology':
+ return ;
+ case 'concept':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getNodeColor = (type: GraphNode['type']) => {
+ switch (type) {
+ case 'person':
+ return 'from-purple-500 to-indigo-600';
+ case 'institution':
+ return 'from-blue-500 to-cyan-600';
+ case 'project':
+ return 'from-purple-500 to-pink-600';
+ case 'technology':
+ return 'from-orange-500 to-yellow-600';
+ case 'concept':
+ return 'from-green-500 to-emerald-600';
+ default:
+ return 'from-slate-500 to-slate-600';
+ }
+ };
+
+ const filteredEdges = filter === 'all'
+ ? edges
+ : edges.filter(e => e.type === filter);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
Knowledge Graph
+
+ {nodes.length} nodes • {edges.length} connections
+
+
+
+
+
+ {/* Filter Dropdown */}
+ setFilter(e.target.value)}
+ className="px-3 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-sm text-slate-300 focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ >
+ All Relationships
+ Education
+ Work
+ Technologies
+ Related
+
+
+ {/* Fullscreen Toggle */}
+ setIsFullscreen(!isFullscreen)}
+ className="p-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-slate-300 hover:text-white hover:border-purple-500/50 transition-all"
+ title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
+ >
+ {isFullscreen ? : }
+
+
+ {/* Refresh */}
+ window.location.reload()}
+ className="p-2 bg-slate-900/50 border border-slate-700/50 rounded-lg text-slate-300 hover:text-white hover:border-purple-500/50 transition-all"
+ title="Refresh graph"
+ >
+
+
+
+
+
+
+ {/* Graph Visualization Area */}
+
+ {/* SVG Canvas for edges */}
+
+
+
+
+
+
+
+ {filteredEdges.map((edge) => {
+ const sourceNode = nodes.find(n => n.id === edge.source);
+ const targetNode = nodes.find(n => n.id === edge.target);
+
+ if (!sourceNode || !targetNode) return null;
+
+ return (
+
+
+
+ {edge.label}
+
+
+ );
+ })}
+
+
+ {/* Nodes */}
+
+ {nodes.map((node) => (
+
setSelectedNode(node)}
+ style={{
+ position: 'absolute',
+ left: node.position.x,
+ top: node.position.y,
+ cursor: 'grab',
+ }}
+ className="group"
+ >
+
+
+
+ {getNodeIcon(node.type)}
+
+
+
+ {node.label}
+
+ {node.data.attributes && (
+
+ {Object.values(node.data.attributes)[0] as string}
+
+ )}
+
+
+
+ {/* Tooltip on hover */}
+
+
+ {node.type.charAt(0).toUpperCase() + node.type.slice(1)}: {node.data.name}
+
+
+
+
+ ))}
+
+
+
+ {/* Selected Node Details Sidebar */}
+ {selectedNode && (
+
+
+
Node Details
+ setSelectedNode(null)}
+ className="text-slate-400 hover:text-white transition-colors"
+ >
+ ×
+
+
+
+
+
+
Type
+
+ {getNodeIcon(selectedNode.type)}
+ {selectedNode.type}
+
+
+
+
+
Name
+
{selectedNode.data.name}
+
+
+ {selectedNode.data.attributes && (
+
+
Attributes
+
+ {Object.entries(selectedNode.data.attributes).map(([key, value]) => (
+
+ {key}:
+ {String(value)}
+
+ ))}
+
+
+ )}
+
+
+
Connections
+
+ {edges
+ .filter(e => e.source === selectedNode.id || e.target === selectedNode.id)
+ .map(edge => {
+ const connectedNodeId = edge.source === selectedNode.id ? edge.target : edge.source;
+ const connectedNode = nodes.find(n => n.id === connectedNodeId);
+ return (
+
+
+ {edge.label}
+ →
+ {connectedNode?.label}
+
+ );
+ })}
+
+
+
+
+ )}
+
+ {/* Instructions */}
+
+
+
+ Drag nodes to rearrange • Click to view details • Use filter to show specific relationships
+
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/MemoryGraph.tsx b/packages/frontend/src/components/experimental/MemoryGraph.tsx
new file mode 100644
index 0000000..f571f50
--- /dev/null
+++ b/packages/frontend/src/components/experimental/MemoryGraph.tsx
@@ -0,0 +1,388 @@
+/**
+ * Memory Graph Visualization (Phase 3)
+ *
+ * Interactive graph visualization using React Flow showing:
+ * - Entity nodes with hierarchical attributes
+ * - Typed relationships between entities
+ * - Graph traversal and exploration
+ * - Node details on click
+ */
+
+"use client";
+
+import React, { useState, useCallback, useEffect } from "react";
+import ReactFlow, {
+ Node,
+ Edge,
+ Controls,
+ Background,
+ useNodesState,
+ useEdgesState,
+ addEdge,
+ Connection,
+ Panel,
+ MiniMap,
+} from "reactflow";
+import "reactflow/dist/style.css";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ Network,
+ Loader2,
+ AlertTriangle,
+ X,
+ Info,
+ Maximize2,
+ Filter,
+} from "lucide-react";
+import experimentalAPI from "@/lib/api/experimental-client";
+
+interface MemoryGraphProps {
+ userId: string;
+ entityType?: string;
+}
+
+interface GraphNode {
+ id: string;
+ label: string;
+ type: string;
+ content: string;
+ attributes: Record;
+ created_at: string | null;
+}
+
+interface GraphEdge {
+ id: string;
+ source: string;
+ target: string;
+ label: string;
+ strength: number;
+ metadata: Record;
+}
+
+interface GraphData {
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+}
+
+// Node color mapping by entity type
+const NODE_COLORS: Record = {
+ person: "#10b981", // green
+ place: "#3b82f6", // blue
+ project: "#8b5cf6", // purple
+ organization: "#f59e0b", // amber
+ object: "#ec4899", // pink
+ event: "#06b6d4", // cyan
+ unknown: "#6b7280", // gray
+};
+
+export function MemoryGraph({ userId, entityType }: MemoryGraphProps) {
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [showFilters, setShowFilters] = useState(false);
+
+ // Load graph data
+ const loadGraph = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ // Use the experimental API client instead of hardcoded localhost
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ const data = await experimentalAPI.getGraphVisualization(
+ userId,
+ entityType || undefined,
+ 50
+ );
+
+ // Convert to React Flow format
+ const flowNodes: Node[] = data.nodes.map((node, index) => ({
+ id: node.id,
+ type: "default",
+ position: {
+ // Simple circular layout
+ x: 400 + 300 * Math.cos((index / data.nodes.length) * 2 * Math.PI),
+ y: 300 + 300 * Math.sin((index / data.nodes.length) * 2 * Math.PI),
+ },
+ data: {
+ label: (
+
+
{node.label}
+
{node.type}
+
+ ),
+ node: node, // Store full node data
+ },
+ style: {
+ background: NODE_COLORS[node.type] || NODE_COLORS.unknown,
+ color: "white",
+ border: "2px solid rgba(255,255,255,0.3)",
+ borderRadius: "8px",
+ padding: "10px",
+ minWidth: "120px",
+ },
+ }));
+
+ const flowEdges: Edge[] = data.edges.map((edge) => ({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ label: edge.label,
+ animated: edge.strength > 0.8,
+ style: {
+ stroke: `rgba(148, 163, 184, ${edge.strength})`,
+ strokeWidth: 1 + edge.strength,
+ },
+ labelStyle: {
+ fill: "#94a3b8",
+ fontSize: 10,
+ },
+ }));
+
+ setNodes(flowNodes);
+ setEdges(flowEdges);
+ } catch (err: any) {
+ console.error("Failed to load graph:", err);
+ setError(err.message || "Failed to load graph");
+ } finally {
+ setLoading(false);
+ }
+ }, [userId, entityType, setNodes, setEdges]);
+
+ useEffect(() => {
+ loadGraph();
+ }, [loadGraph]);
+
+ const onNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
+ setSelectedNode(node.data.node);
+ }, []);
+
+ const onConnect = useCallback(
+ (params: Connection) => setEdges((eds) => addEdge(params, eds)),
+ [setEdges]
+ );
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ Memory Graph
+
+
+ {nodes.length} entities, {edges.length} relationships
+
+
+
+
+
+ setShowFilters(!showFilters)}
+ className={`p-1.5 sm:p-2 rounded-lg border transition-all ${
+ showFilters
+ ? "bg-purple-500/20 border-purple-500/30 text-purple-300"
+ : "bg-slate-900/50 border-slate-700/50 text-slate-300 hover:text-white"
+ }`}
+ >
+
+
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Graph Canvas */}
+
+ {loading ? (
+
+
+
+ Loading graph...
+
+
+ ) : error ? (
+
+
+
+
+ Error loading graph
+
+
{error}
+
+
+ ) : nodes.length === 0 ? (
+
+
+
+
+ No graph data
+
+
+ Start chatting to create hierarchical memories and build your
+ knowledge graph!
+
+
+
+ ) : (
+
+
+
+ {
+ const nodeData = node.data?.node as GraphNode | undefined;
+ return (
+ NODE_COLORS[nodeData?.type || "unknown"] ||
+ NODE_COLORS.unknown
+ );
+ }}
+ />
+
+
+
+
Legend
+ {Object.entries(NODE_COLORS).map(([type, color]) => (
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Node Detail Panel */}
+
+ {selectedNode && (
+ <>
+ {/* Mobile overlay */}
+ setSelectedNode(null)}
+ />
+
+
+
+
+
+ {selectedNode.type}
+
+
+ {selectedNode.label}
+
+
+
setSelectedNode(null)}
+ className="p-2 hover:bg-slate-700 rounded-lg transition-all flex-shrink-0 ml-2"
+ >
+
+
+
+
+
+
+
+ Content
+
+
+ {selectedNode.content}
+
+
+
+
+
+ Attributes
+
+
+ {Object.entries(selectedNode.attributes).map(
+ ([key, value]) => (
+
+
+ {key}
+
+
+ {Array.isArray(value)
+ ? value.join(", ")
+ : typeof value === "object"
+ ? JSON.stringify(value, null, 2)
+ : String(value)}
+
+
+ )
+ )}
+
+
+
+ {selectedNode.created_at && (
+
+
+ Created
+
+
+ {new Date(selectedNode.created_at).toLocaleString()}
+
+
+ )}
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/MemoryNotifications.tsx b/packages/frontend/src/components/experimental/MemoryNotifications.tsx
new file mode 100644
index 0000000..eccafc8
--- /dev/null
+++ b/packages/frontend/src/components/experimental/MemoryNotifications.tsx
@@ -0,0 +1,150 @@
+/**
+ * Memory Notifications Component
+ *
+ * Shows real-time notifications when memories are being created/updated
+ * - Appears as a side panel during chat
+ * - Shows loading state while processing
+ * - Displays created memories with animations
+ */
+
+"use client";
+
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { Brain, Sparkles, Check, Loader2, Tag, Link2 } from "lucide-react";
+
+interface Memory {
+ id: string;
+ content: string;
+ memory_type: string;
+ categories?: string[];
+}
+
+interface MemoryNotificationsProps {
+ isProcessing: boolean;
+ recentMemories: Memory[];
+}
+
+export function MemoryNotifications({
+ isProcessing,
+ recentMemories,
+}: MemoryNotificationsProps) {
+ if (!isProcessing && recentMemories.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Memory Processing
+
+ {isProcessing && (
+
+ )}
+
+
+
+ {/* Content */}
+
+ {/* Processing Indicator */}
+
+ {isProcessing && (
+
+
+
+
+
+
+
+ Extracting facts...
+
+
+ Analyzing your message and creating memories
+
+
+
+
+ )}
+
+
+ {/* Recent Memories */}
+
+
+ {recentMemories.map((memory, index) => (
+
+
+
+
+
+
+
+ {memory.content}
+
+
+
+
+ {memory.memory_type}
+
+
+ {memory.categories && memory.categories.length > 0 && (
+
+
+
+ {memory.categories.slice(0, 2).join(", ")}
+
+
+ )}
+
+
+
+
+ ))}
+
+
+ {recentMemories.length === 0 && !isProcessing && (
+
+ )}
+
+
+
+ {/* Footer Stats */}
+ {recentMemories.length > 0 && (
+
+
+
+ {recentMemories.length}{" "}
+ {recentMemories.length === 1 ? "memory" : "memories"} created
+
+ Active
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/MemoryVisualization.tsx b/packages/frontend/src/components/experimental/MemoryVisualization.tsx
new file mode 100644
index 0000000..da0524d
--- /dev/null
+++ b/packages/frontend/src/components/experimental/MemoryVisualization.tsx
@@ -0,0 +1,411 @@
+/**
+ * Memory Visualization Component (Modernized)
+ *
+ * Dark theme memory browser with:
+ * - Lucide React icons (no emojis)
+ * - Glassmorphism effects
+ * - List and graph views
+ * - Filter, search, and delete functionality
+ * - Edit capabilities
+ */
+
+"use client";
+
+import React, { useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ List,
+ Network,
+ RefreshCw,
+ Search,
+ Filter,
+ Trash2,
+ Edit2,
+ Calendar,
+ Tag,
+ Brain,
+ Loader2,
+ AlertTriangle,
+ X,
+} from "lucide-react";
+import { useMemories } from "@/lib/hooks/useExperimentalAPI";
+import { Memory } from "@/lib/api/experimental-client";
+import { EditMemoryModal } from "./EditMemoryModal";
+import { CleanupPanel } from "./CleanupPanel";
+
+type ViewMode = "list" | "graph";
+
+export function MemoryVisualization({ userId }: { userId: string }) {
+ const [viewMode, setViewMode] = useState("list");
+ const [selectedType, setSelectedType] = useState("all");
+ const [searchQuery, setSearchQuery] = useState("");
+ const [editingMemory, setEditingMemory] = useState(null);
+ const [memoryLimit, setMemoryLimit] = useState(50);
+ const [showCleanupPanel, setShowCleanupPanel] = useState(false);
+
+ const { memories, loading, error, refresh, deleteMemory } = useMemories({
+ user_id: userId,
+ memory_type: selectedType === "all" ? undefined : selectedType,
+ limit: memoryLimit,
+ autoRefresh: true,
+ refreshInterval: 60000, // Refresh every 1 minute (manual refresh button available)
+ });
+
+ const filteredMemories = memories.filter((memory) =>
+ searchQuery
+ ? memory.content.toLowerCase().includes(searchQuery.toLowerCase())
+ : true
+ );
+
+ const handleDelete = async (memoryId: string) => {
+ if (confirm("Are you sure you want to delete this memory?")) {
+ try {
+ await deleteMemory(memoryId);
+ } catch (err) {
+ alert("Failed to delete memory: " + (err as Error).message);
+ }
+ }
+ };
+
+ const handleEdit = (memory: Memory) => {
+ setEditingMemory(memory);
+ };
+
+ const handleUpdate = async (memoryId: string, content: string) => {
+ try {
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ await experimentalAPI.updateMemory(memoryId, { content });
+ setEditingMemory(null);
+ refresh(); // Refresh the list
+ } catch (err) {
+ alert("Failed to update memory: " + (err as Error).message);
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ Memory Browser
+
+
+ View and manage your AI's knowledge base
+
+
+
+
+
+
+
+
+ {/* View Mode Toggle */}
+
+
+ setViewMode("list")}
+ className={`px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium flex items-center gap-1.5 sm:gap-2 transition-all ${
+ viewMode === "list"
+ ? "bg-gradient-to-r from-purple-600 to-indigo-600 text-white"
+ : "text-slate-400 hover:text-white hover:bg-slate-800/50"
+ }`}
+ >
+
+ List
+
+ setViewMode("graph")}
+ className={`px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium flex items-center gap-1.5 sm:gap-2 border-l border-slate-700/50 transition-all ${
+ viewMode === "graph"
+ ? "bg-gradient-to-r from-purple-600 to-indigo-600 text-white"
+ : "text-slate-400 hover:text-white hover:bg-slate-800/50"
+ }`}
+ >
+
+ Graph
+
+
+
+ {/* Type Filter */}
+
setSelectedType(e.target.value)}
+ className="px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-slate-900/50 border border-slate-700/50 rounded-lg text-slate-300 focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ >
+ All Types
+ Personal
+ Project
+ Task
+ Fact
+
+
+ {/* Search */}
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search memories..."
+ className="w-full pl-8 sm:pl-10 pr-8 sm:pr-10 py-1.5 sm:py-2 text-xs sm:text-sm bg-slate-900/50 border border-slate-700/50 rounded-lg text-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ />
+ {searchQuery && (
+ setSearchQuery("")}
+ className="absolute right-2 sm:right-3 top-1/2 transform -translate-y-1/2 text-slate-500 hover:text-white"
+ >
+
+
+ )}
+
+
+ {/* Memory Limit Selector */}
+
setMemoryLimit(Number(e.target.value))}
+ className="px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-slate-900/50 border border-slate-700/50 rounded-lg text-slate-300 focus:outline-none focus:ring-2 focus:ring-purple-500/50"
+ title="Number of memories to load"
+ >
+ 50
+ 100
+ 200
+ 500
+ All
+
+
+ {/* Cleanup Button */}
+
setShowCleanupPanel(!showCleanupPanel)}
+ className={`px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium rounded-lg border transition-all flex items-center gap-1.5 sm:gap-2 ${
+ showCleanupPanel
+ ? "bg-red-500/20 border-red-500/30 text-red-300"
+ : "bg-slate-900/50 border-slate-700/50 text-slate-300 hover:text-white hover:border-purple-500/50"
+ }`}
+ title="Clean up problematic memories"
+ >
+
+ Cleanup
+
+
+
+ {/* Stats Bar */}
+
+
+ Total:{" "}
+ {filteredMemories.length}
+
+ {selectedType !== "all" && (
+
+
+ Filtered by:
+ {selectedType}
+
+ )}
+ {searchQuery && (
+
+
+ Search:
+
+ "{searchQuery}"
+
+
+ )}
+
+
+
+ {/* Cleanup Panel */}
+
+ {showCleanupPanel && (
+ {
+ refresh();
+ setShowCleanupPanel(false);
+ }}
+ />
+ )}
+
+
+ {/* Content Area */}
+
+ {loading ? (
+
+
+
+
+ Loading memories...
+
+
+
+ ) : error ? (
+
+
+
+
+ Error loading memories
+
+
{error.message}
+
+ Make sure the experimental backend server is running on port
+ 8001
+
+
+
+ ) : filteredMemories.length === 0 ? (
+
+
+
+
+ No memories found
+
+
+ {searchQuery || selectedType !== "all"
+ ? "Try adjusting your filters"
+ : "Start chatting to create some memories!"}
+
+
+
+ ) : viewMode === "list" ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Edit Modal */}
+ {editingMemory && (
+
setEditingMemory(null)}
+ onSave={handleUpdate}
+ />
+ )}
+
+ );
+}
+
+// ============================================================================
+// List View
+// ============================================================================
+
+function ListView({
+ memories,
+ onDelete,
+ onEdit,
+}: {
+ memories: Memory[];
+ onDelete: (id: string) => void;
+ onEdit: (memory: Memory) => void;
+}) {
+ return (
+
+ {memories.map((memory, idx) => (
+
+
+
+ {/* Type Badge */}
+
+
+ {memory.memory_type}
+
+
+
+
+ {new Date(memory.created_at).toLocaleDateString()}
+
+
+
+
+ {/* Content */}
+
+ {memory.content}
+
+
+ {/* Categories */}
+ {memory.metadata?.categories &&
+ memory.metadata.categories.length > 0 && (
+
+ {memory.metadata.categories.map(
+ (category: string, idx: number) => (
+
+
+ {category}
+
+ )
+ )}
+
+ )}
+
+
+ {/* Actions */}
+
+ onEdit(memory)}
+ className="p-1.5 sm:p-2 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-slate-400 hover:text-white transition-all"
+ title="Edit memory"
+ >
+
+
+ onDelete(memory.id)}
+ className="p-1.5 sm:p-2 bg-red-500/10 hover:bg-red-500/20 rounded-lg text-red-400 hover:text-red-300 transition-all border border-red-500/20"
+ title="Delete memory"
+ >
+
+
+
+
+
+ ))}
+
+ );
+}
+
+// ============================================================================
+// Graph View (Placeholder)
+// ============================================================================
+
+function GraphViewPlaceholder() {
+ return (
+
+
+
+
+ Graph View Coming Soon
+
+
+ This will display an interactive knowledge graph of your memories
+ using D3.js or React Flow
+
+
+ For now, use the List View to browse your memories
+
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/UserSwitcher.tsx b/packages/frontend/src/components/experimental/UserSwitcher.tsx
new file mode 100644
index 0000000..af9f8be
--- /dev/null
+++ b/packages/frontend/src/components/experimental/UserSwitcher.tsx
@@ -0,0 +1,220 @@
+/**
+ * User Switcher Component
+ *
+ * Allows switching between different test users for development/testing
+ * - Shows current user ID
+ * - List of recent users
+ * - Create new user button
+ * - Clear current user button
+ */
+
+"use client";
+
+import React, { useState, useEffect, useRef } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { User, ChevronDown, Plus, Trash2, Check, Copy } from "lucide-react";
+import { generateUUID } from "@/lib/utils/uuid";
+
+interface UserSwitcherProps {
+ currentUserId: string;
+ onUserChange: () => void;
+}
+
+export function UserSwitcher({
+ currentUserId,
+ onUserChange,
+}: UserSwitcherProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [recentUsers, setRecentUsers] = useState([]);
+ const [copied, setCopied] = useState(false);
+ const dropdownRef = useRef(null);
+
+ // Load recent users from localStorage
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ const recent = JSON.parse(localStorage.getItem("recent_users") || "[]");
+ setRecentUsers(recent);
+
+ // Add current user to recent if not already there
+ if (!recent.includes(currentUserId)) {
+ const updated = [currentUserId, ...recent].slice(0, 5);
+ localStorage.setItem("recent_users", JSON.stringify(updated));
+ setRecentUsers(updated);
+ }
+ }
+ }, [currentUserId]);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () =>
+ document.removeEventListener("mousedown", handleClickOutside);
+ }
+ }, [isOpen]);
+
+ const switchToUser = async (userId: string) => {
+ // Ensure user exists in database
+ try {
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ await experimentalAPI.ensureUser(userId);
+ } catch (error) {
+ console.error("Failed to ensure user exists:", error);
+ // Continue anyway - might be in mock mode
+ }
+
+ localStorage.setItem("experimental_user_id", userId);
+
+ // Update recent users
+ const updated = [
+ userId,
+ ...recentUsers.filter((id) => id !== userId),
+ ].slice(0, 5);
+ localStorage.setItem("recent_users", JSON.stringify(updated));
+ setRecentUsers(updated);
+
+ setIsOpen(false);
+ onUserChange(); // Trigger page reload
+ };
+
+ const createNewUser = async () => {
+ const newId = generateUUID();
+ await switchToUser(newId);
+ };
+
+ const copyUserId = () => {
+ navigator.clipboard.writeText(currentUserId);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const clearCurrentUser = () => {
+ if (confirm("Clear current user and create a new one?")) {
+ createNewUser();
+ }
+ };
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className="flex items-center gap-2 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-300 hover:text-white hover:border-purple-500/50 transition-all"
+ >
+
+
+ {currentUserId.slice(0, 8)}...
+
+
+
+
+
+ {isOpen && (
+
+ {/* Current User Section */}
+
+
+
+ Current User
+
+
+ {copied ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+
+
+
+
+ {currentUserId}
+
+
+
+
+ {/* Recent Users Section */}
+ {recentUsers.length > 1 && (
+
+
+ Recent Users
+
+
+ {recentUsers
+ .filter((userId) => userId !== currentUserId)
+ .map((userId) => (
+ switchToUser(userId)}
+ className="w-full text-left px-3 py-2 text-xs font-mono text-slate-300 hover:text-white hover:bg-slate-700/50 rounded-lg transition-all flex items-center gap-2"
+ >
+
+ {userId}
+
+ ))}
+
+
+ )}
+
+ {/* Actions Section */}
+
+
+
+ New User
+
+
+
+
+ Clear & Reset
+
+
+
+ {/* Info */}
+
+
+ Switching users will reload the page with a different user ID.
+ Memories are tied to each user.
+
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/frontend/src/components/experimental/index.ts b/packages/frontend/src/components/experimental/index.ts
new file mode 100644
index 0000000..95c8e74
--- /dev/null
+++ b/packages/frontend/src/components/experimental/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Experimental Components
+ *
+ * Export all experimental components for easy importing
+ */
+
+export { ChatInterface } from './ChatInterface';
+export { MemoryVisualization } from './MemoryVisualization';
+export { AnalyticsDashboard } from './AnalyticsDashboard';
+export { ConfigurationPanel } from './ConfigurationPanel';
+export { KnowledgeGraph } from './KnowledgeGraph';
diff --git a/packages/frontend/src/components/marketing/CollaborativeStory.tsx b/packages/frontend/src/components/marketing/CollaborativeStory.tsx
new file mode 100644
index 0000000..66674f2
--- /dev/null
+++ b/packages/frontend/src/components/marketing/CollaborativeStory.tsx
@@ -0,0 +1,154 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Users, Link, Sparkles, Crown } from 'lucide-react';
+
+export const CollaborativeStory: React.FC = () => {
+ const [step, setStep] = useState(0);
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setStep((prev) => (prev + 1) % 3);
+ }, 4000);
+ return () => clearInterval(timer);
+ }, []);
+
+ return (
+
+ {/* Left: The Concept - Swiss Design Typography */}
+
+
+
+
+
+
+ Multiplayer Narrative
+
+
+ Sync Your Sagas.
+
+
+ Don't just chat. Co-author. Delight lets you link your progress with a friend.
+ When they hit the gym, your shared storyline unlocks a new chapter.
+
+
+
+
+
+
+
Party Buffs
+
Shared XP multiplier
+
+
+
+
Chain Reactions
+
Your win triggers their loot
+
+
+
+
+ {/* Right: The Visualizer */}
+
+
+ {/* Grid Line Background */}
+
+
+
+ {/* Connection Beam */}
+
+
+ {/* Player Nodes */}
+
+
+
+ YOU
+
+
+ Writing Ch. 4
+
+
+
+
+
+ ALLY
+
+
+ Design Sprint
+
+
+
+
+ {/* The Shared Story Event */}
+
+
+
+
+ Story Event
+
+
+ {step === 0 && (
+
+
The Alliance Forms
+
You and Ally have synced objectives. A shared timeline has been created.
+
+ )}
+ {step === 1 && (
+
+
Double Impact
+
Ally completed "Design Sprint". Your motivation buff increased by 15%.
+
+ )}
+ {step === 2 && (
+
+
Boss Defeated
+
Combined output reached 100%. The "Procrastination Beast" retreats.
+
+ )}
+
+
+
+ {/* Connecting lines animation */}
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/Companions.tsx b/packages/frontend/src/components/marketing/Companions.tsx
new file mode 100644
index 0000000..c644361
--- /dev/null
+++ b/packages/frontend/src/components/marketing/Companions.tsx
@@ -0,0 +1,193 @@
+'use client';
+
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Character } from '@/types/marketing';
+import { Heart, Zap, Brain, Shield } from 'lucide-react';
+
+const CHARACTERS: Character[] = [
+ {
+ id: 'eliza',
+ name: 'Eliza',
+ role: 'Emotional Navigator',
+ description: 'She helps you process the emotional friction of starting. When you are paralyzed by perfectionism, she asks the question beneath the question.',
+ domain: 'health',
+ sampleQuote: "You're avoiding this task because you're afraid it won't be perfect. What if we just made a 'bad draft' first?",
+ avatarColor: 'bg-rose-500 text-rose-100'
+ },
+ {
+ id: 'thorne',
+ name: 'Thorne',
+ role: 'Tactical Sergeant',
+ description: 'He breaks ambiguity into brute force mechanics. No sympathy, just physics. Good for when you need a drill instructor.',
+ domain: 'craft',
+ sampleQuote: "Emotions are noise. Action is signal. Set a timer for 300 seconds. Write one sentence. Go.",
+ avatarColor: 'bg-amber-600 text-amber-100'
+ },
+ {
+ id: 'lyra',
+ name: 'Lyra',
+ role: 'System Architect',
+ description: 'She sees the constellation, not the star. She connects today\'s boring task to your 5-year arc to give you perspective.',
+ domain: 'growth',
+ sampleQuote: "This feels small, but it's a foundational node for your 'Author' identity. 1% clearer means 100% closer.",
+ avatarColor: 'bg-cyan-500 text-cyan-100'
+ },
+ {
+ id: 'elara',
+ name: 'Elara',
+ role: 'Community Weaver',
+ description: 'Reminds you that you live in a world with others. When you isolate, she nudges you to signal the tribe.',
+ domain: 'connection',
+ sampleQuote: "You've been in deep work for 4 hours. The tribe needs your signal. Send one update to a peer now.",
+ avatarColor: 'bg-purple-500 text-purple-100'
+ }
+];
+
+const SCENARIOS = [
+ "I'm too tired to work.",
+ "I feel like an imposter.",
+ "I don't know where to start."
+];
+
+export const Companions: React.FC = () => {
+ const [activeId, setActiveId] = useState(CHARACTERS[0].id);
+ const [scenario, setScenario] = useState(SCENARIOS[0]);
+ const activeChar = CHARACTERS.find(c => c.id === activeId) || CHARACTERS[0];
+
+ const getResponse = (charId: string, scen: string) => {
+ if (charId === 'thorne') return "Fatigue is a feeling, not a fact. Do 5 minutes anyway.";
+ if (charId === 'eliza') return "It's okay to be tired. Your body is asking for rest, or maybe a change of pace?";
+ if (charId === 'lyra') return "Energy fluctuates. The system accounts for this. Let's switch to a low-energy maintenance task.";
+ if (charId === 'elara') return "Maybe you're drained from solitude. Call a friend for 5 minutes to recharge.";
+ return "Let's break this down.";
+ };
+
+ return (
+
+
+
+
The Squad
+
+ Delight isn't a chatbot. It's a council of specialized agents.
+ Each represents a different facet of human drive: Empathy, Discipline, Vision, and Tribe.
+
+ They don't just cheerlead. They intervene.
+
+
+
+
+
+ {/* Character Selection List */}
+
+
Select Agent
+ {CHARACTERS.map((char) => (
+
setActiveId(char.id)}
+ className={`w-full text-left px-5 py-4 rounded-xl border transition-all duration-300 group relative overflow-hidden ${
+ activeId === char.id
+ ? 'bg-zinc-800/80 border-zinc-700 text-white shadow-xl'
+ : 'bg-zinc-900/30 border-transparent text-zinc-500 hover:bg-zinc-800/50 hover:text-zinc-300'
+ }`}
+ >
+
+
+ {char.name[0]}
+
+
+ {char.name}
+ {char.role}
+
+
+
+ ))}
+
+
+ {/* Interactive Chat Demo */}
+
+
+
+ {/* Header */}
+
+
+
+ {activeChar.domain === 'health' && }
+ {activeChar.domain === 'craft' && }
+ {activeChar.domain === 'growth' && }
+ {activeChar.domain === 'connection' && }
+
+
+
{activeChar.name}
+
+
+ Online
+
+
+
+
+
{activeChar.description}
+
+
+
+ {/* Chat Body */}
+
+
+ {/* Simulating User Message */}
+
+
You
+
+ "{scenario}"
+
+
+
+ {/* Agent Response */}
+
+ {activeChar.name}
+
+
+ "{getResponse(activeChar.id, scenario)}"
+
+
+
+
+
+
+ {/* Scenario Selector Footer */}
+
+
Simulate Struggle
+
+ {SCENARIOS.map(s => (
+ setScenario(s)}
+ className={`text-xs px-3 py-2 rounded-lg border transition-colors ${
+ scenario === s
+ ? 'bg-white text-black border-white font-medium'
+ : 'bg-transparent text-zinc-500 border-zinc-800 hover:border-zinc-600'
+ }`}
+ >
+ {s}
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/Constellation.tsx b/packages/frontend/src/components/marketing/Constellation.tsx
new file mode 100644
index 0000000..7449c8d
--- /dev/null
+++ b/packages/frontend/src/components/marketing/Constellation.tsx
@@ -0,0 +1,181 @@
+'use client';
+
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { Plus } from 'lucide-react';
+
+// Each attribute is a "Branch" of the star system
+interface Attribute {
+ id: string;
+ label: string;
+ value: number;
+ color: string;
+ angle: number; // 0 to 360
+}
+
+export const Constellation: React.FC = () => {
+ const [attributes, setAttributes] = useState([
+ { id: 'growth', label: 'Intellect', value: 30, color: '#3b82f6', angle: 270 }, // Top
+ { id: 'connection', label: 'Empathy', value: 40, color: '#a855f7', angle: 0 }, // Right
+ { id: 'health', label: 'Vitality', value: 50, color: '#10b981', angle: 90 }, // Bottom
+ { id: 'craft', label: 'Discipline', value: 60, color: '#f59e0b', angle: 180 }, // Left
+ ]);
+
+ const [hoveredNode, setHoveredNode] = useState(null);
+
+ const handleLevelUp = (id: string) => {
+ setAttributes(prev => prev.map(attr => {
+ if (attr.id === id) {
+ return { ...attr, value: Math.min(100, attr.value + 10) };
+ }
+ return attr;
+ }));
+ };
+
+ // Calculate node position based on angle and distance (value)
+ const getPosition = (angle: number, distance: number) => {
+ const rad = (angle * Math.PI) / 180;
+ // Base radius 150, max offset 120
+ const cx = 200;
+ const cy = 200;
+ const r = (distance / 100) * 140;
+ return {
+ x: cx + r * Math.cos(rad),
+ y: cy + r * Math.sin(rad)
+ };
+ };
+
+ return (
+
+
+ {/* Swiss Asymmetry: Description on left (4 cols) */}
+
+
+
+ Your Expanding Universe.
+
+
+ Static profiles are dead. In Delight, your attributes are a living star system.
+ As you complete missions, your constellation physically expands, unlocking new capabilities in the network.
+
+
+
+
+
Evolution Controls
+ {attributes.map(attr => (
+
handleLevelUp(attr.id)}
+ onMouseEnter={() => setHoveredNode(attr.id)}
+ onMouseLeave={() => setHoveredNode(null)}
+ className="group flex items-center justify-between w-full p-3 rounded-lg bg-zinc-900/40 border border-white/5 hover:border-white/20 hover:bg-zinc-800/40 transition-all"
+ >
+
+
+
+ ))}
+
+
+
+ {/* Visualization: The Star Map (8 cols) */}
+
+
+ {/* Background Space Dust */}
+
+
+
+
+
+ {/* Core */}
+
+
+
+
+ {attributes.map((attr) => {
+ const endPos = getPosition(attr.angle, attr.value);
+ // Calculate milestone nodes along the path
+ const milestones = [0.3, 0.6, 0.9].map(p => ({
+ pos: getPosition(attr.angle, attr.value * p),
+ active: true
+ }));
+
+ return (
+
+ {/* The main line connection */}
+
+
+ {/* Milestone Nodes (The "Growing" part) */}
+ {milestones.map((m, idx) => (
+
+ ))}
+
+ {/* The Head Star */}
+
+
+
+ {/* Label floating near the star */}
+
+ {attr.value}%
+
+
+
+ );
+ })}
+
+
+
+ {/* Decorative corner UI */}
+
+
Total Mass
+
+ {attributes.reduce((acc, curr) => acc + curr.value, 0)}.00
+
+
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/DailyLoop.tsx b/packages/frontend/src/components/marketing/DailyLoop.tsx
new file mode 100644
index 0000000..1782527
--- /dev/null
+++ b/packages/frontend/src/components/marketing/DailyLoop.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { Sun, Moon, Coffee, Sunset } from 'lucide-react';
+
+const TIMELINE = [
+ { time: 8, label: 'Morning Kickoff', desc: 'Gentle prioritization. What matters today?', icon: Sun, color: 'from-orange-500/20 to-blue-900/20' },
+ { time: 14, label: 'Midday Slump', desc: 'Compassionate check-in. Need a pivot?', icon: Coffee, color: 'from-blue-500/20 to-zinc-900/20' },
+ { time: 19, label: 'Evening Reflection', desc: 'Log the wins. Update the constellation.', icon: Sunset, color: 'from-purple-900/20 to-black' },
+ { time: 23, label: 'Dream State', desc: 'System offline. Memory consolidation.', icon: Moon, color: 'from-black to-zinc-900' },
+];
+
+export const DailyLoop: React.FC = () => {
+ const [hour, setHour] = useState(8);
+
+ const activePhase = TIMELINE.reduce((prev, curr) => {
+ return Math.abs(curr.time - hour) < Math.abs(prev.time - hour) ? curr : prev;
+ });
+
+ const Icon = activePhase.icon;
+
+ return (
+
+ {/* Dynamic Background */}
+
+
+
+
+
+
+
+
+
+ {activePhase.label}
+
+
+ {activePhase.desc}
+
+
+
+
+ {/* Scrubber */}
+
+
setHour(parseFloat(e.target.value))}
+ className="w-full h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-white hover:accent-blue-400 transition-all"
+ />
+
+ 06:00
+ 12:00
+ 18:00
+ 24:00
+
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/FeatureDeepDive.tsx b/packages/frontend/src/components/marketing/FeatureDeepDive.tsx
new file mode 100644
index 0000000..f4968a8
--- /dev/null
+++ b/packages/frontend/src/components/marketing/FeatureDeepDive.tsx
@@ -0,0 +1,194 @@
+'use client';
+
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Split, Clock, CheckCircle2 } from 'lucide-react';
+
+export const FeatureDeepDive: React.FC = () => {
+ const [activeTab, setActiveTab] = useState<'breakdown' | 'timeline'>('breakdown');
+
+ return (
+
+ {/* Controls */}
+
+
+
+ Mechanics of Momentum .
+
+
+ Delight isn't just a tracker. It's a procedural generation engine for your life.
+ See how we turn vague ambition into concrete gameplay.
+
+
+
+
+
setActiveTab('breakdown')}
+ className={`text-left p-4 rounded-xl border transition-all ${activeTab === 'breakdown' ? 'bg-zinc-800 border-blue-500/50 shadow-[0_0_20px_rgba(59,130,246,0.1)]' : 'bg-transparent border-zinc-800 hover:bg-zinc-900'}`}
+ >
+
+
+
+
+
Fractal Decomposition
+
+
+ Big goals are paralyzing. We shatter them into playable micro-missions automatically.
+
+
+
+
setActiveTab('timeline')}
+ className={`text-left p-4 rounded-xl border transition-all ${activeTab === 'timeline' ? 'bg-zinc-800 border-purple-500/50 shadow-[0_0_20px_rgba(168,85,247,0.1)]' : 'bg-transparent border-zinc-800 hover:bg-zinc-900'}`}
+ >
+
+
+
+
+
Narrative Evolution
+
+
+ Your story isn't static. Watch how your character arc evolves over 30 days of consistency.
+
+
+
+
+
+ {/* Visualization Area */}
+
+
+ {activeTab === 'breakdown' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Background FX */}
+
+
+
+ );
+};
+
+const FractalGoalDemo = () => {
+ return (
+
+ {/* Main Goal */}
+
+ The Impossible Goal
+ "Launch my Indie App"
+
+
+ {/* Connecting Lines */}
+
+
+ {/* Split Level 1 */}
+
+ {['Design System', 'Core Engine', 'Market Strategy'].map((item, i) => (
+
+
+
+ {item}
+
+
+ {/* Recursive Micro tasks for the middle one */}
+ {i === 1 && (
+
+
+
+ {['Setup Repo', 'Auth Flow', 'DB Schema'].map((sub, j) => (
+
+
+ {sub}
+
+ ))}
+
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+const NarrativeTimelineDemo = () => {
+ const events = [
+ { day: 1, title: 'The Awakening', desc: 'You accepted the call to adventure.', status: 'completed' },
+ { day: 7, title: 'First Blood', desc: 'You shipped your first prototype despite fear.', status: 'completed' },
+ { day: 15, title: 'The Valley of Doubt', desc: 'Progress slowed. You persisted anyway.', status: 'active' },
+ { day: 30, title: 'Ascension', desc: 'Locked.', status: 'locked' },
+ ];
+
+ return (
+
+
+
+
+ {events.map((evt, i) => (
+
+ {/* Node */}
+
+ {evt.status === 'active' &&
}
+
+
+
+
+ Day {evt.day}
+ {evt.status === 'completed' && Chapter Closed }
+
+
{evt.title}
+
{evt.desc}
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/MissionControl.tsx b/packages/frontend/src/components/marketing/MissionControl.tsx
new file mode 100644
index 0000000..903a36e
--- /dev/null
+++ b/packages/frontend/src/components/marketing/MissionControl.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { Battery, BatteryWarning, Zap } from 'lucide-react';
+
+export const MissionControl: React.FC = () => {
+ const [energy, setEnergy] = useState(50);
+
+ // Derived state
+ const getPhase = (e: number) => {
+ if (e < 30) return { label: 'Recovery', color: 'text-rose-400', bg: 'bg-rose-500', text: 'Micro-missions only. 10m max.', icon: BatteryWarning };
+ if (e < 70) return { label: 'Steady', color: 'text-emerald-400', bg: 'bg-emerald-500', text: 'Standard output. Focus blocks engaged.', icon: Battery };
+ return { label: 'Overdrive', color: 'text-blue-400', bg: 'bg-blue-500', text: 'Deep work protocols. Stretch goals active.', icon: Zap };
+ };
+
+ const phase = getPhase(energy);
+ const Icon = phase.icon;
+
+ return (
+
+
+
+ From Overwhelm
+ To Action
+
+
+ Most tools ignore your biology. Delight reads your energy and reshapes your workload instantly.
+
+
+
+
+
+ Burnout
+ Flow
+
+
setEnergy(parseInt(e.target.value))}
+ className="w-full h-1.5 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-white hover:accent-zinc-200 transition-all"
+ />
+
+
+
+
+
+ {/* Background Glow based on energy */}
+
+
+ {/* The Card Stack */}
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ {/* Active Card */}
+
+
+
+
+
+
+ {phase.label} Protocol
+
+
Daily Briefing
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/NarrativeDemo.tsx b/packages/frontend/src/components/marketing/NarrativeDemo.tsx
new file mode 100644
index 0000000..d1a7cc6
--- /dev/null
+++ b/packages/frontend/src/components/marketing/NarrativeDemo.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Sparkles, ArrowRight, Loader2 } from 'lucide-react';
+import { WorldType, NarrativeResponse } from '@/types/marketing';
+import { generateNarrative } from '@/lib/services/narrative-service';
+
+export const NarrativeDemo: React.FC = () => {
+ const [goal, setGoal] = useState("");
+ const [world, setWorld] = useState(WorldType.CYBER);
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+
+ const handleGenerate = async () => {
+ if (!goal.trim()) return;
+ setLoading(true);
+ setResult(null);
+
+ // Artificial delay for dramatic effect if API is too fast
+ const [data] = await Promise.all([
+ generateNarrative(goal, world),
+ new Promise(r => setTimeout(r, 1200))
+ ]);
+
+ setResult(data);
+ setLoading(false);
+ };
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
+
+ Reality Engine v2.5
+
+
setWorld(e.target.value as WorldType)}
+ className="bg-transparent text-xs text-zinc-400 border-b border-zinc-800 focus:border-blue-500 outline-none pb-1 transition-colors cursor-pointer"
+ >
+ {Object.values(WorldType).map(w => (
+ {w}
+ ))}
+
+
+
+ {/* Input Area */}
+
+
+
Current Objective
+
+
setGoal(e.target.value)}
+ placeholder="e.g. Finish my portfolio website..."
+ className="w-full bg-zinc-900/50 border border-zinc-800 rounded-lg px-4 py-3 text-white placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-blue-500/50 transition-all"
+ onKeyDown={(e) => e.key === 'Enter' && handleGenerate()}
+ />
+
+ {loading ? : }
+
+
+
+
+ {/* Output Area */}
+
+
+ {result ? (
+
+ {result.title}
+ {result.content}
+
+ {result.tags.map((tag, i) => (
+
+ {tag}
+
+ ))}
+
+
+ ) : (
+
+
+ Awaiting Input
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/Psychology.tsx b/packages/frontend/src/components/marketing/Psychology.tsx
new file mode 100644
index 0000000..8379666
--- /dev/null
+++ b/packages/frontend/src/components/marketing/Psychology.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Brain, Sparkles, Swords } from 'lucide-react';
+
+export const Psychology: React.FC = () => {
+ return (
+
+
+
+
+ The Science of Story
+
+
+ Your brain is wired for adversity.
+
+
+ Neuroscience shows that dopamine isn't released when you complete a task—it's released when you perceive progress towards a meaningful goal.
+
+
+ Checklists feel like chores because they lack context. Stories feel like adventures because they have stakes. Delight hacks your dopamine system by turning "sending an email" into "forging an alliance".
+
+
+
+
+
+
+
+
+
Adversity is Fuel
+
We reframe setbacks as plot twists, preventing the shame spiral.
+
+
+
+
+
+
+
+
Identity Shifting
+
You don't "do" tasks. You "become" the character who does them.
+
+
+
+
+
+
+
+
+
+
+
The Old Way (ToDo Lists)
+
+
+
+
+
+
The Delight Way
+
+
+
+
Mission: Career
+
Secure the Alliance
+
Dispatch communique to leadership.
+
+
+
+
Mission: Vitality
+
Forge the Foundation
+
Strengthen lower pillars (Legs).
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/WhyDelight.tsx b/packages/frontend/src/components/marketing/WhyDelight.tsx
new file mode 100644
index 0000000..b481a62
--- /dev/null
+++ b/packages/frontend/src/components/marketing/WhyDelight.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import React from 'react';
+import { motion } from 'framer-motion';
+
+export const WhyDelight: React.FC = () => {
+ return (
+
+ {/* Subtle Background Texture */}
+
+
+
+
+
+
+ {/* Giant Background Text */}
+
+ MANIFESTO
+
+
+
+
+
Why We Built This
+
+
+ The world wants you to be a
+ Passive Consumer.
+
+
+
+
+
+ We believe that ambition is the most precious resource on the planet.
+ Yet, the tools we use to manage it—to-do lists, calendars, spreadsheets—are
+ designed for robots, not humans.
+
+
+ Delight is different. We use the psychology of video games
+ and the structure of narrative to turn your life into something playable.
+
+
+ Because when you feel like the protagonist of a story, you don't burn out.
+ You develop character.
+
+
+
+
+
+
+ Identity over Productivity
+
+
+
+ Story over Statistics
+
+
+
+ Co-op over Solo
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/WorldMap.tsx b/packages/frontend/src/components/marketing/WorldMap.tsx
new file mode 100644
index 0000000..b8a4737
--- /dev/null
+++ b/packages/frontend/src/components/marketing/WorldMap.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { MapPin, Book, Sword, Coffee, Tent, Wifi, Radio } from 'lucide-react';
+
+interface Zone {
+ id: string;
+ name: string;
+ desc: string;
+ count: number;
+ icon: React.ElementType;
+ x: number;
+ y: number;
+ color: string;
+}
+
+const ZONES: Zone[] = [
+ { id: 'library', name: 'The Great Archive', desc: 'Deep Work • Silence Enforced', count: 432, icon: Book, x: 25, y: 35, color: 'text-cyan-400' },
+ { id: 'arena', name: 'The Proving Grounds', desc: 'High Intensity • PvP Sprints', count: 156, icon: Sword, x: 70, y: 25, color: 'text-rose-400' },
+ { id: 'hearth', name: 'The Hearth', desc: 'Social Lounge • Recovery', count: 89, icon: Coffee, x: 45, y: 65, color: 'text-amber-400' },
+ { id: 'wilds', name: 'The Unknown', desc: 'Exploration • Quest Finding', count: 210, icon: Tent, x: 75, y: 70, color: 'text-emerald-400' },
+];
+
+export const WorldMap: React.FC = () => {
+ const [activeZone, setActiveZone] = useState(null);
+
+ return (
+
+ {/* Header / HUD */}
+
+
+
+
+ Live Satellite Feed
+
+
+ The Open World
+
+
+
+
+ 1,842
+ ONLINE
+
+
+ 4
+ SECTORS
+
+
+ STABLE
+ NETWORK
+
+
+
+
+ {/* Map Visualizer */}
+
+
+ {/* Grid Background */}
+
+
+ {/* Atmospheric Fog */}
+
+
+
+
+ {/* Connection Lines */}
+
+
+
+
+
+
+ {/* Zones */}
+ {ZONES.map((zone) => {
+ const isActive = activeZone === zone.id;
+ const Icon = zone.icon;
+
+ return (
+
setActiveZone(zone.id)}
+ onMouseLeave={() => setActiveZone(null)}
+ >
+ {/* Zone Marker */}
+
+
+
+ {/* Orbiting particles */}
+ {isActive && (
+
+ )}
+
+
+ {/* Label */}
+
+
{zone.name}
+
+
+ {zone.count} Agents
+
+
+
+ {/* Tooltip Card */}
+
+ {isActive && (
+
+ {zone.name}
+ {zone.desc}
+
+ Signal Strength: 100%
+
+
+ )}
+
+
+ );
+ })}
+
+ {/* Moving Dots (Users) - More of them, faster */}
+ {[...Array(20)].map((_, i) => (
+
+ ))}
+
+ {/* Location UI Overlay */}
+
+
Current Sector
+
NEO-TOKYO
+
35.6762° N, 139.6503° E
+
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/hero-animation.tsx b/packages/frontend/src/components/marketing/hero-animation.tsx
index daf59da..e94c2da 100644
--- a/packages/frontend/src/components/marketing/hero-animation.tsx
+++ b/packages/frontend/src/components/marketing/hero-animation.tsx
@@ -3,17 +3,17 @@
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
+const narrativePhases = [
+ "Day 5: You open Delight, feeling overwhelmed by your project backlog...",
+ "Eliza greets you: 'Let's turn that anxiety into action. What matters most today?'",
+ "Breaking down your presentation into three focused missions...",
+];
+
export function HeroAnimation() {
const [currentPhase, setCurrentPhase] = useState(0);
const [narrativeText, setNarrativeText] = useState("");
const [progress, setProgress] = useState(0);
- const narrativePhases = [
- "Day 5: You open Delight, feeling overwhelmed by your project backlog...",
- "Eliza greets you: 'Let's turn that anxiety into action. What matters most today?'",
- "Breaking down your presentation into three focused missions...",
- ];
-
const missions = [
{ id: 1, title: "Research key points", time: "20 min", points: 15 },
{ id: 2, title: "Create outline", time: "30 min", points: 25 },
@@ -95,12 +95,16 @@ export function HeroAnimation() {
strokeDasharray={`${2 * Math.PI * 40}`}
strokeDashoffset={`${2 * Math.PI * 40 * (1 - progress / 100)}`}
initial={{ strokeDashoffset: 2 * Math.PI * 40 }}
- animate={{ strokeDashoffset: 2 * Math.PI * 40 * (1 - progress / 100) }}
+ animate={{
+ strokeDashoffset: 2 * Math.PI * 40 * (1 - progress / 100),
+ }}
transition={{ duration: 0.8, ease: "easeOut" }}
/>
- {Math.round(progress)}%
+
+ {Math.round(progress)}%
+
@@ -136,16 +140,24 @@ export function HeroAnimation() {
>
- {mission.id}
+
+ {mission.id}
+
-
{mission.title}
-
{mission.time}
+
+ {mission.title}
+
+
+ {mission.time}
+
Progress
-
+{mission.points} XP
+
+ +{mission.points} XP
+
))}
diff --git a/packages/frontend/src/components/marketing/ui/Globe.tsx b/packages/frontend/src/components/marketing/ui/Globe.tsx
new file mode 100644
index 0000000..c58593f
--- /dev/null
+++ b/packages/frontend/src/components/marketing/ui/Globe.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import React from 'react';
+import { motion } from 'framer-motion';
+
+export const Globe: React.FC = () => {
+ return (
+
+ {/* Atmosphere Halo */}
+
+
+
+ {/* Core */}
+
+
+ {/* Latitudes - Glowing Rings */}
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+ {/* Longitudes - Vertical Arcs */}
+ {[...Array(8)].map((_, i) => (
+
+ ))}
+
+ {/* Floating Data Points */}
+ {[...Array(12)].map((_, i) => (
+
+ ))}
+
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/ui/ScrollProgress.tsx b/packages/frontend/src/components/marketing/ui/ScrollProgress.tsx
new file mode 100644
index 0000000..71fca39
--- /dev/null
+++ b/packages/frontend/src/components/marketing/ui/ScrollProgress.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { motion } from 'framer-motion';
+
+const SECTIONS = [
+ { id: 'hero', label: '00 START' },
+ { id: 'psychology', label: '01 PSYCH' },
+ { id: 'mission', label: '02 ENGINE' },
+ { id: 'squad', label: '03 SQUAD' },
+ { id: 'world', label: '04 WORLD' },
+ { id: 'growth', label: '05 GROWTH' },
+ { id: 'manifesto', label: '06 END' },
+];
+
+export const ScrollProgress: React.FC = () => {
+ const [activeSection, setActiveSection] = useState('hero');
+
+ useEffect(() => {
+ const handleScroll = () => {
+ const sections = SECTIONS.map(s => document.getElementById(s.id));
+ const scrollPosition = window.scrollY + window.innerHeight / 3;
+
+ for (const section of sections) {
+ if (section && section.offsetTop <= scrollPosition && (section.offsetTop + section.offsetHeight) > scrollPosition) {
+ setActiveSection(section.id);
+ }
+ }
+ };
+
+ window.addEventListener('scroll', handleScroll);
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ return (
+
+ );
+};
diff --git a/packages/frontend/src/components/marketing/ui/SectionFrame.tsx b/packages/frontend/src/components/marketing/ui/SectionFrame.tsx
new file mode 100644
index 0000000..fb5e6a8
--- /dev/null
+++ b/packages/frontend/src/components/marketing/ui/SectionFrame.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import React from 'react';
+import { clsx } from 'clsx';
+import { motion } from 'framer-motion';
+
+interface SectionFrameProps {
+ children: React.ReactNode;
+ className?: string;
+ id?: string;
+ label?: string;
+}
+
+export const SectionFrame: React.FC = ({ children, className, id, label }) => {
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+ {children}
+
+ );
+};
diff --git a/packages/frontend/src/components/navigation/main-nav.tsx b/packages/frontend/src/components/navigation/main-nav.tsx
index f7f11b1..d9cb34b 100644
--- a/packages/frontend/src/components/navigation/main-nav.tsx
+++ b/packages/frontend/src/components/navigation/main-nav.tsx
@@ -1,9 +1,8 @@
"use client";
import Link from "next/link";
-import Image from "next/image";
import { useState } from "react";
-import { Menu, X } from "lucide-react";
+import { Menu, X, MoveUpRight } from "lucide-react";
import {
SignInButton,
SignUpButton,
@@ -16,109 +15,132 @@ export function MainNav() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
-
-
-
- {/* Logo and Brand */}
-
-
-
- Delight
-
+
+ {/* Logo and Brand */}
+
+
+
+ Delight.
+
+
+
+ {/* Desktop Navigation */}
+
+
+ Product
+
+
+ Why Delight
+
+
+ Future
+
+
+
+ Companion
+
+
+ Waitlist
+
+
- {/* Desktop Navigation */}
-
-
- Product
-
-
- Why Delight
-
-
- Future
-
-
-
- Companion
-
-
-
- Waitlist
-
+ {/* Desktop CTA */}
+
+
+
+
+ Sign In
+
+
+
+
+ Enter Simulation
+
+
+
+
+
+
+
+
- {/* Desktop CTA */}
-
-
-
-
- Sign In
-
-
-
-
- Sign Up
-
-
-
-
-
-
-
+ {/* Mobile menu button */}
+
setMobileMenuOpen(!mobileMenuOpen)}
+ className="md:hidden p-2 text-white transition-colors"
+ aria-label="Toggle menu"
+ >
+ {mobileMenuOpen ? : }
+
- {/* Mobile menu button */}
-
setMobileMenuOpen(!mobileMenuOpen)}
- className="md:hidden p-2 text-muted-foreground hover:text-foreground transition-colors"
- aria-label="Toggle menu"
- >
- {mobileMenuOpen ? : }
-
-
+ {/* Mobile Navigation */}
+ {mobileMenuOpen && (
+
+
+ {/* Mobile Header */}
+
+
setMobileMenuOpen(false)}
+ >
+
+
+ Delight.
+
+
+
setMobileMenuOpen(false)}
+ className="p-2 text-white"
+ aria-label="Close menu"
+ >
+
+
+
- {/* Mobile Navigation */}
- {mobileMenuOpen && (
-
-
+ {/* Mobile Navigation Links */}
+
setMobileMenuOpen(false)}
>
Product
setMobileMenuOpen(false)}
>
Why Delight
setMobileMenuOpen(false)}
>
Future
@@ -126,7 +148,7 @@ export function MainNav() {
setMobileMenuOpen(false)}
>
Companion
@@ -134,34 +156,42 @@ export function MainNav() {
setMobileMenuOpen(false)}
>
Waitlist
-
+
+ {/* Mobile Auth Buttons */}
+
-
+ setMobileMenuOpen(false)}
+ >
Sign In
-
- Sign Up
+ setMobileMenuOpen(false)}
+ >
+ Enter Simulation
-
- )}
-
-
+
+ )}
+
);
}
diff --git a/packages/frontend/src/lib/api/experimental-client.ts b/packages/frontend/src/lib/api/experimental-client.ts
new file mode 100644
index 0000000..5427033
--- /dev/null
+++ b/packages/frontend/src/lib/api/experimental-client.ts
@@ -0,0 +1,801 @@
+/**
+ * API Client for Experimental Backend Server
+ *
+ * This client connects to the experimental agent backend running on port 8001
+ * and provides type-safe access to all experimental features:
+ * - Chat with AI agent
+ * - Memory management and search
+ * - Analytics and token usage
+ * - Configuration management
+ * - Real-time updates via WebSocket
+ */
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface Memory {
+ id: string;
+ content: string;
+ memory_type: string;
+ user_id: string;
+ metadata: Record
;
+ created_at: string;
+ embedding?: number[];
+}
+
+export interface SearchResult {
+ id: string;
+ content: string;
+ memory_type: string;
+ score: number;
+ metadata?: Record;
+}
+
+export interface MemoryStats {
+ total_memories: number;
+ by_type: Record;
+ by_category: Record;
+ total_embeddings: number;
+ avg_embedding_time_ms: number;
+ storage_size_bytes: number;
+}
+
+export interface TokenUsage {
+ model: string;
+ tokens_input: number;
+ tokens_output: number;
+ cost: number;
+ timestamp?: string;
+}
+
+export interface TokenUsageSummary {
+ total_tokens: number;
+ total_cost: number;
+ by_model: Record<
+ string,
+ {
+ tokens: number;
+ cost: number;
+ }
+ >;
+}
+
+export interface SystemConfig {
+ models: {
+ chat_model: string;
+ reasoning_model: string;
+ expensive_model: string;
+ embedding_model: string;
+ };
+ search: {
+ similarity_threshold: number;
+ default_search_limit: number;
+ hybrid_search_weight_vector: number;
+ graph_traversal_max_depth: number;
+ };
+ fact_extraction: {
+ max_facts_per_message: number;
+ auto_categorize: boolean;
+ max_categories_per_fact: number;
+ min_fact_length: number;
+ };
+}
+
+export interface GraphData {
+ nodes: Array<{
+ id: string;
+ label: string;
+ type: string;
+ categories: string[];
+ created_at: string;
+ }>;
+ edges: Array<{
+ source: string;
+ target: string;
+ type: string;
+ }>;
+}
+
+export interface ChatMessage {
+ role: "user" | "assistant" | "system";
+ content: string;
+ timestamp?: string;
+ memories_used?: SearchResult[];
+ memories_created?: Memory[];
+}
+
+export interface ChatRequest {
+ message: string;
+ user_id?: string;
+ conversation_history?: Array<{
+ role: string;
+ content: string;
+ timestamp?: string;
+ }>;
+}
+
+export interface ChatResponse {
+ response: string;
+ memories_retrieved: Array<{
+ id: string;
+ content: string;
+ memory_type: string;
+ score?: number;
+ categories?: string[];
+ }>;
+ memories_created: Array<{
+ id: string;
+ content: string;
+ memory_type: string;
+ categories?: string[];
+ }>;
+ timestamp: string;
+}
+
+export interface Conversation {
+ id: string;
+ user_id: string;
+ title: string;
+ message_count: number;
+ is_archived: boolean;
+ created_at: string;
+ updated_at: string;
+ messages?: ConversationMessage[];
+}
+
+export interface ConversationMessage {
+ id: string;
+ conversation_id: string;
+ user_id: string;
+ role: "user" | "assistant" | "system";
+ content: string;
+ metadata?: {
+ memories_retrieved?: SearchResult[];
+ memories_created?: Memory[];
+ };
+ created_at: string;
+}
+
+// ============================================================================
+// API Client
+// ============================================================================
+
+/**
+ * FIXES APPLIED (2024):
+ * =====================
+ * 1. Request Caching: Added 2-second cache for GET requests
+ * - Prevents duplicate API calls when components re-render rapidly
+ * - Reduces backend load from excessive polling
+ * - Cache is automatically cleared after mutations (create/delete/update)
+ *
+ * 2. Error Handling: Improved error messages for network failures
+ * - Better diagnostics for connection issues
+ * - Helpful messages for ngrok/network access scenarios
+ */
+class ExperimentalAPIClient {
+ private baseUrl: string;
+ private wsUrl: string;
+ private ws: WebSocket | null = null;
+ private wsCallbacks: Map void> = new Map();
+ private requestCache: Map =
+ new Map();
+ private readonly CACHE_TTL = 5000; // 5 seconds cache for GET requests (increased from 2s)
+
+ constructor(baseUrl?: string) {
+ // Use environment variable if available, otherwise fallback to localhost
+ // NEXT_PUBLIC_ prefix makes it available in the browser
+ this.baseUrl =
+ baseUrl ||
+ (typeof window !== "undefined"
+ ? process.env.NEXT_PUBLIC_EXPERIMENTAL_API_URL ||
+ "http://localhost:8001"
+ : process.env.EXPERIMENTAL_API_URL || "http://localhost:8001");
+ this.wsUrl = this.baseUrl.replace("http", "ws");
+ }
+
+ // ============================================================================
+ // Core HTTP Methods
+ // ============================================================================
+
+ private async request(
+ endpoint: string,
+ options: RequestInit = {}
+ ): Promise {
+ const url = `${this.baseUrl}${endpoint}`;
+
+ try {
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...options.headers,
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`API Error (${response.status}): ${error}`);
+ }
+
+ return response.json();
+ } catch (error) {
+ // Provide helpful error message for network issues
+ if (error instanceof TypeError && error.message === "Failed to fetch") {
+ const currentHost =
+ typeof window !== "undefined" ? window.location.host : "unknown";
+ const isLocalhost =
+ this.baseUrl.includes("localhost") ||
+ this.baseUrl.includes("127.0.0.1");
+ const isNetworkAccess =
+ currentHost !== "localhost" &&
+ currentHost !== "127.0.0.1" &&
+ !currentHost.includes("localhost");
+
+ if (isLocalhost && isNetworkAccess) {
+ throw new Error(
+ `Cannot connect to backend at ${this.baseUrl}. ` +
+ `When accessing the frontend via network (${currentHost}), ` +
+ `you must set NEXT_PUBLIC_EXPERIMENTAL_API_URL to a network-accessible backend URL. ` +
+ `If using ngrok, expose the backend and set: ` +
+ `NEXT_PUBLIC_EXPERIMENTAL_API_URL=https://your-backend-ngrok-url.ngrok.io`
+ );
+ }
+ throw new Error(
+ `Failed to fetch from ${url}. ` +
+ `Make sure the experimental backend is running and accessible at ${this.baseUrl}`
+ );
+ }
+ throw error;
+ }
+ }
+
+ private pendingRequests: Map> = new Map();
+
+ private async get(endpoint: string, useCache: boolean = true): Promise {
+ // Check cache for recent requests to prevent duplicate calls
+ if (useCache) {
+ const cached = this.requestCache.get(endpoint);
+ if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
+ console.log(`📦 Cache hit for ${endpoint}`);
+ return cached.data as T;
+ }
+
+ // Check if there's already a pending request for this endpoint
+ const pending = this.pendingRequests.get(endpoint);
+ if (pending) {
+ console.log(`⏳ Reusing pending request for ${endpoint}`);
+ return pending as Promise;
+ }
+ }
+
+ // Create new request and track it
+ const requestPromise = this.request(endpoint, { method: "GET" })
+ .then((data) => {
+ // Cache the result
+ if (useCache) {
+ this.requestCache.set(endpoint, { data, timestamp: Date.now() });
+ }
+ // Remove from pending requests
+ this.pendingRequests.delete(endpoint);
+ return data;
+ })
+ .catch((error) => {
+ // Remove from pending requests on error
+ this.pendingRequests.delete(endpoint);
+ throw error;
+ });
+
+ if (useCache) {
+ this.pendingRequests.set(endpoint, requestPromise);
+ }
+
+ return requestPromise;
+ }
+
+ private async post(endpoint: string, data?: any): Promise {
+ return this.request(endpoint, {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ }
+
+ private async delete(endpoint: string): Promise {
+ return this.request(endpoint, { method: "DELETE" });
+ }
+
+ // ============================================================================
+ // Health Check
+ // ============================================================================
+
+ async healthCheck(): Promise<{
+ status: string;
+ timestamp: string;
+ storage: string;
+ version: string;
+ }> {
+ return this.get("/health");
+ }
+
+ // ============================================================================
+ // Configuration API
+ // ============================================================================
+
+ async getConfig(): Promise {
+ return this.get("/api/config");
+ }
+
+ async updateConfig(
+ config: SystemConfig
+ ): Promise<{ status: string; message: string }> {
+ return this.post("/api/config", config);
+ }
+
+ async getAvailableModels(): Promise<{
+ chat: string[];
+ reasoning: string[];
+ embedding: string[];
+ }> {
+ return this.get("/api/models/available");
+ }
+
+ // ============================================================================
+ // Analytics API
+ // ============================================================================
+
+ async getMemoryStats(userId?: string): Promise {
+ const params = userId ? `?user_id=${userId}` : "";
+ return this.get(`/api/analytics/stats${params}`);
+ }
+
+ async getTokenUsage(
+ hours: number = 24,
+ userId?: string
+ ): Promise {
+ const params = new URLSearchParams();
+ params.append("hours", hours.toString());
+ if (userId) params.append("user_id", userId);
+ return this.get(`/api/analytics/token-usage?${params.toString()}`);
+ }
+
+ async getSearchPerformance(limit: number = 100): Promise {
+ return this.get(`/api/analytics/search-performance?limit=${limit}`);
+ }
+
+ async recordTokenUsage(usage: TokenUsage): Promise<{ status: string }> {
+ return this.post("/api/analytics/token-usage", usage);
+ }
+
+ // ============================================================================
+ // Memory API
+ // ============================================================================
+
+ async getMemories(filters?: {
+ user_id?: string;
+ memory_type?: string;
+ category?: string;
+ limit?: number;
+ }): Promise {
+ const params = new URLSearchParams();
+ if (filters?.user_id) params.append("user_id", filters.user_id);
+ if (filters?.memory_type) params.append("memory_type", filters.memory_type);
+ if (filters?.category) params.append("category", filters.category);
+ if (filters?.limit) params.append("limit", filters.limit.toString());
+
+ const queryString = params.toString();
+ return this.get(`/api/memories${queryString ? `?${queryString}` : ""}`);
+ }
+
+ async getMemory(memoryId: string): Promise {
+ return this.get(`/api/memories/${memoryId}`);
+ }
+
+ async updateMemory(
+ memoryId: string,
+ updates: {
+ content?: string;
+ importance?: number;
+ metadata?: Record;
+ }
+ ): Promise {
+ return this.request(`/api/memories/${memoryId}`, {
+ method: "PUT",
+ body: JSON.stringify(updates),
+ });
+ }
+
+ async deleteMemory(
+ memoryId: string
+ ): Promise<{ status: string; message: string }> {
+ return this.delete(`/api/memories/${memoryId}`);
+ }
+
+ async getCategoryHierarchy(
+ userId?: string
+ ): Promise>> {
+ const params = userId ? `?user_id=${userId}` : "";
+ return this.get(`/api/memories/categories/hierarchy${params}`);
+ }
+
+ // ============================================================================
+ // Graph API (Phase 3)
+ // ============================================================================
+
+ /**
+ * Get basic memory graph (legacy format)
+ */
+ async getMemoryGraph(
+ userId?: string,
+ limit: number = 100
+ ): Promise {
+ const params = new URLSearchParams();
+ if (userId) params.append("user_id", userId);
+ params.append("limit", limit.toString());
+
+ return this.get(`/api/graph/memories?${params.toString()}`);
+ }
+
+ /**
+ * Get graph visualization data (React Flow format)
+ */
+ async getGraphVisualization(
+ userId: string,
+ entityType?: string,
+ limit: number = 50
+ ): Promise<{
+ nodes: Array<{
+ id: string;
+ label: string;
+ type: string;
+ content: string;
+ attributes: Record;
+ created_at: string | null;
+ }>;
+ edges: Array<{
+ id: string;
+ source: string;
+ target: string;
+ label: string;
+ strength: number;
+ metadata: Record;
+ }>;
+ }> {
+ const params = new URLSearchParams();
+ if (entityType) params.append("entity_type", entityType);
+ params.append("limit", limit.toString());
+
+ return this.get(`/api/graph/visualize/${userId}?${params.toString()}`);
+ }
+
+ /**
+ * Create a typed relationship between two memories
+ */
+ async createRelationship(request: {
+ from_memory_id: string;
+ to_memory_id: string;
+ relationship_type: string;
+ strength?: number;
+ metadata?: Record;
+ bidirectional?: boolean;
+ }): Promise<{
+ status: string;
+ relationship: {
+ id: string;
+ from_memory_id: string;
+ to_memory_id: string;
+ relationship_type: string;
+ strength: number;
+ metadata: Record;
+ created_at: string;
+ };
+ }> {
+ return this.post("/api/graph/relationship", request);
+ }
+
+ /**
+ * Get all relationships for a specific memory
+ */
+ async getRelationships(
+ memoryId: string,
+ relationshipType?: string
+ ): Promise<{
+ memory_id: string;
+ relationships: Array<{
+ id: string;
+ from_memory_id: string;
+ to_memory_id: string;
+ relationship_type: string;
+ strength: number;
+ metadata: Record;
+ related_memory: {
+ id: string;
+ content: string;
+ entity_id: string | null;
+ entity_type: string | null;
+ };
+ direction: "outgoing" | "incoming";
+ }>;
+ }> {
+ const params = relationshipType
+ ? `?relationship_type=${relationshipType}`
+ : "";
+ return this.get(`/api/graph/relationships/${memoryId}${params}`);
+ }
+
+ /**
+ * Traverse the graph from a starting memory
+ */
+ async traverseGraph(
+ startMemoryId: string,
+ maxDepth: number = 3,
+ minStrength: number = 0.5,
+ relationshipType?: string
+ ): Promise<{
+ start_memory_id: string;
+ paths: Array<{
+ nodes: Array<{
+ memory_id: string;
+ entity_id: string | null;
+ entity_type: string | null;
+ content: string;
+ depth: number;
+ }>;
+ edges: Array<{
+ from_id: string;
+ to_id: string;
+ relationship_type: string;
+ strength: number;
+ }>;
+ total_strength: number;
+ }>;
+ }> {
+ const params = new URLSearchParams();
+ params.append("max_depth", maxDepth.toString());
+ params.append("min_strength", minStrength.toString());
+ if (relationshipType) params.append("relationship_type", relationshipType);
+
+ return this.get(
+ `/api/graph/traverse/${startMemoryId}?${params.toString()}`
+ );
+ }
+
+ /**
+ * Find the shortest path between two memories
+ */
+ async findShortestPath(
+ fromMemoryId: string,
+ toMemoryId: string,
+ maxDepth: number = 5
+ ): Promise<{
+ from_memory_id: string;
+ to_memory_id: string;
+ path: Array<{
+ memory_id: string;
+ entity_id: string | null;
+ content: string;
+ relationship_to_next: string | null;
+ }> | null;
+ path_length: number;
+ }> {
+ const params = new URLSearchParams();
+ params.append("max_depth", maxDepth.toString());
+
+ return this.get(
+ `/api/graph/shortest-path/${fromMemoryId}/${toMemoryId}?${params.toString()}`
+ );
+ }
+
+ /**
+ * Get the complete entity graph for a user
+ */
+ async getEntityGraph(
+ userId: string,
+ entityType?: string
+ ): Promise<{
+ user_id: string;
+ entity_graph: Record<
+ string,
+ Array<{
+ to_entity_id: string;
+ relationship_type: string;
+ strength: number;
+ }>
+ >;
+ }> {
+ const params = entityType ? `?entity_type=${entityType}` : "";
+ return this.get(`/api/graph/entity-graph/${userId}${params}`);
+ }
+
+ // ============================================================================
+ // Chat API
+ // ============================================================================
+
+ async sendChatMessage(request: ChatRequest): Promise {
+ return this.post("/api/chat/message", request);
+ }
+
+ async checkChatHealth(): Promise<{
+ status: string;
+ service: string;
+ timestamp: string;
+ }> {
+ return this.get("/api/chat/health");
+ }
+
+ // ============================================================================
+ // User API
+ // ============================================================================
+
+ async ensureUser(
+ userId: string
+ ): Promise<{ status: string; user_id: string; message: string }> {
+ return this.post("/api/users/ensure", { user_id: userId });
+ }
+
+ // ============================================================================
+ // Conversation API
+ // ============================================================================
+
+ async createConversation(
+ userId: string,
+ title?: string
+ ): Promise {
+ return this.post("/api/conversations/", { user_id: userId, title });
+ }
+
+ async getConversations(
+ userId: string,
+ includeArchived = false,
+ useCache: boolean = true
+ ): Promise {
+ const params = new URLSearchParams();
+ params.append("user_id", userId);
+ if (includeArchived) params.append("include_archived", "true");
+
+ return this.get(`/api/conversations/?${params.toString()}`, useCache);
+ }
+
+ // Clear cache (useful after mutations)
+ clearCache(endpoint?: string): void {
+ if (endpoint) {
+ this.requestCache.delete(endpoint);
+ this.pendingRequests.delete(endpoint);
+ console.log(`🗑️ Cleared cache for ${endpoint}`);
+ } else {
+ this.requestCache.clear();
+ this.pendingRequests.clear();
+ console.log(`🗑️ Cleared all cache`);
+ }
+ }
+
+ async getConversation(
+ conversationId: string,
+ useCache: boolean = true
+ ): Promise {
+ return this.get(`/api/conversations/${conversationId}`, useCache);
+ }
+
+ async saveMessage(
+ conversationId: string,
+ userId: string,
+ role: "user" | "assistant" | "system",
+ content: string,
+ metadata?: {
+ memories_retrieved?: SearchResult[];
+ memories_created?: Memory[];
+ }
+ ): Promise {
+ return this.post("/api/conversations/messages", {
+ conversation_id: conversationId,
+ user_id: userId,
+ role,
+ content,
+ metadata,
+ });
+ }
+
+ async deleteConversation(
+ conversationId: string
+ ): Promise<{ status: string; message: string }> {
+ return this.delete(`/api/conversations/${conversationId}`);
+ }
+
+ async archiveConversation(
+ conversationId: string
+ ): Promise<{ status: string; message: string }> {
+ return this.post(`/api/conversations/${conversationId}/archive`, {});
+ }
+
+ // ============================================================================
+ // WebSocket - Real-time Updates
+ // ============================================================================
+
+ connectWebSocket(onUpdate: (data: any) => void): void {
+ if (this.ws) {
+ this.ws.close();
+ }
+
+ this.ws = new WebSocket(`${this.wsUrl}/ws/updates`);
+
+ this.ws.onopen = () => {
+ console.log("✅ WebSocket connected");
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ onUpdate(data);
+
+ // Call specific callbacks
+ this.wsCallbacks.forEach((callback) => {
+ callback(data);
+ });
+ } catch (error) {
+ console.error("WebSocket message error:", error);
+ }
+ };
+
+ this.ws.onerror = (error) => {
+ console.error("WebSocket error:", error);
+ };
+
+ this.ws.onclose = () => {
+ console.log("WebSocket disconnected");
+ // Auto-reconnect after 3 seconds
+ setTimeout(() => {
+ if (this.ws?.readyState === WebSocket.CLOSED) {
+ this.connectWebSocket(onUpdate);
+ }
+ }, 3000);
+ };
+ }
+
+ disconnectWebSocket(): void {
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+ }
+
+ onWebSocketUpdate(id: string, callback: (data: any) => void): void {
+ this.wsCallbacks.set(id, callback);
+ }
+
+ offWebSocketUpdate(id: string): void {
+ this.wsCallbacks.delete(id);
+ }
+
+ sendWebSocketMessage(data: any): void {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(data));
+ }
+ }
+}
+
+// ============================================================================
+// Export Singleton Instance
+// ============================================================================
+
+// Create instance with environment variable support
+// In browser: uses NEXT_PUBLIC_EXPERIMENTAL_API_URL
+// On server: uses EXPERIMENTAL_API_URL
+const getExperimentalAPIUrl = (): string => {
+ if (typeof window !== "undefined") {
+ // Client-side: use NEXT_PUBLIC_ prefixed variable
+ return (
+ process.env.NEXT_PUBLIC_EXPERIMENTAL_API_URL || "http://localhost:8001"
+ );
+ } else {
+ // Server-side: use non-prefixed variable
+ return (
+ process.env.EXPERIMENTAL_API_URL ||
+ process.env.NEXT_PUBLIC_EXPERIMENTAL_API_URL ||
+ "http://localhost:8001"
+ );
+ }
+};
+
+export const experimentalAPI = new ExperimentalAPIClient(
+ getExperimentalAPIUrl()
+);
+export default experimentalAPI;
diff --git a/packages/frontend/src/lib/hooks/useExperimentalAPI.ts b/packages/frontend/src/lib/hooks/useExperimentalAPI.ts
new file mode 100644
index 0000000..d5ff042
--- /dev/null
+++ b/packages/frontend/src/lib/hooks/useExperimentalAPI.ts
@@ -0,0 +1,285 @@
+/**
+ * React Hooks for Experimental API
+ *
+ * Provides easy-to-use hooks for accessing the experimental backend API
+ */
+
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import experimentalAPI, {
+ Memory,
+ MemoryStats,
+ SystemConfig,
+ TokenUsageSummary,
+ GraphData,
+ ChatMessage,
+} from '../api/experimental-client';
+
+// ============================================================================
+// useMemoryStats - Get memory statistics
+// ============================================================================
+
+export function useMemoryStats(userId?: string, autoRefresh?: boolean, refreshInterval?: number) {
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const data = await experimentalAPI.getMemoryStats(userId);
+ setStats(data);
+ } catch (err) {
+ setError(err as Error);
+ } finally {
+ setLoading(false);
+ }
+ }, [userId]);
+
+ useEffect(() => {
+ refresh();
+
+ // Auto-refresh if enabled
+ if (autoRefresh) {
+ const interval = setInterval(refresh, refreshInterval || 5000);
+ return () => clearInterval(interval);
+ }
+ }, [refresh, autoRefresh, refreshInterval]);
+
+ return { stats, loading, error, refresh };
+}
+
+// ============================================================================
+// useMemories - Get and manage memories
+// ============================================================================
+
+export function useMemories(filters?: {
+ user_id?: string;
+ memory_type?: string;
+ category?: string;
+ limit?: number;
+ autoRefresh?: boolean;
+ refreshInterval?: number; // in milliseconds
+}) {
+ const [memories, setMemories] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Stringify filters to prevent infinite loop from object reference changes
+ const filtersString = JSON.stringify(filters || {});
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const parsedFilters = filtersString ? JSON.parse(filtersString) : undefined;
+ const data = await experimentalAPI.getMemories(parsedFilters);
+ setMemories(data);
+ } catch (err) {
+ setError(err as Error);
+ } finally {
+ setLoading(false);
+ }
+ }, [filtersString]); // Use stringified version to prevent object reference issues
+
+ const deleteMemory = useCallback(async (memoryId: string) => {
+ try {
+ await experimentalAPI.deleteMemory(memoryId);
+ setMemories((prev) => prev.filter((m) => m.id !== memoryId));
+ } catch (err) {
+ throw err;
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+
+ // Auto-refresh if enabled
+ if (filters?.autoRefresh) {
+ const interval = setInterval(refresh, filters.refreshInterval || 5000);
+ return () => clearInterval(interval);
+ }
+ }, [refresh, filters?.autoRefresh, filters?.refreshInterval]);
+
+ return { memories, loading, error, refresh, deleteMemory };
+}
+
+// ============================================================================
+// useConfig - Get and update configuration
+// ============================================================================
+
+export function useConfig() {
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const data = await experimentalAPI.getConfig();
+ setConfig(data);
+ } catch (err) {
+ setError(err as Error);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const updateConfig = useCallback(async (newConfig: SystemConfig) => {
+ try {
+ setSaving(true);
+ setError(null);
+ await experimentalAPI.updateConfig(newConfig);
+ setConfig(newConfig);
+ } catch (err) {
+ setError(err as Error);
+ throw err;
+ } finally {
+ setSaving(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return { config, loading, error, saving, refresh, updateConfig };
+}
+
+// ============================================================================
+// useTokenUsage - Get token usage analytics
+// ============================================================================
+
+export function useTokenUsage(hours: number = 24, userId?: string, autoRefresh?: boolean, refreshInterval?: number) {
+ const [usage, setUsage] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const data = await experimentalAPI.getTokenUsage(hours, userId);
+ setUsage(data);
+ } catch (err) {
+ setError(err as Error);
+ } finally {
+ setLoading(false);
+ }
+ }, [hours, userId]);
+
+ useEffect(() => {
+ refresh();
+
+ // Auto-refresh if enabled
+ if (autoRefresh) {
+ const interval = setInterval(refresh, refreshInterval || 5000);
+ return () => clearInterval(interval);
+ }
+ }, [refresh, autoRefresh, refreshInterval]);
+
+ return { usage, loading, error, refresh };
+}
+
+// ============================================================================
+// useMemoryGraph - Get memory graph data
+// ============================================================================
+
+export function useMemoryGraph(userId?: string, limit: number = 100) {
+ const [graph, setGraph] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const data = await experimentalAPI.getMemoryGraph(userId, limit);
+ setGraph(data);
+ } catch (err) {
+ setError(err as Error);
+ } finally {
+ setLoading(false);
+ }
+ }, [userId, limit]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return { graph, loading, error, refresh };
+}
+
+// ============================================================================
+// useWebSocket - Real-time updates
+// ============================================================================
+
+export function useWebSocket(onUpdate?: (data: any) => void) {
+ const [connected, setConnected] = useState(false);
+ const [lastUpdate, setLastUpdate] = useState(null);
+
+ useEffect(() => {
+ const handleUpdate = (data: any) => {
+ setLastUpdate(data);
+ onUpdate?.(data);
+ };
+
+ experimentalAPI.connectWebSocket(handleUpdate);
+ setConnected(true);
+
+ // Check connection status periodically
+ const interval = setInterval(() => {
+ // This is a simplification - you'd check actual WebSocket state
+ setConnected(true);
+ }, 5000);
+
+ return () => {
+ clearInterval(interval);
+ experimentalAPI.disconnectWebSocket();
+ setConnected(false);
+ };
+ }, [onUpdate]);
+
+ const send = useCallback((data: any) => {
+ experimentalAPI.sendWebSocketMessage(data);
+ }, []);
+
+ return { connected, lastUpdate, send };
+}
+
+// ============================================================================
+// useHealthCheck - Check API health
+// ============================================================================
+
+export function useHealthCheck() {
+ const [healthy, setHealthy] = useState(false);
+ const [checking, setChecking] = useState(true);
+ const [health, setHealth] = useState(null);
+
+ const check = useCallback(async () => {
+ try {
+ setChecking(true);
+ const data = await experimentalAPI.healthCheck();
+ setHealth(data);
+ setHealthy(data.status === 'healthy');
+ } catch (err) {
+ setHealthy(false);
+ setHealth(null);
+ } finally {
+ setChecking(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ check();
+ // Check every 30 seconds
+ const interval = setInterval(check, 30000);
+ return () => clearInterval(interval);
+ }, [check]);
+
+ return { healthy, checking, health, check };
+}
diff --git a/packages/frontend/src/lib/hooks/usePersistentUser.ts b/packages/frontend/src/lib/hooks/usePersistentUser.ts
new file mode 100644
index 0000000..5cc8245
--- /dev/null
+++ b/packages/frontend/src/lib/hooks/usePersistentUser.ts
@@ -0,0 +1,77 @@
+/**
+ * Persistent User Hook
+ *
+ * Manages a persistent user ID across sessions using localStorage
+ * This ensures memories are tied to the same user even after page refresh
+ *
+ * Future: Can be replaced with Clerk user ID when auth is implemented
+ */
+
+"use client";
+
+import { useState, useEffect } from "react";
+import { generateUUID } from "@/lib/utils/uuid";
+
+const USER_ID_KEY = "experimental_user_id";
+
+export function usePersistentUser() {
+ const [userId, setUserId] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ // Only run on client side
+ if (typeof window === "undefined") {
+ setIsLoading(false);
+ return;
+ }
+
+ const initializeUser = async () => {
+ // Get or create persistent user ID
+ let stored = localStorage.getItem(USER_ID_KEY);
+
+ if (!stored) {
+ // Generate new UUID
+ stored = generateUUID();
+ localStorage.setItem(USER_ID_KEY, stored);
+ }
+
+ // Ensure user exists in database
+ try {
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ await experimentalAPI.ensureUser(stored);
+ } catch (error) {
+ console.error("Failed to ensure user exists:", error);
+ // Continue anyway - user might be using mock mode
+ }
+
+ setUserId(stored);
+ setIsLoading(false);
+ };
+
+ initializeUser();
+ }, []);
+
+ const clearUser = async () => {
+ if (typeof window !== "undefined") {
+ localStorage.removeItem(USER_ID_KEY);
+ const newId = generateUUID();
+ localStorage.setItem(USER_ID_KEY, newId);
+
+ // Ensure new user exists in database
+ try {
+ const { default: experimentalAPI } = await import(
+ "@/lib/api/experimental-client"
+ );
+ await experimentalAPI.ensureUser(newId);
+ } catch (error) {
+ console.error("Failed to ensure new user exists:", error);
+ }
+
+ setUserId(newId);
+ }
+ };
+
+ return { userId, isLoading, clearUser };
+}
diff --git a/packages/frontend/src/lib/services/narrative-service.ts b/packages/frontend/src/lib/services/narrative-service.ts
new file mode 100644
index 0000000..12a0aaf
--- /dev/null
+++ b/packages/frontend/src/lib/services/narrative-service.ts
@@ -0,0 +1,37 @@
+import { WorldType, NarrativeResponse } from '@/types/marketing';
+
+// Mock narrative generation for demo purposes
+// In production, this would call the backend API with OpenAI integration
+export const generateNarrative = async (
+ goal: string,
+ world: WorldType
+): Promise => {
+ // Simulate API delay for realistic feel
+ await new Promise(resolve => setTimeout(resolve, 800));
+
+ // Generate contextual narrative based on world type
+ const narratives: Record NarrativeResponse> = {
+ [WorldType.CYBER]: (g) => ({
+ title: 'The Neon Protocol',
+ content: `The neural interface flickers as you upload your objective: "${g}". In the sprawling megacity, every goal becomes a data packet to decrypt. Your handler assigns you a covert op—break this mission into executable subroutines. The Corporation watches, but you move in the shadows.`,
+ tags: ['Stealth', 'Execution', 'Tech'],
+ }),
+ [WorldType.MODERN]: (g) => ({
+ title: 'The Urban Quest',
+ content: `You stand at the crossroads of a bustling metropolis with one clear intention: "${g}". The city rewards those who take deliberate steps. Your companion maps out the first move—a focused mission to turn this ambition into tangible progress. The streets are yours to navigate.`,
+ tags: ['Focus', 'Momentum', 'Action'],
+ }),
+ [WorldType.FANTASY]: (g) => ({
+ title: 'The Guild Chronicle',
+ content: `The ancient scroll unfurls before you, revealing your quest: "${g}". The Guild Masters have convened. This undertaking requires more than mere intention—it demands strategy, resilience, and small victories. Your first trial begins at dawn.`,
+ tags: ['Honor', 'Strategy', 'Growth'],
+ }),
+ [WorldType.SCI_FI]: (g) => ({
+ title: 'Transmission Received',
+ content: `Mission parameters uploaded: "${g}". Station Command acknowledges. In the void of space, ambitions become orbital mechanics—each micro-burn propels you closer to your destination. Your AI co-pilot calculates the optimal trajectory. First maneuver: engage thrusters.`,
+ tags: ['Precision', 'Systems', 'Progress'],
+ }),
+ };
+
+ return narratives[world](goal);
+};
diff --git a/packages/frontend/src/lib/utils/uuid.ts b/packages/frontend/src/lib/utils/uuid.ts
new file mode 100644
index 0000000..22f1822
--- /dev/null
+++ b/packages/frontend/src/lib/utils/uuid.ts
@@ -0,0 +1,30 @@
+/**
+ * UUID Generation Utility
+ *
+ * Browser-compatible UUID generation with fallback for environments
+ * where crypto.randomUUID() is not available.
+ */
+
+/**
+ * Generates a UUID v4 using crypto.randomUUID() if available,
+ * otherwise falls back to a manual implementation.
+ */
+export function generateUUID(): string {
+ // Check if crypto.randomUUID is available (modern browsers, Node 16+)
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
+ try {
+ return crypto.randomUUID();
+ } catch (error) {
+ // Fall through to manual implementation if randomUUID fails
+ console.warn("crypto.randomUUID() failed, using fallback:", error);
+ }
+ }
+
+ // Fallback: Manual UUID v4 implementation
+ // Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+}
diff --git a/packages/frontend/src/middleware.ts b/packages/frontend/src/middleware.ts
index e0cff48..7e5e94f 100644
--- a/packages/frontend/src/middleware.ts
+++ b/packages/frontend/src/middleware.ts
@@ -16,8 +16,11 @@ export default clerkMiddleware(async (auth, request) => {
// Protect all routes except public ones
if (!isPublicRoute(request)) {
// auth().protect() automatically redirects to sign-in if not authenticated
- (await auth()).protect();
+ const authObj = await auth();
+ authObj.protect();
}
+
+ return NextResponse.next();
});
export const config = {
diff --git a/packages/frontend/src/types/marketing.ts b/packages/frontend/src/types/marketing.ts
new file mode 100644
index 0000000..9facd7d
--- /dev/null
+++ b/packages/frontend/src/types/marketing.ts
@@ -0,0 +1,29 @@
+export interface Character {
+ id: string;
+ name: string;
+ role: string;
+ description: string;
+ domain: 'growth' | 'health' | 'craft' | 'connection';
+ sampleQuote: string;
+ avatarColor: string;
+}
+
+export interface NarrativeResponse {
+ title: string;
+ content: string;
+ tags: string[];
+}
+
+export enum WorldType {
+ MODERN = 'Modern Metropolis',
+ CYBER = 'Cyberpunk Megacity',
+ FANTASY = 'Ancient Guild',
+ SCI_FI = 'Orbital Station'
+}
+
+export interface Mission {
+ title: string;
+ type: 'focus' | 'quick' | 'deep';
+ duration: string;
+ energyLevel: number;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b5219fd..e325c6a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -42,6 +42,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.0(react@19.2.0)
+ reactflow:
+ specifier: ^11.11.4
+ version: 11.11.4(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
tailwind-merge:
specifier: ^2.2.0
version: 2.6.0
@@ -996,6 +999,42 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+ '@reactflow/background@11.3.14':
+ resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+
+ '@reactflow/controls@11.2.14':
+ resolution: {integrity: sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+
+ '@reactflow/core@11.11.4':
+ resolution: {integrity: sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+
+ '@reactflow/minimap@11.7.14':
+ resolution: {integrity: sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+
+ '@reactflow/node-resizer@2.2.14':
+ resolution: {integrity: sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+
+ '@reactflow/node-toolbar@1.3.14':
+ resolution: {integrity: sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -1131,6 +1170,99 @@ packages:
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
+ '@types/d3-array@3.2.2':
+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+ '@types/d3-axis@3.0.6':
+ resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
+
+ '@types/d3-brush@3.0.6':
+ resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
+
+ '@types/d3-chord@3.0.6':
+ resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
+
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-contour@3.0.6':
+ resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
+
+ '@types/d3-delaunay@6.0.4':
+ resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
+
+ '@types/d3-dispatch@3.0.7':
+ resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
+
+ '@types/d3-drag@3.0.7':
+ resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
+
+ '@types/d3-dsv@3.0.7':
+ resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-fetch@3.0.7':
+ resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
+
+ '@types/d3-force@3.0.10':
+ resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
+
+ '@types/d3-format@3.0.4':
+ resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
+
+ '@types/d3-geo@3.1.0':
+ resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
+
+ '@types/d3-hierarchy@3.1.7':
+ resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-polygon@3.0.2':
+ resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
+
+ '@types/d3-quadtree@3.0.6':
+ resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
+
+ '@types/d3-random@3.0.3':
+ resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==}
+
+ '@types/d3-scale-chromatic@3.1.0':
+ resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-selection@3.0.11':
+ resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
+
+ '@types/d3-shape@3.1.7':
+ resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
+
+ '@types/d3-time-format@4.0.3':
+ resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
+ '@types/d3-transition@3.0.9':
+ resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
+
+ '@types/d3-zoom@3.0.8':
+ resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
+
+ '@types/d3@7.4.3':
+ resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
+
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -1143,6 +1275,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/geojson@7946.0.16':
+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@@ -1743,6 +1878,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+ classcat@5.0.5:
+ resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
+
clean-stack@4.2.0:
resolution: {integrity: sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg==}
engines: {node: '>=12'}
@@ -1884,6 +2022,44 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-dispatch@3.0.1:
+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+ engines: {node: '>=12'}
+
+ d3-drag@3.0.0:
+ resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+ engines: {node: '>=12'}
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-selection@3.0.0:
+ resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
+ d3-transition@3.0.1:
+ resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ d3-selection: 2 - 3
+
+ d3-zoom@3.0.0:
+ resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+ engines: {node: '>=12'}
+
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -3841,6 +4017,12 @@ packages:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'}
+ reactflow@11.11.4:
+ resolution: {integrity: sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+
read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@@ -4715,6 +4897,21 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+ zustand@4.5.7:
+ resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0.6'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -5825,6 +6022,84 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
+ '@reactflow/background@11.3.14(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@reactflow/core': 11.11.4(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ classcat: 5.0.5
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ zustand: 4.5.7(@types/react@18.3.26)(immer@9.0.21)(react@19.2.0)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+
+ '@reactflow/controls@11.2.14(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@reactflow/core': 11.11.4(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ classcat: 5.0.5
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ zustand: 4.5.7(@types/react@18.3.26)(immer@9.0.21)(react@19.2.0)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+
+ '@reactflow/core@11.11.4(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@types/d3': 7.4.3
+ '@types/d3-drag': 3.0.7
+ '@types/d3-selection': 3.0.11
+ '@types/d3-zoom': 3.0.8
+ classcat: 5.0.5
+ d3-drag: 3.0.0
+ d3-selection: 3.0.0
+ d3-zoom: 3.0.0
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ zustand: 4.5.7(@types/react@18.3.26)(immer@9.0.21)(react@19.2.0)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+
+ '@reactflow/minimap@11.7.14(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@reactflow/core': 11.11.4(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@types/d3-selection': 3.0.11
+ '@types/d3-zoom': 3.0.8
+ classcat: 5.0.5
+ d3-selection: 3.0.0
+ d3-zoom: 3.0.0
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ zustand: 4.5.7(@types/react@18.3.26)(immer@9.0.21)(react@19.2.0)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+
+ '@reactflow/node-resizer@2.2.14(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@reactflow/core': 11.11.4(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ classcat: 5.0.5
+ d3-drag: 3.0.0
+ d3-selection: 3.0.0
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ zustand: 4.5.7(@types/react@18.3.26)(immer@9.0.21)(react@19.2.0)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+
+ '@reactflow/node-toolbar@1.3.14(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@reactflow/core': 11.11.4(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ classcat: 5.0.5
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ zustand: 4.5.7(@types/react@18.3.26)(immer@9.0.21)(react@19.2.0)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.14.1': {}
@@ -6051,6 +6326,123 @@ snapshots:
dependencies:
'@types/node': 24.10.1
+ '@types/d3-array@3.2.2': {}
+
+ '@types/d3-axis@3.0.6':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-brush@3.0.6':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-chord@3.0.6': {}
+
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-contour@3.0.6':
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/geojson': 7946.0.16
+
+ '@types/d3-delaunay@6.0.4': {}
+
+ '@types/d3-dispatch@3.0.7': {}
+
+ '@types/d3-drag@3.0.7':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-dsv@3.0.7': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-fetch@3.0.7':
+ dependencies:
+ '@types/d3-dsv': 3.0.7
+
+ '@types/d3-force@3.0.10': {}
+
+ '@types/d3-format@3.0.4': {}
+
+ '@types/d3-geo@3.1.0':
+ dependencies:
+ '@types/geojson': 7946.0.16
+
+ '@types/d3-hierarchy@3.1.7': {}
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-polygon@3.0.2': {}
+
+ '@types/d3-quadtree@3.0.6': {}
+
+ '@types/d3-random@3.0.3': {}
+
+ '@types/d3-scale-chromatic@3.1.0': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-selection@3.0.11': {}
+
+ '@types/d3-shape@3.1.7':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time-format@4.0.3': {}
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
+ '@types/d3-transition@3.0.9':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-zoom@3.0.8':
+ dependencies:
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3@7.4.3':
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/d3-axis': 3.0.6
+ '@types/d3-brush': 3.0.6
+ '@types/d3-chord': 3.0.6
+ '@types/d3-color': 3.1.3
+ '@types/d3-contour': 3.0.6
+ '@types/d3-delaunay': 6.0.4
+ '@types/d3-dispatch': 3.0.7
+ '@types/d3-drag': 3.0.7
+ '@types/d3-dsv': 3.0.7
+ '@types/d3-ease': 3.0.2
+ '@types/d3-fetch': 3.0.7
+ '@types/d3-force': 3.0.10
+ '@types/d3-format': 3.0.4
+ '@types/d3-geo': 3.1.0
+ '@types/d3-hierarchy': 3.1.7
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-path': 3.1.1
+ '@types/d3-polygon': 3.0.2
+ '@types/d3-quadtree': 3.0.6
+ '@types/d3-random': 3.0.3
+ '@types/d3-scale': 4.0.9
+ '@types/d3-scale-chromatic': 3.1.0
+ '@types/d3-selection': 3.0.11
+ '@types/d3-shape': 3.1.7
+ '@types/d3-time': 3.0.4
+ '@types/d3-time-format': 4.0.3
+ '@types/d3-timer': 3.0.2
+ '@types/d3-transition': 3.0.9
+ '@types/d3-zoom': 3.0.8
+
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
@@ -6065,6 +6457,8 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@types/geojson@7946.0.16': {}
+
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -6677,6 +7071,8 @@ snapshots:
dependencies:
clsx: 2.1.1
+ classcat@5.0.5: {}
+
clean-stack@4.2.0:
dependencies:
escape-string-regexp: 5.0.0
@@ -6798,6 +7194,42 @@ snapshots:
csstype@3.1.3: {}
+ d3-color@3.1.0: {}
+
+ d3-dispatch@3.0.1: {}
+
+ d3-drag@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-selection: 3.0.0
+
+ d3-ease@3.0.1: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-selection@3.0.0: {}
+
+ d3-timer@3.0.1: {}
+
+ d3-transition@3.0.1(d3-selection@3.0.0):
+ dependencies:
+ d3-color: 3.1.0
+ d3-dispatch: 3.0.1
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-timer: 3.0.1
+
+ d3-zoom@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
damerau-levenshtein@1.0.8: {}
data-uri-to-buffer@6.0.2: {}
@@ -9370,6 +9802,20 @@ snapshots:
react@19.2.0: {}
+ reactflow@11.11.4(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ dependencies:
+ '@reactflow/background': 11.3.14(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@reactflow/controls': 11.2.14(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@reactflow/core': 11.11.4(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@reactflow/minimap': 11.7.14(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@reactflow/node-resizer': 2.2.14(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@reactflow/node-toolbar': 1.3.14(@types/react@18.3.26)(immer@9.0.21)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+
read-cache@1.0.0:
dependencies:
pify: 2.3.0
@@ -10579,4 +11025,12 @@ snapshots:
zod@3.25.76: {}
+ zustand@4.5.7(@types/react@18.3.26)(immer@9.0.21)(react@19.2.0):
+ dependencies:
+ use-sync-external-store: 1.6.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 18.3.26
+ immer: 9.0.21
+ react: 19.2.0
+
zwitch@2.0.4: {}
diff --git a/railpack.json b/railpack.json
new file mode 100644
index 0000000..f2f739b
--- /dev/null
+++ b/railpack.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://schema.railpack.com",
+ "language": "python",
+ "build": {
+ "commands": [
+ "python -V",
+ "pip install --upgrade pip",
+ "pip install poetry",
+ "cd packages/backend && poetry config virtualenvs.create false",
+ "cd packages/backend && poetry install --no-dev --no-interaction"
+ ]
+ },
+ "start": {
+ "command": "cd packages/backend && poetry run python -m experiments.web.dashboard_server"
+ }
+}
diff --git a/railway.json b/railway.json
new file mode 100644
index 0000000..50fff07
--- /dev/null
+++ b/railway.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://railway.app/railway.schema.json",
+ "build": {
+ "builder": "RAILPACK"
+ },
+ "deploy": {
+ "startCommand": "cd packages/backend && poetry run python -m experiments.web.dashboard_server",
+ "restartPolicyType": "ON_FAILURE",
+ "restartPolicyMaxRetries": 10
+ }
+}
diff --git a/runtime.txt b/runtime.txt
new file mode 100644
index 0000000..87b4829
--- /dev/null
+++ b/runtime.txt
@@ -0,0 +1,2 @@
+python-3.11
+