feat(ai-search): optimize dev server, add qdrant boot sync, fix orb overflow
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
This commit is contained in:
@@ -1,88 +1,371 @@
|
||||
/* eslint-disable react/no-unknown-property */
|
||||
'use client';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { Sphere, MeshDistortMaterial, Environment, Float } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface AIOrbProps {
|
||||
isThinking: boolean;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
function Orb({ isThinking }: AIOrbProps) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const materialRef = useRef<any>(null);
|
||||
|
||||
// Dynamic properties based on state
|
||||
const targetDistort = isThinking ? 0.6 : 0.3;
|
||||
const targetSpeed = isThinking ? 5 : 2;
|
||||
const color = isThinking ? '#00FF88' : '#00A3FF'; // Green/Blue based on thinking state
|
||||
|
||||
useFrame((state) => {
|
||||
if (!materialRef.current) return;
|
||||
|
||||
// Smoothly interpolate material properties
|
||||
materialRef.current.distort = THREE.MathUtils.lerp(
|
||||
materialRef.current.distort,
|
||||
targetDistort,
|
||||
0.1,
|
||||
);
|
||||
materialRef.current.speed = THREE.MathUtils.lerp(materialRef.current.speed, targetSpeed, 0.1);
|
||||
|
||||
// Smooth color transition
|
||||
const currentColor = materialRef.current.color;
|
||||
const targetColorObj = new THREE.Color(color);
|
||||
currentColor.lerp(targetColorObj, 0.05);
|
||||
|
||||
// Slow rotation
|
||||
if (meshRef.current) {
|
||||
meshRef.current.rotation.x = state.clock.getElapsedTime() * 0.2;
|
||||
meshRef.current.rotation.y = state.clock.getElapsedTime() * 0.3;
|
||||
}
|
||||
});
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
// Simple noise function for organic movement
|
||||
function noise(x: number, y: number, t: number): number {
|
||||
return (
|
||||
<Float
|
||||
speed={isThinking ? 4 : 2}
|
||||
rotationIntensity={isThinking ? 2 : 1}
|
||||
floatIntensity={isThinking ? 2 : 1}
|
||||
>
|
||||
<Sphere ref={meshRef} args={[1, 64, 64]} scale={1.5}>
|
||||
<MeshDistortMaterial
|
||||
ref={materialRef}
|
||||
color="#00A3FF"
|
||||
envMapIntensity={2}
|
||||
clearcoat={1}
|
||||
clearcoatRoughness={0}
|
||||
metalness={0.8}
|
||||
roughness={0.1}
|
||||
distort={0.3}
|
||||
speed={2}
|
||||
/>
|
||||
</Sphere>
|
||||
</Float>
|
||||
Math.sin(x * 1.3 + t * 0.7) * Math.cos(y * 0.9 + t * 0.5) * 0.5 +
|
||||
Math.sin(x * 2.7 + y * 1.1 + t * 1.3) * 0.25 +
|
||||
Math.cos(x * 0.8 - y * 2.3 + t * 0.9) * 0.25
|
||||
);
|
||||
}
|
||||
|
||||
export default function AIOrb({ isThinking = false }: AIOrbProps) {
|
||||
return (
|
||||
<div className="w-full h-full min-w-[32px] min-h-[32px] relative flex items-center justify-center">
|
||||
{/* Ambient glow effect behind the orb */}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full blur-xl opacity-50 transition-colors duration-1000 ${isThinking ? 'bg-[#00FF88]/50' : 'bg-[#00A3FF]/40'}`}
|
||||
/>
|
||||
// ── Particle ───────────────────────────────────────────────────
|
||||
interface Particle {
|
||||
// Sphere position (target shape)
|
||||
theta: number;
|
||||
phi: number;
|
||||
// Current position
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
// Velocity
|
||||
vx: number;
|
||||
vy: number;
|
||||
vz: number;
|
||||
// Properties
|
||||
size: number;
|
||||
baseSize: number;
|
||||
hue: number; // 0=blue, 1=green
|
||||
brightness: number;
|
||||
phase: number;
|
||||
orbitSpeed: number;
|
||||
noiseScale: number;
|
||||
}
|
||||
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 4], fov: 45 }}
|
||||
className="w-full h-full cursor-pointer z-10 block"
|
||||
>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1.5} />
|
||||
<directionalLight position={[-10, -10, -5]} intensity={0.5} color="#00FF88" />
|
||||
<Orb isThinking={isThinking} />
|
||||
<Environment preset="city" />
|
||||
</Canvas>
|
||||
function createParticles(count: number): Particle[] {
|
||||
const particles: Particle[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Fibonacci sphere distribution for even spacing
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
const y = 1 - (i / (count - 1)) * 2;
|
||||
const radiusAtY = Math.sqrt(1 - y * y);
|
||||
const theta = golden * i;
|
||||
const phi = Math.acos(y);
|
||||
|
||||
particles.push({
|
||||
theta,
|
||||
phi,
|
||||
x: Math.cos(theta) * radiusAtY,
|
||||
y,
|
||||
z: Math.sin(theta) * radiusAtY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
vz: 0,
|
||||
size: 0.4 + Math.random() * 0.8,
|
||||
baseSize: 0.4 + Math.random() * 0.8,
|
||||
hue: Math.random() > 0.45 ? 0 : 1,
|
||||
brightness: 0.5 + Math.random() * 0.5,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
orbitSpeed: (0.1 + Math.random() * 0.4) * (Math.random() > 0.5 ? 1 : -1),
|
||||
noiseScale: 0.5 + Math.random() * 1.5,
|
||||
});
|
||||
}
|
||||
return particles;
|
||||
}
|
||||
|
||||
export default function AIOrb({ isThinking = false, hasError = false }: AIOrbProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const animRef = useRef<number>(0);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
|
||||
const mouse = useRef({ x: 0.5, y: 0.5, hover: false });
|
||||
const state = useRef({
|
||||
pulse: 0,
|
||||
hover: 0,
|
||||
error: 0,
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
rotY: 0,
|
||||
rotX: 0,
|
||||
breathe: 0,
|
||||
scatter: 0,
|
||||
shake: 0,
|
||||
});
|
||||
|
||||
const onMove = useCallback((e: React.PointerEvent) => {
|
||||
const r = wrapRef.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
mouse.current.x = (e.clientX - r.left) / r.width;
|
||||
mouse.current.y = (e.clientY - r.top) / r.height;
|
||||
}, []);
|
||||
const onEnter = useCallback(() => {
|
||||
mouse.current.hover = true;
|
||||
}, []);
|
||||
const onLeave = useCallback(() => {
|
||||
mouse.current.hover = false;
|
||||
mouse.current.x = 0.5;
|
||||
mouse.current.y = 0.5;
|
||||
}, []);
|
||||
|
||||
const draw = useCallback(
|
||||
function drawStep() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width * dpr;
|
||||
const h = rect.height * dpr;
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const minDim = Math.min(w, h);
|
||||
// Reduced further to give maximum breathing room for glow + movement
|
||||
const sphereR = minDim * 0.16;
|
||||
const time = performance.now() / 1000;
|
||||
const s = state.current;
|
||||
const m = mouse.current;
|
||||
|
||||
// ── Interpolate state ──
|
||||
s.pulse = lerp(s.pulse, isThinking ? 1 : 0, 0.03);
|
||||
s.hover = lerp(s.hover, m.hover ? 1 : 0, 0.12);
|
||||
s.error = lerp(s.error, hasError ? 1 : 0, 0.05);
|
||||
s.mouseX = lerp(s.mouseX, m.x, 0.12);
|
||||
s.mouseY = lerp(s.mouseY, m.y, 0.12);
|
||||
s.scatter = lerp(s.scatter, m.hover ? 0.8 : hasError ? 0.5 : 0, 0.06);
|
||||
s.shake += 0.15 * s.error;
|
||||
|
||||
// Global rotation — ALWAYS rotating + ALWAYS facing cursor
|
||||
s.rotY += lerp(0.008, 0.04, Math.max(s.pulse, s.hover));
|
||||
const mouseRotY = (s.mouseX - 0.5) * 1.2; // always face cursor
|
||||
const mouseRotX = (s.mouseY - 0.5) * 0.8;
|
||||
|
||||
s.breathe += lerp(1.2, 3.0, s.pulse) / 60;
|
||||
const breathe = Math.sin(s.breathe) * 0.5 + 0.5;
|
||||
|
||||
// ── Clear ──
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// ── Subtle core glow ──
|
||||
const shakeX = Math.sin(s.shake * 17) * s.error * minDim * 0.02;
|
||||
const glowCX = cx + shakeX;
|
||||
const glowCY = cy;
|
||||
// Clamp glow radius so it never exceeds ~48% of canvas (leaves padding for movement)
|
||||
const glowR = Math.min(
|
||||
sphereR * lerp(2.2, 4.0, Math.max(s.pulse, s.hover * 0.8)),
|
||||
minDim * 0.48,
|
||||
);
|
||||
const glowA = lerp(0.1, 0.4, Math.max(s.pulse, s.hover * 0.7, s.error * 0.8));
|
||||
const glow = ctx.createRadialGradient(glowCX, glowCY, 0, glowCX, glowCY, glowR);
|
||||
// Glow color: blue normally, red on error
|
||||
const glowR1 = Math.round(lerp(20, 255, s.error));
|
||||
const glowG1 = Math.round(lerp(60, 40, s.error));
|
||||
const glowB1 = Math.round(lerp(255, 40, s.error));
|
||||
glow.addColorStop(0, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 2})`);
|
||||
glow.addColorStop(
|
||||
0.25,
|
||||
`rgba(${Math.round(lerp(80, 200, s.error))}, ${Math.round(lerp(140, 50, s.error))}, ${Math.round(lerp(255, 50, s.error))}, ${glowA * 1.2})`,
|
||||
);
|
||||
glow.addColorStop(0.6, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 0.4})`);
|
||||
glow.addColorStop(1, `rgba(${glowR1}, ${glowG1}, ${glowB1}, 0)`);
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(glowCX, glowCY, glowR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// ── Create particles if empty ──
|
||||
if (particlesRef.current.length === 0) {
|
||||
particlesRef.current = createParticles(350);
|
||||
}
|
||||
|
||||
// ── Update & draw particles ──
|
||||
const cosRY = Math.cos(s.rotY + mouseRotY);
|
||||
const sinRY = Math.sin(s.rotY + mouseRotY);
|
||||
const cosRX = Math.cos(mouseRotX);
|
||||
const sinRX = Math.sin(mouseRotX);
|
||||
|
||||
// Sort by z for correct layering
|
||||
type ParticleWithScreen = { p: Particle; sx: number; sy: number; sz: number; depth: number };
|
||||
const projected: ParticleWithScreen[] = [];
|
||||
|
||||
for (const p of particlesRef.current) {
|
||||
// Target position: sphere surface + noise displacement
|
||||
const n = noise(p.theta * p.noiseScale, p.phi * p.noiseScale, time * 0.5 + p.phase);
|
||||
const displacement = 1 + n * lerp(0.12, 0.3, s.pulse);
|
||||
|
||||
// Orbit: rotate theta — always moving, faster idle
|
||||
const activeTheta = p.theta + time * p.orbitSpeed * lerp(0.35, 0.8, s.pulse);
|
||||
|
||||
// Sphere coordinates to cartesian
|
||||
const sinPhi = Math.sin(p.phi);
|
||||
const tgtX = Math.cos(activeTheta) * sinPhi * displacement;
|
||||
// Excitement from hover + pulse + error
|
||||
const targetExcite = Math.max(s.hover * 0.9, s.pulse, s.error * 0.8);
|
||||
const tgtY = Math.cos(p.phi) * displacement;
|
||||
const tgtZ = Math.sin(activeTheta) * sinPhi * displacement;
|
||||
|
||||
// Scatter on hover: push particles outward
|
||||
const scatterMul = 1 + s.scatter * (0.5 + n * 0.5);
|
||||
|
||||
// Spring physics toward target
|
||||
const tx = tgtX * scatterMul;
|
||||
const ty = tgtY * scatterMul;
|
||||
const tz = tgtZ * scatterMul;
|
||||
|
||||
p.vx += (tx - p.x) * 0.08;
|
||||
p.vy += (ty - p.y) * 0.08;
|
||||
p.vz += (tz - p.z) * 0.08;
|
||||
p.vx *= 0.88;
|
||||
p.vy *= 0.88;
|
||||
p.vz *= 0.88;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.z += p.vz;
|
||||
|
||||
// 3D rotation (Y then X)
|
||||
const rx = p.x * cosRY - p.z * sinRY;
|
||||
const rz = p.x * sinRY + p.z * cosRY;
|
||||
const ry = p.y * cosRX - rz * sinRX;
|
||||
const finalZ = p.y * sinRX + rz * cosRX;
|
||||
|
||||
// Project to screen
|
||||
const perspective = 3;
|
||||
const scale = perspective / (perspective + finalZ);
|
||||
const sx = cx + rx * sphereR * scale;
|
||||
const sy = cy + ry * sphereR * scale;
|
||||
|
||||
projected.push({ p, sx, sy, sz: finalZ, depth: scale });
|
||||
}
|
||||
|
||||
// Sort back-to-front
|
||||
projected.sort((a, b) => a.sz - b.sz);
|
||||
|
||||
for (const { p, sx, sy, sz, depth } of projected) {
|
||||
// Depth-based alpha and size
|
||||
const depthAlpha = 0.25 + (sz + 1) * 0.375; // 0.25 (back) → 1.0 (front)
|
||||
const twinkle = 0.75 + 0.25 * Math.sin(time * 3.5 + p.phase);
|
||||
|
||||
const alpha =
|
||||
depthAlpha * twinkle * p.brightness * lerp(0.8, 1.3, Math.max(s.pulse, s.hover * 0.8));
|
||||
|
||||
const drawSize =
|
||||
p.baseSize * depth * dpr * lerp(1.0, 2.0, Math.max(s.pulse, s.hover * 0.7));
|
||||
|
||||
// Color — shift to red on error
|
||||
let r: number, g: number, b: number;
|
||||
if (s.error > 0.1) {
|
||||
// Error: red family
|
||||
if (p.hue === 0) {
|
||||
r = Math.round(lerp(40 + sz * 30, 255, s.error));
|
||||
g = Math.round(lerp(80 + sz * 40, 40 + sz * 20, s.error));
|
||||
b = Math.round(lerp(255, 40, s.error));
|
||||
} else {
|
||||
r = Math.round(lerp(100 + sz * 30, 230, s.error));
|
||||
g = Math.round(lerp(220 + sz * 17, 60, s.error));
|
||||
b = Math.round(lerp(20, 20, s.error));
|
||||
}
|
||||
} else if (p.hue === 0) {
|
||||
r = 60 + Math.round(sz * 40);
|
||||
g = 100 + Math.round(sz * 50);
|
||||
b = 255;
|
||||
} else {
|
||||
r = 120 + Math.round(sz * 30);
|
||||
g = 237 + Math.round(sz * 10);
|
||||
b = 30;
|
||||
}
|
||||
|
||||
// Thinking: shift toward brighter, more saturated
|
||||
if (s.pulse > 0.1) {
|
||||
r = Math.round(lerp(r, p.hue === 0 ? 100 : 130, s.pulse * 0.3));
|
||||
g = Math.round(lerp(g, p.hue === 0 ? 140 : 237, s.pulse * 0.3));
|
||||
b = Math.round(lerp(b, p.hue === 0 ? 255 : 32, s.pulse * 0.3));
|
||||
}
|
||||
|
||||
// Micro glow — always visible, stronger on front
|
||||
if (depthAlpha > 0.25) {
|
||||
const gSize = drawSize * lerp(4, 7, s.hover);
|
||||
const pg = ctx.createRadialGradient(sx, sy, 0, sx, sy, gSize);
|
||||
pg.addColorStop(0, `rgba(${r},${g},${b},${alpha * 0.5})`);
|
||||
pg.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
||||
ctx.fillStyle = pg;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, gSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Core dot — bright
|
||||
ctx.fillStyle = `rgba(${Math.min(r + 40, 255)},${Math.min(g + 30, 255)},${b},${Math.min(alpha * 1.6, 1)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, Math.max(drawSize * 0.5, 0.3 * dpr), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Loading rings (thinking) ──
|
||||
if (s.pulse > 0.02) {
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
|
||||
// Spinning arc
|
||||
const spinAngle = time * 2;
|
||||
const arcLen = Math.PI * lerp(0.3, 1.0, (Math.sin(time * 1.5) + 1) / 2);
|
||||
ctx.rotate(spinAngle);
|
||||
ctx.strokeStyle = `rgba(130, 237, 32, ${s.pulse * 0.4})`;
|
||||
ctx.lineWidth = 1.2 * dpr;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, sphereR * 1.25, 0, arcLen);
|
||||
ctx.stroke();
|
||||
|
||||
// Counter-spinning arc
|
||||
ctx.rotate(-spinAngle * 2);
|
||||
ctx.strokeStyle = `rgba(1, 29, 255, ${s.pulse * 0.3})`;
|
||||
ctx.lineWidth = 0.8 * dpr;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, sphereR * 1.35, 0, arcLen * 0.6);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Expanding pulse
|
||||
const pulsePhase = (time * 0.8) % 1;
|
||||
const pulseR = sphereR * (1 + pulsePhase * 1.5);
|
||||
const pulseA = s.pulse * (1 - pulsePhase) * 0.15;
|
||||
ctx.strokeStyle = `rgba(130, 237, 32, ${pulseA})`;
|
||||
ctx.lineWidth = 1 * dpr;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(drawStep);
|
||||
},
|
||||
[isThinking, hasError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
animRef.current = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [draw]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="w-full h-full relative overflow-visible"
|
||||
onPointerMove={onMove}
|
||||
onPointerEnter={onEnter}
|
||||
onPointerLeave={onLeave}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full h-full block" style={{ imageRendering: 'auto' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user