diff --git a/.gitignore b/.gitignore index 26225fea8858f56e4a03aa7c6d76684e22cdede4..c1dbd8b34c8312df3c3d9aa6845b714f9bf11ad7 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ pids *.pid *.seed *.pid.lock +*.bak # Coverage directory used by tools like istanbul coverage/ diff --git a/.prettierignore b/.prettierignore index 27a587df6060570ac61714f9a60f8cd4a8ed3fee..72842592fd0b2d2e7e186e4beeeacada46dabed7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,35 @@ -dist -node_modules -.git -*.min.js -*.min.css +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.dist + +# Generated files +*.tsbuildinfo +coverage/ + +# Package files +package-lock.json +pnpm-lock.yaml + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log + +# Environment files +.env* + +# IDE files +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db diff --git a/client/src/App.tsx b/client/src/App.tsx index 67b48db07bcd5b03b8047a08b47cf04a8b5ee144..8d20eafabf316916867c5b50cf05be6f90b6f2bd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,12 +6,16 @@ import ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "./contexts/ThemeContext"; import { LanguageProvider } from "./contexts/LanguageContext"; import Home from "./pages/Home"; - +import Products from "./pages/Products"; +import Orders from "./pages/Orders"; function Router() { + // make sure to consider if you need authentication for certain routes return ( + + {/* Final fallback route */} @@ -28,7 +32,7 @@ function App() { return ( diff --git a/client/src/_core/hooks/useAuth.ts b/client/src/_core/hooks/useAuth.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcef9bd849ec892c16cda7874d4c93a4b77c9f7c --- /dev/null +++ b/client/src/_core/hooks/useAuth.ts @@ -0,0 +1,84 @@ +import { getLoginUrl } from "@/const"; +import { trpc } from "@/lib/trpc"; +import { TRPCClientError } from "@trpc/client"; +import { useCallback, useEffect, useMemo } from "react"; + +type UseAuthOptions = { + redirectOnUnauthenticated?: boolean; + redirectPath?: string; +}; + +export function useAuth(options?: UseAuthOptions) { + const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } = + options ?? {}; + const utils = trpc.useUtils(); + + const meQuery = trpc.auth.me.useQuery(undefined, { + retry: false, + refetchOnWindowFocus: false, + }); + + const logoutMutation = trpc.auth.logout.useMutation({ + onSuccess: () => { + utils.auth.me.setData(undefined, null); + }, + }); + + const logout = useCallback(async () => { + try { + await logoutMutation.mutateAsync(); + } catch (error: unknown) { + if ( + error instanceof TRPCClientError && + error.data?.code === "UNAUTHORIZED" + ) { + return; + } + throw error; + } finally { + utils.auth.me.setData(undefined, null); + await utils.auth.me.invalidate(); + } + }, [logoutMutation, utils]); + + const state = useMemo(() => { + localStorage.setItem( + "manus-runtime-user-info", + JSON.stringify(meQuery.data) + ); + return { + user: meQuery.data ?? null, + loading: meQuery.isLoading || logoutMutation.isPending, + error: meQuery.error ?? logoutMutation.error ?? null, + isAuthenticated: Boolean(meQuery.data), + }; + }, [ + meQuery.data, + meQuery.error, + meQuery.isLoading, + logoutMutation.error, + logoutMutation.isPending, + ]); + + useEffect(() => { + if (!redirectOnUnauthenticated) return; + if (meQuery.isLoading || logoutMutation.isPending) return; + if (state.user) return; + if (typeof window === "undefined") return; + if (window.location.pathname === redirectPath) return; + + window.location.href = redirectPath + }, [ + redirectOnUnauthenticated, + redirectPath, + logoutMutation.isPending, + meQuery.isLoading, + state.user, + ]); + + return { + ...state, + refresh: () => meQuery.refetch(), + logout, + }; +} diff --git a/client/src/components/AIChatBox.tsx b/client/src/components/AIChatBox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1c00871fcd7099a42c6a73b8f96d1ec398d01bd2 --- /dev/null +++ b/client/src/components/AIChatBox.tsx @@ -0,0 +1,335 @@ +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { Loader2, Send, User, Sparkles } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { Streamdown } from "streamdown"; + +/** + * Message type matching server-side LLM Message interface + */ +export type Message = { + role: "system" | "user" | "assistant"; + content: string; +}; + +export type AIChatBoxProps = { + /** + * Messages array to display in the chat. + * Should match the format used by invokeLLM on the server. + */ + messages: Message[]; + + /** + * Callback when user sends a message. + * Typically you'll call a tRPC mutation here to invoke the LLM. + */ + onSendMessage: (content: string) => void; + + /** + * Whether the AI is currently generating a response + */ + isLoading?: boolean; + + /** + * Placeholder text for the input field + */ + placeholder?: string; + + /** + * Custom className for the container + */ + className?: string; + + /** + * Height of the chat box (default: 600px) + */ + height?: string | number; + + /** + * Empty state message to display when no messages + */ + emptyStateMessage?: string; + + /** + * Suggested prompts to display in empty state + * Click to send directly + */ + suggestedPrompts?: string[]; +}; + +/** + * A ready-to-use AI chat box component that integrates with the LLM system. + * + * Features: + * - Matches server-side Message interface for seamless integration + * - Markdown rendering with Streamdown + * - Auto-scrolls to latest message + * - Loading states + * - Uses global theme colors from index.css + * + * @example + * ```tsx + * const ChatPage = () => { + * const [messages, setMessages] = useState([ + * { role: "system", content: "You are a helpful assistant." } + * ]); + * + * const chatMutation = trpc.ai.chat.useMutation({ + * onSuccess: (response) => { + * // Assuming your tRPC endpoint returns the AI response as a string + * setMessages(prev => [...prev, { + * role: "assistant", + * content: response + * }]); + * }, + * onError: (error) => { + * console.error("Chat error:", error); + * // Optionally show error message to user + * } + * }); + * + * const handleSend = (content: string) => { + * const newMessages = [...messages, { role: "user", content }]; + * setMessages(newMessages); + * chatMutation.mutate({ messages: newMessages }); + * }; + * + * return ( + * + * ); + * }; + * ``` + */ +export function AIChatBox({ + messages, + onSendMessage, + isLoading = false, + placeholder = "Type your message...", + className, + height = "600px", + emptyStateMessage = "Start a conversation with AI", + suggestedPrompts, +}: AIChatBoxProps) { + const [input, setInput] = useState(""); + const scrollAreaRef = useRef(null); + const containerRef = useRef(null); + const inputAreaRef = useRef(null); + const textareaRef = useRef(null); + + // Filter out system messages + const displayMessages = messages.filter((msg) => msg.role !== "system"); + + // Calculate min-height for last assistant message to push user message to top + const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0); + + useEffect(() => { + if (containerRef.current && inputAreaRef.current) { + const containerHeight = containerRef.current.offsetHeight; + const inputHeight = inputAreaRef.current.offsetHeight; + const scrollAreaHeight = containerHeight - inputHeight; + + // Reserve space for: + // - padding (p-4 = 32px top+bottom) + // - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px + // Note: margin-bottom is not counted because it naturally pushes the assistant message down + const userMessageReservedHeight = 56; + const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight; + + setMinHeightForLastMessage(Math.max(0, calculatedHeight)); + } + }, []); + + // Scroll to bottom helper function with smooth animation + const scrollToBottom = () => { + const viewport = scrollAreaRef.current?.querySelector( + '[data-radix-scroll-area-viewport]' + ) as HTMLDivElement; + + if (viewport) { + requestAnimationFrame(() => { + viewport.scrollTo({ + top: viewport.scrollHeight, + behavior: 'smooth' + }); + }); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedInput = input.trim(); + if (!trimmedInput || isLoading) return; + + onSendMessage(trimmedInput); + setInput(""); + + // Scroll immediately after sending + scrollToBottom(); + + // Keep focus on input + textareaRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + return ( +
+ {/* Messages Area */} +
+ {displayMessages.length === 0 ? ( +
+
+
+ +

{emptyStateMessage}

+
+ + {suggestedPrompts && suggestedPrompts.length > 0 && ( +
+ {suggestedPrompts.map((prompt, index) => ( + + ))} +
+ )} +
+
+ ) : ( + +
+ {displayMessages.map((message, index) => { + // Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it) + const isLastMessage = index === displayMessages.length - 1; + const shouldApplyMinHeight = + isLastMessage && !isLoading && minHeightForLastMessage > 0; + + return ( +
+ {message.role === "assistant" && ( +
+ +
+ )} + +
+ {message.role === "assistant" ? ( +
+ {message.content} +
+ ) : ( +

+ {message.content} +

+ )} +
+ + {message.role === "user" && ( +
+ +
+ )} +
+ ); + })} + + {isLoading && ( +
0 + ? { minHeight: `${minHeightForLastMessage}px` } + : undefined + } + > +
+ +
+
+ +
+
+ )} +
+
+ )} +
+ + {/* Input Area */} +
+