Todos os componentes

AI Core

AI Core é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT other

O que é

AI Core é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i motion

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

AI Core

index.tsx
"use client";

import {
  type MotionValue,
  useAnimationFrame,
  useMotionValue,
} from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";

/**
 * The single state contract shared by every SmoothUI AI component.
 *
 * Passing the same value to an orb, a prompt input and a tool card makes the
 * whole surface move as one organism instead of a set of independent widgets.
 */
export type AIState =
  | "idle"
  | "listening"
  | "thinking"
  | "streaming"
  | "done"
  | "error";

/**
 * A behavioural hint each component expresses **in its own material**.
 *
 * Deliberately not an overlay. Bolting one shared graphic onto every orb makes
 * four different materials look like the same widget wearing a costume, and it
 * pushes literal iconography (a checkmark, a warning ring) onto pieces that are
 * decorative by nature. Instead: a shader warps, a canvas ring loses harmonics,
 * a character changes expression. Same vocabulary, different flesh.
 */
export type AIStateMotif =
  | "breathe"
  | "receive"
  | "scan"
  | "pulse"
  | "ping"
  | "fault";

/** Semantic accent applied on top of the component's own palette. */
export type AIStateAccent = "success" | "danger" | null;

/** Motion parameters a component reads to render a given {@link AIState}. */
export type AIStateMotion = {
  /** Semantic colour override, so status is carried by hue and not only motion. */
  accent: AIStateAccent;
  /** Outer bloom strength, 0–1. */
  glow: number;
  /** Palette hue rotation in degrees. Small shifts read as a mood change. */
  hueRotate: number;
  /** Overall motion energy, 0–1. Scales ambient loops and displacement. */
  intensity: number;
  /** Behavioural hint; each component expresses it in its own material. */
  motif: AIStateMotif;
  /** Seconds between discrete pulses, for rhythmic behaviour. */
  pulseSeconds: number;
  /**
   * How far a shader's domain warp pushes the field, 0–1. Low values read as a
   * calm surface; high values churn without the silhouette growing.
   */
  turbulence: number;
  /** Revolutions per second of the noise field — the slow tumble. */
  tumble: number;
  /** How much external amplitude reaches the surface, 0 = ignore it. */
  reactivity: number;
  /** Chroma multiplier. Below 1 desaturates. */
  saturation: number;
  /** Resting scale of the surface, 1 = no change. */
  scale: number;
  /** Ambient loop speed multiplier, 1 = the component's base tempo. */
  speed: number;
};

/**
 * Per-state presets.
 *
 * Deliberate choices worth keeping:
 * - `thinking` has `scale: 1` — internal churn only, so layout stays calm while
 *   the model works. Growing the surface here makes pages feel unstable.
 * - `error` desaturates instead of growing, so it reads as a state change
 *   rather than an attention grab.
 * - `done` overshoots once; components are expected to settle back to `idle`.
 * - Each state owns a distinct `motif`, so the six states are told apart by
 *   what moves, not by how fast it moves.
 */
