diff --git a/.gitea/workflows/build-and-repair.yml b/.gitea/workflows/build-and-repair.yml new file mode 100644 index 0000000..005935d --- /dev/null +++ b/.gitea/workflows/build-and-repair.yml @@ -0,0 +1,58 @@ +name: Build & Auto-Repair +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Install Playwright + run: npx playwright install chromium firefox + + - name: Build check + id: build + continue-on-error: true + run: npx tsc --noEmit + + - name: Run self-healing setup + if: steps.build.outcome == 'failure' + run: node scripts/setup.mjs --self-heal + + - name: Run code repair agent + if: steps.build.outcome == 'failure' + env: + OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }} + run: node scripts/code-repair-agent.mjs --ci + + - name: Re-check build after repair + id: rebuild + continue-on-error: true + run: npx tsc --noEmit + + - name: Commit fixes + if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success' + run: | + git config user.name "CRM Repair Bot" + git config user.email "bot@coastit.co.za" + git add -A + git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors" + git push + + - name: Build failed — report + if: steps.rebuild.outcome == 'failure' + run: | + echo "::error::Build still failing after auto-repair. Manual intervention required." + exit 1 diff --git a/.setup-templates/registry.json b/.setup-templates/registry.json new file mode 100644 index 0000000..de7ef0f --- /dev/null +++ b/.setup-templates/registry.json @@ -0,0 +1,42 @@ +{ + "version": 2, + "description": "Template registry for self-healing setup. Maps internal import paths to templates.", + "templates": { + "data/stickers": { + "template": "stickers.ts", + "description": "Emoji-based sticker packs for media picker" + }, + "data/constants": { + "template": "constants.ts", + "description": "App-wide constants (lead statuses, roles, event types)" + }, + "lib/utils": { + "template": "utils.ts", + "description": "Utility functions (cn, formatters, helpers)" + }, + "types/index": { + "template": "types-index.ts", + "description": "Core type definitions (User, Lead, DashboardStats)" + }, + "hooks/use-media": { + "template": "hooks/use-media.ts", + "description": "Media query hook" + }, + "hooks/use-debounce": { + "template": "hooks/use-debounce.ts", + "description": "Debounce hook" + }, + "hooks/use-local-storage": { + "template": "hooks/use-local-storage.ts", + "description": "LocalStorage hook with SSR safety" + } + }, + "fallbackTemplates": { + "@/data/*": "data-generic.ts", + "@/lib/*": "lib-generic.ts", + "@/hooks/*": "hook-generic.ts", + "@/types/*": "types-generic.ts", + "@/components/*": "component-generic.tsx", + "@/utils/*": "utils-generic.ts" + } +} \ No newline at end of file diff --git a/.setup-templates/templates/component-generic.tsx b/.setup-templates/templates/component-generic.tsx new file mode 100644 index 0000000..d200bb1 --- /dev/null +++ b/.setup-templates/templates/component-generic.tsx @@ -0,0 +1,7 @@ +// ── Generic Component Template ──────────────────────────────────────── +// Auto-generated by self-healing setup. +// Placeholder for @/components/* imports. Customize as needed. + +export default function GenericComponent() { + return
; +} diff --git a/.setup-templates/templates/constants.ts b/.setup-templates/templates/constants.ts new file mode 100644 index 0000000..17c4217 --- /dev/null +++ b/.setup-templates/templates/constants.ts @@ -0,0 +1,54 @@ +// ── App Constants ──────────────────────────────────────────────────── +// Auto-generated by self-healing setup. Edit .setup-templates/templates/constants.ts to customize. + +export const APP_NAME = "CoastIT CRM"; +export const APP_VERSION = "1.0.0"; + +export const LEAD_STATUSES = { + new: { label: "New", color: "bg-blue-500" }, + contacted: { label: "Contacted", color: "bg-yellow-500" }, + qualified: { label: "Qualified", color: "bg-purple-500" }, + proposal: { label: "Proposal", color: "bg-orange-500" }, + won: { label: "Won", color: "bg-green-500" }, + lost: { label: "Lost", color: "bg-red-500" }, +} as const; + +export const USER_ROLES = { + super_admin: { label: "Super Admin", level: 1 }, + admin: { label: "Admin", level: 2 }, + sales: { label: "Sales", level: 3 }, + dev: { label: "Developer", level: 4 }, +} as const; + +export const EVENT_TYPES = [ + "meeting", + "call", + "email", + "task", + "note", +] as const; + +export const EVENT_STATUSES = [ + "scheduled", + "completed", + "cancelled", + "rescheduled", +] as const; + +export const AI_MODELS = { + chat: "llama3.2:3b", + scraper: "dolphin-llama3:8b", + coder: "qwen2.5-coder:1.5b-base", +} as const; + +export const PAGINATION = { + defaultLimit: 50, + maxLimit: 200, +} as const; + +export const DATE_FORMATS = { + short: "MMM d, yyyy", + long: "MMMM d, yyyy", + time: "h:mm a", + datetime: "MMM d, yyyy h:mm a", +} as const; \ No newline at end of file diff --git a/.setup-templates/templates/data-generic.ts b/.setup-templates/templates/data-generic.ts new file mode 100644 index 0000000..290dc41 --- /dev/null +++ b/.setup-templates/templates/data-generic.ts @@ -0,0 +1,17 @@ +// ── Generic Data Module Template ───────────────────────────────────── +// Auto-generated by self-healing setup. +// Placeholder for @/data/* imports. Customize as needed. + +export interface DataItem { + id: string; + name: string; + [key: string]: unknown; +} + +export function getDataItems(): DataItem[] { + return []; +} + +export function getDataItem(id: string): DataItem | undefined { + return undefined; +} \ No newline at end of file diff --git a/.setup-templates/templates/hook-generic.ts b/.setup-templates/templates/hook-generic.ts new file mode 100644 index 0000000..3ba9a41 --- /dev/null +++ b/.setup-templates/templates/hook-generic.ts @@ -0,0 +1,10 @@ +// ── Generic Hook Template ──────────────────────────────────────────── +// Auto-generated by self-healing setup. +// Placeholder for @/hooks/* imports. Customize as needed. + +import { useState, useEffect } from "react"; + +export function useHook(initialValue: T): [T, (value: T) => void] { + const [value, setValue] = useState(initialValue); + return [value, setValue]; +} \ No newline at end of file diff --git a/.setup-templates/templates/hooks/use-debounce.ts b/.setup-templates/templates/hooks/use-debounce.ts new file mode 100644 index 0000000..de9d434 --- /dev/null +++ b/.setup-templates/templates/hooks/use-debounce.ts @@ -0,0 +1,15 @@ +// ── useDebounce Hook ────────────────────────────────────────────────── +// Auto-generated by self-healing setup. Edit to customize. + +import { useState, useEffect } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/.setup-templates/templates/hooks/use-local-storage.ts b/.setup-templates/templates/hooks/use-local-storage.ts new file mode 100644 index 0000000..5a011cd --- /dev/null +++ b/.setup-templates/templates/hooks/use-local-storage.ts @@ -0,0 +1,29 @@ +// ── useLocalStorage Hook ────────────────────────────────────────────── +// Auto-generated by self-healing setup. Edit to customize. + +import { useState, useEffect } from "react"; + +export function useLocalStorage(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] { + const [storedValue, setStoredValue] = useState(initialValue); + + useEffect(() => { + try { + const item = window.localStorage.getItem(key); + if (item) setStoredValue(JSON.parse(item)); + } catch { + // corrupted data — use initial value + } + }, [key]); + + const setValue = (value: T | ((prev: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } catch { + // storage full or disabled + } + }; + + return [storedValue, setValue]; +} diff --git a/.setup-templates/templates/hooks/use-media.ts b/.setup-templates/templates/hooks/use-media.ts new file mode 100644 index 0000000..4b4acfc --- /dev/null +++ b/.setup-templates/templates/hooks/use-media.ts @@ -0,0 +1,18 @@ +// ── useMedia Hook ───────────────────────────────────────────────────── +// Auto-generated by self-healing setup. Edit to customize. + +import { useState, useEffect } from "react"; + +export function useMedia(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const mql = window.matchMedia(query); + setMatches(mql.matches); + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, [query]); + + return matches; +} diff --git a/.setup-templates/templates/lib-generic.ts b/.setup-templates/templates/lib-generic.ts new file mode 100644 index 0000000..df1fca1 --- /dev/null +++ b/.setup-templates/templates/lib-generic.ts @@ -0,0 +1,7 @@ +// ── Generic Lib Module Template ────────────────────────────────────── +// Auto-generated by self-healing setup. +// Placeholder for @/lib/* imports. Customize as needed. + +export function libFunction(): void { + // Placeholder +} \ No newline at end of file diff --git a/.setup-templates/templates/stickers.ts b/.setup-templates/templates/stickers.ts new file mode 100644 index 0000000..6452718 --- /dev/null +++ b/.setup-templates/templates/stickers.ts @@ -0,0 +1,95 @@ +// ── Sticker Packs ───────────────────────────────────────────────── +// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize. + +export interface Sticker { + id: string; + name: string; + emoji?: string; + svg?: string; +} + +export interface StickerPack { + id: string; + name: string; + stickers: Sticker[]; +} + +const reactionStickers: Sticker[] = [ + { id: "thumbs-up", name: "Thumbs Up", emoji: "👍" }, + { id: "thumbs-down", name: "Thumbs Down", emoji: "👎" }, + { id: "heart", name: "Heart", emoji: "❤️" }, + { id: "laugh", name: "Laugh", emoji: "😂" }, + { id: "wow", name: "Wow", emoji: "😮" }, + { id: "sad", name: "Sad", emoji: "😢" }, + { id: "angry", name: "Angry", emoji: "😡" }, + { id: "clap", name: "Clap", emoji: "👏" }, + { id: "fire", name: "Fire", emoji: "🔥" }, + { id: "100", name: "100", emoji: "💯" }, + { id: "eyes", name: "Eyes", emoji: "👀" }, + { id: "pray", name: "Pray", emoji: "🙏" }, +]; + +const animalStickers: Sticker[] = [ + { id: "cat", name: "Cat", emoji: "🐱" }, + { id: "dog", name: "Dog", emoji: "🐶" }, + { id: "panda", name: "Panda", emoji: "🐼" }, + { id: "fox", name: "Fox", emoji: "🦊" }, + { id: "bear", name: "Bear", emoji: "🐻" }, + { id: "koala", name: "Koala", emoji: "🐨" }, + { id: "tiger", name: "Tiger", emoji: "🐯" }, + { id: "lion", name: "Lion", emoji: "🦁" }, + { id: "monkey", name: "Monkey", emoji: "🐵" }, + { id: "frog", name: "Frog", emoji: "🐸" }, + { id: "penguin", name: "Penguin", emoji: "🐧" }, + { id: "bird", name: "Bird", emoji: "🐦" }, +]; + +const foodStickers: Sticker[] = [ + { id: "pizza", name: "Pizza", emoji: "🍕" }, + { id: "burger", name: "Burger", emoji: "🍔" }, + { id: "taco", name: "Taco", emoji: "🌮" }, + { id: "sushi", name: "Sushi", emoji: "🍣" }, + { id: "ramen", name: "Ramen", emoji: "🍜" }, + { id: "ice-cream", name: "Ice Cream", emoji: "🍦" }, + { id: "cake", name: "Cake", emoji: "🍰" }, + { id: "coffee", name: "Coffee", emoji: "☕" }, + { id: "beer", name: "Beer", emoji: "🍺" }, + { id: "wine", name: "Wine", emoji: "🍷" }, + { id: "donut", name: "Donut", emoji: "🍩" }, + { id: "cookie", name: "Cookie", emoji: "🍪" }, +]; + +const activityStickers: Sticker[] = [ + { id: "football", name: "Football", emoji: "⚽" }, + { id: "basketball", name: "Basketball", emoji: "🏀" }, + { id: "tennis", name: "Tennis", emoji: "🎾" }, + { id: "running", name: "Running", emoji: "🏃" }, + { id: "swimming", name: "Swimming", emoji: "🏊" }, + { id: "cycling", name: "Cycling", emoji: "🚴" }, + { id: "gym", name: "Gym", emoji: "🏋️" }, + { id: "yoga", name: "Yoga", emoji: "🧘" }, + { id: "music", name: "Music", emoji: "🎵" }, + { id: "party", name: "Party", emoji: "🎉" }, + { id: "gift", name: "Gift", emoji: "🎁" }, + { id: "trophy", name: "Trophy", emoji: "🏆" }, +]; + +const stickerPacks: StickerPack[] = [ + { id: "reactions", name: "Reactions", stickers: reactionStickers }, + { id: "animals", name: "Animals", stickers: animalStickers }, + { id: "food", name: "Food & Drink", stickers: foodStickers }, + { id: "activities", name: "Activities", stickers: activityStickers }, +]; + +export function getStickerPacks(): StickerPack[] { + return stickerPacks; +} + +export function getStickerPack(id: string): StickerPack | undefined { + return stickerPacks.find((p) => p.id === id); +} + +export function getSticker(packId: string, stickerId: string): Sticker | undefined { + const pack = getStickerPack(packId); + return pack?.stickers.find((s) => s.id === stickerId); +} \ No newline at end of file diff --git a/.setup-templates/templates/types-generic.ts b/.setup-templates/templates/types-generic.ts new file mode 100644 index 0000000..33d0b1c --- /dev/null +++ b/.setup-templates/templates/types-generic.ts @@ -0,0 +1,10 @@ +// ── Generic Types Template ──────────────────────────────────────────── +// Auto-generated by self-healing setup. +// Placeholder for @/types/* imports. Customize as needed. + +export type GenericType = Record; + +export interface GenericInterface { + id: string; + [key: string]: unknown; +} diff --git a/.setup-templates/templates/types-index.ts b/.setup-templates/templates/types-index.ts new file mode 100644 index 0000000..bf9e45f --- /dev/null +++ b/.setup-templates/templates/types-index.ts @@ -0,0 +1,141 @@ +// ── Type Definitions ────────────────────────────────────────────────── +// Auto-generated by self-healing setup. Edit .setup-templates/templates/types-index.ts to customize. + +export type UserRole = "super_admin" | "admin" | "sales" | "dev"; + +export interface User { + id: string; + name: string; + email: string; + phone?: string; + role: UserRole; + active: boolean; + avatar: string; + createdAt: string; +} + +export interface Lead { + id: string; + companyName: string; + contactName: string; + email: string; + phone: string; + source: string; + description: string; + status: LeadStatus; + assignedUserId: string | null; + assignedUser: User | null; + createdAt: string; + updatedAt: string; +} + +export type LeadStatus = + | "new" + | "contacted" + | "qualified" + | "proposal" + | "won" + | "lost"; + +export interface DashboardStats { + totalLeads: number; + newLeads: number; + contactedLeads: number; + qualifiedLeads: number; + proposalLeads: number; + wonLeads: number; + lostLeads: number; + conversionRate: number; + monthlyBreakdown: { + label: string; + total: number; + new: number; + contacted: number; + qualified: number; + proposal: number; + won: number; + lost: number; + }[]; + leadsPerMonth: { label: string; leads: number; won: number }[]; + recentLeads: Lead[]; + statusDistribution: { name: string; value: number; color: string }[]; + periodLabel: string; + trends: Record; +} + +export interface Note { + id: string; + leadId: string; + userId: string; + authorName: string; + authorAvatar: string; + authorRole: UserRole; + note: string; + createdAt: string; + updatedAt: string; +} + +export type NotificationType = + | "lead_created" + | "lead_status_changed" + | "lead_assigned" + | "chat_message" + | "note_added" + | "event_scheduled"; + +export interface Notification { + id: string; + type: NotificationType; + title: string; + description: string; + timestamp: string; + read: boolean; + link?: string; +} + +export interface ChatMessage { + id: string; + conversationId: string; + senderId: string; + senderName: string; + senderAvatar: string; + content: string; + timestamp: string; +} + +export interface Conversation { + id: string; + participants: { id: string; name: string; avatar: string; role: string }[]; + lastMessage: string; + lastMessageTime: string; + unread: number; + messages: ChatMessage[]; +} + +export type EventType = "meeting" | "call" | "email" | "task" | "note"; +export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"; + +export interface CalendarEvent { + id: string; + userId: string; + participantId: string | null; + developerId: string | null; + leadId: string | null; + conversationId: string | null; + title: string; + description: string | null; + participantNotes: string | null; + eventType: EventType; + startTime: string; + endTime: string | null; + durationMinutes: number | null; + status: EventStatus; + creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null; + participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null; + developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null; + lead: { id: string; companyName: string; contactName: string } | null; + clientName: string | null; + clientEmail: string | null; + clientPhone: string | null; + createdAt: string; +} \ No newline at end of file diff --git a/.setup-templates/templates/utils-generic.ts b/.setup-templates/templates/utils-generic.ts new file mode 100644 index 0000000..490b49b --- /dev/null +++ b/.setup-templates/templates/utils-generic.ts @@ -0,0 +1,7 @@ +// ── Generic Utils Template ──────────────────────────────────────────── +// Auto-generated by self-healing setup. +// Placeholder for @/utils/* imports. Customize as needed. + +export function utilsFunction(): void { + // Placeholder +} diff --git a/.setup-templates/templates/utils.ts b/.setup-templates/templates/utils.ts new file mode 100644 index 0000000..0ad46ae --- /dev/null +++ b/.setup-templates/templates/utils.ts @@ -0,0 +1,76 @@ +// ── Utility Functions ──────────────────────────────────────────────── +// Auto-generated by self-healing setup. Edit .setup-templates/templates/utils.ts to customize. + +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + ...options, + }); +} + +export function formatTime(date: Date | string): string { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); +} + +export function formatCurrency(amount: number, currency = "USD"): string { + return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount); +} + +export function truncate(str: string, length: number): string { + if (str.length <= length) return str; + return str.slice(0, length - 3) + "..."; +} + +export function generateId(): string { + return crypto.randomUUID(); +} + +export function debounce unknown>( + fn: T, + delay: number +): (...args: Parameters) => void { + let timeoutId: ReturnType; + return (...args: Parameters) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(...args), delay); + }; +} + +export function throttle unknown>( + fn: T, + limit: number +): (...args: Parameters) => void { + let inThrottle = false; + return (...args: Parameters) => { + if (!inThrottle) { + fn(...args); + inThrottle = true; + setTimeout(() => (inThrottle = false), limit); + } + }; +} + +export function classNames(...classes: (string | boolean | undefined | null)[]): string { + return classes.filter(Boolean).join(" "); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function retry(fn: () => Promise, retries = 3, delay = 1000): Promise { + return fn().catch((err) => + retries > 0 ? sleep(delay).then(() => retry(fn, retries - 1, delay * 2)) : Promise.reject(err) + ); +} \ No newline at end of file diff --git a/Web_Backgrounds/default-theme.css b/Web_Backgrounds/default-theme.css new file mode 100644 index 0000000..288e733 --- /dev/null +++ b/Web_Backgrounds/default-theme.css @@ -0,0 +1,35 @@ +/* ============================================================================ + Default Theme — Clean, Professional SaaS Design Tokens + This is the standard CRM appearance. No Spidey branding or assets. + ============================================================================ */ + +.theme-default { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 80% 52%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 215 25% 22%; + --input: 215 25% 22%; + --ring: 210 80% 52%; + --radius: 0.5rem; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 210 80% 52%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 215 25% 22%; + --sidebar-ring: 210 80% 52%; +} diff --git a/Web_Backgrounds/spidey-theme.css b/Web_Backgrounds/spidey-theme.css index 63c5a63..5a68e00 100644 --- a/Web_Backgrounds/spidey-theme.css +++ b/Web_Backgrounds/spidey-theme.css @@ -11,8 +11,6 @@ --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; - --primary: 0 100% 53%; - --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; @@ -23,16 +21,12 @@ --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; - --ring: 0 100% 53%; --radius: 0.5rem; --sidebar: 222.2 84% 4.9%; --sidebar-foreground: 210 40% 98%; - --sidebar-primary: 0 100% 53%; - --sidebar-primary-foreground: 0 0% 100%; --sidebar-accent: 217.2 32.6% 17.5%; --sidebar-accent-foreground: 210 40% 98%; --sidebar-border: 217.2 32.6% 17.5%; - --sidebar-ring: 0 100% 53%; } .light.theme-spidey { diff --git a/ai-server/index.mjs b/ai-server/index.mjs index 4ffb1f5..872affa 100644 --- a/ai-server/index.mjs +++ b/ai-server/index.mjs @@ -202,6 +202,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego { role: "user", content: userMessage }, ], stream: false, + keep_alive: "30m", options: { temperature: 0.7, num_predict: 1024 }, }), }) @@ -576,11 +577,40 @@ async function processRequest(req, res, body, startTime) { } }) +// ── Warm up model on startup ──────────────────────────────────── +// Pre-loads the model into Ollama's memory so the first user +// request doesn't pay the cold-start penalty. +async function warmModel() { + console.log(`Warming up model ${MODEL}...`) + try { + const res = await fetch(`${OLLAMA_URL}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(300000), + body: JSON.stringify({ + model: MODEL, + prompt: "Hello", + keep_alive: "30m", + options: { num_predict: 1 }, + }), + }) + if (res.ok) { + console.log(`Model ${MODEL} loaded and kept alive for 30 minutes`) + } else { + console.warn(`Model warm-up returned ${res.status}`) + } + } catch (err) { + console.warn(`Model warm-up failed (will load on first request): ${err.message}`) + } +} + // ── Start ─────────────────────────────────────────────────────── server.listen(PORT, HOST, () => { console.log(`CRM AI server listening on http://${HOST}:${PORT}`) console.log(` Model: ${MODEL}`) console.log(` Ollama: ${OLLAMA_URL}`) + // Kick off warm-up in background (don't block server start) + warmModel() }) initPg() diff --git a/database/migrations/002_seed.sql b/database/migrations/002_seed.sql index 6120547..987dd7e 100644 --- a/database/migrations/002_seed.sql +++ b/database/migrations/002_seed.sql @@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING; -- admin_demo / AdminAccess@2026 -- sales_demo / SalesAccess@2026 -- dev_demo / DevTesting@2026 +-- ewan (password change required on first login) +-- caitlin (password change required on first login) +-- dillen (password change required on first login) INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES ('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za', @@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is 'Sales', 'User', TRUE, TRUE), ('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za', '$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa', - 'Dev', 'User', TRUE, TRUE) + 'Dev', 'User', TRUE, TRUE), + ('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za', + '$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG', + 'Ewan', 'Scheepers', TRUE, TRUE), + ('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za', + '$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG', + 'Caitlin', 'Hermanus', TRUE, TRUE), + ('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za', + '$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW', + 'Dillen', 'van der Merwe', TRUE, TRUE) ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING; -- Update the SUPER_ADMIN to set created_by to self (post-bootstrap) @@ -217,7 +229,7 @@ WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL; -- Set created_by for other users (SUPER_ADMIN created them) UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001' -WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004') +WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007') AND created_by IS NULL; -- ============================================================================ @@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING; INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES ('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), ('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), - ('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001') + ('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), + ('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), + ('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), + ('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001') ON CONFLICT DO NOTHING; -- ============================================================================ diff --git a/database/migrations/013_security_upgrade.sql b/database/migrations/013_security_upgrade.sql index 62ebc44..f020ff2 100644 --- a/database/migrations/013_security_upgrade.sql +++ b/database/migrations/013_security_upgrade.sql @@ -595,6 +595,11 @@ WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026') WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL; +-- NOTE: New admin users (ewan, caitlin, dillen) have password_encrypted=NULL. +-- They will set their own passwords on first login (password_change_required=TRUE). +-- SUPER_ADMIN can populate password_encrypted later via the recovery endpoint +-- after users have set their chosen passwords. + -- ============================================================================ -- FUTURE REQUIREMENT NOTE: -- If this system is ever exposed publicly, remove reversible password storage diff --git a/package.json b/package.json index ac733b7..c8daf94 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,19 @@ "dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start", "dev:signaling": "node signaling-server.mjs", "dev:open": "node scripts/open-browser.mjs", - "dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"", + "dev:repair": "node scripts/code-repair-agent.mjs --watch", + "dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"", "dev:next": "next dev -p 3006", "dev:precheck": "node scripts/precheck.mjs", "dev:ollama": "node scripts/ensure-ollama.mjs", "dev:rust": "node ai-server/index.mjs", "dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py", "setup": "node scripts/setup.mjs", + "setup:self-heal": "node scripts/setup.mjs --self-heal", + "repair": "node scripts/code-repair-agent.mjs", + "repair:watch": "node scripts/code-repair-agent.mjs --watch", + "repair:ci": "node scripts/code-repair-agent.mjs --ci", + "build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci", "build": "next build", "start": "next start -p 3006", "lint": "eslint" diff --git a/scripts/code-repair-agent.mjs b/scripts/code-repair-agent.mjs new file mode 100644 index 0000000..c9534b5 --- /dev/null +++ b/scripts/code-repair-agent.mjs @@ -0,0 +1,346 @@ +// ── Code Repair Agent ───────────────────────────────────────────── +// Phase 2: Auto-fix TypeScript build errors using qwen2.5-coder:1.5b-base +// +// Usage: +// node scripts/code-repair-agent.mjs # one-shot: fix build errors +// node scripts/code-repair-agent.mjs --watch # watch mode: re-fix on file changes +// node scripts/code-repair-agent.mjs --ci # CI mode: fix + commit +// +// Requires: Ollama running at OLLAMA_HOST (default http://localhost:11434) +// Model: qwen2.5-coder:1.5b-base +// +// ── Architecture ────────────────────────────────────────────────── +// 1. Capture build output from `npx tsc --noEmit` +// 2. Parse error locations (file, line, column, message) +// 3. Read the failing source file + surrounding context (~20 lines) +// 4. Send to qwen2.5-coder via Ollama API for fix suggestion +// 5. Apply fix if confidence > threshold (0.7) +// 6. Re-run build to verify fix +// 7. If breaking change detected (wide-reaching import changes), escalate +// 8. In --ci mode: auto-commit with [bot] prefix + +import { execSync } from "node:child_process" +import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs" +import { resolve, dirname } from "node:path" +import { fileURLToPath } from "node:url" +import { createRequire } from "node:module" + +const SELF = dirname(fileURLToPath(import.meta.url)) +const _require = createRequire(import.meta.url) +const ROOT = resolve(SELF, "..") +const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434" +const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base" +const MAX_ITERATIONS = 5 +const CONFIDENCE_THRESHOLD = 0.7 +const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min + +// ── Helpers ──────────────────────────────────────────────────────── + +function log(msg, level = "info") { + const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓" + console.log(` ${prefix} [repair] ${msg}`) +} + +function runTsc() { + try { + const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT }) + return { ok: true, output: out.toString() } + } catch (e) { + return { ok: false, output: e.stdout?.toString() || e.message || "" } + } +} + +function parseErrors(output) { + const errors = [] + // Match: src/file.ts(line,col): error TSxxxx: message + const lineRegex = /(src\/[^:]+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g + let m + while ((m = lineRegex.exec(output)) !== null) { + errors.push({ + file: resolve(ROOT, m[1]), + line: parseInt(m[2], 10), + column: parseInt(m[3], 10), + message: m[4].trim(), + }) + } + return errors +} + +function readContext(filePath, errorLine, contextLines = 20) { + try { + const lines = readFileSync(filePath, "utf-8").split("\n") + const start = Math.max(0, errorLine - 1 - contextLines) + const end = Math.min(lines.length, errorLine - 1 + contextLines) + return { + content: lines.slice(start, end).join("\n"), + contextLine: errorLine - 1 - start, // 0-indexed line of the error in the snippet + totalLines: lines.length, + } + } catch { + return null + } +} + +// ── Ollama Integration ───────────────────────────────────────────── + +async function queryOllama(prompt) { + const response = await fetch(`${OLLAMA_HOST}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: MODEL, + prompt, + stream: false, + options: { temperature: 0.3, num_predict: 2048 }, + }), + }) + if (!response.ok) throw new Error(`Ollama returned ${response.status}`) + const data = await response.json() + return data.response || "" +} + +function buildRepairPrompt(filePath, errorMessage, codeContext) { + return `You are a TypeScript repair agent. Fix the following error. + +FILE: ${filePath} +ERROR: ${errorMessage} + +CODE CONTEXT (line ${codeContext.contextLine + 1} is the error line): +\`\`\`typescript +${codeContext.content} +\`\`\` + +Respond with ONLY the fix JSON in this exact format: +{"fix": "the exact replacement code for the error line(s)", "confidence": 0.0-1.0, "explanation": "brief explanation"} + +Rules: +- Keep the fix minimal and precise +- Only change what's necessary to fix the error +- If the error is a missing import, suggest the import statement +- If the error is a type mismatch, suggest the corrected code +- confidence < 0.7 will not be auto-applied +- If you cannot determine a fix, set confidence to 0` +} + +function parseFixResponse(text) { + try { + // Extract JSON from response (it might have markdown fences) + const jsonMatch = text.match(/\{[\s\S]*"fix"[\s\S]*"confidence"[\s\S]*\}/) + if (!jsonMatch) return null + return JSON.parse(jsonMatch[0]) + } catch { + return null + } +} + +function applyFix(filePath, fixSuggestion) { + if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" } + + try { + const original = readFileSync(filePath, "utf-8") + writeFileSync(filePath, fixSuggestion.fix, "utf-8") + return { applied: true, backup: original } + } catch (e) { + return { applied: false, reason: e.message } + } +} + +// ── Breaking Change Escalation ───────────────────────────────────── + +function isBreakingChange(errors, fixes) { + // Heuristic: if fix touches import structure or exports, it's breaking + for (const fix of fixes) { + if (!fix || !fix.fix) continue + if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) { + return true + } + } + return false +} + +async function escalateBreakingChange(errors, fixes) { + const logFile = resolve(ROOT, "repair-escalation.log") + const timestamp = new Date().toISOString() + const entry = [ + `=== Breaking Change Escalation @ ${timestamp} ===`, + `Errors: ${errors.map(e => `${e.file}:${e.line}: ${e.message}`).join("\n ")}`, + `Fixes: ${fixes.map(f => f?.fix?.slice(0, 200)).join("\n ")}`, + `Auto-apply in ${ESCALATION_TIMEOUT_MS / 60000} minutes or cancel with Ctrl+C`, + "========================================\n", + ].join("\n") + + appendFileSync(logFile, entry) + log(`Breaking change escalated — see ${logFile}`, "warn") + log(`Auto-applying in ${ESCALATION_TIMEOUT_MS / 60000} min (Ctrl+C to cancel)...`, "warn") + + // Wait for timeout or user intervention + await new Promise(resolve => setTimeout(resolve, ESCALATION_TIMEOUT_MS)) + log("Escalation timeout reached — applying fixes") + return true +} + +// ── Git Auto-Commit ──────────────────────────────────────────────── + +function gitCommit(message) { + try { + execSync("git add -A", { stdio: "pipe", cwd: ROOT }) + execSync(`git commit -m "[bot] ${message}"`, { stdio: "pipe", cwd: ROOT }) + log(`Committed: [bot] ${message}`) + return true + } catch (e) { + log(`Git commit failed: ${e.message}`, "warn") + return false + } +} + +// ── Main Repair Loop ─────────────────────────────────────────────── + +async function repairOnce(doCommit = false) { + log("Running build check...") + const build = runTsc() + if (build.ok) { + log("No build errors found") + return { fixed: false, errors: [] } + } + + const errors = parseErrors(build.output) + if (errors.length === 0) { + log("Could not parse build errors from output", "warn") + log(build.output.slice(0, 1000)) + return { fixed: false, errors: [] } + } + + log(`Found ${errors.length} error(s):`) + for (const e of errors.slice(0, 10)) { + const shortFile = e.file.replace(ROOT, "").replace(/^\//, "") + log(`${shortFile}:${e.line}:${e.column} — ${e.message}`) + } + + const fixes = [] + for (const error of errors) { + const context = readContext(error.file, error.line) + if (!context) { + log(`Cannot read ${error.file}`, "error") + continue + } + + log(`Asking ${MODEL} to fix ${error.file}:${error.line}...`) + try { + const prompt = buildRepairPrompt(error.file, error.message, context) + const response = await queryOllama(prompt) + const fix = parseFixResponse(response) + + if (fix && fix.confidence >= CONFIDENCE_THRESHOLD) { + const result = applyFix(error.file, fix) + if (result.applied) { + log(`${fix.explanation || "Applied fix"} (confidence: ${fix.confidence})`) + fixes.push(fix) + } else { + log(`Failed to apply fix: ${result.reason}`, "error") + } + } else { + log(`Low confidence (${fix?.confidence || 0}) or invalid response — skipping`, "warn") + } + } catch (e) { + log(`Ollama error: ${e.message}`, "error") + } + } + + if (fixes.length === 0) { + log("No fixes could be applied", "error") + return { fixed: false, errors } + } + + // Verify by re-running build + log("Verifying fixes...") + const verify = runTsc() + if (verify.ok) { + log("All fixes verified — build passes!") + if (doCommit) gitCommit(`Auto-fix: ${fixes.length} error(s) resolved`) + return { fixed: true, errors: [], fixes } + } + + const remainingErrors = parseErrors(verify.output) + log(`${remainingErrors.length} error(s) remaining after fix`, "warn") + + // Check for breaking changes + if (isBreakingChange(remainingErrors, fixes)) { + log("Breaking change detected — escalating", "warn") + const approved = await escalateBreakingChange(remainingErrors, fixes) + if (approved) { + if (doCommit) gitCommit(`Breaking change applied after escalation`) + return { fixed: true, errors: remainingErrors, fixes, escalated: true } + } + } + + return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes } +} + +async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) { + for (let i = 0; i < maxIterations; i++) { + log(`=== Repair iteration ${i + 1}/${maxIterations} ===`) + const result = await repairOnce(doCommit) + if (!result.fixed) { + if (result.errors?.length > 0) { + log(`${result.errors.length} error(s) remaining — run again for deeper fix`, "warn") + } + break + } + if (result.errors?.length === 0) break + } + log("Repair agent finished") +} + +// ── Watch Mode ───────────────────────────────────────────────────── + +function watchMode() { + log("Watch mode active — monitoring src/ for changes...", "info") + const chokidar = (() => { + try { + return _require("chokidar") + } catch { + return null + } + })() + + if (!chokidar) { + log("chokidar not available — using simple polling (5s interval)", "warn") + setInterval(async () => { + const result = await repairOnce(false) + if (result.fixed) log("Auto-fix applied") + }, 5000) + return + } + + const watcher = chokidar.watch("src/**/*.{ts,tsx}", { cwd: ROOT, ignoreInitial: true }) + let debounceTimer = null + + watcher.on("change", async (filePath) => { + clearTimeout(debounceTimer) + debounceTimer = setTimeout(async () => { + log(`Change detected: ${filePath}`) + await repairLoop(3, false) + }, 1000) + }) + + log("Watching for changes...") +} + +// ── Entry ────────────────────────────────────────────────────────── + +const args = process.argv.slice(2) +const isWatch = args.includes("--watch") || args.includes("-w") +const isCI = args.includes("--ci") || args.includes("-c") + +async function main() { + if (isWatch) { + watchMode() + } else { + await repairLoop(MAX_ITERATIONS, isCI) + } +} + +main().catch((e) => { + console.error("Repair agent crashed:", e) + process.exit(1) +}) diff --git a/scripts/lib/module-generator.ts b/scripts/lib/module-generator.ts new file mode 100644 index 0000000..7170e05 --- /dev/null +++ b/scripts/lib/module-generator.ts @@ -0,0 +1,396 @@ +// ── Module Generator ───────────────────────────────────────────────── +// Generates missing internal modules from templates. +// Supports built-in templates and custom .setup-templates/ directory. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, "..", ".."); +const SETUP_TEMPLATES_DIR = path.join(ROOT, ".setup-templates"); +const REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json"); +const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates"); + +export interface TemplateRegistry { + [importPath: string]: { + template: string; + params?: Record; + generator?: string; // optional custom generator function name + }; +} + +export interface GenerationResult { + success: boolean; + filePath: string; + internalPath: string; + message?: string; + error?: string; +} + +export interface FallbackRegistry { + [pattern: string]: string; +} + +/** Load the template registry from .setup-templates/registry.json */ +export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } { + const builtInTemplates: TemplateRegistry = { + "data/stickers": { template: "stickers.ts" }, + "data/constants": { template: "constants.ts" }, + "lib/utils": { template: "utils.ts" }, + "types/index": { template: "types-index.ts", params: { inferFromUsage: true } }, + }; + const builtInFallbacks: FallbackRegistry = { + "@/data/*": "data-generic.ts", + "@/lib/*": "lib-generic.ts", + "@/hooks/*": "hook-generic.ts", + "@/types/*": "types-generic.ts", + "@/components/*": "component-generic.tsx", + "@/utils/*": "utils-generic.ts", + }; + + try { + if (fs.existsSync(REGISTRY_PATH)) { + const content = fs.readFileSync(REGISTRY_PATH, "utf-8"); + const custom = JSON.parse(content); + return { + templates: { ...builtInTemplates, ...(custom.templates || {}) }, + fallbacks: { ...builtInFallbacks, ...(custom.fallbackTemplates || {}) }, + }; + } + } catch { + // Use built-in + } + return { templates: builtInTemplates, fallbacks: builtInFallbacks }; +} + +/** Match an internal path against fallback patterns */ +export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined { + for (const [pattern, template] of Object.entries(fallbacks)) { + // Convert glob pattern to regex + const regexStr = "^" + pattern + .replace(/\//g, "\\/") + .replace(/\./g, "\\.") + .replace(/\*/g, ".*") + .replace(/[$^+(){}[\]|]/g, "\\$&") + + "$"; + const regex = new RegExp(regexStr); + if (regex.test(internalPath)) { + return template; + } + } + return undefined; +} + +/** Render a template with parameters */ +function renderTemplate(templateContent: string, params: Record = {}): string { + let result = templateContent; + for (const [key, value] of Object.entries(params)) { + const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g"); + result = result.replace(regex, String(value)); + } + return result; +} + +/** Get template content - first from custom templates dir, then built-in */ +function getTemplateContent(templateName: string): string { + // Check custom templates first + const customPath = path.join(TEMPLATES_DIR, templateName); + if (fs.existsSync(customPath)) { + return fs.readFileSync(customPath, "utf-8"); + } + + // Built-in templates (embedded) + const builtInTemplates: Record = { + "stickers.ts": `// ── Sticker Packs ───────────────────────────────────────────────── +// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize. + +export interface Sticker { + id: string; + name: string; + emoji?: string; + svg?: string; +} + +export interface StickerPack { + id: string; + name: string; + stickers: Sticker[]; +} + +export function getStickerPacks(): StickerPack[] { + return [ + { + id: "reactions", + name: "Reactions", + stickers: [ + { id: "thumbs-up", name: "Thumbs Up", emoji: "👍" }, + { id: "thumbs-down", name: "Thumbs Down", emoji: "👎" }, + { id: "heart", name: "Heart", emoji: "❤️" }, + { id: "laugh", name: "Laugh", emoji: "😂" }, + { id: "wow", name: "Wow", emoji: "😮" }, + { id: "sad", name: "Sad", emoji: "😢" }, + { id: "angry", name: "Angry", emoji: "😡" }, + { id: "clap", name: "Clap", emoji: "👏" }, + { id: "fire", name: "Fire", emoji: "🔥" }, + { id: "100", name: "100", emoji: "💯" }, + { id: "eyes", name: "Eyes", emoji: "👀" }, + { id: "pray", name: "Pray", emoji: "🙏" }, + ], + }, + { + id: "animals", + name: "Animals", + stickers: [ + { id: "cat", name: "Cat", emoji: "🐱" }, + { id: "dog", name: "Dog", emoji: "🐶" }, + { id: "panda", name: "Panda", emoji: "🐼" }, + { id: "fox", name: "Fox", emoji: "🦊" }, + { id: "bear", name: "Bear", emoji: "🐻" }, + { id: "koala", name: "Koala", emoji: "🐨" }, + { id: "tiger", name: "Tiger", emoji: "🐯" }, + { id: "lion", name: "Lion", emoji: "🦁" }, + { id: "monkey", name: "Monkey", emoji: "🐵" }, + { id: "frog", name: "Frog", emoji: "🐸" }, + { id: "penguin", name: "Penguin", emoji: "🐧" }, + { id: "bird", name: "Bird", emoji: "🐦" }, + ], + }, + { + id: "food", + name: "Food & Drink", + stickers: [ + { id: "pizza", name: "Pizza", emoji: "🍕" }, + { id: "burger", name: "Burger", emoji: "🍔" }, + { id: "taco", name: "Taco", emoji: "🌮" }, + { id: "sushi", name: "Sushi", emoji: "🍣" }, + { id: "ramen", name: "Ramen", emoji: "🍜" }, + { id: "ice-cream", name: "Ice Cream", emoji: "🍦" }, + { id: "cake", name: "Cake", emoji: "🎂" }, + { id: "coffee", name: "Coffee", emoji: "☕" }, + { id: "beer", name: "Beer", emoji: "🍺" }, + { id: "wine", name: "Wine", emoji: "🍷" }, + { id: "donut", name: "Donut", emoji: "🍩" }, + { id: "cookie", name: "Cookie", emoji: "🍪" }, + ], + }, + ]; +}; + +export function getStickerPacks(): StickerPack[] { + return [ + // reactions, animals, food packs defined above + // (inline for brevity - full packs in template file) + ]; +} +`, + "constants.ts": `// ── App Constants ──────────────────────────────────────────────────── +// Auto-generated by self-healing setup. + +export const APP_NAME = "CoastIT CRM"; +export const APP_VERSION = "1.0.0"; + +export const LEAD_STATUSES = { + new: { label: "New", color: "bg-blue-500" }, + contacted: { label: "Contacted", color: "bg-yellow-500" }, + qualified: { label: "Qualified", color: "bg-purple-500" }, + proposal: { label: "Proposal", color: "bg-orange-500" }, + won: { label: "Won", color: "bg-green-500" }, + lost: { label: "Lost", color: "bg-red-500" }, +} as const; + +export const USER_ROLES = { + super_admin: { label: "Super Admin", level: 1 }, + admin: { label: "Admin", level: 2 }, + sales: { label: "Sales", level: 3 }, + dev: { label: "Developer", level: 4 }, +} as const; + +export const EVENT_TYPES = [ + "meeting", + "call", + "email", + "task", + "note", +] as const; +`, + "utils.ts": `// ── Utility Functions ──────────────────────────────────────────────── +// Auto-generated by self-healing setup. + +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + ...options, + }); +} + +export function formatTime(date: Date | string): string { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); +} + +export function formatCurrency(amount: number, currency = "USD"): string { + return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount); +} + +export function truncate(str: string, length: number): string { + if (str.length <= length) return str; + return str.slice(0, length - 3) + "..."; +} + +export function generateId(): string { + return crypto.randomUUID(); +} + +export function debounce unknown>( + fn: T, + delay: number +): (...args: Parameters) => void { + let timeoutId: ReturnType; + return (...args: Parameters) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(...args), delay); + }; +} +`, + "types-index.ts": `// ── Type Definitions ────────────────────────────────────────────────── +// Auto-generated by self-healing setup. + +export type UserRole = "super_admin" | "admin" | "sales" | "dev"; + +export interface User { + id: string; + name: string; + email: string; + phone?: string; + role: UserRole; + active: boolean; + avatar: string; + createdAt: string; +} + +export interface Lead { + id: string; + companyName: string; + contactName: string; + email: string; + phone: string; + source: string; + description: string; + status: LeadStatus; + assignedUserId: string | null; + assignedUser: User | null; + createdAt: string; + updatedAt: string; +} + +export type LeadStatus = "new" | "contacted" | "qualified" | "proposal" | "won" | "lost"; + +export interface DashboardStats { + totalLeads: number; + newLeads: number; + contactedLeads: number; + qualifiedLeads: number; + proposalLeads: number; + wonLeads: number; + lostLeads: number; + conversionRate: number; +} +`, + }; + + return builtInTemplates[templateName] || ""; +} + +/** Generate a single missing module */ +export function generateModule( + internalPath: string, + templates: TemplateRegistry, + fallbacks: FallbackRegistry +): GenerationResult { + let entry = templates[internalPath]; + let templateFile = entry?.template; + + // Try fallback patterns + if (!entry) { + const fallbackTemplate = matchFallback(internalPath, fallbacks); + if (fallbackTemplate) { + templateFile = fallbackTemplate; + entry = { template: fallbackTemplate }; + } + } + + if (!templateFile) { + return { + success: false, + filePath: "", + internalPath, + error: `No template registered for @/${internalPath}`, + }; + } + + const templateContent = getTemplateContent(templateFile); + if (!templateContent) { + return { + success: false, + filePath: "", + internalPath, + error: `Template not found: ${entry.template}`, + }; + } + + const rendered = renderTemplate(templateContent, entry.params || {}); + const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts")); + + // Ensure directory exists + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Write file + try { + fs.writeFileSync(filePath, rendered, "utf-8"); + return { + success: true, + filePath, + internalPath, + message: `Generated @/${internalPath}`, + }; + } catch (e) { + return { + success: false, + filePath, + internalPath, + error: `Failed to write file: ${e instanceof Error ? e.message : String(e)}`, + }; + } +} + +/** Generate all missing modules for a list of internal paths */ +export function generateAll(internalPaths: string[]): GenerationResult[] { + const { templates, fallbacks } = loadRegistry(); + const results: GenerationResult[] = []; + + for (const internalPath of internalPaths) { + const result = generateModule(internalPath, templates, fallbacks); + results.push(result); + if (result.success) { + console.log(` ✓ ${result.message}`); + } else { + console.error(` ✗ Failed @/${internalPath}: ${result.error}`); + } + } + + return results; +} \ No newline at end of file diff --git a/scripts/lib/ts-error-parser.ts b/scripts/lib/ts-error-parser.ts new file mode 100644 index 0000000..2261930 --- /dev/null +++ b/scripts/lib/ts-error-parser.ts @@ -0,0 +1,92 @@ +// ── TypeScript Error Parser ────────────────────────────────────────── +// Parses `tsc --noEmit` or `npm run build` output to extract +// "Module not found: Can't resolve '@/path/to/module'" errors. +// Returns actionable missing module info for the generator. + +export interface MissingModule { + /** The import path that failed, e.g. "@/data/stickers" */ + importPath: string; + /** The file that contains the failing import */ + importerFile: string; + /** Line number in importer file */ + line: number; + /** Column number */ + column: number; + /** Full error message for context */ + message: string; + /** Whether this is an internal (@/...) module we can generate */ + isInternal: boolean; + /** The normalized internal path, e.g. "data/stickers" */ + internalPath?: string; +} + +/** Parse TypeScript compiler output for missing module errors */ +export function parseMissingModules(tscOutput: string): MissingModule[] { + const results: MissingModule[] = []; + + // Pattern: error TS2307: Cannot find module '@/data/stickers' or its corresponding type declarations. + // File: src/components/chats/media-picker.tsx:10:1 + const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'\s+or its corresponding type declarations\.\s*(?:\n\s*(.*?):(\d+):(\d+))?/g; + + let match; + while ((match = errorRegex.exec(tscOutput)) !== null) { + const importPath = match[1]; + const importerFile = match[2] || ""; + const line = parseInt(match[3] || "0", 10); + const column = parseInt(match[4] || "0", 10); + + // Also handle: Module not found: Error: Can't resolve '@/data/stickers' in 'C:\...\src\components\chats' + const altRegex = /Module not found: Error: Can't resolve\s+'([^']+)'\s+in\s+'([^']+)'/g; + let altMatch; + let altImporter = importerFile; + while ((altMatch = altRegex.exec(tscOutput)) !== null) { + if (altMatch[1] === importPath) { + altImporter = altMatch[2]; + break; + } + } + + const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/"); + + let internalPath: string | undefined; + if (isInternal) { + internalPath = importPath.replace(/^[@~]\//, ""); + } + + results.push({ + importPath, + importerFile: altImporter, + line, + column, + message: `Cannot find module '${importPath}'`, + isInternal, + internalPath, + }); + } + + // Deduplicate by importPath + importerFile + const seen = new Set(); + return results.filter((m) => { + const key = `${m.importPath}|${m.importerFile}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +/** Filter to only internal (@/...) modules we can auto-generate */ +export function filterGeneratable(modules: MissingModule[]): MissingModule[] { + return modules.filter((m) => m.isInternal && m.internalPath); +} + +/** Group missing modules by internal path */ +export function groupByInternalPath(modules: MissingModule[]): Map { + const groups = new Map(); + for (const m of modules) { + if (!m.internalPath) continue; + const existing = groups.get(m.internalPath) || []; + existing.push(m); + groups.set(m.internalPath, existing); + } + return groups; +} \ No newline at end of file diff --git a/scripts/setup.mjs b/scripts/setup.mjs index 1cbdf0c..c8cec1f 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -5,17 +5,22 @@ // 2. pip install -r requirements.txt (Python dependencies) // 3. playwright install firefox chromium (Playwright browsers) // 4. Copies .env.example to .env.local if not exists +// 5. --self-heal flag: runs build check + auto-generates missing modules // // All steps are cross-platform (Windows, Mac, Linux). -// Uses execSync for simplicity since each step blocks the next. import { execSync } from "node:child_process" -import { existsSync, copyFileSync } from "node:fs" +import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs" import { platform } from "node:os" +import { resolve, dirname } from "node:path" +import { fileURLToPath } from "node:url" const SEP = platform() === "win32" ? "&" : ";" +const SELF = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SELF, "..") + +// ── Helpers ──────────────────────────────────────────────────────── -// Auto-detect Python executable (python vs python3) function detectPython() { const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"] for (const cmd of candidates) { @@ -28,7 +33,6 @@ function detectPython() { process.exit(1) } -// Auto-detect pip (pip vs pip3), fall back to python -m pip function detectPip(python) { const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"] for (const cmd of candidates) { @@ -40,41 +44,216 @@ function detectPip(python) { return `${python} -m pip` } -const PY = detectPython() -const PIP = detectPip(PY) - -function run(cmd, label) { +function run(cmd, label, opts = {}) { console.log(`\n── ${label} ──`) try { - execSync(cmd, { stdio: "inherit", timeout: 120000 }) + execSync(cmd, { stdio: "inherit", timeout: opts.timeout || 120000, cwd: opts.cwd || ROOT }) } catch (e) { + if (opts.optional) { + console.log(` ~ Skipped (${e.message})`) + return false + } console.error(` ✗ Failed: ${e.message}`) process.exit(1) } + return true } +// ── Self-Healing Module Registry ─────────────────────────────────── + +function loadRegistry() { + const registryPath = resolve(ROOT, ".setup-templates", "registry.json") + const templatesDir = resolve(ROOT, ".setup-templates", "templates") + + const builtInTemplates = { + "data/stickers": { template: "stickers.ts" }, + "data/constants": { template: "constants.ts" }, + "lib/utils": { template: "utils.ts" }, + "types/index": { template: "types-index.ts" }, + "hooks/use-media": { template: "hooks/use-media.ts" }, + "hooks/use-debounce": { template: "hooks/use-debounce.ts" }, + "hooks/use-local-storage": { template: "hooks/use-local-storage.ts" }, + } + + const builtInFallbacks = { + "@/data/*": "data-generic.ts", + "@/lib/*": "lib-generic.ts", + "@/hooks/*": "hook-generic.ts", + "@/types/*": "types-generic.ts", + "@/components/*": "component-generic.tsx", + "@/utils/*": "utils-generic.ts", + } + + let templates = { ...builtInTemplates } + let fallbacks = { ...builtInFallbacks } + + try { + if (existsSync(registryPath)) { + const custom = JSON.parse(readFileSync(registryPath, "utf-8")) + templates = { ...templates, ...(custom.templates || {}) } + fallbacks = { ...fallbacks, ...(custom.fallbackTemplates || {}) } + } + } catch {} + + const templateCache = {} + + function getTemplate(templateName) { + if (templateCache[templateName]) return templateCache[templateName] + const candidatePaths = [ + resolve(templatesDir, templateName), + resolve(templatesDir, "hooks", templateName.replace("hooks/", "")), + ] + for (const p of candidatePaths) { + if (existsSync(p)) { + const content = readFileSync(p, "utf-8") + templateCache[templateName] = content + return content + } + } + return "" + } + + function matchFallback(internalPath) { + for (const [pattern, templateFile] of Object.entries(fallbacks)) { + const regexStr = "^" + pattern + .replace(/\//g, "\\/").replace(/\./g, "\\.") + .replace(/\*/g, ".*") + .replace(/[$^+(){}[\]|]/g, "\\$&") + "$" + const regex = new RegExp(regexStr) + if (regex.test(internalPath)) return templateFile + } + return null + } + + function generateModule(internalPath) { + let templateFile = templates[internalPath]?.template + if (!templateFile) templateFile = matchFallback(internalPath) + if (!templateFile) return { success: false, error: `No template for @/${internalPath}` } + + const content = getTemplate(templateFile) + if (!content) return { success: false, error: `Template file not found: ${templateFile}` } + + const ext = templateFile.endsWith(".tsx") ? ".tsx" : ".ts" + const filePath = resolve(ROOT, "src", internalPath + ext) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, content, "utf-8") + return { success: true, filePath, message: `Generated @/${internalPath}` } + } + + return { generateModule } +} + +function parseMissingModules(buildOutput) { + const results = [] + const seen = new Set() + + // Pattern: error TS2307: Cannot find module '@/path' or its corresponding type declarations. + const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'/g + let match + while ((match = errorRegex.exec(buildOutput)) !== null) { + const importPath = match[1] + if (!seen.has(importPath)) { + seen.add(importPath) + const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/") + const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined + results.push({ importPath, isInternal, internalPath }) + } + } + + // Also catch webpack-style: Module not found: Error: Can't resolve '@/path' + const webpackRegex = /Module not found: Error: Can't resolve\s+'([^']+)'/g + while ((match = webpackRegex.exec(buildOutput)) !== null) { + const importPath = match[1] + if (!seen.has(importPath)) { + seen.add(importPath) + const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/") + const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined + results.push({ importPath, isInternal, internalPath }) + } + } + + return results +} + +// ── Main ─────────────────────────────────────────────────────────── + +const args = process.argv.slice(2) +const doSelfHeal = args.includes("--self-heal") || args.includes("-s") +const PY = detectPython() +const PIP = detectPip(PY) + console.log("=== CoastIT CRM Setup ===\n") // 1. Node dependencies run("npm install", "Installing Node.js dependencies") -// 2. Python dependencies (run from browser-use-service directory) +// 2. Python dependencies run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies") -// 3. Playwright browsers (Firefox for primary scraping, Chromium for Chrome/Edge/Opera + Agent fallback) +// 3. Playwright browsers run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers") -// 4. .env file — create from template if it doesn't exist -if (!existsSync(".env.local")) { +// 4. .env file +if (!existsSync(resolve(ROOT, ".env.local"))) { console.log("\n── Creating .env.local ──") - copyFileSync(".env.example", ".env.local") + copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local")) console.log(" ✓ Created .env.local from .env.example — edit it with your settings") } else { console.log("\n── .env.local already exists, skipping ──") } -// 5. Remaining manual steps -console.log("\n── Next steps ──") +// 5. Self-healing build check (--self-heal flag) +if (doSelfHeal) { + console.log("\n── Self-Healing Build Check ──") + const { generateModule } = loadRegistry() + let lastGeneratedCount = -1 + let iteration = 0 + const MAX_ITERATIONS = 5 + + while (iteration < MAX_ITERATIONS) { + iteration++ + console.log(` Build check pass ${iteration}/${MAX_ITERATIONS}...`) + + let buildOutput = "" + try { + buildOutput = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT }).toString() + } catch (e) { + buildOutput = e.stdout?.toString() || e.message || "" + } + + const missing = parseMissingModules(buildOutput) + const internalMissing = missing + .filter(m => m.isInternal && m.internalPath) + .filter((m, i, arr) => arr.findIndex(x => x.internalPath === m.internalPath) === i) // dedup + + if (internalMissing.length === 0) { + console.log(" ✓ No missing internal modules found!") + break + } + + console.log(` Found ${internalMissing.length} missing internal module(s)`) + + let generated = 0 + for (const mod of internalMissing) { + const result = generateModule(mod.internalPath) + if (result.success) { + console.log(` ✓ ${result.message}`) + generated++ + } else { + console.log(` ✗ ${result.error}`) + } + } + + if (generated === 0 || generated === lastGeneratedCount) { + console.log(" No new modules could be generated. Stopping.") + break + } + lastGeneratedCount = generated + } +} + +// 6. Final summary +console.log("\n── Final Steps ──") console.log(" 1. Make sure PostgreSQL is running with database 'crm'") console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b") console.log(" 3. Edit .env.local with your settings") diff --git a/src/app/(dashboard)/ai-assistant/page.tsx b/src/app/(dashboard)/ai-assistant/page.tsx index aa8f866..4800a41 100644 --- a/src/app/(dashboard)/ai-assistant/page.tsx +++ b/src/app/(dashboard)/ai-assistant/page.tsx @@ -1,13 +1,23 @@ "use client" -import { useState, useCallback, useEffect } from "react" +import { useState, useCallback, useRef, useEffect } from "react" import { AIChat } from "@/components/ai/ai-chat" import { JobSelector } from "@/components/ai/job-selector" -import { Bot, Lightbulb, Target, MessageSquare, Wifi, WifiOff } from "lucide-react" +import { Bot, ChevronRight, Wifi, WifiOff } from "lucide-react" + +const tips = [ + "Ask for cold email templates for a specific job", + "Request objection handling tips", + "Ask for outreach strategies per industry", + "Generate a follow up sequence", + "Get LinkedIn connection message templates", +] export default function AIAssistantPage() { const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null) + const [recentPrompts, setRecentPrompts] = useState([]) const [aiOnline, setAiOnline] = useState(null) + const aiChatRef = useRef<{ fillInput: (text: string) => void } | null>(null) const handleJobSelect = useCallback((job: typeof selectedJob) => { setSelectedJob(job) @@ -28,83 +38,121 @@ export default function AIAssistantPage() { return () => { cancelled = true; clearInterval(id) } }, []) + const handleTipClick = useCallback((tip: string) => { + aiChatRef.current?.fillInput(tip) + }, []) + + const handleRecentPromptClick = useCallback((prompt: string) => { + aiChatRef.current?.fillInput(prompt) + }, []) + + const handleMessageSent = useCallback((msg: string) => { + setRecentPrompts((prev) => { + const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3) + return next + }) + }, []) + return ( -
-
-
-
-
- +
+
+
+
+
+
+ +
+
+

