This commit is contained in:
2026-06-26 19:27:22 +02:00
6 changed files with 238 additions and 83 deletions
+36 -23
View File
@@ -513,26 +513,27 @@ def is_offer(text: str) -> bool:
# Each run picks 2-4 random queries to vary behavior and reduce detection. # Each run picks 2-4 random queries to vary behavior and reduce detection.
FB_SEARCHES = [ FB_SEARCHES = [
"looking for web developer", "looking for web developer",
"need a website designed",
"need website built South Africa",
"need someone to build my website", "need someone to build my website",
"need web designer",
"looking for someone to create website",
"need ecommerce website built",
"want to hire web developer",
"need wordpress website",
"I need a website for my business",
"need a site for my business",
"looking for website designer South Africa",
"need website for my small business", "need website for my small business",
"who can build me a website", "who can build me a website",
"want to create a website for my business", "recommend a web developer",
"looking for affordable web design", "I need a website for my",
"need a web developer South Africa", "need a site for my business",
"looking for web designer",
"help me build a website", "help me build a website",
"need someone to design my website", "need someone to design my website",
"looking for web designer near me", "anyone know a web developer",
"need a website for my startup", "need a web developer for my project",
"need website for my startup",
"want a website for my company",
"need a new website for my",
"looking for web developer for my business",
"need ecommerce website for my",
"looking for affordable web design",
"need wordpress website",
"who can design a website for my",
"looking for someone to create a website",
"recommendations for a web designer",
] ]
VIEWPORTS = [ VIEWPORTS = [
@@ -1401,26 +1402,38 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
logger.warning("AI classification failed, falling back to keyword filter: %s", e) logger.warning("AI classification failed, falling back to keyword filter: %s", e)
if not ai_succeeded: if not ai_succeeded:
strict_keywords = [ web_terms = [
"website", "web design", "web develop", "web dev", "website", "web design", "web develop", "web dev",
"web designer", "web developer",
"build my website", "build a website", "create a website", "build my website", "build a website", "create a website",
"need web", "looking for web", "new website", "landing page", "wordpress", "ecommerce",
"landing page", "wordpress", "my website", "business website",
"need a website", "my website", "business website", "site for my", "site for my business",
"need a designer", "help with my website", "new website", "redesign my website",
"redesign", "update my website", "help with my website", "update my website",
]
request_terms = [
"looking for", "need a", "need an", "looking to", "looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone", "need someone", "hire a", "want someone",
"need help with", "would like", "build me", "need help with", "would like", "build me",
"design my", "make me a", "create my", "design my", "make me a", "create my",
"looking", "need", "want", "help",
"who can", "i need",
"recommend", "anyone know", "anyone recommend",
"know a", "know any", "recommendation",
"suggest", "looking for recommendations",
"can anyone", "does anyone",
] ]
filtered = [] filtered = []
for r in results: for r in results:
t = r['title'].lower() t = r['title'].lower()
if not any(kw in t for kw in strict_keywords): has_web = any(kw in t for kw in web_terms)
has_request = any(kw in t for kw in request_terms)
if not has_web or not has_request:
continue continue
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer', if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
'affordable web design package', 'web hosting']): 'affordable web design package', 'web hosting',
'i do web design', 'i develop websites']):
continue continue
filtered.append(r) filtered.append(r)
return filtered[:10] return filtered[:10]
+1 -1
View File
@@ -920,7 +920,7 @@ const formatPreviewContent = (content: string) => {
</div> </div>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server"
import { chatWithAI } from "@/lib/ai"
import { getSessionUser } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { message } = await request.json()
if (!message || typeof message !== "string") {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
const sessionCookie = request.cookies.get("session")
const jwtToken = sessionCookie?.value
if (!jwtToken) {
return NextResponse.json({ error: "No session token" }, { status: 401 })
}
const response = await chatWithAI(message, jwtToken)
return NextResponse.json({ response })
} catch (error: any) {
console.error("AI chat error:", error)
return NextResponse.json({ error: error.message || "AI service error" }, { status: 500 })
}
}
+111 -33
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useRef, useEffect, Fragment } from "react" import { useState, useRef, useEffect, Fragment } from "react"
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react" import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react"
function linkifyText(text: string) { function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/ const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
@@ -24,9 +24,26 @@ export function AIChat() {
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null) const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
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)
}
} catch {
setTimeout(checkServer, 2000)
}
}
useEffect(() => { useEffect(() => {
fetch("/api/ai/jobs") fetch("/api/ai/jobs")
.then((r) => r.json()) .then((r) => r.json())
@@ -56,29 +73,13 @@ export function AIChat() {
}, },
]) ])
}) })
checkServer()
}, []) }, [])
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages]) }, [messages])
const checkOllama = async () => {
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "__ping__" }),
})
setOllamaStatus(res.status !== 503)
} catch {
setOllamaStatus(false)
}
}
useEffect(() => { checkOllama() }, [])
const sendMessage = async () => { const sendMessage = async () => {
const msg = input.trim() const msg = input.trim()
if (!msg || loading) return if (!msg || loading) return
@@ -96,8 +97,8 @@ export function AIChat() {
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json().catch(() => ({}))
throw new Error(data.error || "Failed to get response") throw new Error(data.error || `Error ${res.status}`)
} }
const data = await res.json() const data = await res.json()
@@ -121,17 +122,86 @@ export function AIChat() {
} }
} }
const bootOverlay = (
<div className="flex-1 flex items-center justify-center">
<style>{`
@keyframes walk {
0%, 100% { transform: translateX(0) translateY(0); }
25% { transform: translateX(12px) translateY(-4px); }
50% { transform: translateX(24px) translateY(0); }
75% { transform: translateX(12px) translateY(-4px); }
}
@keyframes legLeft {
0%, 100% { transform: rotate(-10deg); }
50% { transform: rotate(10deg); }
}
@keyframes legRight {
0%, 100% { transform: rotate(10deg); }
50% { transform: rotate(-10deg); }
}
@keyframes armLeft {
0%, 100% { transform: rotate(15deg); }
50% { transform: rotate(-15deg); }
}
@keyframes armRight {
0%, 100% { transform: rotate(-15deg); }
50% { transform: rotate(15deg); }
}
@keyframes blink {
0%, 45%, 55%, 100% { height: 4px; }
50% { height: 1px; }
}
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
.robot-eye { animation: blink 2s ease-in-out infinite; }
`}</style>
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-[#6a6a75]">Servers booting...</p>
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center overflow-hidden">
<div className="robot-walk relative">
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
<rect x="10" y="2" width="20" height="16" rx="3" fill="#1BB0CE" opacity="0.9"/>
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#1BB0CE" opacity="0.7"/>
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#1BB0CE" opacity="0.7"/>
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0d1117"/>
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#1BB0CE" opacity="0.8"/>
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#1BB0CE" opacity="0.8"/>
</svg>
</div>
</div>
</div>
</div>
)
const readyOverlay = (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center">
<Check className="h-6 w-6 text-green-400" />
</div>
<div className="text-center space-y-1">
<p className="text-xs text-[#6a6a75]">Try these commands:</p>
<div className="flex gap-2">
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> lists</code>
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> leads</code>
</div>
</div>
</div>
</div>
)
if (bootState === "booting") return bootOverlay
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{ollamaStatus === false && ( {bootState === "ready" && readyOverlay}
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
<AlertCircle className="h-3.5 w-3.5 flex-none" />
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin"> <div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
{messages.map((msg, i) => ( {messages.map((msg, i) => (
@@ -163,7 +233,10 @@ export function AIChat() {
<Bot className="h-4 w-4 text-[#1BB0CE]" /> <Bot className="h-4 w-4 text-[#1BB0CE]" />
</div> </div>
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]"> <div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" /> <svg className="h-4 w-4 animate-spin text-[#1BB0CE]" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
</div> </div>
</div> </div>
)} )}
@@ -192,10 +265,15 @@ export function AIChat() {
disabled={loading || !input.trim()} disabled={loading || !input.trim()}
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors" className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
> >
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />} {loading ? (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
) : <Send className="h-4 w-4" />}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) )
} }
+61 -26
View File
@@ -1,41 +1,76 @@
import http from "http"
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001" const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
function parseUrl(url: string) {
const u = new URL(url)
return { hostname: u.hostname, port: parseInt(u.port) || 80, path: u.pathname + u.search }
}
export async function chatWithAI(message: string, jwtToken: string) { export async function chatWithAI(message: string, jwtToken: string) {
const res = await fetch(`${AI_SERVICE}/ai/chat`, { const body = JSON.stringify({ message })
method: "POST", const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
body: JSON.stringify({ message }), const text = await new Promise<string>((resolve, reject) => {
const req = http.request(
{ hostname, port, path, method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}`, "Content-Length": Buffer.byteLength(body) } },
(res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(data)
} else {
reject(new Error(`AI error ${res.statusCode}: ${data.substring(0, 200)}`))
}
})
}
)
req.on("error", reject)
req.write(body)
req.end()
}) })
if (!res.ok) { const data = JSON.parse(text)
const text = await res.text()
throw new Error(`AI service error (${res.status}): ${text}`)
}
const data = await res.json()
return data.response || "" return data.response || ""
} }
export async function fetchJobs() { export async function checkAiServiceStatus() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/health`)
try { try {
const res = await fetch(`${AI_SERVICE}/ai/jobs`) await new Promise<void>((resolve, reject) => {
if (!res.ok) return [] const req = http.get({ hostname, port, path, timeout: 3000 }, (res) => {
const data = await res.json() let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => {
try { resolve(JSON.parse(data).status === "ok" ? undefined : reject(new Error("not ok"))) } catch { reject(new Error("bad response")) }
})
})
req.on("error", reject)
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
})
return true
} catch {
return false
}
}
export async function fetchJobs() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/jobs`)
try {
const text = await new Promise<string>((resolve, reject) => {
const req = http.get({ hostname, port, path, timeout: 5000 }, (res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => resolve(data))
})
req.on("error", reject)
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
})
const data = JSON.parse(text)
return data.jobs || [] return data.jobs || []
} catch { } catch {
console.warn("Failed to fetch AI jobs") console.warn("Failed to fetch AI jobs")
return [] return []
} }
} }
export async function checkAiServiceStatus() {
try {
const res = await fetch(`${AI_SERVICE}/health`)
if (!res.ok) return false
const data = await res.json()
return data.status === "ok"
} catch {
console.warn("Failed to check AI service status")
return false
}
}