export const AI_STATE_MOTION: Record<AIState, AIStateMotion> = {
  done: {
    accent: "success",
    glow: 0.7,
    hueRotate: 0,
    intensity: 0.4,
    motif: "ping",
    pulseSeconds: 0.65,
    reactivity: 0,
    saturation: 1,
    scale: 1.1,
    speed: 0.8,
    // The field settles almost flat — stillness is what reads as "finished".
    tumble: 0.02,
    turbulence: 0.08,
  },
  error: {
    accent: "danger",
    glow: 0.25,
    hueRotate: 0,
    intensity: 0.5,
    motif: "fault",
    pulseSeconds: 0.9,
    reactivity: 0,
    saturation: 0.3,
    scale: 0.96,
    speed: 1,
    // Tumble stalls while turbulence stays mid: the surface twitches in place
    // instead of flowing, which is what a fault feels like.
    tumble: 0,
    turbulence: 0.55,
  },
  idle: {
    accent: null,
    glow: 0.15,
    hueRotate: 0,
    intensity: 0.3,
    motif: "breathe",
    pulseSeconds: 4.5,
    reactivity: 0,
    saturation: 0.75,
    scale: 0.94,
    speed: 0.6,
    tumble: 0.012,
    turbulence: 0.14,
  },
  listening: {
    accent: null,
    glow: 0.6,
    hueRotate: 0,
    intensity: 0.75,
    motif: "receive",
    pulseSeconds: 1.6,
    reactivity: 1,
    saturation: 1.05,
    scale: 1.06,
    speed: 1,
    tumble: 0.03,
    turbulence: 0.42,
  },
  streaming: {
    accent: null,
    glow: 0.45,
    hueRotate: -10,
    intensity: 0.6,
    motif: "pulse",
    pulseSeconds: 1.25,
    reactivity: 0.6,
    saturation: 1,
    scale: 1.02,
    speed: 1.4,
    tumble: 0.06,
    turbulence: 0.5,
  },
  thinking: {
    accent: null,
    glow: 0.35,
    hueRotate: 18,
    intensity: 1,
    motif: "scan",
    pulseSeconds: 1.1,
    reactivity: 0.15,
    saturation: 1,
    scale: 1,
    speed: 2.4,
    // High churn, unchanged silhouette: the work is visible without the orb
    // growing and unsettling the layout around it.
    tumble: 0.14,
    turbulence: 0.95,
  },
};

/** Semantic accents. Deliberately not tokens — orbs render outside a theme. */
export const AI_ACCENT_COLORS: Record<"success" | "danger", string> = {
  danger: "oklch(63% 0.21 25)",
  success: "oklch(72% 0.17 150)",
};

/**
 * Colour used by a state's overlay motif: the semantic accent when the state
 * has one, otherwise the component's own secondary colour.
 */
export const getAIStateAccentColor = (
  state: AIState | undefined,
  fallback: string
): string => {
  const accent = AI_STATE_MOTION[state ?? "idle"]?.accent;
  return accent ? AI_ACCENT_COLORS[accent] : fallback;
};

/** Motion preset for a state, falling back to `idle` for unknown values. */
export const getAIStateMotion = (state: AIState | undefined): AIStateMotion =>
  AI_STATE_MOTION[state ?? "idle"] ?? AI_STATE_MOTION.idle;

/**
 * Amplitude accepted by every reactive AI component.
 *
 * A `MotionValue` is the preferred form: it updates outside React, so a 60fps
 * audio signal never triggers a re-render.
 */
export type AIAmplitude = number | MotionValue<number> | undefined;

const isMotionValue = (value: AIAmplitude): value is MotionValue<number> =>
  typeof value === "object" && value !== null && "get" in value;

/**
 * Normalises the `amplitude` prop into a stable `MotionValue<number>` so
 * component internals only deal with one shape.
 */
export const useAmplitudeValue = (
  amplitude: AIAmplitude
): MotionValue<number> => {
  const fallback = useMotionValue(0);
  const numeric = typeof amplitude === "number" ? amplitude : null;

  useEffect(() => {
    if (numeric !== null) {
      fallback.set(numeric);
    }
  }, [numeric, fallback]);

  return isMotionValue(amplitude) ? amplitude : fallback;
};

export type AudioAmplitudeStatus =
  | "idle"
  | "requesting"
  | "active"
  | "denied"
  | "unsupported";

export type UseAudioAmplitudeOptions = {
  /** Request microphone access as soon as the hook mounts. */
  autoStart?: boolean;
  /**
   * Envelope smoothing, 0–1. Higher is smoother and lazier; the default keeps
   * attack snappy so an orb reacts on the first syllable.
   */
  smoothing?: number;
  /** FFT size handed to the analyser node. Must be a power of two. */
  fftSize?: number;
};

export type UseAudioAmplitudeResult = {
  /** Smoothed RMS level, 0–1, as a `MotionValue`. */
  amplitude: MotionValue<number>;
  status: AudioAmplitudeStatus;
  start: () => Promise<void>;
  stop: () => void;
};

const DEFAULT_SMOOTHING = 0.55;
const DEFAULT_FFT_SIZE = 512;
/** Raw RMS rarely exceeds ~0.3 for speech, so normalise into a usable 0–1. */
const RMS_TO_UNIT = 3.2;
/** Attack is faster than release so peaks land immediately and decay gently. */
const ATTACK_FACTOR = 0.35;

