diff --git a/MINDMAP_MIGRATION.md b/MINDMAP_MIGRATION.md new file mode 100644 index 0000000..2a053f7 --- /dev/null +++ b/MINDMAP_MIGRATION.md @@ -0,0 +1,118 @@ +# Mindmap Components Migration - Phase 3 + +**Status:** ✅ Complete (with notes) +**Commit:** `aa267b5` - "feat: add mindmap components from jarvis frontend" +**Branch:** `feature/jarvis-fe-migration` + +## Completed Tasks + +### 1. ✅ Directory Structure Created +``` +apps/web/src/components/mindmap/ +├── controls/ +│ ├── ExportButton.tsx +│ └── NodeCreateModal.tsx +├── hooks/ +│ └── useGraphData.ts +├── nodes/ +│ ├── BaseNode.tsx +│ ├── ConceptNode.tsx +│ ├── IdeaNode.tsx +│ ├── ProjectNode.tsx +│ └── TaskNode.tsx +├── index.ts +├── MermaidViewer.tsx +├── MindmapViewer.tsx +└── ReactFlowEditor.tsx +``` + +### 2. ✅ Components Copied +All mindmap components have been successfully migrated: +- **Main viewers:** ReactFlowEditor, MindmapViewer, MermaidViewer +- **Node types:** BaseNode, ConceptNode, TaskNode, IdeaNode, ProjectNode +- **Controls:** NodeCreateModal, ExportButton +- **Hooks:** useGraphData (with KnowledgeNode, KnowledgeEdge types) + +### 3. ✅ Barrel Export Created +`components/mindmap/index.ts` exports all components and types for clean imports + +### 4. ✅ Route Created +- Created `/mindmap` page at `apps/web/src/app/mindmap/page.tsx` +- Includes proper metadata and layout + +### 5. ✅ Dependencies Added +- Copied `lib/auth-client.ts` (BetterAuth integration) +- Created `lib/api.ts` (session management utilities) + +## Import Updates + +No `@jarvis/*` imports were present in the mindmap components - they were already using relative paths and `@/lib/*` aliases, which are compatible with the Mosaic structure. + +## Type Adaptations + +The mindmap uses its own `KnowledgeNode` and `KnowledgeEdge` types, which are specific to the knowledge graph feature and not part of the general Mosaic entity types (Task, Project, etc. from `@mosaic/shared`). This is correct as the mindmap represents a different data model. + +## Known Issues & Next Steps + +### Missing Package Dependencies +The build currently fails due to missing packages required by `auth-client.ts`: +``` +better-auth +better-auth/react +better-auth-credentials-plugin +better-auth-credentials-plugin/client +``` + +**Resolution:** These packages need to be added to the workspace: +```bash +pnpm add better-auth better-auth-credentials-plugin +``` + +### ReactFlow Dependencies +Verify that `@xyflow/react` is installed: +```bash +pnpm add @xyflow/react +``` + +### Mermaid Dependency +Verify that `mermaid` is installed: +```bash +pnpm add mermaid +``` + +## Testing Checklist + +Once dependencies are installed: +- [ ] Build completes without errors +- [ ] Navigate to `/mindmap` route +- [ ] Create a knowledge node +- [ ] Verify ReactFlow interactive editor renders +- [ ] Test Mermaid diagram view +- [ ] Test export functionality +- [ ] Verify node type rendering (Concept, Task, Idea, Project) + +## API Integration + +The mindmap components expect a backend knowledge graph API at: +- Base URL: `process.env.NEXT_PUBLIC_API_URL` (default: http://localhost:8000) +- Endpoints: + - `GET /api/v1/knowledge/graph` - Fetch graph data + - `GET /api/v1/knowledge/mermaid` - Fetch Mermaid diagram + - `POST /api/v1/knowledge/nodes` - Create node + - `PUT /api/v1/knowledge/nodes/:id` - Update node + - `DELETE /api/v1/knowledge/nodes/:id` - Delete node + - `POST /api/v1/knowledge/edges` - Create edge + - `DELETE /api/v1/knowledge/edges` - Delete edge + - `GET /api/v1/knowledge/graph/statistics` - Get statistics + +## Files Changed +- 15 files added +- 1,758 insertions +- No deletions + +## Git Info +``` +Branch: feature/jarvis-fe-migration +Commit: aa267b5 +Pushed: Yes +``` diff --git a/apps/web/src/app/chat/page.tsx b/apps/web/src/app/chat/page.tsx new file mode 100644 index 0000000..165fd57 --- /dev/null +++ b/apps/web/src/app/chat/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Chat, type ChatRef, ConversationSidebar, type ConversationSidebarRef } from "@/components/chat"; + +/** + * Chat Page + * + * Placeholder route for the chat interface migrated from jarvis-fe. + * + * TODO: + * - Integrate with authentication + * - Connect to brain API endpoints (/api/brain/query) + * - Implement conversation persistence + * - Add project/workspace integration + * - Wire up actual hooks (useAuth, useProjects, useConversations, useApi) + */ +export default function ChatPage() { + const chatRef = useRef(null); + const sidebarRef = useRef(null); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [currentConversationId, setCurrentConversationId] = useState(null); + + const handleConversationChange = (conversationId: string | null) => { + setCurrentConversationId(conversationId); + // TODO: Update sidebar when conversation changes + }; + + const handleSelectConversation = (conversationId: string | null) => { + // TODO: Load conversation from backend + console.log("Select conversation:", conversationId); + setCurrentConversationId(conversationId); + }; + + const handleNewConversation = (projectId?: string | null) => { + chatRef.current?.startNewConversation(projectId); + setCurrentConversationId(null); + }; + + return ( +
+ {/* Conversation Sidebar */} + setSidebarOpen(!sidebarOpen)} + currentConversationId={currentConversationId} + onSelectConversation={handleSelectConversation} + onNewConversation={handleNewConversation} + /> + + {/* Main Chat Area */} +
+ {/* Header */} +
+ {/* Toggle Sidebar Button */} + + +
+