AI Sales Assistant

+

Powered by local AI

+
-
-

AI Sales Assistant

-

Uncensored sales tips and strategies powered by local AI

-
-
- - {aiOnline === null ? "Checking..." : aiOnline ? "Connected" : "Disconnected"} - - {aiOnline === null ? ( - - ) : aiOnline ? ( - - ) : ( - - )} +
+
+ + {aiOnline === null ? "Checking..." : aiOnline ? "Connected" : "Disconnected"} + + {aiOnline === null ? ( + + ) : aiOnline ? ( + + ) : ( + + )} +
+
+
+ Local AI Model +
- -
-
- + +
+
+
+
+ + Target Job
- -
-
-

- - Target Job -

- -
- - {selectedJob && ( -
-

{selectedJob.job_title}

-
- {selectedJob.industry} -
-

{selectedJob.description}

-
- {selectedJob.keywords.map((kw, i) => ( - - {kw} - - ))} -
+ + {selectedJob && ( +
+

{selectedJob.job_title}

+ {selectedJob.industry} +

{selectedJob.description}

+
+ {selectedJob.keywords.map((kw, i) => ( + {kw} + ))}
- )} - -
-

- - Tips -

-
    -
  • - - Ask for cold email templates for a specific job -
  • -
  • - - Request objection handling tips -
  • -
  • - - Ask for outreach strategies per industry -
  • -
+
+ )} +
+
+
+ + Quick Tips +
+ {tips.map((tip, i) => ( +
handleTipClick(tip)} + className="flex items-start gap-2.5 bg-card/50 hover:bg-card border border-border hover:border-primary/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group" + > + + {tip} +
+ ))} +
+
+
+ + Recent Prompts +
+ {recentPrompts.length > 0 ? ( + recentPrompts.map((prompt, i) => ( +
handleRecentPromptClick(prompt)} + className="bg-card/50 rounded-xl p-3 mb-2 border border-border text-muted-foreground text-xs truncate hover:text-foreground cursor-pointer transition-colors duration-200" + > + {prompt} +
+ )) + ) : ( +
Your recent prompts will appear here
+ )} +
+
+
+
+
Messages today
+
24
+
+
+
Tokens used
+
12.4k
diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 52cafb4..cb288a3 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -86,7 +86,7 @@ export default function DashboardPage() {
-

Pipeline Overview

+

Pipeline Overview

{stats @@ -97,7 +97,7 @@ export default function DashboardPage() { }
-

Analytics

+

Analytics

diff --git a/src/app/api/ai/giphy/route.ts b/src/app/api/ai/giphy/route.ts new file mode 100644 index 0000000..be69b06 --- /dev/null +++ b/src/app/api/ai/giphy/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" + +const GIPHY_API_KEY = process.env.GIPHY_API_KEY +const GIPHY_BASE = "https://api.giphy.com/v1/gifs" + +export async function GET(request: NextRequest) { + try { + const user = await getSessionUser() + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + if (!GIPHY_API_KEY) { + return NextResponse.json({ error: "GIPHY API key not configured" }, { status: 500 }) + } + + const { searchParams } = new URL(request.url) + const type = searchParams.get("type") || "trending" + const query = searchParams.get("q") || "" + const offset = parseInt(searchParams.get("offset") || "0", 10) + const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50) + + let url: string + if (type === "search" && query) { + url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&rating=g` + } else { + url = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${offset}&rating=g` + } + + const res = await fetch(url) + if (!res.ok) { + const errBody = await res.text() + console.error("GIPHY API error:", res.status, errBody) + return NextResponse.json({ error: `GIPHY API error (${res.status}): ${errBody}` }, { status: res.status }) + } + + const data = await res.json() + const gifs = (data.data || []).map((gif: any) => ({ + id: gif.id, + title: gif.title, + url: gif.images?.original?.url || "", + previewUrl: gif.images?.fixed_width?.url || "", + previewHeight: gif.images?.fixed_width?.height || 150, + width: gif.images?.original?.width || 0, + height: gif.images?.original?.height || 0, + })) + + return NextResponse.json({ + gifs, + pagination: data.pagination || { total_count: 0, count: gifs.length, offset }, + }) + } catch (error: any) { + console.error("GIPHY proxy error:", error) + return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/api/ai/health/route.ts b/src/app/api/ai/health/route.ts new file mode 100644 index 0000000..031c367 --- /dev/null +++ b/src/app/api/ai/health/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server" +import { checkAiServiceStatus } from "@/lib/ai" + +export async function GET() { + const ok = await checkAiServiceStatus() + return NextResponse.json({ status: ok ? "ok" : "unavailable" }, { status: ok ? 200 : 503 }) +} diff --git a/src/app/api/gifs/route.ts b/src/app/api/gifs/route.ts index 8ecf18e..1b7c5b6 100644 --- a/src/app/api/gifs/route.ts +++ b/src/app/api/gifs/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" -const TENOR_API_KEY = process.env.TENOR_API_KEY || "" -const TENOR_BASE = "https://tenor.googleapis.com/v2" +const GIPHY_API_KEY = process.env.GIPHY_API_KEY +const GIPHY_BASE = "https://api.giphy.com/v1/gifs" export async function GET(request: NextRequest) { try { @@ -12,41 +12,78 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const q = searchParams.get("q") || "" const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50) - const pos = searchParams.get("pos") || "" + const pos = parseInt(searchParams.get("pos") || "0", 10) || 0 - if (!TENOR_API_KEY) { + if (!GIPHY_API_KEY) { + console.error("Missing GIPHY_API_KEY environment variable") return NextResponse.json({ results: [], noKey: true }) } - const endpoint = q - ? `${TENOR_BASE}/search?q=${encodeURIComponent(q)}&key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal` - : `${TENOR_BASE}/featured?key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal` + let data: any - const url = pos ? `${endpoint}&pos=${pos}` : endpoint + if (q) { + const url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(q)}&limit=${limit}&offset=${pos}&rating=g` + const res = await fetch(url, { signal: AbortSignal.timeout(8000) }) + if (!res.ok) { + const errBody = await res.text() + console.error("GIPHY API search error:", res.status, errBody) + return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 }) + } + data = await res.json() + } else { + const trendingUrl = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${pos}&rating=g` + const trendingRes = await fetch(trendingUrl, { signal: AbortSignal.timeout(8000) }) - const res = await fetch(url, { signal: AbortSignal.timeout(8000) }) - if (!res.ok) { - return NextResponse.json({ results: [], error: "Tenor API error" }, { status: 502 }) + if (trendingRes.ok) { + data = await trendingRes.json() + if (!data.data?.length && pos === 0) { + console.log("Trending returned no results, falling back to default search") + const fallbackUrl = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=funny&limit=${limit}&offset=${pos}&rating=g` + const fallbackRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(8000) }) + if (!fallbackRes.ok) { + const errBody = await fallbackRes.text() + console.error("GIPHY fallback search error:", fallbackRes.status, errBody) + return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 }) + } + data = await fallbackRes.json() + } + } else { + const errBody = await trendingRes.text() + console.error("GIPHY trending error:", trendingRes.status, errBody) + console.log("Trending failed, falling back to default search") + const fallbackUrl = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=funny&limit=${limit}&offset=${pos}&rating=g` + const fallbackRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(8000) }) + if (!fallbackRes.ok) { + const fallbackErr = await fallbackRes.text() + console.error("GIPHY fallback search error:", fallbackRes.status, fallbackErr) + return NextResponse.json({ results: [], error: `GIPHY error: ${fallbackErr}` }, { status: 502 }) + } + data = await fallbackRes.json() + } } - - const data = await res.json() - - const results = (data.results || []).map((item: any) => { - const media = item.media_formats?.gif || item.media_formats?.tinygif || {} - const preview = item.media_formats?.tinygif || item.media_formats?.gif || {} + const results = (data.data || []).map((item: any) => { + const dims = item.images?.original return { id: item.id, title: item.title || "", - url: media.url || "", - previewUrl: preview.url || "", - width: media.dims?.[0] || 200, - height: media.dims?.[1] || 200, + url: dims?.url || "", + previewUrl: item.images?.fixed_width?.url || "", + width: dims?.width ? parseInt(dims.width, 10) : 200, + height: dims?.height ? parseInt(dims.height, 10) : 200, } }) - return NextResponse.json({ results, next: data.next || "" }) - } catch (error) { - console.error("GIF API error:", error) - return NextResponse.json({ results: [], error: "Failed to fetch GIFs" }, { status: 500 }) + const nextOffset = pos + results.length + const total = data.pagination?.total_count || 0 + const hasMore = total ? nextOffset < total : results.length === limit + const nextPos = hasMore ? String(nextOffset) : "" + + return NextResponse.json({ results, next: nextPos }) + } catch (error: any) { + console.error("GIF proxy error:", error) + const message = error?.message === "Failed to fetch" + ? "Could not reach the GIF service." + : "Failed to fetch GIFs." + return NextResponse.json({ results: [], error: message }, { status: 500 }) } } diff --git a/src/app/call/[roomId]/page.tsx b/src/app/call/[roomId]/page.tsx index 780d963..f438469 100644 --- a/src/app/call/[roomId]/page.tsx +++ b/src/app/call/[roomId]/page.tsx @@ -53,24 +53,24 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str if (!joined) { return ( -
-
+
+
{connectionTimeout ? ( <> -

This call is no longer available.

+

This call is no longer available.

) : !isReady ? ( <>
- -

Connecting to call...

+ +

Connecting to call...

) : ( <> -

Join Call

+

Join Call

{micError && ( -

{micError}

+

{micError}

)} setDisplayName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }} placeholder="Enter your name" - className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]" + className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]" />
@@ -112,21 +112,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str {callState === "waiting" && (
-

Waiting for participant

-

Share the call link to invite someone...

+

Waiting for participant

+

Share the call link to invite someone...

{participants.length > 0 && ( -
-
+
+
Participants ({participants.length})
{participants.map((p) => ( -
{p.label}
+
{p.label}
))}
)}
-
@@ -135,21 +135,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str {callState === "participant_joined" && (
-

Participant joined

+

Participant joined

{participants.length > 0 && ( -
-
+
+
Participants ({participants.length})
{participants.map((p) => ( -
{p.label}
+
{p.label}
))}
)} -

Connecting...

+

Connecting...

-
@@ -158,10 +158,10 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str {callState === "connecting" && (
-

Connecting

-

Establishing secure connection...

+

Connecting

+

Establishing secure connection...

-
@@ -170,37 +170,37 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str {callState === "connected" && (
-

Connected

+

Connected

{participants.length > 0 && ( -
-
+
+
Participants ({participants.length})
{participants.map((p) => ( -
{p.label}
+
{p.label}
))}
)} -
+
{formatDuration(callDuration)}
@@ -210,15 +210,15 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str {(callState === "ended" || callState === "idle") && (
-

Call ended

+

Call ended

{callDuration > 0 && ( -

Duration: {formatDuration(callDuration)}

+

Duration: {formatDuration(callDuration)}

)}
)} {error && ( -

{error}

+

{error}

)}
diff --git a/src/app/globals.css b/src/app/globals.css index 44aa3c7..f4a849e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,6 +2,8 @@ @import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Audiowide&display=swap'); +@import "../styles/ai-assistant.css"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -76,35 +78,41 @@ } :root { - --background: 220 14% 84%; - --foreground: 222.2 84% 4.9%; - --card: 220 10% 91%; - --card-foreground: 222.2 84% 4.9%; - --popover: 220 14% 96%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 120 100% 50%; + --background: 60 20% 95%; + --foreground: 71 20% 16%; + --card: 60 29% 97%; + --card-foreground: 71 20% 16%; + --popover: 60 29% 97%; + --popover-foreground: 71 20% 16%; + --primary: 0 53% 54%; --primary-foreground: 0 0% 100%; - --secondary: 220 14% 88%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 220 14% 88%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 220 14% 88%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 221.2 83.2% 53.3%; - --sidebar: 216 12% 92%; - --sidebar-foreground: 0 0% 20%; - --sidebar-primary: 0 91.2% 59.8%; + --secondary: 210 47% 56%; + --secondary-foreground: 0 0% 100%; + --muted: 75 10% 93%; + --muted-foreground: 75 10% 52%; + --accent: 159 32% 43%; + --accent-foreground: 0 0% 100%; + --destructive: 0 53% 54%; + --destructive-foreground: 0 0% 100%; + --border: 80 13% 82%; + --input: 80 13% 82%; + --ring: 0 53% 54%; + --sidebar: 80 17% 92%; + --sidebar-foreground: 71 20% 16%; + --sidebar-primary: 0 53% 54%; --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 218 16% 87%; - --sidebar-accent-foreground: 0 0% 20%; - --sidebar-border: 220 11% 84%; - --sidebar-ring: 0 76.3% 48%; + --sidebar-accent: 80 17% 92%; + --sidebar-accent-foreground: 71 20% 16%; + --sidebar-border: 80 13% 82%; + --sidebar-ring: 0 53% 54%; --radius: 0.5rem; --theme-primary: hsl(var(--primary)); + --color-blue: 210 47% 56%; + --color-teal: 159 32% 43%; + --color-red-tint: 0 62% 95%; + --color-blue-tint: 208 54% 93%; + --color-teal-tint: 157 35% 91%; + --color-avatar: 102 21% 52%; --event-call: 160 84% 39%; --event-follow_up: 38 92% 48%; --event-website_creation: 263 70% 50%; @@ -728,3 +736,4 @@ main { .checkbox-text { color: rgba(232,232,239,0.38); } .footer-text { color: rgba(232,232,239,0.2); } + diff --git a/src/app/join/[token]/page.tsx b/src/app/join/[token]/page.tsx index fb1fb49..690040e 100644 --- a/src/app/join/[token]/page.tsx +++ b/src/app/join/[token]/page.tsx @@ -7,9 +7,9 @@ interface Props { function ErrorPage({ message }: { message: string }) { return ( -
-
-

+
+
+

{message}

@@ -42,24 +42,24 @@ export default async function JoinPage({ params }: Props) { } return ( -
-
-

+
+
+

You're Invited!

-

+

Someone wants to connect with you on our platform.

-
-

Contact Number

-

{invite.phone}

+
+

Contact Number

+

{invite.phone}

-

+

Create an account to start making free voice calls to this person and others on the platform.

Create Account diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 77f23f2..ceac5ab 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from "@/providers/theme-provider" import { WebsiteThemeProvider } from "@/providers/website-theme-provider" import { Toaster } from "@/components/ui/sonner" import "./globals.css" +import "../../Web_Backgrounds/default-theme.css" import "../../Web_Backgrounds/spidey-theme.css" const inter = Inter({ @@ -32,7 +33,7 @@ export default function RootLayout({ return ( - + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index d730956..cb5f887 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -4,6 +4,8 @@ import { useState, useEffect, useRef, Suspense } from "react" import { useSearchParams, useRouter } from "next/navigation" import { Eye, EyeOff, Loader2 } from "lucide-react" +const WEBSITE_THEME_KEY = "crm-website-theme" + const waves = [ { a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" }, { a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" }, @@ -237,6 +239,9 @@ function LoginForm() { }) if (res.ok) { + localStorage.removeItem(WEBSITE_THEME_KEY) + const root = document.documentElement + root.className = root.className.split(" ").filter((c) => !c.startsWith("theme-")).join(" ").trim() const redirectTo = searchParams.get("redirect") || "/dashboard" router.push(redirectTo) } else { diff --git a/src/components/ai/ai-chat.tsx b/src/components/ai/ai-chat.tsx index 28b4ba7..f98f202 100644 --- a/src/components/ai/ai-chat.tsx +++ b/src/components/ai/ai-chat.tsx @@ -1,18 +1,15 @@ "use client" -import { useState, useRef, useEffect, Fragment } from "react" -import { Send, Bot, User, AlertCircle, Terminal, Sparkles, Lightbulb, Target, MessageSquare } from "lucide-react" +import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react" +import { Send, Bot, User, AlertCircle, Sparkles, Lightbulb, Target, MessageSquare, Terminal } from "lucide-react" +import { GifPicker } from "./gif-picker" function linkifyText(text: string) { const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/ const parts = text.split(urlRegex) return parts.map((part, i) => { if (part.startsWith("http://") || part.startsWith("https://")) { - return ( - - {part} - - ) + return {part} } return {part} }) @@ -33,20 +30,44 @@ function formatContent(text: string) { if (last < text.length) { blocks.push({ type: "text", content: text.slice(last) }) } - return blocks.length ? blocks : [{ type: "text" as const, content: text }] + if (blocks.length) return blocks + return [{ type: "text" as const, content: text }] +} + +function formatBullets(text: string) { + const lines = text.split("\n") + return lines.map((line, i) => { + const trimmed = line.trim() + if (trimmed.startsWith("•") || trimmed.startsWith("-")) { + const content = trimmed.replace(/^[•\-]\s*/, "") + return ( +
+ + {linkifyText(content)} +
+ ) + } + if (line === "") return
+ return

{linkifyText(line)}

+ }) } interface ChatMessage { role: "user" | "assistant" content: string + gif?: { + url: string + previewUrl: string + title: string + } } function TypingDots() { return (
- - - + + +
) } @@ -58,34 +79,83 @@ const suggestions = [ { icon: MessageSquare, label: "Objections", query: "How do I handle common objections?" }, ] -export function AIChat() { +interface AIChatProps { + onMessageSent?: (msg: string) => void +} + +export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatProps>(({ onMessageSent }, ref) => { const [messages, setMessages] = useState([]) const [input, setInput] = useState("") const [loading, setLoading] = useState(false) const [error, setError] = useState("") const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting") + const [showGifPicker, setShowGifPicker] = useState(false) const messagesEndRef = useRef(null) - const inputRef = useRef(null) - const chatContainerRef = useRef(null) + const textareaRef = useRef(null) + const containerRef = useRef(null) + const hasUserMessage = messages.some(m => m.role === "user") + const loadedFromStorage = useRef(false) - const checkServer = async () => { - try { - const res = await fetch("/api/ai/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "ping" }), - }) - if (res.status !== 503) { - setBootState("ready") - } else { - setTimeout(checkServer, 2000) + useImperativeHandle(ref, () => ({ + fillInput(text: string) { + setInput(text) + setTimeout(() => textareaRef.current?.focus(), 50) + }, + }), []) + + const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => { + setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }]) + setShowGifPicker(false) + }, []) + + useEffect(() => { + if (showGifPicker) { + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowGifPicker(false) + } } + document.addEventListener("mousedown", handler) + return () => document.removeEventListener("mousedown", handler) + } + }, [showGifPicker]) + + useEffect(() => { + const saved = localStorage.getItem("ai-chat-messages") + if (saved) { + try { + const parsed = JSON.parse(saved) + if (Array.isArray(parsed) && parsed.length > 0) { + setMessages(parsed) + loadedFromStorage.current = true + } + } catch {} + } else { + loadedFromStorage.current = false + } + }, []) + + useEffect(() => { + if (messages.length > 0) { + localStorage.setItem("ai-chat-messages", JSON.stringify(messages)) + } + }, [messages]) + + const checkServer = useCallback(async () => { + try { + const res = await fetch("/api/ai/health") + if (res.ok) { + setBootState("ready") + return + } + setTimeout(checkServer, 2000) } catch { setTimeout(checkServer, 2000) } - } + }, []) useEffect(() => { + if (loadedFromStorage.current) return fetch("/api/ai/jobs") .then((r) => r.json()) .then((data) => { @@ -115,25 +185,26 @@ export function AIChat() { ]) }) checkServer() - }, []) + }, [checkServer]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) }, [messages]) useEffect(() => { - if (inputRef.current) { - inputRef.current.style.height = "auto" - inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 120)}px` + if (textareaRef.current) { + textareaRef.current.style.height = "auto" + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px` } }, [input]) - const sendMessage = async (text?: string) => { + const sendMessage = useCallback(async (text?: string) => { const msg = (text || input).trim() if (!msg || loading) return setInput("") setError("") + onMessageSent?.(msg) setMessages((prev) => [...prev, { role: "user", content: msg }]) setLoading(true) @@ -161,132 +232,137 @@ export function AIChat() { } finally { setLoading(false) } - } + }, [input, loading, onMessageSent]) - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() sendMessage() } - } + }, [sendMessage]) - const bootOverlay = ( -
- -
-
-
- - - - - - - - - - - - + const handleTextareaInput = useCallback((e: React.FormEvent) => { + const t = e.target as HTMLTextAreaElement + t.style.height = "auto" + t.style.height = t.scrollHeight + "px" + }, []) + + if (bootState === "booting") { + return ( +
+ +
+

Servers booting...

+
+
+ + + + + + + + + + + + +
-
-

Waking up the AI...

-

Loading model — this takes about a minute on first run

-
-
- ) + ) + } const hasMessages = messages.length > 0 - let mainArea: React.ReactNode - if (bootState === "booting") { - mainArea = bootOverlay - } else if (hasMessages) { - mainArea = ( -
-
- {messages.map((msg, i) => { - const isUser = msg.role === "user" - return ( -
- {!isUser && ( -
- + return ( +
+
+
+ {messages.map((msg, i) => ( +
+ {msg.role === "assistant" ? ( +
+
+
- )} -
-
- {formatContent(msg.content).map((block, bi) => - block.type === "code" ? ( -
-                          {block.content}
-                        
- ) : ( - {linkifyText(block.content)} - ) - )} +
+
+
+ {formatContent(msg.content).map((block, bi) => + block.type === "code" ? ( +
+                              {block.content}
+                            
+ ) : ( + {formatBullets(block.content)} + ) + )} +
+
+
AI Assistant
- {isUser && ( -
- + ) : ( +
+
+
+ {msg.gif ? ( + {msg.gif.title + ) : ( +
{msg.content}
+ )} +
- )} -
- ) - })} +
+ )} +
+ ))} {loading && ( -
-
- +
+
+
-
+
@@ -294,37 +370,17 @@ export function AIChat() {
- ) - } else { - mainArea = ( -
-
-
- -
-
-

AI Sales Assistant

-

Ask me anything about sales strategies, outreach, and prospect targeting.

-
-
-
- ) - } - - return ( -
- {mainArea} - {bootState !== "booting" && ( -
+
+
{!hasMessages && ( -
+
{suggestions.map((s, i) => ( + {showGifPicker && setShowGifPicker(false)} />}