/**
 * Reads microphone loudness as a 0–1 `MotionValue`.
 *
 * SSR-safe, permission-aware, and silent on failure — a denied prompt leaves
 * the amplitude at 0 so the consuming component simply falls back to its
 * ambient animation.
 */
export const useAudioAmplitude = (
  options: UseAudioAmplitudeOptions = {}
): UseAudioAmplitudeResult => {
  const {
    autoStart = false,
    smoothing = DEFAULT_SMOOTHING,
    fftSize = DEFAULT_FFT_SIZE,
  } = options;

  const amplitude = useMotionValue(0);
  const [status, setStatus] = useState<AudioAmplitudeStatus>("idle");

  const contextRef = useRef<AudioContext | null>(null);
  const streamRef = useRef<MediaStream | null>(null);
  const analyserRef = useRef<AnalyserNode | null>(null);
  const bufferRef = useRef<Float32Array<ArrayBuffer> | null>(null);

  const stop = useCallback(() => {
    for (const track of streamRef.current?.getTracks() ?? []) {
      track.stop();
    }
    streamRef.current = null;
    analyserRef.current = null;
    bufferRef.current = null;
    contextRef.current?.close();
    contextRef.current = null;
    amplitude.set(0);
    setStatus("idle");
  }, [amplitude]);

  const start = useCallback(async () => {
    if (analyserRef.current) {
      return;
    }

    const AudioContextCtor =
      typeof window === "undefined"
        ? undefined
        : (window.AudioContext ??
          (window as unknown as { webkitAudioContext?: typeof AudioContext })
            .webkitAudioContext);

    if (!(AudioContextCtor && navigator.mediaDevices?.getUserMedia)) {
      setStatus("unsupported");
      return;
    }

    setStatus("requesting");

    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const context = new AudioContextCtor();
      const analyser = context.createAnalyser();
      analyser.fftSize = fftSize;
      context.createMediaStreamSource(stream).connect(analyser);

      streamRef.current = stream;
      contextRef.current = context;
      analyserRef.current = analyser;
      bufferRef.current = new Float32Array(analyser.fftSize);
      setStatus("active");
    } catch {
      setStatus("denied");
    }
  }, [fftSize]);

  useEffect(() => {
    if (autoStart) {
      start();
    }
    return stop;
  }, [autoStart, start, stop]);

  useAnimationFrame(() => {
    const analyser = analyserRef.current;
    const buffer = bufferRef.current;
    if (!(analyser && buffer)) {
      return;
    }

    analyser.getFloatTimeDomainData(buffer);

    let sumOfSquares = 0;
    for (const sample of buffer) {
      sumOfSquares += sample * sample;
    }
    const rms = Math.sqrt(sumOfSquares / buffer.length);
    const target = Math.min(1, rms * RMS_TO_UNIT);

    const previous = amplitude.get();
    const factor = target > previous ? smoothing * ATTACK_FACTOR : smoothing;
    amplitude.set(previous + (target - previous) * (1 - factor));
  });

  return { amplitude, start, status, stop };
};

/**
 * Amplitude generator for demos, docs and previews — no microphone involved.
 *
 * Produces a plausible speech-like envelope whose energy follows the current
 * {@link AIState}, so every example can show the reactive behaviour without
 * asking the visitor for permissions.
 */
export const useSimulatedAmplitude = (
  state: AIState = "idle"
): MotionValue<number> => {
  const amplitude = useMotionValue(0);
  const motion = getAIStateMotion(state);

  useAnimationFrame((time) => {
    const t = time / 1000;
    // Three detuned sines read as organic; a single sine reads as a metronome.
    const envelope =
      0.5 +
      0.3 * Math.sin(t * 2.1 * motion.speed) +
      0.14 * Math.sin(t * 5.3 * motion.speed + 1.7) +
      0.06 * Math.sin(t * 11.7 * motion.speed + 0.4);

    amplitude.set(Math.min(1, Math.max(0, envelope * motion.intensity)));
  });

  return amplitude;
};

Componentes parecidos