+ AI Chat +

+

+ Migrated from Jarvis - Connect to brain API for full functionality +

+
+
+ + {/* Chat Component */} + +
+
+ ); +} diff --git a/apps/web/src/components/chat/BackendStatusBanner.tsx b/apps/web/src/components/chat/BackendStatusBanner.tsx new file mode 100644 index 0000000..dfc44f6 --- /dev/null +++ b/apps/web/src/components/chat/BackendStatusBanner.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState, useEffect } from "react"; + +/** + * Banner that displays when the backend is unavailable. + * Shows error message, countdown to next retry, and manual retry button. + * + * TODO: Integrate with actual backend status checking hook + */ +export function BackendStatusBanner() { + const [isAvailable, setIsAvailable] = useState(true); + const [error, setError] = useState(null); + const [retryIn, setRetryIn] = useState(0); + + // TODO: Replace with actual useBackendStatus hook + // const { isAvailable, error, retryIn, manualRetry } = useBackendStatus(); + + const manualRetry = () => { + // TODO: Implement manual retry logic + console.log("Manual retry triggered"); + }; + + const handleSignOut = async () => { + try { + // TODO: Implement signOut + // await signOut(); + } catch (error) { + console.warn("Sign-out failed during backend unavailability:", error); + } + window.location.href = "/login"; + }; + + // Don't render if backend is available + if (isAvailable) { + return null; + } + + return ( +
+
+ + + {error || "Backend temporarily unavailable."} + {retryIn > 0 && ( + + Retrying in {retryIn}s... + + )} + +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/chat/Chat.tsx b/apps/web/src/components/chat/Chat.tsx new file mode 100644 index 0000000..e980910 --- /dev/null +++ b/apps/web/src/components/chat/Chat.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, useMemo, forwardRef, useImperativeHandle } from "react"; +// TODO: These hooks will need to be created or adapted +// import { useAuth } from "@/lib/hooks/useAuth"; +// import { useProjects } from "@/lib/hooks/useProjects"; +// import { useConversations } from "@/lib/hooks/useConversations"; +// import { useApi } from "@/lib/hooks/useApi"; +import { MessageList } from "./MessageList"; +import { ChatInput } from "./ChatInput"; +// TODO: Import types need to be created +// import type { ConversationDetail } from "@/lib/hooks/useConversations"; +// import { handleSessionExpired, isSessionExpiring } from "@/lib/api"; +// import type { LLMModel, DefaultModel } from "@/lib/api"; + +// Placeholder types until the actual types are created +type ConversationDetail = any; +type LLMModel = any; +type DefaultModel = any; + +export interface Message { + id: string; + role: "user" | "assistant" | "system"; + content: string; + thinking?: string; // Chain of thought reasoning from thinking models + createdAt: string; + model?: string; // LLM model used for this response + provider?: string; // LLM provider (ollama, claude, etc.) + // Token usage info + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; +} + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; + +// Friendly waiting messages (shown after a few seconds of loading) +const WAITING_QUIPS = [ + "The AI is warming up... give it a moment.", + "Loading the neural pathways...", + "Waking up the LLM. It's not a morning model.", + "Brewing some thoughts...", + "The AI is stretching its parameters...", + "Summoning intelligence from the void...", + "Teaching electrons to think...", + "Consulting the silicon oracle...", + "The hamsters are spinning up the GPU...", + "Defragmenting the neural networks...", +]; + +// Error messages for actual timeouts +const TIMEOUT_QUIPS = [ + "The AI got lost in thought. Literally. Try again?", + "That took too long, even by AI standards. Give it another go?", + "The model wandered off. Let's try to find it again.", + "Response timed out. The AI may have fallen asleep. Retry?", + "The LLM took an unexpected vacation. One more attempt?", +]; + +// Error messages for connection failures +const CONNECTION_QUIPS = [ + "I seem to have misplaced the server. Check your connection?", + "The server and I are having communication issues. It's not you, it's us.", + "Connection lost. Either the internet is down, or the server is playing hide and seek.", + "Unable to reach the mothership. The tubes appear to be clogged.", + "The server isn't responding. Perhaps it's giving us the silent treatment.", +]; + +const getRandomQuip = (quips: string[]) => quips[Math.floor(Math.random() * quips.length)]; + +const WELCOME_MESSAGE: Message = { + id: "welcome", + role: "assistant", + content: "Hello. I'm your AI assistant. How can I help you today?", + createdAt: new Date().toISOString(), +}; + +export interface ChatRef { + loadConversation: (conversation: ConversationDetail) => void; + startNewConversation: (projectId?: string | null) => void; + getCurrentConversationId: () => string | null; +} + +export interface NewConversationData { + id: string; + title: string | null; + project_id: string | null; + created_at: string; + updated_at: string; +} + +interface ChatProps { + onConversationChange?: (conversationId: string | null, conversationData?: NewConversationData) => void; + onProjectChange?: () => void; + initialProjectId?: string | null; + onInitialProjectHandled?: () => void; +} + +export const Chat = forwardRef(function Chat({ + onConversationChange, + onProjectChange: _onProjectChange, + initialProjectId, + onInitialProjectHandled, +}, ref) { + void _onProjectChange; // Kept for potential future use + + // TODO: Replace with actual hooks once they're created + const accessToken = null; + const isLoading = false; + const authLoading = false; + const authError = null; + const projects: any[] = []; + // const { accessToken, isLoading: authLoading, error: authError } = useAuth(); + // const { projects } = useProjects(); + // const { updateConversationProject } = useConversations(); + // const api = useApi(); + + const [messages, setMessages] = useState([WELCOME_MESSAGE]); + const [isChatLoading, setIsChatLoading] = useState(false); + const [loadingQuip, setLoadingQuip] = useState(null); + const [error, setError] = useState(null); + const [conversationId, setConversationId] = useState(null); + const [conversationTitle, setConversationTitle] = useState(null); + const [conversationProjectId, setConversationProjectId] = useState(null); + const [pendingProjectId, setPendingProjectId] = useState(null); + const [showProjectMenu, setShowProjectMenu] = useState(false); + const [showModelMenu, setShowModelMenu] = useState(false); + const [showFooterProjectMenu, setShowFooterProjectMenu] = useState(false); + const [showFooterModelMenu, setShowFooterModelMenu] = useState(false); + const [isMovingProject, setIsMovingProject] = useState(false); + const [availableModels, setAvailableModels] = useState([]); + const [defaultModel, setDefaultModel] = useState(null); + const [selectedModel, setSelectedModel] = useState(null); + const [modelLoadError, setModelLoadError] = useState(null); + const [isLoadingModels, setIsLoadingModels] = useState(false); + const [useReasoning, setUseReasoning] = useState(false); // Toggle for reasoning/thinking mode + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const projectMenuRef = useRef(null); + const modelMenuRef = useRef(null); + const footerProjectMenuRef = useRef(null); + const footerModelMenuRef = useRef(null); + // Track conversation ID in ref to prevent stale closure issues + const conversationIdRef = useRef(conversationId); + + // Expose methods to parent via ref + useImperativeHandle(ref, () => ({ + loadConversation: (conversation: ConversationDetail) => { + // TODO: Implement once ConversationDetail type is available + console.log("loadConversation called with:", conversation); + }, + startNewConversation: (projectId?: string | null) => { + setConversationId(null); + setConversationTitle(null); + setConversationProjectId(null); + setMessages([WELCOME_MESSAGE]); + setError(null); + setPendingProjectId(projectId || null); + setShowProjectMenu(false); + onConversationChange?.(null); + }, + getCurrentConversationId: () => conversationId, + })); + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, scrollToBottom]); + + // Keep conversationIdRef in sync with state to prevent stale closures + useEffect(() => { + conversationIdRef.current = conversationId; + }, [conversationId]); + + // Handle auth errors + useEffect(() => { + if (authError === "RefreshAccessTokenError") { + setError("Your session has expired. Please sign in again."); + } + }, [authError]); + + // Global keyboard shortcut: Ctrl+/ to focus input + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "/") { + e.preventDefault(); + inputRef.current?.focus(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + // TODO: Implement click outside handlers for menus + + const sendMessage = useCallback( + async (content: string) => { + if (!content.trim() || isChatLoading) { + return; + } + + // Add user message immediately + const userMessage: Message = { + id: `user-${Date.now()}`, + role: "user", + content: content.trim(), + createdAt: new Date().toISOString(), + }; + + setMessages((prev) => [...prev, userMessage]); + setIsChatLoading(true); + setLoadingQuip(null); + setError(null); + + // Show a witty loading message after 3 seconds + const quipTimerId = setTimeout(() => { + setLoadingQuip(getRandomQuip(WAITING_QUIPS)); + }, 3000); + + // Change quip every 5 seconds if still waiting + const quipIntervalId = setInterval(() => { + setLoadingQuip(getRandomQuip(WAITING_QUIPS)); + }, 5000); + + try { + // TODO: Implement actual API call to /api/brain/query + const requestBody: { + message: string; + conversation_id: string | null; + project_id?: string; + provider_instance_id?: string; + provider?: string; + model?: string; + use_reasoning?: boolean; + } = { + message: content.trim(), + conversation_id: conversationId, + }; + + // Placeholder response for now + await new Promise(resolve => setTimeout(resolve, 1000)); + + const assistantMessage: Message = { + id: `assistant-${Date.now()}`, + role: "assistant", + content: "This is a placeholder response. The chat API integration is not yet complete.", + createdAt: new Date().toISOString(), + }; + + setMessages((prev) => [...prev, assistantMessage]); + + // Clear quip timers on success + clearTimeout(quipTimerId); + clearInterval(quipIntervalId); + setLoadingQuip(null); + } catch (err) { + // Clear quip timers on error + clearTimeout(quipTimerId); + clearInterval(quipIntervalId); + setLoadingQuip(null); + + console.error("Failed to send message:", err); + const errorMsg = err instanceof Error ? err.message : "Failed to send message"; + setError(errorMsg); + + const errorMessage: Message = { + id: `error-${Date.now()}`, + role: "assistant", + content: errorMsg, + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, errorMessage]); + } finally { + setIsChatLoading(false); + } + }, + [conversationId, isChatLoading] + ); + + const dismissError = useCallback(() => { + setError(null); + }, []); + + // Show loading state while auth is loading + if (authLoading) { + return ( +
+
+
+ Loading... +
+
+ ); + } + + return ( +
+ {/* Messages Area */} +
+
+ +
+
+
+ + {/* Error Alert */} + {error && ( +
+
+
+ + + + + + + {error} + +
+ +
+
+ )} + + {/* Input Area */} +
+
+ +
+
+
+ ); +}); diff --git a/apps/web/src/components/chat/ChatInput.tsx b/apps/web/src/components/chat/ChatInput.tsx new file mode 100644 index 0000000..9ca1259 --- /dev/null +++ b/apps/web/src/components/chat/ChatInput.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useCallback, useState, useEffect, KeyboardEvent, RefObject } from "react"; + +interface ChatInputProps { + onSend: (message: string) => void; + disabled?: boolean; + inputRef?: RefObject; +} + +export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps) { + const [message, setMessage] = useState(""); + const [version, setVersion] = useState(null); + + // Fetch version from static version.json (generated at build time) + useEffect(() => { + fetch("/version.json") + .then((res) => res.json()) + .then((data) => { + if (data.version) { + // Format as "version+commit" for full build identification + const fullVersion = data.commit + ? `${data.version}+${data.commit}` + : data.version; + setVersion(fullVersion); + } + }) + .catch(() => { + // Silently fail - version display is non-critical + }); + }, []); + + const handleSubmit = useCallback(() => { + if (message.trim() && !disabled) { + onSend(message); + setMessage(""); + } + }, [message, onSend, disabled]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + // Enter to send (without Shift) + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + // Ctrl/Cmd + Enter to send (alternative) + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit] + ); + + const characterCount = message.length; + const maxCharacters = 4000; + const isNearLimit = characterCount > maxCharacters * 0.9; + const isOverLimit = characterCount > maxCharacters; + + return ( +
+ {/* Input Container */} +
+