feat: Enhance provider components with detailed documentation and comments
- Added comprehensive comments and JSDoc-style documentation to NotificationProvider, ThemeProvider, UserProvider, and WebsiteThemeProvider for better clarity and maintainability. - Improved type definitions in index.ts for better code understanding and usage. - Introduced Docker support with Dockerfiles for various services including AI server, signaling server, and browser-use service. - Created a docker-compose.yml file to orchestrate multiple services including PostgreSQL, AI, scraper, and frontend. - Added a startup guide (startup.txt) for setting up the CRM environment on Ubuntu with Docker. - Included a .dockerignore file to exclude unnecessary files from Docker builds.
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
// ── AI Assistant: Main Chat Component ──
|
||||
// Full conversational AI chat interface with message history, GIF support,
|
||||
// server-health polling, localStorage persistence, and an animated boot sequence.
|
||||
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
|
||||
import { Bot, Terminal } from "lucide-react"
|
||||
import { GifPicker } from "./gif-picker"
|
||||
|
||||
/**
|
||||
* Converts URLs in text into clickable anchor elements.
|
||||
* Splits text by URL regex and renders each part as either a link or plain text fragment.
|
||||
*/
|
||||
function linkifyText(text: string) {
|
||||
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
||||
const parts = text.split(urlRegex)
|
||||
@@ -15,6 +23,10 @@ function linkifyText(text: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders AI response text with support for bullet lists (• or -) and paragraphs.
|
||||
* Each line is transformed: bullets get a colored dot prefix, empty lines become spacing.
|
||||
*/
|
||||
function formatContent(text: string) {
|
||||
const lines = text.split("\n")
|
||||
return lines.map((line, i) => {
|
||||
@@ -33,6 +45,7 @@ function formatContent(text: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** A single chat message, either from the user or the AI assistant */
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
@@ -45,16 +58,24 @@ interface ChatMessage {
|
||||
}
|
||||
|
||||
interface AIChatProps {
|
||||
/** Called when the user sends a message */
|
||||
onMessageSent?: (msg: string) => void
|
||||
}
|
||||
|
||||
|
||||
/** Exposed imperative methods for parent components */
|
||||
export interface AIChatHandle {
|
||||
fillInput: (text: string) => void
|
||||
addAssistantMessage: (content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AIChat — the main conversational AI assistant interface.
|
||||
* Features: boot animation, message history with localStorage, GIF picker,
|
||||
* markdown-like formatting, and automatic scroll-to-bottom.
|
||||
*/
|
||||
export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent }, ref) => {
|
||||
// ── State & refs ──
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -67,6 +88,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
const hasUserMessage = messages.some(m => m.role === "user")
|
||||
const loadedFromStorage = useRef(false)
|
||||
|
||||
// ── Expose imperative methods to parent via ref ──
|
||||
useImperativeHandle(ref, () => ({
|
||||
fillInput(text: string) {
|
||||
setInput(text)
|
||||
@@ -85,11 +107,13 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
},
|
||||
}), [])
|
||||
|
||||
// ── Handle GIF selection from the GIF picker ──
|
||||
const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => {
|
||||
setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }])
|
||||
setShowGifPicker(false)
|
||||
}, [])
|
||||
|
||||
// ── Close GIF picker on outside click ──
|
||||
useEffect(() => {
|
||||
if (showGifPicker) {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -102,6 +126,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
}
|
||||
}, [showGifPicker])
|
||||
|
||||
// ── Load messages from localStorage, filtering out messages older than 1 week ──
|
||||
const _WEEK_MS = 604800000
|
||||
|
||||
useEffect(() => {
|
||||
@@ -123,6 +148,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Persist messages to localStorage whenever they change ──
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
const now = Date.now()
|
||||
@@ -130,6 +156,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// ── Poll the AI server until it is no longer returning 503 (booting) ──
|
||||
const checkServer = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/ai/chat", {
|
||||
@@ -147,6 +174,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── On mount: poll server, load jobs to build welcome message ──
|
||||
useEffect(() => {
|
||||
checkServer()
|
||||
if (loadedFromStorage.current) return
|
||||
@@ -180,10 +208,12 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
})
|
||||
}, [checkServer])
|
||||
|
||||
// ── Auto-scroll to bottom when new messages arrive ──
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages])
|
||||
|
||||
// ── Send a message to the AI API ──
|
||||
const sendMessage = useCallback(async (text?: string) => {
|
||||
const msg = (text || input).trim()
|
||||
if (!msg || loading) return
|
||||
@@ -220,6 +250,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
}
|
||||
}, [input, loading, onMessageSent])
|
||||
|
||||
// ── Enter key submits; Shift+Enter inserts newline ──
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
@@ -227,12 +258,14 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
}
|
||||
}, [sendMessage])
|
||||
|
||||
// ── Auto-resize textarea on input ──
|
||||
const handleTextareaInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const t = e.target as HTMLTextAreaElement
|
||||
t.style.height = "auto"
|
||||
t.style.height = t.scrollHeight + "px"
|
||||
}, [])
|
||||
|
||||
// ── Boot screen ──
|
||||
if (bootState === "booting") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -259,6 +292,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Welcome screen (ready, no user messages yet) ──
|
||||
if (bootState === "ready" && !hasUserMessage) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
|
||||
@@ -273,6 +307,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* ── Input area (sticky bottom) ── */}
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-card rounded-2xl border border-border focus-within:border-primary/40 focus-within:shadow-[0_0_20px_hsl(var(--primary)_/_0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3 relative" ref={containerRef}>
|
||||
@@ -307,13 +342,16 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chat view (messages present) ──
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* ── Message list ── */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i}>
|
||||
{msg.role === "assistant" ? (
|
||||
/* ── AI assistant bubble (left-aligned) ── */
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
|
||||
<Bot className="h-4 w-4" />
|
||||
@@ -326,6 +364,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── User bubble (right-aligned) ── */
|
||||
<div className="flex gap-3 items-start flex-row-reverse">
|
||||
<div className="flex-1 min-w-0 flex justify-end">
|
||||
<div className="bg-gradient-to-br from-primary to-primary rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
|
||||
@@ -345,6 +384,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* ── Typing indicator (bouncing dots) ── */}
|
||||
{loading && (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
|
||||
@@ -360,6 +400,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
{/* ── Input area (sticky bottom) ── */}
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{error && (
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// ── AI Assistant: GIF Picker Component ──
|
||||
// Inline GIF search and selection UI backed by GIPHY.
|
||||
// Supports trending, search with debounce, infinite scroll, and client-side caching.
|
||||
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { Search, Loader2, ImageIcon } from "lucide-react"
|
||||
|
||||
/** A single GIF result from the GIPHY API */
|
||||
interface GifResult {
|
||||
id: string
|
||||
title: string
|
||||
@@ -14,12 +19,19 @@ interface GifResult {
|
||||
}
|
||||
|
||||
interface GifPickerProps {
|
||||
/** Called when a GIF is selected */
|
||||
onSelect: (gif: { url: string; previewUrl: string; title: string }) => void
|
||||
/** Called to close the picker */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** In-memory cache keyed by search query or "__trending__" */
|
||||
const SEARCH_CACHE = new Map<string, GifResult[]>()
|
||||
|
||||
/**
|
||||
* GifPicker — inline GIF search/selection popover.
|
||||
* Fetches trending GIFs on open, supports debounced search and infinite scroll via IntersectionObserver.
|
||||
*/
|
||||
export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||
const [gifs, setGifs] = useState<GifResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -32,6 +44,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
// ── Fetch GIFs from the API, with optional cache hit ──
|
||||
const fetchGifs = useCallback(async (q: string, off: number, append: boolean) => {
|
||||
if (off === 0) {
|
||||
const cacheKey = q || "__trending__"
|
||||
@@ -45,6 +58,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Perform the API request ──
|
||||
try {
|
||||
if (!append) setLoading(true)
|
||||
else setLoadingMore(true)
|
||||
@@ -67,6 +81,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||
const data = await res.json()
|
||||
const newGifs: GifResult[] = data.gifs || []
|
||||
|
||||
// Append or replace results, caching on first page
|
||||
if (append) {
|
||||
setGifs((prev) => [...prev, ...newGifs])
|
||||
} else {
|
||||
@@ -89,14 +104,17 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Load trending GIFs on mount ──
|
||||
useEffect(() => {
|
||||
fetchGifs("", 0, false)
|
||||
}, [fetchGifs])
|
||||
|
||||
// ── Auto-focus the search input ──
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// ── Debounced search: 400ms after the user stops typing ──
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
|
||||
if (!search.trim()) {
|
||||
@@ -111,6 +129,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||
}
|
||||
}, [search, fetchGifs])
|
||||
|
||||
// ── Infinite scroll via IntersectionObserver on the sentinel ──
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel) return
|
||||
@@ -129,7 +148,9 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||
}, [hasMore, loadingMore, loading, offset, search, fetchGifs])
|
||||
|
||||
return (
|
||||
// ── Popover container ──
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-card border border-border rounded-2xl shadow-xl shadow-black/20 overflow-hidden z-50">
|
||||
{/* ── Search bar ── */}
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -144,6 +165,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── GIF grid with loading / error / empty states ── */}
|
||||
<div className="overflow-y-auto" style={{ maxHeight: "360px" }}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
@@ -184,6 +206,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{/* Sentinel element for infinite scroll detection */}
|
||||
{hasMore && <div ref={sentinelRef} className="h-4" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// ── AI Assistant: Job Selector Component ──
|
||||
// Dropdown that fetches and displays job categories from the API.
|
||||
// Allows the user to pick a job, then triggers a Facebook search.
|
||||
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react"
|
||||
|
||||
/** A job category returned from the API */
|
||||
interface Job {
|
||||
job_title: string
|
||||
keywords: string[]
|
||||
@@ -11,17 +16,25 @@ interface Job {
|
||||
}
|
||||
|
||||
interface JobSelectorProps {
|
||||
/** Called when a job is selected or cleared (null) */
|
||||
onSelect: (job: Job | null) => void
|
||||
/** Called when the user clicks the "Search Facebook" button */
|
||||
onSearch?: (job: Job) => void
|
||||
/** Whether a search is currently in progress */
|
||||
searching?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* JobSelector — dropdown to pick a job category and initiate a Facebook search.
|
||||
* Fetches available jobs on mount and provides a "Search Facebook" CTA once a job is selected.
|
||||
*/
|
||||
export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) {
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<Job | null>(null)
|
||||
|
||||
// ── Fetch available job categories on mount ──
|
||||
useEffect(() => {
|
||||
fetch("/api/ai/jobs")
|
||||
.then((r) => r.json())
|
||||
@@ -30,6 +43,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// ── Select a job, close dropdown, notify parent ──
|
||||
const handleSelect = (job: Job) => {
|
||||
setSelected(job)
|
||||
setOpen(false)
|
||||
@@ -38,6 +52,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* ── Dropdown trigger button ── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
@@ -50,8 +65,10 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> : <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
|
||||
</button>
|
||||
|
||||
{/* ── Dropdown menu — visible when open ── */}
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop to close on click outside */}
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-card border border-border rounded-xl shadow-xl max-h-60 overflow-y-auto">
|
||||
{jobs.map((job, i) => (
|
||||
@@ -71,6 +88,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* ── "Search Facebook" CTA — shown only when a job is selected ── */}
|
||||
{selected && onSearch && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user