apex-ui-kit 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ApexUI-Kit/templates/AiChat/AiChat.jsx +171 -0
- package/dist/ApexUI-Kit/templates/AiModelPrompt/AiModelPrompt.jsx +221 -0
- package/dist/ApexUI-Kit/templates/AiModelPrompt/index.js +2 -0
- package/dist/ApexUI-Kit/templates/AiModelPrompt/models.js +15 -0
- package/dist/ApexUI-Kit/templates/AiPromptBox/AiPromptBox.jsx +173 -0
- package/dist/ApexUI-Kit/templates/AiResponse/AiResponse.jsx +172 -0
- package/dist/ApexUI-Kit/templates/AiThinking/AiThinking.jsx +106 -0
- package/dist/ApexUI-Kit/templates/AiVoiceBars/AiVoiceBars.jsx +78 -0
- package/dist/ApexUI-Kit/templates/AiVoiceBlob/AiVoiceBlob.jsx +105 -0
- package/dist/ApexUI-Kit/templates/AiVoiceOrb/AiVoiceOrb.jsx +74 -0
- package/dist/ApexUI-Kit/templates/AiVoiceRing/AiVoiceRing.jsx +120 -0
- package/dist/ApexUI-Kit/templates/AiVoiceWave/AiVoiceWave.jsx +107 -0
- package/dist/ApexUI-Kit/templates/AnimatedSwitch/AnimatedSwitch.jsx +80 -0
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +10 -2
- package/dist/ApexUI-Kit/templates/AuroraBackground/AuroraBackground.jsx +48 -0
- package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +53 -0
- package/dist/ApexUI-Kit/templates/CardSwap3D/CardSwap3D.jsx +80 -0
- package/dist/ApexUI-Kit/templates/CircularText/CircularText.jsx +49 -0
- package/dist/ApexUI-Kit/templates/ClickSpark/ClickSpark.jsx +72 -0
- package/dist/ApexUI-Kit/templates/Coverflow3D/Coverflow3D.jsx +108 -0
- package/dist/ApexUI-Kit/templates/CubeGallery/CubeGallery.jsx +166 -0
- package/dist/ApexUI-Kit/templates/CursorTrail/CursorTrail.jsx +200 -0
- package/dist/ApexUI-Kit/templates/ElasticSlider/ElasticSlider.jsx +152 -0
- package/dist/ApexUI-Kit/templates/ExtrudedText/ExtrudedText.jsx +78 -0
- package/dist/ApexUI-Kit/templates/FlipBook/FlipBook.jsx +191 -0
- package/dist/ApexUI-Kit/templates/FloatingNavbar/FloatingNavbar.jsx +162 -0
- package/dist/ApexUI-Kit/templates/Folder/Folder.jsx +101 -0
- package/dist/ApexUI-Kit/templates/GlitchText/GlitchText.jsx +56 -0
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +310 -0
- package/dist/ApexUI-Kit/templates/HoldToConfirm/HoldToConfirm.jsx +169 -0
- package/dist/ApexUI-Kit/templates/HoldToConfirm/UndoNotice.jsx +58 -0
- package/dist/ApexUI-Kit/templates/HoloCard/HoloCard.jsx +92 -0
- package/dist/ApexUI-Kit/templates/ImageCompare/ImageCompare.jsx +94 -0
- package/dist/ApexUI-Kit/templates/IsoStack/IsoStack.jsx +86 -0
- package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +545 -101
- package/dist/ApexUI-Kit/templates/MagnetLines/MagnetLines.jsx +67 -0
- package/dist/ApexUI-Kit/templates/MagneticCursor/MagneticCursor.jsx +2 -160
- package/dist/ApexUI-Kit/templates/PixelTransition/PixelTransition.jsx +75 -0
- package/dist/ApexUI-Kit/templates/PricingCards/PricingCards.jsx +115 -0
- package/dist/ApexUI-Kit/templates/ProximityText/ProximityText.jsx +66 -0
- package/dist/ApexUI-Kit/templates/RotatingText/RotatingText.jsx +50 -0
- package/dist/ApexUI-Kit/templates/ShinyText/ShinyText.jsx +30 -0
- package/dist/ApexUI-Kit/templates/StarBorder/StarBorder.jsx +33 -0
- package/dist/ApexUI-Kit/templates/Stepper/Stepper.jsx +129 -0
- package/dist/ApexUI-Kit/templates/SwipeCards/SwipeCards.jsx +123 -0
- package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +73 -0
- package/dist/ApexUI-Kit/templates/TrueFocus/TrueFocus.jsx +64 -0
- package/dist/ApexUI-Kit/templates/TypewriterText/TypewriterText.jsx +70 -0
- package/dist/cli/index.js +43 -0
- package/package.json +1 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import React, { memo, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
3
|
+
import { ArrowUp, Bot, Sparkles } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* AI Chat: a compact chat window. Replies stream in word by word behind a
|
|
7
|
+
* typing indicator, suggestions fill the input, and the list keeps itself
|
|
8
|
+
* scrolled. Pass `onSend(text)` returning a string or a Promise of one to
|
|
9
|
+
* hook up your own model; without it the demo answers on its own.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEMO_REPLIES = [
|
|
13
|
+
"Sure! Wrap your section in a container with `grid` and `gap-6`, then let the cards span two columns on large screens. Want me to write the code?",
|
|
14
|
+
"Great question. Animate only `transform` and `opacity` — they run on the GPU and never trigger layout, so the motion stays at 60fps.",
|
|
15
|
+
"Here's a quick idea: use a spring with stiffness 380 and damping 30. It feels snappy without bouncing too much.",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// One message row. Memoised so streaming only re-renders the last message.
|
|
19
|
+
const Message = memo(function Message({ role, text, streaming, accentColor, bubbleAi }) {
|
|
20
|
+
const user = role === "user";
|
|
21
|
+
return (
|
|
22
|
+
<motion.div
|
|
23
|
+
initial={{ opacity: 0, y: 8 }}
|
|
24
|
+
animate={{ opacity: 1, y: 0 }}
|
|
25
|
+
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
|
26
|
+
className={`flex items-end gap-2 ${user ? "justify-end" : "justify-start"}`}
|
|
27
|
+
>
|
|
28
|
+
{!user && (
|
|
29
|
+
<span className="mb-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-lg text-black" style={{ background: accentColor }}>
|
|
30
|
+
<Bot className="h-3.5 w-3.5" />
|
|
31
|
+
</span>
|
|
32
|
+
)}
|
|
33
|
+
<p
|
|
34
|
+
className={`max-w-[80%] whitespace-pre-wrap break-words rounded-2xl px-3.5 py-2.5 text-[0.88rem] leading-relaxed ${user ? "rounded-br-md text-black" : `rounded-bl-md ${bubbleAi}`}`}
|
|
35
|
+
style={user ? { background: accentColor } : undefined}
|
|
36
|
+
>
|
|
37
|
+
{text}
|
|
38
|
+
{streaming && <span className="ml-0.5 inline-block h-3.5 w-1.5 animate-pulse rounded-sm align-[-2px]" style={{ background: accentColor }} />}
|
|
39
|
+
</p>
|
|
40
|
+
</motion.div>
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const AiChat = ({
|
|
45
|
+
title = "Apex Assistant",
|
|
46
|
+
greeting = "Hi! I can help you design, build and animate your UI. What are we making today?",
|
|
47
|
+
suggestions = ["Make a bento grid", "Smooth animations?", "Best spring settings"],
|
|
48
|
+
onSend,
|
|
49
|
+
accentColor = "#a3e635",
|
|
50
|
+
theme = "dark",
|
|
51
|
+
height = "30rem",
|
|
52
|
+
className = "",
|
|
53
|
+
}) => {
|
|
54
|
+
const [messages, setMessages] = useState([{ role: "assistant", text: greeting }]);
|
|
55
|
+
const [input, setInput] = useState("");
|
|
56
|
+
const [busy, setBusy] = useState(false);
|
|
57
|
+
const listRef = useRef(null);
|
|
58
|
+
const demoIndex = useRef(0);
|
|
59
|
+
const dark = theme !== "light";
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
|
|
63
|
+
}, [messages, busy]);
|
|
64
|
+
|
|
65
|
+
// Reveal a reply a few words at a time.
|
|
66
|
+
const stream = (full) =>
|
|
67
|
+
new Promise((resolve) => {
|
|
68
|
+
const words = full.split(" ");
|
|
69
|
+
let i = 0;
|
|
70
|
+
// Two words per tick reads naturally and halves the re-renders.
|
|
71
|
+
setMessages((m) => [...m, { role: "assistant", text: "", streaming: true }]);
|
|
72
|
+
const id = setInterval(() => {
|
|
73
|
+
i = Math.min(words.length, i + 2);
|
|
74
|
+
setMessages((m) => {
|
|
75
|
+
const copy = [...m];
|
|
76
|
+
copy[copy.length - 1] = { role: "assistant", text: words.slice(0, i).join(" "), streaming: i < words.length };
|
|
77
|
+
return copy;
|
|
78
|
+
});
|
|
79
|
+
if (i >= words.length) {
|
|
80
|
+
clearInterval(id);
|
|
81
|
+
resolve();
|
|
82
|
+
}
|
|
83
|
+
}, 55);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const send = async (text) => {
|
|
87
|
+
const clean = text.trim();
|
|
88
|
+
if (!clean || busy) return;
|
|
89
|
+
setInput("");
|
|
90
|
+
setMessages((m) => [...m, { role: "user", text: clean }]);
|
|
91
|
+
setBusy(true);
|
|
92
|
+
try {
|
|
93
|
+
const reply = onSend ? await onSend(clean) : await new Promise((r) => setTimeout(() => r(DEMO_REPLIES[demoIndex.current++ % DEMO_REPLIES.length]), 900));
|
|
94
|
+
await stream(String(reply ?? ""));
|
|
95
|
+
} catch {
|
|
96
|
+
await stream("Sorry, something went wrong. Please try again.");
|
|
97
|
+
}
|
|
98
|
+
setBusy(false);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const panel = dark ? "border-white/10 bg-neutral-950 text-white" : "border-black/10 bg-white text-neutral-900";
|
|
102
|
+
const bubbleAi = dark ? "bg-white/[0.06]" : "bg-black/[0.04]";
|
|
103
|
+
const muted = dark ? "text-white/50" : "text-neutral-500";
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className={`flex w-full max-w-md flex-col overflow-hidden rounded-3xl border shadow-[0_30px_70px_-30px_rgba(0,0,0,0.6)] ${panel} ${className}`} style={{ height }}>
|
|
107
|
+
<header className={`flex items-center gap-3 border-b px-4 py-3 ${dark ? "border-white/10" : "border-black/10"}`}>
|
|
108
|
+
<span className="relative grid h-9 w-9 place-items-center rounded-xl text-black" style={{ background: accentColor }}>
|
|
109
|
+
<Bot className="h-5 w-5" />
|
|
110
|
+
<span className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-current bg-emerald-400" style={{ borderColor: dark ? "#0a0a0a" : "#fff" }} />
|
|
111
|
+
</span>
|
|
112
|
+
<div>
|
|
113
|
+
<p className="text-[0.9rem] font-semibold">{title}</p>
|
|
114
|
+
<p className={`text-[0.72rem] ${muted}`}>{busy ? "Typing…" : "Online"}</p>
|
|
115
|
+
</div>
|
|
116
|
+
</header>
|
|
117
|
+
|
|
118
|
+
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-4" aria-live="polite">
|
|
119
|
+
{messages.map((m, i) => (
|
|
120
|
+
<Message key={i} role={m.role} text={m.text} streaming={m.streaming} accentColor={accentColor} bubbleAi={bubbleAi} />
|
|
121
|
+
))}
|
|
122
|
+
<AnimatePresence>
|
|
123
|
+
{busy && messages[messages.length - 1]?.role === "user" && (
|
|
124
|
+
<motion.div key="typing" initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex items-end gap-2" aria-label="Assistant is typing">
|
|
125
|
+
<span className="mb-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-lg text-black" style={{ background: accentColor }}>
|
|
126
|
+
<Bot className="h-3.5 w-3.5" />
|
|
127
|
+
</span>
|
|
128
|
+
<span className={`inline-flex h-9 items-center gap-1 rounded-2xl rounded-bl-md px-3.5 ${bubbleAi}`}>
|
|
129
|
+
{[0, 1, 2].map((d) => (
|
|
130
|
+
<motion.span key={d} className="h-1.5 w-1.5 rounded-full bg-current" animate={{ y: [0, -3, 0], opacity: [0.35, 1, 0.35] }} transition={{ duration: 0.9, repeat: Infinity, delay: d * 0.15 }} />
|
|
131
|
+
))}
|
|
132
|
+
</span>
|
|
133
|
+
</motion.div>
|
|
134
|
+
)}
|
|
135
|
+
</AnimatePresence>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{messages.length <= 1 && suggestions.length > 0 && (
|
|
139
|
+
<div className="flex flex-wrap gap-2 px-4 pb-3">
|
|
140
|
+
{suggestions.map((s) => (
|
|
141
|
+
<button key={s} type="button" onClick={() => send(s)} className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-[0.75rem] transition ${dark ? "border-white/10 hover:border-white/25 hover:bg-white/[0.05]" : "border-black/10 hover:border-black/20 hover:bg-black/[0.03]"}`}>
|
|
142
|
+
<Sparkles className="h-3 w-3" style={{ color: accentColor }} />
|
|
143
|
+
{s}
|
|
144
|
+
</button>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
<form
|
|
150
|
+
onSubmit={(e) => {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
send(input);
|
|
153
|
+
}}
|
|
154
|
+
className={`flex items-center gap-2 border-t p-3 ${dark ? "border-white/10" : "border-black/10"}`}
|
|
155
|
+
>
|
|
156
|
+
<input
|
|
157
|
+
value={input}
|
|
158
|
+
onChange={(e) => setInput(e.target.value)}
|
|
159
|
+
placeholder="Ask anything…"
|
|
160
|
+
aria-label="Message"
|
|
161
|
+
className={`h-11 flex-1 rounded-2xl px-4 text-[0.9rem] outline-none transition focus:ring-2 ${dark ? "bg-white/[0.06] placeholder:text-white/35 focus:ring-white/15" : "bg-black/[0.04] placeholder:text-neutral-400 focus:ring-black/10"}`}
|
|
162
|
+
/>
|
|
163
|
+
<motion.button whileTap={{ scale: 0.9 }} type="submit" aria-label="Send" disabled={!input.trim() || busy} className="grid h-11 w-11 place-items-center rounded-2xl text-black transition disabled:opacity-40" style={{ background: accentColor }}>
|
|
164
|
+
<ArrowUp className="h-5 w-5" />
|
|
165
|
+
</motion.button>
|
|
166
|
+
</form>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export default AiChat;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import React, { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
3
|
+
import { ArrowUp, Brain, Check, ChevronDown, Eye, SlidersHorizontal, Sparkles, Zap } from "lucide-react";
|
|
4
|
+
import { DEFAULT_LLM_MODELS } from "./models.js";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* AI Model Prompt: a prompt composer with a model picker and per-model
|
|
8
|
+
* settings. Each model declares which settings it supports (temperature,
|
|
9
|
+
* max tokens, reasoning effort); the settings panel only shows those and
|
|
10
|
+
* remembers a configuration per model. Works controlled or on its own.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const TAG_ICON = { reasoning: Brain, vision: Eye, fast: Zap };
|
|
14
|
+
const TOKEN_OPTIONS = [1024, 4096, 8192, 32000];
|
|
15
|
+
const defaultsFor = (model) => ({
|
|
16
|
+
...(model?.settings?.temperature ? { temperature: 0.7 } : {}),
|
|
17
|
+
...(model?.settings?.maxTokens ? { maxTokens: 4096 } : {}),
|
|
18
|
+
...(model?.settings?.reasoning ? { reasoning: model.settings.reasoning[1] ?? model.settings.reasoning[0] } : {}),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Close a popover on outside click or Escape.
|
|
22
|
+
function useDismiss(open, setMenu, ref) {
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!open) return undefined;
|
|
25
|
+
const down = (e) => !ref.current?.contains(e.target) && setMenu(null);
|
|
26
|
+
const key = (e) => e.key === "Escape" && setMenu(null);
|
|
27
|
+
document.addEventListener("pointerdown", down);
|
|
28
|
+
document.addEventListener("keydown", key);
|
|
29
|
+
return () => {
|
|
30
|
+
document.removeEventListener("pointerdown", down);
|
|
31
|
+
document.removeEventListener("keydown", key);
|
|
32
|
+
};
|
|
33
|
+
}, [open, setMenu, ref]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const AiModelPrompt = ({
|
|
37
|
+
models = DEFAULT_LLM_MODELS,
|
|
38
|
+
value,
|
|
39
|
+
onModelChange,
|
|
40
|
+
configurations,
|
|
41
|
+
onConfigurationChange,
|
|
42
|
+
prompt,
|
|
43
|
+
onPromptChange,
|
|
44
|
+
onSubmit,
|
|
45
|
+
placeholder = "Ask anything…",
|
|
46
|
+
accentColor = "#a3e635",
|
|
47
|
+
theme = "dark",
|
|
48
|
+
className = "",
|
|
49
|
+
}) => {
|
|
50
|
+
const id = useId();
|
|
51
|
+
const [innerValue, setInnerValue] = useState(models[0]?.value);
|
|
52
|
+
const [innerConfigs, setInnerConfigs] = useState({});
|
|
53
|
+
const [innerPrompt, setInnerPrompt] = useState("");
|
|
54
|
+
const [menu, setMenu] = useState(null); // "models" | "settings" | null
|
|
55
|
+
const [sent, setSent] = useState(false);
|
|
56
|
+
const modelRef = useRef(null);
|
|
57
|
+
const settingsRef = useRef(null);
|
|
58
|
+
const dark = theme !== "light";
|
|
59
|
+
|
|
60
|
+
const current = models.find((m) => m.value === (value ?? innerValue)) ?? models[0];
|
|
61
|
+
const configs = configurations ?? innerConfigs;
|
|
62
|
+
const config = { ...defaultsFor(current), ...configs[current.value] };
|
|
63
|
+
const text = prompt ?? innerPrompt;
|
|
64
|
+
|
|
65
|
+
useDismiss(menu === "models", setMenu, modelRef);
|
|
66
|
+
useDismiss(menu === "settings", setMenu, settingsRef);
|
|
67
|
+
|
|
68
|
+
const pickModel = (m) => {
|
|
69
|
+
if (value === undefined) setInnerValue(m.value);
|
|
70
|
+
onModelChange?.(m);
|
|
71
|
+
setMenu(null);
|
|
72
|
+
};
|
|
73
|
+
const setConfig = (key, v) => {
|
|
74
|
+
const nextConfig = { ...config, [key]: v };
|
|
75
|
+
const next = { ...configs, [current.value]: nextConfig };
|
|
76
|
+
if (configurations === undefined) setInnerConfigs(next);
|
|
77
|
+
onConfigurationChange?.(current, nextConfig, next);
|
|
78
|
+
};
|
|
79
|
+
const setText = (t) => {
|
|
80
|
+
if (prompt === undefined) setInnerPrompt(t);
|
|
81
|
+
onPromptChange?.(t);
|
|
82
|
+
};
|
|
83
|
+
const submit = () => {
|
|
84
|
+
if (!text.trim()) return;
|
|
85
|
+
onSubmit?.({ model: current, configuration: config, prompt: text.trim() });
|
|
86
|
+
setText("");
|
|
87
|
+
setSent(true);
|
|
88
|
+
setTimeout(() => setSent(false), 1200);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const panel = dark ? "border-white/10 bg-neutral-900 text-white shadow-[0_20px_50px_-20px_rgba(0,0,0,0.8)]" : "border-black/10 bg-white text-neutral-900 shadow-[0_20px_50px_-20px_rgba(0,0,0,0.25)]";
|
|
92
|
+
const chip = dark ? "bg-white/[0.06] hover:bg-white/10" : "bg-black/[0.04] hover:bg-black/[0.07]";
|
|
93
|
+
const muted = dark ? "text-white/50" : "text-neutral-500";
|
|
94
|
+
const groups = [...new Set(models.map((m) => m.provider))];
|
|
95
|
+
const pop = { initial: { opacity: 0, y: 6, scale: 0.98 }, animate: { opacity: 1, y: 0, scale: 1 }, exit: { opacity: 0, y: 6, scale: 0.98 }, transition: { duration: 0.15, ease: [0.22, 1, 0.36, 1] } };
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className={`w-full max-w-2xl rounded-[1.6rem] border p-3 ${dark ? "border-white/10 bg-neutral-950 text-white" : "border-black/10 bg-white text-neutral-900"} ${className}`}>
|
|
99
|
+
<textarea
|
|
100
|
+
rows={3}
|
|
101
|
+
value={text}
|
|
102
|
+
onChange={(e) => setText(e.target.value)}
|
|
103
|
+
onKeyDown={(e) => {
|
|
104
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
submit();
|
|
107
|
+
}
|
|
108
|
+
}}
|
|
109
|
+
placeholder={placeholder}
|
|
110
|
+
aria-label="Prompt"
|
|
111
|
+
className={`block w-full resize-none bg-transparent px-2 py-1.5 text-[0.95rem] leading-relaxed outline-none ${dark ? "placeholder:text-white/35" : "placeholder:text-neutral-400"}`}
|
|
112
|
+
/>
|
|
113
|
+
|
|
114
|
+
<div className="mt-2 flex items-center gap-2">
|
|
115
|
+
{/* Model picker */}
|
|
116
|
+
<div ref={modelRef} className="relative">
|
|
117
|
+
<button type="button" aria-haspopup="listbox" aria-expanded={menu === "models"} onClick={() => setMenu((m) => (m === "models" ? null : "models"))} className={`inline-flex h-9 items-center gap-2 rounded-full pl-2 pr-3 text-[0.82rem] font-medium transition ${chip}`}>
|
|
118
|
+
<span className="grid h-5 w-5 place-items-center rounded-full text-black" style={{ background: accentColor }}>
|
|
119
|
+
<Sparkles className="h-3 w-3" />
|
|
120
|
+
</span>
|
|
121
|
+
{current.label}
|
|
122
|
+
<ChevronDown className={`h-3.5 w-3.5 opacity-60 transition-transform ${menu === "models" ? "rotate-180" : ""}`} />
|
|
123
|
+
</button>
|
|
124
|
+
<AnimatePresence>
|
|
125
|
+
{menu === "models" && (
|
|
126
|
+
<motion.div {...pop} role="listbox" aria-label="Models" className={`absolute bottom-full left-0 z-30 mb-2 max-h-[min(21rem,60vh)] w-[min(19rem,80vw)] origin-bottom-left overflow-y-auto overscroll-contain rounded-2xl border p-1.5 ${panel}`}>
|
|
127
|
+
{groups.map((g) => (
|
|
128
|
+
<div key={g} className="py-1">
|
|
129
|
+
<p className={`px-2.5 pb-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] ${muted}`}>{g}</p>
|
|
130
|
+
{models
|
|
131
|
+
.filter((m) => m.provider === g)
|
|
132
|
+
.map((m) => {
|
|
133
|
+
const on = m.value === current.value;
|
|
134
|
+
return (
|
|
135
|
+
<button key={m.value} type="button" role="option" aria-selected={on} onClick={() => pickModel(m)} className={`flex w-full items-start gap-2.5 rounded-xl px-2.5 py-2 text-left transition ${on ? (dark ? "bg-white/[0.07]" : "bg-black/[0.05]") : dark ? "hover:bg-white/[0.04]" : "hover:bg-black/[0.03]"}`}>
|
|
136
|
+
<span className="min-w-0 flex-1">
|
|
137
|
+
<span className="flex items-center gap-1.5 text-[0.85rem] font-medium">
|
|
138
|
+
{m.label}
|
|
139
|
+
{m.tags?.map((t) => {
|
|
140
|
+
const Icon = TAG_ICON[t];
|
|
141
|
+
return Icon ? <Icon key={t} className={`h-3 w-3 ${muted}`} aria-label={t} /> : null;
|
|
142
|
+
})}
|
|
143
|
+
</span>
|
|
144
|
+
<span className={`block text-[0.74rem] ${muted}`}>{m.description}</span>
|
|
145
|
+
</span>
|
|
146
|
+
{on && <Check className="mt-0.5 h-4 w-4 shrink-0" style={{ color: accentColor }} />}
|
|
147
|
+
</button>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
</motion.div>
|
|
153
|
+
)}
|
|
154
|
+
</AnimatePresence>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Per-model settings */}
|
|
158
|
+
<div ref={settingsRef} className="relative">
|
|
159
|
+
<button type="button" aria-label="Model settings" aria-expanded={menu === "settings"} onClick={() => setMenu((m) => (m === "settings" ? null : "settings"))} className={`grid h-9 w-9 place-items-center rounded-full transition ${chip}`}>
|
|
160
|
+
<SlidersHorizontal className="h-4 w-4" />
|
|
161
|
+
</button>
|
|
162
|
+
<AnimatePresence>
|
|
163
|
+
{menu === "settings" && (
|
|
164
|
+
<motion.div {...pop} role="dialog" aria-label={`${current.label} settings`} className={`absolute bottom-full left-0 z-30 mb-2 w-[min(17rem,78vw)] origin-bottom-left space-y-4 rounded-2xl border p-4 ${panel}`}>
|
|
165
|
+
<p className="text-[0.8rem] font-semibold">{current.label} settings</p>
|
|
166
|
+
{current.settings?.temperature && (
|
|
167
|
+
<label className="block">
|
|
168
|
+
<span className="mb-1.5 flex justify-between text-[0.75rem]">
|
|
169
|
+
<span className={muted}>Temperature</span>
|
|
170
|
+
<span className="font-mono">{config.temperature.toFixed(1)}</span>
|
|
171
|
+
</span>
|
|
172
|
+
<input type="range" min={0} max={2} step={0.1} value={config.temperature} onChange={(e) => setConfig("temperature", Number(e.target.value))} className="w-full" style={{ accentColor }} />
|
|
173
|
+
</label>
|
|
174
|
+
)}
|
|
175
|
+
{current.settings?.maxTokens && (
|
|
176
|
+
<div>
|
|
177
|
+
<p className={`mb-1.5 text-[0.75rem] ${muted}`}>Max tokens</p>
|
|
178
|
+
<div className="grid grid-cols-4 gap-1">
|
|
179
|
+
{TOKEN_OPTIONS.map((t) => (
|
|
180
|
+
<button key={t} type="button" onClick={() => setConfig("maxTokens", t)} className={`rounded-lg py-1 font-mono text-[0.7rem] transition ${config.maxTokens === t ? "text-black" : chip}`} style={config.maxTokens === t ? { background: accentColor } : undefined}>
|
|
181
|
+
{t >= 1000 ? `${Math.round(t / 1000)}k` : t}
|
|
182
|
+
</button>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
{current.settings?.reasoning && (
|
|
188
|
+
<div>
|
|
189
|
+
<p className={`mb-1.5 text-[0.75rem] ${muted}`}>Reasoning effort</p>
|
|
190
|
+
<div className={`relative grid rounded-lg p-0.5 ${dark ? "bg-white/[0.06]" : "bg-black/[0.04]"}`} style={{ gridTemplateColumns: `repeat(${current.settings.reasoning.length}, 1fr)` }}>
|
|
191
|
+
{current.settings.reasoning.map((r) => (
|
|
192
|
+
<button key={r} type="button" onClick={() => setConfig("reasoning", r)} className={`relative rounded-md py-1 text-[0.72rem] font-medium capitalize ${config.reasoning === r ? "text-black" : ""}`}>
|
|
193
|
+
{config.reasoning === r && <motion.span layoutId={`${id}-effort`} className="absolute inset-0 rounded-md" style={{ background: accentColor }} transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
|
|
194
|
+
<span className="relative">{r}</span>
|
|
195
|
+
</button>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</motion.div>
|
|
201
|
+
)}
|
|
202
|
+
</AnimatePresence>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<span className={`ml-auto hidden text-[0.72rem] sm:inline ${muted}`}>
|
|
206
|
+
{config.reasoning ? `${config.reasoning} effort · ` : ""}
|
|
207
|
+
{config.maxTokens ? `${Math.round(config.maxTokens / 1000)}k tokens` : ""}
|
|
208
|
+
</span>
|
|
209
|
+
<motion.button whileTap={{ scale: 0.9 }} type="button" onClick={submit} disabled={!text.trim()} aria-label="Send" className="ml-auto grid h-10 w-10 place-items-center rounded-full text-black transition disabled:opacity-35 sm:ml-0" style={{ background: accentColor }}>
|
|
210
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
211
|
+
<motion.span key={sent ? "ok" : "go"} initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} transition={{ duration: 0.14 }}>
|
|
212
|
+
{sent ? <Check className="h-5 w-5" /> : <ArrowUp className="h-5 w-5" />}
|
|
213
|
+
</motion.span>
|
|
214
|
+
</AnimatePresence>
|
|
215
|
+
</motion.button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export default AiModelPrompt;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Ready-made model list for AiModelPrompt. Each model says which settings it
|
|
3
|
+
* supports; the settings panel only shows those. Swap in your own list or
|
|
4
|
+
* extend this one.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_LLM_MODELS = [
|
|
7
|
+
{ value: "claude-fable-5", label: "Claude Fable 5", provider: "Anthropic", description: "Most capable for deep, multi-step work.", tags: ["reasoning", "vision"], settings: { temperature: true, maxTokens: true, reasoning: ["low", "medium", "high"] } },
|
|
8
|
+
{ value: "claude-opus-5-5", label: "Claude Opus 5.5", provider: "Anthropic", description: "Frontier quality for hard problems.", tags: ["reasoning", "vision"], settings: { temperature: true, maxTokens: true, reasoning: ["low", "medium", "high"] } },
|
|
9
|
+
{ value: "claude-sonnet-5-5", label: "Claude Sonnet 5.5", provider: "Anthropic", description: "Great balance for writing and code.", tags: ["reasoning", "vision"], settings: { temperature: true, maxTokens: true, reasoning: ["low", "medium", "high"] } },
|
|
10
|
+
{ value: "claude-haiku-5-5", label: "Claude Haiku 5.5", provider: "Anthropic", description: "Fast and light for everyday tasks.", tags: ["fast"], settings: { temperature: true, maxTokens: true } },
|
|
11
|
+
{ value: "gpt-5", label: "GPT-5", provider: "OpenAI", description: "General purpose flagship.", tags: ["reasoning", "vision"], settings: { maxTokens: true, reasoning: ["minimal", "low", "medium", "high"] } },
|
|
12
|
+
{ value: "gpt-5-mini", label: "GPT-5 mini", provider: "OpenAI", description: "Small, quick and capable.", tags: ["fast"], settings: { maxTokens: true, reasoning: ["low", "medium", "high"] } },
|
|
13
|
+
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro", provider: "Google", description: "Long context and multimodal.", tags: ["reasoning", "vision"], settings: { temperature: true, maxTokens: true } },
|
|
14
|
+
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash", provider: "Google", description: "Low latency at scale.", tags: ["fast", "vision"], settings: { temperature: true, maxTokens: true } },
|
|
15
|
+
];
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
3
|
+
import { ArrowUp, ChevronDown, Paperclip, Square, Wand2, X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* AI Prompt Box: the input of an AI app. The border lights up with a moving
|
|
7
|
+
* gradient while focused and spins faster while generating; the textarea
|
|
8
|
+
* grows with its text; you can pick a model, attach files and use quick
|
|
9
|
+
* actions. The send button turns into a stop button while busy.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEFAULT_MODELS = ["Apex 2 Pro", "Apex 2 Flash", "Apex Vision"];
|
|
13
|
+
const DEFAULT_ACTIONS = ["Summarize", "Write code", "Brainstorm", "Translate"];
|
|
14
|
+
|
|
15
|
+
const AiPromptBox = ({
|
|
16
|
+
placeholder = "Describe what you want to build…",
|
|
17
|
+
models = DEFAULT_MODELS,
|
|
18
|
+
actions = DEFAULT_ACTIONS,
|
|
19
|
+
onSubmit,
|
|
20
|
+
maxLength = 2000,
|
|
21
|
+
colors = ["#a3e635", "#22d3ee", "#a78bfa", "#f472b6"],
|
|
22
|
+
theme = "dark",
|
|
23
|
+
className = "",
|
|
24
|
+
}) => {
|
|
25
|
+
const [value, setValue] = useState("");
|
|
26
|
+
const [focused, setFocused] = useState(false);
|
|
27
|
+
const [busy, setBusy] = useState(false);
|
|
28
|
+
const [model, setModel] = useState(models[0]);
|
|
29
|
+
const [menu, setMenu] = useState(false);
|
|
30
|
+
const [files, setFiles] = useState([]);
|
|
31
|
+
const area = useRef(null);
|
|
32
|
+
const fileInput = useRef(null);
|
|
33
|
+
const timer = useRef(0);
|
|
34
|
+
const dark = theme !== "light";
|
|
35
|
+
|
|
36
|
+
// Grow with the text, up to about eight lines.
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const el = area.current;
|
|
39
|
+
if (!el) return;
|
|
40
|
+
el.style.height = "auto";
|
|
41
|
+
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
|
42
|
+
}, [value]);
|
|
43
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
44
|
+
|
|
45
|
+
const submit = async () => {
|
|
46
|
+
if (busy) {
|
|
47
|
+
clearTimeout(timer.current);
|
|
48
|
+
setBusy(false);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!value.trim()) return;
|
|
52
|
+
setBusy(true);
|
|
53
|
+
const payload = { prompt: value.trim(), model, files };
|
|
54
|
+
try {
|
|
55
|
+
if (onSubmit) await onSubmit(payload);
|
|
56
|
+
else await new Promise((r) => (timer.current = setTimeout(r, 2200)));
|
|
57
|
+
} finally {
|
|
58
|
+
setBusy(false);
|
|
59
|
+
setValue("");
|
|
60
|
+
setFiles([]);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const glow = `conic-gradient(from var(--apex-angle), ${colors.join(", ")}, ${colors[0]})`;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={`w-full max-w-2xl ${className}`}>
|
|
68
|
+
<style>{"@property --apex-angle{syntax:'<angle>';initial-value:0deg;inherits:false}@keyframes apex-spin{to{--apex-angle:360deg}}"}</style>
|
|
69
|
+
<div
|
|
70
|
+
className="relative rounded-[1.6rem] p-[1.5px] transition-shadow duration-500"
|
|
71
|
+
style={{
|
|
72
|
+
background: focused || busy ? glow : dark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)",
|
|
73
|
+
animation: focused || busy ? `apex-spin ${busy ? 1.2 : 4}s linear infinite` : "none",
|
|
74
|
+
boxShadow: focused || busy ? `0 0 40px -10px ${colors[0]}88` : "none",
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<div className={`rounded-[1.55rem] p-3 ${dark ? "bg-neutral-950 text-white" : "bg-white text-neutral-900"}`}>
|
|
78
|
+
<AnimatePresence>
|
|
79
|
+
{files.length > 0 && (
|
|
80
|
+
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="flex flex-wrap gap-2 overflow-hidden px-1 pb-2">
|
|
81
|
+
{files.map((f) => (
|
|
82
|
+
<span key={f} className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[0.75rem] ${dark ? "bg-white/[0.07]" : "bg-black/[0.05]"}`}>
|
|
83
|
+
<Paperclip className="h-3 w-3" /> {f}
|
|
84
|
+
<button type="button" aria-label={`Remove ${f}`} onClick={() => setFiles((l) => l.filter((x) => x !== f))} className="opacity-60 hover:opacity-100">
|
|
85
|
+
<X className="h-3 w-3" />
|
|
86
|
+
</button>
|
|
87
|
+
</span>
|
|
88
|
+
))}
|
|
89
|
+
</motion.div>
|
|
90
|
+
)}
|
|
91
|
+
</AnimatePresence>
|
|
92
|
+
<textarea
|
|
93
|
+
ref={area}
|
|
94
|
+
rows={2}
|
|
95
|
+
value={value}
|
|
96
|
+
maxLength={maxLength}
|
|
97
|
+
onChange={(e) => setValue(e.target.value)}
|
|
98
|
+
onFocus={() => setFocused(true)}
|
|
99
|
+
onBlur={() => setFocused(false)}
|
|
100
|
+
onKeyDown={(e) => {
|
|
101
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
submit();
|
|
104
|
+
}
|
|
105
|
+
}}
|
|
106
|
+
placeholder={placeholder}
|
|
107
|
+
aria-label="Prompt"
|
|
108
|
+
className={`block w-full resize-none bg-transparent px-2 py-1.5 text-[0.95rem] leading-relaxed outline-none ${dark ? "placeholder:text-white/35" : "placeholder:text-neutral-400"}`}
|
|
109
|
+
/>
|
|
110
|
+
<div className="mt-2 flex items-center gap-2">
|
|
111
|
+
<input ref={fileInput} type="file" multiple hidden onChange={(e) => setFiles((l) => [...new Set([...l, ...[...e.target.files].map((f) => f.name)])])} />
|
|
112
|
+
<button type="button" aria-label="Attach files" onClick={() => fileInput.current?.click()} className={`grid h-9 w-9 place-items-center rounded-full transition ${dark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
|
|
113
|
+
<Paperclip className="h-4 w-4" />
|
|
114
|
+
</button>
|
|
115
|
+
<div className="relative">
|
|
116
|
+
<button type="button" aria-haspopup="listbox" aria-expanded={menu} onClick={() => setMenu((m) => !m)} className={`inline-flex h-9 items-center gap-1 rounded-full px-3 text-[0.8rem] font-medium transition ${dark ? "bg-white/[0.06] hover:bg-white/10" : "bg-black/[0.04] hover:bg-black/[0.07]"}`}>
|
|
117
|
+
{model}
|
|
118
|
+
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${menu ? "rotate-180" : ""}`} />
|
|
119
|
+
</button>
|
|
120
|
+
<AnimatePresence>
|
|
121
|
+
{menu && (
|
|
122
|
+
<motion.ul
|
|
123
|
+
role="listbox"
|
|
124
|
+
initial={{ opacity: 0, y: 6, scale: 0.97 }}
|
|
125
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
126
|
+
exit={{ opacity: 0, y: 6, scale: 0.97 }}
|
|
127
|
+
className={`absolute bottom-full left-0 z-20 mb-2 min-w-[10rem] rounded-xl border p-1 shadow-xl ${dark ? "border-white/10 bg-neutral-900" : "border-black/10 bg-white"}`}
|
|
128
|
+
>
|
|
129
|
+
{models.map((m) => (
|
|
130
|
+
<li key={m}>
|
|
131
|
+
<button type="button" role="option" aria-selected={m === model} onClick={() => { setModel(m); setMenu(false); }} className={`w-full rounded-lg px-3 py-1.5 text-left text-[0.8rem] ${m === model ? (dark ? "bg-white/10" : "bg-black/5") : dark ? "hover:bg-white/[0.06]" : "hover:bg-black/[0.04]"}`}>
|
|
132
|
+
{m}
|
|
133
|
+
</button>
|
|
134
|
+
</li>
|
|
135
|
+
))}
|
|
136
|
+
</motion.ul>
|
|
137
|
+
)}
|
|
138
|
+
</AnimatePresence>
|
|
139
|
+
</div>
|
|
140
|
+
<span className={`ml-auto font-mono text-[0.7rem] tabular-nums ${dark ? "text-white/35" : "text-neutral-400"}`}>
|
|
141
|
+
{value.length}/{maxLength}
|
|
142
|
+
</span>
|
|
143
|
+
<motion.button
|
|
144
|
+
type="button"
|
|
145
|
+
whileTap={{ scale: 0.9 }}
|
|
146
|
+
onClick={submit}
|
|
147
|
+
aria-label={busy ? "Stop generating" : "Send"}
|
|
148
|
+
disabled={!busy && !value.trim()}
|
|
149
|
+
className="grid h-10 w-10 place-items-center rounded-full text-black transition disabled:opacity-35"
|
|
150
|
+
style={{ background: busy ? (dark ? "#fff" : "#111") : colors[0], color: busy ? (dark ? "#111" : "#fff") : "#000" }}
|
|
151
|
+
>
|
|
152
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
153
|
+
<motion.span key={busy ? "stop" : "send"} initial={{ scale: 0, rotate: -90 }} animate={{ scale: 1, rotate: 0 }} exit={{ scale: 0, rotate: 90 }} transition={{ duration: 0.15 }}>
|
|
154
|
+
{busy ? <Square className="h-3.5 w-3.5" fill="currentColor" /> : <ArrowUp className="h-5 w-5" />}
|
|
155
|
+
</motion.span>
|
|
156
|
+
</AnimatePresence>
|
|
157
|
+
</motion.button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="mt-3 flex flex-wrap justify-center gap-2">
|
|
162
|
+
{actions.map((a) => (
|
|
163
|
+
<button key={a} type="button" onClick={() => { setValue((v) => (v ? `${a}: ${v}` : `${a}: `)); area.current?.focus(); }} className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-[0.78rem] transition ${dark ? "border-white/10 text-white/70 hover:border-white/25 hover:text-white" : "border-black/10 text-neutral-600 hover:border-black/20 hover:text-neutral-900"}`}>
|
|
164
|
+
<Wand2 className="h-3 w-3" style={{ color: colors[0] }} />
|
|
165
|
+
{a}
|
|
166
|
+
</button>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export default AiPromptBox;
|