AI Chat Assistant New
A complete chat assistant app with streaming, reasoning, web search, artifacts, image and video generation previews.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/apps-ai-assistant.json02
Or copy the source below (13 files). Dependencies: lucide-react.
Source
components/niceui/pages/apps/assistant/AssistantApp.tsx
import { useEffect, useRef, useState } from 'react';
import { ArrowDown, Clapperboard, Code2, Globe, ImageIcon, PanelLeftOpen, PenLine, Share, SquarePen, TriangleAlert } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useAssistant } from './useAssistant';
import { suggestions } from './data';
import { Sidebar } from './Sidebar';
import { Composer } from './Composer';
import { ArtifactPanel } from './ArtifactPanel';
import { AssistantMessageView, UserMessage } from './Message';
import type { Artifact } from './types';
const tagIcon = { Code: Code2, Image: ImageIcon, Video: Clapperboard, Search: Globe, Write: PenLine, Error: TriangleAlert } as const;
function greeting() {
const h = new Date().getHours();
return h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
}
export function AssistantApp() {
const api = useAssistant();
const { active } = api;
const [sidebar, setSidebar] = useState(true);
const [drawer, setDrawer] = useState(false);
const [artifact, setArtifact] = useState<Artifact | null>(null);
const [stick, setStick] = useState(true);
const scroller = useRef<HTMLDivElement>(null);
const autoOpened = useRef(new Set<string>());
const selectChat = (id: string | null) => {
api.setActiveId(id);
setArtifact(null);
setDrawer(false);
setStick(true);
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
selectChat(null);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
const messages = active?.messages;
useEffect(() => {
if (stick) scroller.current?.scrollTo({ top: scroller.current.scrollHeight });
}, [messages, stick]);
useEffect(() => {
if (!messages || !api.streamingId) return;
const msg = messages.find((m) => m.id === api.streamingId);
if (!msg || msg.role !== 'assistant') return;
for (const p of msg.parts) {
if (p.type === 'artifact' && !autoOpened.current.has(p.artifact.id)) {
autoOpened.current.add(p.artifact.id);
setArtifact(p.artifact);
}
}
}, [messages, api.streamingId]);
useEffect(() => {
if (!artifact || !messages) return;
for (const m of messages) {
if (m.role !== 'assistant') continue;
for (const p of m.parts) {
if (p.type === 'artifact' && p.artifact.id === artifact.id && p.artifact !== artifact) setArtifact(p.artifact);
}
}
}, [messages, artifact]);
const onScroll = () => {
const el = scroller.current;
if (el) setStick(el.scrollHeight - el.scrollTop - el.clientHeight < 80);
};
const sidebarProps = {
conversations: api.conversations,
activeId: api.activeId,
onSelect: selectChat,
onRename: api.renameChat,
onPin: api.togglePin,
onDelete: api.deleteChat,
};
const composer = (
<Composer
onSend={(t, a, m) => { setStick(true); api.send(t, a, m); }}
onStop={api.stop}
isStreaming={api.isStreaming}
model={api.model}
onModel={api.setModelId}
extended={api.extended}
onExtended={api.setExtended}
autoFocus={!active}
/>
);
const lastAssistant = messages ? [...messages].reverse().find((m) => m.role === 'assistant')?.id : undefined;
const lastUser = messages ? [...messages].reverse().find((m) => m.role === 'user')?.id : undefined;
return (
<div data-fill className="relative flex h-[calc(100vh-3.5rem)] overflow-hidden bg-ink-950 text-ink-100">
<div className={cn('hidden shrink-0 overflow-hidden transition-[width] duration-500 ease-smooth lg:block', sidebar ? 'w-[272px]' : 'w-0')}>
<Sidebar {...sidebarProps} onClose={() => setSidebar(false)} />
</div>
{drawer && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={() => setDrawer(false)} />
<div className="relative h-full w-[272px] animate-fade-in">
<Sidebar {...sidebarProps} onClose={() => setDrawer(false)} />
</div>
</div>
)}
<div className="flex min-w-0 flex-1">
<main className={cn('relative flex min-w-0 flex-1 flex-col', artifact && 'hidden md:flex')}>
<header className="flex h-14 shrink-0 items-center gap-1 px-3">
<button onClick={() => setDrawer(true)} aria-label="Open sidebar" className="grid h-9 w-9 place-items-center rounded-lg text-ink-400 hover:bg-white/5 hover:text-white lg:hidden">
<PanelLeftOpen className="h-4 w-4" />
</button>
{!sidebar && (
<>
<button onClick={() => setSidebar(true)} aria-label="Open sidebar" className="hidden h-9 w-9 place-items-center rounded-lg text-ink-400 hover:bg-white/5 hover:text-white lg:grid">
<PanelLeftOpen className="h-4 w-4" />
</button>
<button onClick={() => selectChat(null)} aria-label="New chat" className="hidden h-9 w-9 place-items-center rounded-lg text-ink-400 hover:bg-white/5 hover:text-white lg:grid">
<SquarePen className="h-4 w-4" />
</button>
</>
)}
<div className="ml-1 min-w-0 flex-1 truncate text-sm font-medium text-ink-200">{active?.title ?? ''}</div>
{active && (
<button className="flex h-8 items-center gap-1.5 rounded-lg border border-white/10 px-3 text-xs font-medium text-ink-200 transition-colors hover:bg-white/5">
<Share className="h-3.5 w-3.5" /> Share
</button>
)}
</header>
{!active ? (
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-4 pb-10">
<div className="w-full max-w-2xl">
<h1 className="mb-8 text-center font-serif text-4xl text-ink-50 animate-fade-up sm:text-5xl">
{greeting()}, <span className="italic text-brand-300">Alex</span>
</h1>
<div className="animate-fade-up [animation-delay:80ms]">{composer}</div>
<div className="mt-6 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{suggestions.map((s, i) => {
const Icon = tagIcon[s.tag as keyof typeof tagIcon];
return (
<button
key={s.label}
onClick={() => { setStick(true); api.send(s.prompt, [], 'auto'); }}
style={{ animationDelay: `${160 + i * 50}ms` }}
className="group flex items-center gap-3 rounded-xl border border-white/[0.06] bg-white/[0.02] p-3 text-left opacity-0 transition-all duration-300 animate-fade-up [animation-fill-mode:forwards] hover:-translate-y-0.5 hover:border-white/15 hover:bg-white/[0.05]"
>
<span className={cn('grid h-8 w-8 shrink-0 place-items-center rounded-lg', s.tag === 'Error' ? 'bg-rose-500/10 text-rose-300' : 'bg-brand-500/10 text-brand-300')}>
{Icon && <Icon className="h-4 w-4" />}
</span>
<span className="text-[13px] leading-snug text-ink-300 group-hover:text-ink-100">{s.label}</span>
</button>
);
})}
</div>
</div>
</div>
) : (
<>
<div ref={scroller} onScroll={onScroll} className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-3xl space-y-8 px-4 pb-8 pt-4">
{active.messages.map((m) =>
m.role === 'user' ? (
<UserMessage key={m.id} msg={m} canEdit={!api.isStreaming && m.id === lastUser} onEdit={(t) => api.editMessage(m.id, t)} />
) : (
<AssistantMessageView
key={m.id}
msg={m}
isLast={m.id === lastAssistant}
openArtifact={artifact?.id ?? null}
onOpenArtifact={(a) => setArtifact((cur) => (cur?.id === a.id ? null : a))}
onRegenerate={() => api.regenerate(m.id)}
onRate={(r) => api.rate(m.id, r)}
onPrompt={(t) => { setStick(true); api.send(t, [], 'image'); }}
/>
),
)}
</div>
</div>
<div className="relative mx-auto w-full max-w-3xl px-4 pb-4">
{!stick && (
<button
onClick={() => setStick(true)}
aria-label="Jump to latest"
className="absolute -top-12 left-1/2 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-white/10 bg-ink-900 text-ink-200 shadow-pop animate-fade-up hover:text-white"
>
<ArrowDown className="h-4 w-4" />
</button>
)}
{composer}
</div>
</>
)}
</main>
{artifact && (
<div className="w-full shrink-0 border-l border-white/[0.06] md:w-[48%] xl:w-[52%]">
<ArtifactPanel artifact={artifact} onClose={() => setArtifact(null)} />
</div>
)}
</div>
</div>
);
}components/niceui/pages/apps/assistant/useAssistant.ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { models, seedConversations } from './data';
import { runScenario, type ScenarioCtx } from './scenarios';
import type { AssistantMessage, Attachment, Conversation, Message, Mode, ModelId, Part } from './types';
class Cancelled extends Error {}
type Token = { cancelled: boolean };
const uid = () => crypto.randomUUID();
const titleFrom = (text: string) => {
const t = text.replace(/\s+/g, ' ').trim();
return t.length > 38 ? `${t.slice(0, 38).trim()}…` : t || 'New chat';
};
export function useAssistant() {
const [conversations, setConversations] = useState<Conversation[]>(() => seedConversations(Date.now()));
const [activeId, setActiveId] = useState<string | null>(null);
const [modelId, setModelId] = useState<ModelId>('sonnet');
const [extended, setExtended] = useState(true);
const [streamingId, setStreamingId] = useState<string | null>(null);
const token = useRef<Token | null>(null);
useEffect(() => () => { if (token.current) token.current.cancelled = true; }, []);
const model = models.find((m) => m.id === modelId) ?? models[1];
const active = conversations.find((c) => c.id === activeId) ?? null;
const updateConv = useCallback((id: string, fn: (c: Conversation) => Conversation) => {
setConversations((list) => list.map((c) => (c.id === id ? fn(c) : c)));
}, []);
const patchMsg = useCallback(
(convId: string, msgId: string, fn: (m: AssistantMessage) => AssistantMessage) => {
updateConv(convId, (c) => ({ ...c, messages: c.messages.map((m) => (m.id === msgId && m.role === 'assistant' ? fn(m) : m)) }));
},
[updateConv],
);
const run = useCallback(
async (convId: string, history: Message[], user: Extract<Message, { role: 'user' }>, mode: Mode, retry = false) => {
if (token.current) token.current.cancelled = true;
const tk: Token = { cancelled: false };
token.current = tk;
const msgId = uid();
const speed = model.speed;
const assistant: AssistantMessage = { id: msgId, role: 'assistant', parts: [], status: 'streaming', model: model.name };
updateConv(convId, (c) => ({ ...c, updatedAt: Date.now(), messages: [...history, user, assistant] }));
setStreamingId(msgId);
let count = 0;
const sleep = (ms: number) =>
new Promise<void>((resolve, reject) => {
window.setTimeout(() => (tk.cancelled ? reject(new Cancelled()) : resolve()), ms / speed);
});
const append = (part: Part) => {
const index = count++;
patchMsg(convId, msgId, (m) => ({ ...m, parts: [...m.parts, part] }));
return index;
};
const setPart = (index: number, fn: (p: Part) => Part) =>
patchMsg(convId, msgId, (m) => ({ ...m, parts: m.parts.map((p, i) => (i === index ? fn(p) : p)) }));
const typeInto = async (index: number, length: number, step: number) => {
for (let shown = 0; shown < length; ) {
await sleep(16);
shown = Math.min(length, shown + step + Math.floor(Math.random() * step));
const s = shown;
setPart(index, (p) => ('shown' in p ? { ...p, shown: s } : p));
}
};
const ctx: ScenarioCtx = {
async think(text, seconds) {
const i = append({ type: 'thinking', text, shown: 0, seconds, done: false });
await sleep(300);
await typeInto(i, text.length, 4);
await sleep(seconds * 250);
setPart(i, (p) => (p.type === 'thinking' ? { ...p, done: true } : p));
},
async tool(name, label, detail, ms, sources) {
const i = append({ type: 'tool', name, label, detail, done: false });
await sleep(ms);
setPart(i, (p) => (p.type === 'tool' ? { ...p, done: true, sources } : p));
await sleep(200);
},
async stream(text) {
const i = append({ type: 'text', text, shown: 0 });
await typeInto(i, text.length, 3);
},
async artifact(artifact) {
const i = append({ type: 'artifact', artifact, done: false });
await sleep(2200);
setPart(i, (p) => (p.type === 'artifact' ? { ...p, done: true } : p));
},
async image(prompt, urls, aspect) {
const i = append({ type: 'image', prompt, urls, progress: 0, aspect });
for (let p = 0; p < 100; ) {
await sleep(90);
p = Math.min(100, p + 2 + Math.random() * 4);
const v = p;
setPart(i, (x) => (x.type === 'image' ? { ...x, progress: v } : x));
}
},
async video(prompt, poster, seconds) {
const i = append({ type: 'video', prompt, poster, progress: 0, seconds });
for (let p = 0; p < 100; ) {
await sleep(120);
p = Math.min(100, p + 1 + Math.random() * 3);
const v = p;
setPart(i, (x) => (x.type === 'video' ? { ...x, progress: v } : x));
}
},
async fail(message) {
await sleep(900);
append({ type: 'error', message });
throw new Error(message);
},
};
const finish = (status: AssistantMessage['status']) => {
patchMsg(convId, msgId, (m) => ({ ...m, status }));
if (token.current === tk) {
token.current = null;
setStreamingId(null);
}
};
try {
await sleep(450);
await runScenario(ctx, { text: user.text, attachments: user.attachments, thinking: model.thinks && extended, retry }, mode);
finish('done');
} catch (e) {
finish(e instanceof Cancelled ? 'stopped' : 'error');
}
},
[extended, model, patchMsg, updateConv],
);
const send = useCallback(
(text: string, attachments: Attachment[], mode: Mode) => {
const user = { id: uid(), role: 'user' as const, text, attachments };
if (active) {
if (active.messages.length === 0) updateConv(active.id, (c) => ({ ...c, title: titleFrom(text) }));
void run(active.id, active.messages, user, mode);
return;
}
const conv: Conversation = { id: uid(), title: titleFrom(text), updatedAt: Date.now(), messages: [] };
setConversations((list) => [conv, ...list]);
setActiveId(conv.id);
void run(conv.id, [], user, mode);
},
[active, run, updateConv],
);
const stop = useCallback(() => {
if (token.current) token.current.cancelled = true;
}, []);
const regenerate = useCallback(
(assistantId: string) => {
if (!active) return;
const idx = active.messages.findIndex((m) => m.id === assistantId);
const user = active.messages[idx - 1];
if (idx < 1 || user.role !== 'user') return;
const failed = active.messages[idx].role === 'assistant' && (active.messages[idx] as AssistantMessage).status === 'error';
void run(active.id, active.messages.slice(0, idx - 1), user, 'auto', failed);
},
[active, run],
);
const editMessage = useCallback(
(userId: string, text: string) => {
if (!active) return;
const idx = active.messages.findIndex((m) => m.id === userId);
const original = active.messages[idx];
if (idx < 0 || original.role !== 'user') return;
void run(active.id, active.messages.slice(0, idx), { ...original, text }, 'auto');
},
[active, run],
);
const rate = useCallback(
(msgId: string, rating: 'up' | 'down') => {
if (!active) return;
patchMsg(active.id, msgId, (m) => ({ ...m, rating: m.rating === rating ? undefined : rating }));
},
[active, patchMsg],
);
const deleteChat = useCallback(
(id: string) => {
setConversations((list) => list.filter((c) => c.id !== id));
if (activeId === id) {
stop();
setActiveId(null);
}
},
[activeId, stop],
);
const renameChat = useCallback((id: string, title: string) => updateConv(id, (c) => ({ ...c, title: title.trim() || c.title })), [updateConv]);
const togglePin = useCallback((id: string) => updateConv(id, (c) => ({ ...c, pinned: !c.pinned })), [updateConv]);
return {
conversations, active, activeId, setActiveId,
model, setModelId, extended, setExtended,
streamingId, isStreaming: streamingId !== null,
send, stop, regenerate, editMessage, rate, deleteChat, renameChat, togglePin,
};
}
export type AssistantApi = ReturnType<typeof useAssistant>;components/niceui/pages/apps/assistant/data.ts
import type { Artifact, Conversation, Model, Source } from './types';
export const models: Model[] = [
{ id: 'opus', name: 'Nova 4 Opus', blurb: 'Deepest reasoning for complex work', speed: 0.75, thinks: true },
{ id: 'sonnet', name: 'Nova 4 Sonnet', blurb: 'Smart, fast, great for everyday tasks', speed: 1, thinks: true },
{ id: 'haiku', name: 'Nova 4 Haiku', blurb: 'Fastest answers, no extended thinking', speed: 1.8, thinks: false },
];
const px = (id: number) => `https://images.pexels.com/photos/${id}/pexels-photo-${id}.jpeg?auto=compress&cs=tinysrgb&h=650&w=940`;
export const imageSets = {
city: [px(36652168), px(12431908), px(37952462), px(37952458)],
nature: [px(6102918), px(2437293), px(11747524), px(6102922)],
};
export const videoPosters = [px(30750101), px(1421264), px(19704701)];
export const searchSources: Source[] = [
{ title: 'The state of AI interfaces in 2026', domain: 'research.niceui.dev', snippet: 'Agentic interfaces now account for most new product launches, with streaming and tool transparency as baseline expectations.' },
{ title: 'Designing trust into AI products', domain: 'uxcollective.io', snippet: 'Showing sources, intermediate steps and clear stop controls measurably increases user trust and retention.' },
{ title: 'Latency budgets for conversational UI', domain: 'web.dev', snippet: 'First token under 400ms feels instant; perceived speed matters more than total completion time.' },
{ title: 'Multimodal generation benchmarks', domain: 'arxiv.org', snippet: 'Image and short-form video generation quality improved roughly 3x on human preference evals year over year.' },
];
const pricingHtml = `<!doctype html>
<html><head><style>
*{box-sizing:border-box;margin:0;font-family:ui-sans-serif,system-ui}
body{min-height:100vh;display:grid;place-items:center;background:#0b0d12;color:#e7e9ee;padding:24px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;width:100%;max-width:680px}
.card{background:#13161d;border:1px solid #232733;border-radius:18px;padding:22px;transition:.3s}
.card:hover{transform:translateY(-4px);border-color:#38bdf8}
.pro{background:linear-gradient(160deg,#1a2a44,#13161d);border-color:#38bdf8}
h3{font-size:14px;color:#9aa3b2;font-weight:500}
.price{font-size:36px;font-weight:600;margin:10px 0 4px}
.price span{font-size:13px;color:#6b7383}
ul{list-style:none;padding:0;margin:16px 0;display:grid;gap:8px;font-size:13px;color:#b8bfcc}
li:before{content:"✓ ";color:#34d399}
button{width:100%;padding:11px;border-radius:10px;border:0;font-weight:600;cursor:pointer;background:#232733;color:#fff}
.pro button{background:#38bdf8}
</style></head><body>
<div class="grid">
<div class="card"><h3>Starter</h3><div class="price">$0<span>/mo</span></div><ul><li>3 projects</li><li>Community support</li></ul><button>Get started</button></div>
<div class="card pro"><h3>Pro</h3><div class="price">$24<span>/mo</span></div><ul><li>Unlimited projects</li><li>Priority support</li><li>Custom domains</li></ul><button>Upgrade</button></div>
<div class="card"><h3>Team</h3><div class="price">$79<span>/mo</span></div><ul><li>Everything in Pro</li><li>SSO & audit logs</li></ul><button>Contact sales</button></div>
</div>
</body></html>`;
const timerHtml = `<!doctype html>
<html><head><style>
*{box-sizing:border-box;margin:0;font-family:ui-sans-serif,system-ui}
body{min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 50% 30%,#13233b,#0b0d12);color:#fff}
.wrap{text-align:center}
svg{transform:rotate(-90deg)}
.time{position:absolute;inset:0;display:grid;place-items:center;font-size:44px;font-weight:600;font-variant-numeric:tabular-nums}
.ring{position:relative;width:220px;height:220px;margin:0 auto}
.row{display:flex;gap:10px;justify-content:center;margin-top:28px}
button{padding:10px 20px;border-radius:999px;border:1px solid #2b3445;background:#151b26;color:#fff;font-weight:600;cursor:pointer}
button.primary{background:#38bdf8;border-color:#38bdf8}
.label{margin-top:14px;color:#8a94a6;font-size:13px;letter-spacing:.15em;text-transform:uppercase}
</style></head><body>
<div class="wrap">
<div class="ring">
<svg width="220" height="220"><circle cx="110" cy="110" r="96" stroke="#1f2633" stroke-width="10" fill="none"/>
<circle id="arc" cx="110" cy="110" r="96" stroke="#38bdf8" stroke-width="10" fill="none" stroke-linecap="round" stroke-dasharray="603" stroke-dashoffset="0"/></svg>
<div class="time" id="t">25:00</div>
</div>
<div class="label" id="l">Focus</div>
<div class="row"><button class="primary" id="go">Start</button><button id="reset">Reset</button></div>
</div>
<script>
let total=25*60,left=total,timer=null;
const t=document.getElementById('t'),arc=document.getElementById('arc'),go=document.getElementById('go');
const draw=()=>{t.textContent=String(Math.floor(left/60)).padStart(2,'0')+':'+String(left%60).padStart(2,'0');arc.style.strokeDashoffset=603*(1-left/total)};
go.onclick=()=>{if(timer){clearInterval(timer);timer=null;go.textContent='Start';return}
go.textContent='Pause';timer=setInterval(()=>{left=Math.max(0,left-1);draw();if(!left){clearInterval(timer);timer=null;go.textContent='Start'}},1000)};
document.getElementById('reset').onclick=()=>{clearInterval(timer);timer=null;left=total;go.textContent='Start';draw()};
draw();
</script>
</body></html>`;
export const artifacts: Record<'pricing' | 'timer', Artifact> = {
pricing: { id: 'pricing', title: 'Pricing table', language: 'html', code: pricingHtml },
timer: { id: 'timer', title: 'Pomodoro timer', language: 'html', code: timerHtml },
};
export const suggestions = [
{ label: 'Build a pomodoro timer app', prompt: 'Build me a pomodoro timer app with a progress ring', tag: 'Code' },
{ label: 'Generate a neon city image', prompt: 'Generate an image of a rainy neon city street at night, cinematic', tag: 'Image' },
{ label: 'Create a drone video of a coast', prompt: 'Create a video: slow aerial drone shot over a turquoise coastline', tag: 'Video' },
{ label: 'Research AI interface trends', prompt: 'Search the latest research on AI interface design trends in 2026', tag: 'Search' },
{ label: 'Explain a concept simply', prompt: 'Explain how transformers work like I am a product designer', tag: 'Write' },
{ label: 'Simulate a failed request', prompt: 'Trigger an error so I can see the failure state', tag: 'Error' },
];
const hour = 3_600_000;
const day = 24 * hour;
function textChat(id: string, title: string, updatedAt: number, q: string, a: string): Conversation {
return {
id, title, updatedAt,
messages: [
{ id: `${id}u`, role: 'user', text: q, attachments: [] },
{ id: `${id}a`, role: 'assistant', status: 'done', model: 'Nova 4 Sonnet', parts: [{ type: 'text', text: a, shown: a.length }] },
],
};
}
export function seedConversations(now: number): Conversation[] {
return [
{
id: 'seed-1',
title: 'Misty lake concept art',
updatedAt: now - 2 * hour,
pinned: true,
messages: [
{ id: 's1u', role: 'user', text: 'Generate a misty mountain lake at sunrise, calm and painterly', attachments: [] },
{
id: 's1a', role: 'assistant', status: 'done', model: 'Nova 4 Sonnet',
parts: [
{ type: 'text', text: 'Here are four takes on a calm, misty sunrise over an alpine lake.', shown: 999 },
{ type: 'image', prompt: 'Misty mountain lake at sunrise, painterly, soft light', urls: imageSets.nature, progress: 100, aspect: 'square' },
],
},
],
},
{
id: 'seed-2',
title: 'Pricing table component',
updatedAt: now - 1.2 * day,
messages: [
{ id: 's2u', role: 'user', text: 'Make me a dark pricing table with three tiers', attachments: [] },
{
id: 's2a', role: 'assistant', status: 'done', model: 'Nova 4 Opus',
parts: [
{ type: 'text', text: 'Here is a responsive three-tier pricing table. The **Pro** tier is highlighted and every card lifts on hover.', shown: 999 },
{ type: 'artifact', artifact: artifacts.pricing, done: true },
],
},
],
},
textChat('seed-3', 'Q3 board update outline', now - 3 * day, 'Outline a Q3 board update for a seed-stage startup',
'## Q3 board update\n1. **Headline metrics:** ARR, burn, runway and net retention.\n2. **Wins:** two enterprise logos and the self-serve launch.\n3. **Misses:** hiring is six weeks behind plan.\n4. **Asks:** intros to two design partners in fintech.\nKeep it to one page and send it 48 hours ahead.'),
textChat('seed-4', 'Onboarding email sequence', now - 5 * day, 'Draft a 3-email onboarding sequence',
'## Day 0: Welcome\nA warm hello and **one** action: create your first project.\n## Day 2: Quick win\nShow a 60-second template that delivers value immediately.\n## Day 5: Social proof\nA short customer story and an invite to a live onboarding call.'),
textChat('seed-5', 'Trip plan: Lisbon in 4 days', now - 12 * day, 'Plan 4 days in Lisbon for a design lover',
'## Day 1: Alfama & Graça\n- Miradouro da Graça at sunset\n## Day 2: Belém\n- MAAT and the Tile Museum\n## Day 3: Sintra day trip\n- Pena Palace, early to beat the crowds\n## Day 4: LX Factory\n- Bookshops, studios and a long lunch'),
];
}components/niceui/pages/apps/assistant/types.ts
export type Source = { title: string; domain: string; snippet: string };
export type Attachment = { id: string; name: string; size: number; kind: 'image' | 'file'; url?: string };
export type Artifact = { id: string; title: string; language: string; code: string };
export type Part =
| { type: 'thinking'; text: string; shown: number; seconds: number; done: boolean }
| { type: 'tool'; name: string; label: string; detail: string; done: boolean; sources?: Source[] }
| { type: 'text'; text: string; shown: number }
| { type: 'artifact'; artifact: Artifact; done: boolean }
| { type: 'image'; prompt: string; urls: string[]; progress: number; aspect: 'square' | 'wide' }
| { type: 'video'; prompt: string; poster: string; progress: number; seconds: number }
| { type: 'error'; message: string };
export type Message =
| { id: string; role: 'user'; text: string; attachments: Attachment[] }
| { id: string; role: 'assistant'; parts: Part[]; status: 'streaming' | 'done' | 'stopped' | 'error'; model: string; rating?: 'up' | 'down' };
export type AssistantMessage = Extract<Message, { role: 'assistant' }>;
export type Conversation = { id: string; title: string; updatedAt: number; pinned?: boolean; messages: Message[] };
export type Mode = 'auto' | 'search' | 'image' | 'video' | 'code';
export type ModelId = 'opus' | 'sonnet' | 'haiku';
export type Model = { id: ModelId; name: string; blurb: string; speed: number; thinks: boolean };components/niceui/pages/apps/assistant/scenarios.ts
import { artifacts, imageSets, searchSources, videoPosters } from './data';
import type { Artifact, Attachment, Mode, Source } from './types';
export type ScenarioCtx = {
think: (text: string, seconds: number) => Promise<void>;
tool: (name: string, label: string, detail: string, ms: number, sources?: Source[]) => Promise<void>;
stream: (text: string) => Promise<void>;
artifact: (a: Artifact) => Promise<void>;
image: (prompt: string, urls: string[], aspect: 'square' | 'wide') => Promise<void>;
video: (prompt: string, poster: string, seconds: number) => Promise<void>;
fail: (message: string) => Promise<void>;
};
export type ScenarioInput = { text: string; attachments: Attachment[]; thinking: boolean; retry: boolean };
type ScenarioId = 'text' | 'search' | 'code' | 'image' | 'video' | 'files' | 'error';
export function pickScenario(text: string, mode: Mode, hasFiles: boolean): ScenarioId {
if (mode !== 'auto') return mode;
const t = text.toLowerCase();
if (/\b(error|fail|break|crash)\b/.test(t)) return 'error';
if (hasFiles) return 'files';
if (/\b(video|clip|animate|film|footage|drone)\b/.test(t)) return 'video';
if (/\b(image|picture|photo|draw|illustrat|render|art|logo|poster)\w*/.test(t)) return 'image';
if (/\b(code|component|html|app|build|function|script|timer|pricing|website|ui)\b/.test(t)) return 'code';
if (/\b(search|latest|news|research|today|current|trend|2026|sources?)\b/.test(t)) return 'search';
return 'text';
}
const quoted = (s: string) => (s.length > 64 ? `${s.slice(0, 64).trim()}…` : s);
const scripts: Record<ScenarioId, (ctx: ScenarioCtx, input: ScenarioInput) => Promise<void>> = {
async text(ctx, { text, thinking, retry }) {
if (thinking) {
await ctx.think(
`The user wants a clear explanation of "${quoted(text)}". I should open with a one-line intuition, then build up in layers, use an analogy that fits their background, and finish with something they can act on. Keep it skimmable with short sections.`,
3,
);
}
await ctx.stream(
`${retry ? 'Connection restored, here is the full answer.\n' : ''}## The short version\nThink of it as a **very attentive reader**. Every word looks at every other word and decides how much each one matters for understanding it.\n## How it works\n1. Text is split into small pieces called **tokens**.\n2. Each token asks the others "how relevant are you to me?", a step called **attention**.\n3. Those relevance scores blend information across the whole sentence, layer after layer.\n4. The final layer predicts the most likely next token, and the loop repeats.\n## Why designers should care\n- Context is everything: the clearer your prompt, the better the attention has to work with.\n- Streaming exists because the model genuinely produces one token at a time.\n- Long inputs cost more because every token attends to every other token.\nWant me to turn this into a one-page visual explainer?`,
);
},
async search(ctx, { text, thinking }) {
if (thinking) await ctx.think(`I need recent, credible sources for "${quoted(text)}". I'll search broadly, then narrow to research and practitioner writing, and synthesize the key trends with citations.`, 2);
await ctx.tool('web_search', 'Searching the web', quoted(text), 1600, searchSources.slice(0, 2));
await ctx.tool('web_search', 'Reading 4 sources', 'AI interface design research 2026', 1400, searchSources);
await ctx.stream(
`## What changed this year\n- **Agents became the default.** Most new launches are agentic, so showing intermediate steps is now table stakes [1].\n- **Transparency builds trust.** Products that expose sources and offer a clear stop control see higher retention [2].\n- **Perceived speed wins.** A first token under 400ms feels instant, even when the full answer takes longer [3].\n- **Multimodal is mainstream.** Image and short video quality roughly tripled on preference evals [4].\n## What it means for your product\nDesign for visible progress: thinking states, tool cards and citations, exactly like this answer.`,
);
},
async code(ctx, { text, thinking }) {
const a = /timer|pomodoro|clock|countdown/i.test(text) ? artifacts.timer : artifacts.pricing;
if (thinking) await ctx.think(`Plan: a single self-contained HTML file so it previews instantly. Dark theme, accessible contrast, clear states. For "${quoted(text)}" I'll keep the logic small and readable and avoid external dependencies.`, 3);
await ctx.stream(`I built a self-contained **${a.title.toLowerCase()}**. It runs as a single file with no dependencies, so you can preview it live on the right or copy the code straight into your project.`);
await ctx.artifact(a);
await ctx.stream(`\n## What's included\n- Fully responsive layout with hover and focus states\n- Accessible color contrast on a dark surface\n- Zero external dependencies\nAsk me to change colors, add states or convert it to React.`);
},
async image(ctx, { text, thinking }) {
const urls = /city|neon|street|urban|night/i.test(text) ? imageSets.city : imageSets.nature;
if (thinking) await ctx.think(`Translating "${quoted(text)}" into a detailed visual prompt: subject, lighting, lens, mood and composition. I'll generate four variations with slightly different framing.`, 2);
await ctx.stream('Generating four variations for you now.');
await ctx.image(text, urls, 'square');
await ctx.stream('\nHover any image to download, upscale or create variations. Click to open it full size.');
},
async video(ctx, { text, thinking }) {
if (thinking) await ctx.think(`Storyboarding "${quoted(text)}": one continuous 6-second shot, slow forward dolly, golden-hour grade, 24fps for a cinematic feel.`, 2);
await ctx.tool('storyboard', 'Writing storyboard', '1 shot · 6s · 24fps · 16:9', 1200);
await ctx.stream('Rendering your clip. Video takes a little longer than images.');
await ctx.video(text, videoPosters[Math.floor(Math.random() * videoPosters.length)], 6);
await ctx.stream('\nYour 6-second clip is ready. Press play to preview, or ask me to extend it or change the camera move.');
},
async files(ctx, { attachments, thinking }) {
const names = attachments.map((a) => a.name).join(', ');
await ctx.tool('read_files', `Reading ${attachments.length} file${attachments.length > 1 ? 's' : ''}`, quoted(names), 1500);
if (thinking) await ctx.think('Scanning structure first, then pulling out the key facts, numbers and open questions. I will summarize, then suggest next steps.', 2);
await ctx.stream(
`## Summary of ${attachments.length > 1 ? 'your files' : `**${attachments[0].name}**`}\n- The document is well structured and easy to follow.\n- Three themes stand out: **timelines**, **budget** and **ownership**.\n- Two sections reference figures that are not sourced, worth double-checking.\n## Suggested next steps\n1. Add owners to every open item.\n2. Move the budget table up front.\n3. Cut the appendix by half.\nWant me to rewrite any section?`,
);
},
async error(ctx, input) {
if (input.retry) return scripts.text(ctx, input);
await ctx.stream('Let me look into that');
await ctx.fail('The connection was interrupted while generating this response. Nothing was lost, and you can retry.');
},
};
export function runScenario(ctx: ScenarioCtx, input: ScenarioInput, mode: Mode) {
const id = pickScenario(input.text, mode, input.attachments.length > 0);
return scripts[id](ctx, input);
}components/niceui/pages/apps/assistant/Sidebar.tsx
import { useMemo, useRef, useState } from 'react';
import { MessageSquare, MoreHorizontal, PanelLeftClose, Pencil, Pin, PinOff, Plus, Search, Sparkle, Trash2, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useClickOutside } from '@/components/niceui/ui/motion/hooks';
import type { Conversation } from './types';
type Props = {
conversations: Conversation[];
activeId: string | null;
onSelect: (id: string | null) => void;
onRename: (id: string, title: string) => void;
onPin: (id: string) => void;
onDelete: (id: string) => void;
onClose: () => void;
};
const DAY = 86_400_000;
function group(list: Conversation[]) {
const now = Date.now();
const sorted = [...list].sort((a, b) => b.updatedAt - a.updatedAt);
const groups: { label: string; items: Conversation[] }[] = [
{ label: 'Pinned', items: sorted.filter((c) => c.pinned) },
{ label: 'Today', items: sorted.filter((c) => !c.pinned && now - c.updatedAt < DAY) },
{ label: 'Previous 7 days', items: sorted.filter((c) => !c.pinned && now - c.updatedAt >= DAY && now - c.updatedAt < 7 * DAY) },
{ label: 'Older', items: sorted.filter((c) => !c.pinned && now - c.updatedAt >= 7 * DAY) },
];
return groups.filter((g) => g.items.length > 0);
}
export function Sidebar({ conversations, activeId, onSelect, onRename, onPin, onDelete, onClose }: Props) {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? conversations.filter((c) => c.title.toLowerCase().includes(q)) : conversations;
}, [conversations, query]);
const groups = group(filtered);
return (
<aside className="flex h-full w-[272px] flex-col border-r border-white/[0.06] bg-ink-950">
<div className="flex items-center justify-between px-3 pb-2 pt-3">
<div className="flex items-center gap-2 px-1">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-brand-400 to-sky-600 text-white"><Sparkle className="h-3.5 w-3.5 fill-current" /></span>
<span className="text-sm font-semibold tracking-tight text-white">Nova</span>
</div>
<button onClick={onClose} aria-label="Close sidebar" className="grid h-8 w-8 place-items-center rounded-lg text-ink-400 transition-colors hover:bg-white/5 hover:text-white">
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
<div className="space-y-2 px-3">
<button onClick={() => onSelect(null)} className="group flex w-full items-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2 text-sm font-medium text-ink-100 transition-all hover:border-white/15 hover:bg-white/[0.06]">
<Plus className="h-4 w-4 transition-transform duration-300 group-hover:rotate-90" /> New chat
<kbd className="ml-auto rounded border border-white/10 px-1.5 font-mono text-[10px] text-ink-500">⌘K</kbd>
</button>
<label className="flex items-center gap-2 rounded-xl bg-white/[0.03] px-3 py-2 text-ink-500 focus-within:ring-1 focus-within:ring-white/15">
<Search className="h-3.5 w-3.5" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search chats" className="w-full bg-transparent text-sm text-ink-100 outline-none placeholder:text-ink-500" />
{query && <button onClick={() => setQuery('')} aria-label="Clear search"><X className="h-3.5 w-3.5 hover:text-white" /></button>}
</label>
</div>
<nav className="mt-3 flex-1 space-y-4 overflow-y-auto px-2 pb-4">
{groups.length === 0 && <p className="px-3 py-6 text-center text-xs text-ink-500">{query ? 'No chats match your search' : 'No conversations yet'}</p>}
{groups.map((g) => (
<div key={g.label}>
<div className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-ink-600">{g.label}</div>
{g.items.map((c) => (
<ChatRow key={c.id} c={c} active={c.id === activeId} onSelect={() => onSelect(c.id)} onRename={(t) => onRename(c.id, t)} onPin={() => onPin(c.id)} onDelete={() => onDelete(c.id)} />
))}
</div>
))}
</nav>
<div className="border-t border-white/[0.06] p-3">
<div className="flex items-center gap-3 rounded-xl p-2 transition-colors hover:bg-white/5">
<span className="grid h-8 w-8 place-items-center rounded-full bg-gradient-to-br from-amber-300 to-rose-400 text-xs font-semibold text-ink-950">AL</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-ink-100">Alex Lindqvist</div>
<div className="text-[11px] text-ink-500">Pro plan</div>
</div>
</div>
</div>
</aside>
);
}
function ChatRow({ c, active, onSelect, onRename, onPin, onDelete }: { c: Conversation; active: boolean; onSelect: () => void; onRename: (t: string) => void; onPin: () => void; onDelete: () => void }) {
const [menu, setMenu] = useState(false);
const [editing, setEditing] = useState(false);
const [confirm, setConfirm] = useState(false);
const [draft, setDraft] = useState(c.title);
const ref = useRef<HTMLDivElement>(null);
useClickOutside([ref], menu, () => { setMenu(false); setConfirm(false); });
if (editing) {
return (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => { onRename(draft); setEditing(false); }}
onKeyDown={(e) => {
if (e.key === 'Enter') { onRename(draft); setEditing(false); }
if (e.key === 'Escape') { setDraft(c.title); setEditing(false); }
}}
className="w-full rounded-lg bg-ink-800 px-3 py-2 text-sm text-white outline-none ring-1 ring-brand-500/50"
/>
);
}
return (
<div ref={ref} className="group relative">
<button
onClick={onSelect}
className={cn('flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors', active ? 'bg-white/[0.08] text-white' : 'text-ink-300 hover:bg-white/[0.04] hover:text-ink-100')}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0 opacity-50" />
<span className="truncate pr-6">{c.title}</span>
</button>
<button
onClick={() => setMenu((m) => !m)}
aria-label="Chat options"
className={cn('absolute right-1 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-md text-ink-400 transition-opacity hover:bg-white/10 hover:text-white', menu ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus:opacity-100')}
>
<MoreHorizontal className="h-4 w-4" />
</button>
{menu && (
<div className="absolute right-0 top-9 z-30 w-44 rounded-xl border border-white/10 bg-ink-900 p-1 shadow-pop animate-scale-in">
<MenuItem icon={Pencil} label="Rename" onClick={() => { setMenu(false); setDraft(c.title); setEditing(true); }} />
<MenuItem icon={c.pinned ? PinOff : Pin} label={c.pinned ? 'Unpin' : 'Pin'} onClick={() => { setMenu(false); onPin(); }} />
{confirm ? (
<MenuItem icon={Trash2} label="Confirm delete" danger onClick={() => { setMenu(false); onDelete(); }} />
) : (
<MenuItem icon={Trash2} label="Delete" danger onClick={() => setConfirm(true)} />
)}
</div>
)}
</div>
);
}
function MenuItem({ icon: Icon, label, onClick, danger }: { icon: typeof Pin; label: string; onClick: () => void; danger?: boolean }) {
return (
<button onClick={onClick} className={cn('flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm transition-colors', danger ? 'text-rose-400 hover:bg-rose-500/10' : 'text-ink-200 hover:bg-white/5')}>
<Icon className="h-3.5 w-3.5" /> {label}
</button>
);
}components/niceui/ui/motion/hooks.ts
import { useEffect, useState, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
/**
* Keeps an element mounted while it animates out.
* `mounted` controls rendering, `visible` drives the enter/exit classes.
*/
export function usePresence(open: boolean, exitMs = 220) {
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(open);
useEffect(() => {
if (open) {
setMounted(true);
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => setVisible(true));
});
return () => {
cancelAnimationFrame(outer);
cancelAnimationFrame(inner);
};
}
setVisible(false);
const t = window.setTimeout(() => setMounted(false), exitMs);
return () => window.clearTimeout(t);
}, [open, exitMs]);
return { mounted, visible };
}
/** Calls `handler` when Escape is pressed while `active`. */
export function useEscape(active: boolean, handler: () => void) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handler();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, handler]);
}
/** Calls `handler` on pointerdown outside every provided ref while `active`. */
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
active: boolean,
handler: () => void,
) {
useEffect(() => {
if (!active) return;
const onDown = (e: PointerEvent) => {
const target = e.target as Node;
if (refs.some((r) => r.current?.contains(target))) return;
handler();
};
document.addEventListener('pointerdown', onDown);
return () => document.removeEventListener('pointerdown', onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, handler]);
}
/**
* Renders overlays into document.body so `position: fixed` is relative to the
* viewport even when an ancestor has a transform (e.g. entrance animations).
*/
export function Portal({ children }: { children: ReactNode }) {
if (typeof document === 'undefined') return null;
return createPortal(children, document.body);
}components/niceui/pages/apps/assistant/Composer.tsx
import { useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { ArrowUp, Brain, Check, ChevronDown, Clapperboard, Code2, Globe, ImageIcon, Paperclip, Square } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useClickOutside } from '@/components/niceui/ui/motion/hooks';
import { models } from './data';
import { AttachmentChip } from './Message';
import type { Attachment, Mode, Model, ModelId } from './types';
const modes: { id: Exclude<Mode, 'auto'>; label: string; icon: typeof Globe; placeholder: string }[] = [
{ id: 'search', label: 'Search', icon: Globe, placeholder: 'Search the web for anything…' },
{ id: 'image', label: 'Image', icon: ImageIcon, placeholder: 'Describe the image you want…' },
{ id: 'video', label: 'Video', icon: Clapperboard, placeholder: 'Describe a shot, camera move and mood…' },
{ id: 'code', label: 'Code', icon: Code2, placeholder: 'Describe the component or app to build…' },
];
type Props = {
onSend: (text: string, attachments: Attachment[], mode: Mode) => void;
onStop: () => void;
isStreaming: boolean;
model: Model;
onModel: (id: ModelId) => void;
extended: boolean;
onExtended: (v: boolean) => void;
autoFocus?: boolean;
};
export function Composer({ onSend, onStop, isStreaming, model, onModel, extended, onExtended, autoFocus }: Props) {
const [text, setText] = useState('');
const [files, setFiles] = useState<Attachment[]>([]);
const [mode, setMode] = useState<Mode>('auto');
const [picker, setPicker] = useState(false);
const [dragging, setDragging] = useState(false);
const ta = useRef<HTMLTextAreaElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
useClickOutside([pickerRef], picker, () => setPicker(false));
useEffect(() => {
const el = ta.current;
if (!el) return;
el.style.height = '0px';
el.style.height = `${Math.min(220, el.scrollHeight)}px`;
}, [text]);
useEffect(() => {
if (autoFocus) ta.current?.focus();
}, [autoFocus]);
const addFiles = (list: FileList | null) => {
if (!list) return;
const next = Array.from(list).slice(0, 6).map<Attachment>((f) => ({
id: crypto.randomUUID(),
name: f.name,
size: f.size,
kind: f.type.startsWith('image/') ? 'image' : 'file',
url: f.type.startsWith('image/') ? URL.createObjectURL(f) : undefined,
}));
setFiles((cur) => [...cur, ...next].slice(0, 6));
};
const canSend = (text.trim().length > 0 || files.length > 0) && !isStreaming;
const submit = () => {
if (!canSend) return;
onSend(text.trim() || 'Summarize these files', files, mode);
setText('');
setFiles([]);
setMode('auto');
};
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragging(false);
addFiles(e.dataTransfer.files);
};
const activeMode = modes.find((m) => m.id === mode);
return (
<div className="w-full">
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={cn(
'relative rounded-[22px] border bg-ink-900/90 shadow-[0_20px_60px_-20px_rgba(0,0,0,0.6)] backdrop-blur-xl transition-all duration-300',
dragging ? 'border-brand-400 ring-4 ring-brand-500/15' : 'border-white/[0.08] focus-within:border-white/20',
)}
>
{dragging && <div className="absolute inset-0 z-10 grid place-items-center rounded-[22px] bg-ink-950/80 text-sm font-medium text-brand-200">Drop files to attach</div>}
{files.length > 0 && (
<div className="flex flex-wrap gap-2 px-3 pt-3">
{files.map((f) => <AttachmentChip key={f.id} a={f} onRemove={() => setFiles((cur) => cur.filter((x) => x.id !== f.id))} />)}
</div>
)}
<textarea
ref={ta}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKey}
rows={1}
aria-label="Message"
placeholder={activeMode?.placeholder ?? 'Ask Nova anything, or drop a file…'}
className="block max-h-[220px] w-full resize-none bg-transparent px-4 pb-2 pt-4 text-[15px] leading-relaxed text-ink-50 outline-none placeholder:text-ink-500"
/>
<div className="flex items-center gap-1 px-2 pb-2">
<input ref={fileInput} type="file" multiple hidden onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }} />
<button onClick={() => fileInput.current?.click()} aria-label="Attach files" title="Attach files" className="grid h-9 w-9 place-items-center rounded-xl text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-100">
<Paperclip className="h-4 w-4" />
</button>
<div className="flex gap-1 overflow-x-auto">
{modes.map((m) => (
<button
key={m.id}
onClick={() => setMode(mode === m.id ? 'auto' : m.id)}
className={cn(
'flex h-9 shrink-0 items-center gap-1.5 rounded-xl px-2.5 text-xs font-medium transition-all duration-200',
mode === m.id ? 'bg-brand-500/15 text-brand-200 ring-1 ring-brand-500/40' : 'text-ink-400 hover:bg-white/5 hover:text-ink-100',
)}
>
<m.icon className="h-3.5 w-3.5" /><span className="hidden sm:inline">{m.label}</span>
</button>
))}
</div>
<div ref={pickerRef} className="relative ml-auto">
<button onClick={() => setPicker((p) => !p)} className="flex h-9 items-center gap-1.5 rounded-xl px-2.5 text-xs font-medium text-ink-300 transition-colors hover:bg-white/5 hover:text-white">
<span className="hidden sm:inline">{model.name}</span><span className="sm:hidden">{model.name.split(' ').pop()}</span>
{model.thinks && extended && <Brain className="h-3.5 w-3.5 text-brand-400" />}
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', picker && 'rotate-180')} />
</button>
{picker && (
<div className="absolute bottom-11 right-0 z-30 w-72 origin-bottom-right rounded-2xl border border-white/10 bg-ink-900 p-1.5 shadow-pop animate-scale-in">
{models.map((m) => (
<button key={m.id} onClick={() => { onModel(m.id); setPicker(false); }} className="flex w-full items-start gap-3 rounded-xl p-2.5 text-left transition-colors hover:bg-white/5">
<div className="flex-1">
<div className="text-sm font-medium text-ink-50">{m.name}</div>
<div className="text-xs text-ink-400">{m.blurb}</div>
</div>
{model.id === m.id && <Check className="mt-0.5 h-4 w-4 text-brand-400" />}
</button>
))}
<div className="mx-1 my-1.5 h-px bg-white/5" />
<button
disabled={!model.thinks}
onClick={() => onExtended(!extended)}
className="flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-colors hover:bg-white/5 disabled:opacity-40"
>
<Brain className="h-4 w-4 text-ink-300" />
<div className="flex-1">
<div className="text-sm text-ink-50">Extended thinking</div>
<div className="text-xs text-ink-400">{model.thinks ? 'Reason step by step before answering' : 'Not available on Haiku'}</div>
</div>
<span className={cn('relative h-5 w-9 rounded-full transition-colors', extended && model.thinks ? 'bg-brand-500' : 'bg-ink-700')}>
<span className={cn('absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-300 ease-spring', extended && model.thinks ? 'translate-x-4' : 'translate-x-0.5')} />
</span>
</button>
</div>
)}
</div>
{isStreaming ? (
<button onClick={onStop} aria-label="Stop generating" className="grid h-9 w-9 place-items-center rounded-xl bg-white text-ink-950 transition-transform hover:scale-105 active:scale-95">
<Square className="h-3.5 w-3.5 fill-current" />
</button>
) : (
<button onClick={submit} disabled={!canSend} aria-label="Send message" className="grid h-9 w-9 place-items-center rounded-xl bg-brand-500 text-white transition-all hover:bg-brand-400 active:scale-95 disabled:bg-ink-700 disabled:text-ink-500">
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
<p className="mt-2 text-center text-[11px] text-ink-600">Nova can make mistakes. All responses in this template are simulated.</p>
</div>
);
}components/niceui/pages/apps/assistant/Message.tsx
import { useState, type ReactNode } from 'react';
import { Check, Copy, FileText, Pencil, RefreshCw, Sparkle, Square, ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/cn';
import { ArtifactCard, ErrorPart, ImagePart, TextPart, ThinkingPart, ToolPart, VideoPart } from './parts';
import type { Artifact, AssistantMessage, Attachment, Message } from './types';
function useCopy() {
const [copied, setCopied] = useState(false);
const copy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
setCopied(false);
}
};
return { copied, copy };
}
const fmtSize = (b: number) => (b > 1e6 ? `${(b / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 1e3))} KB`);
export function AttachmentChip({ a, onRemove }: { a: Attachment; onRemove?: () => void }) {
return (
<div className="group relative flex items-center gap-2 rounded-xl border border-white/[0.08] bg-ink-900 p-1.5 pr-3 animate-scale-in">
{a.kind === 'image' && a.url ? (
<img src={a.url} alt={a.name} className="h-9 w-9 rounded-lg object-cover" />
) : (
<span className="grid h-9 w-9 place-items-center rounded-lg bg-brand-500/10 text-brand-300"><FileText className="h-4 w-4" /></span>
)}
<div className="min-w-0">
<div className="max-w-[140px] truncate text-xs font-medium text-ink-100">{a.name}</div>
<div className="text-[10px] text-ink-500">{fmtSize(a.size)}</div>
</div>
{onRemove && (
<button onClick={onRemove} aria-label={`Remove ${a.name}`} className="absolute -right-1.5 -top-1.5 grid h-5 w-5 place-items-center rounded-full bg-ink-700 text-[10px] text-white opacity-0 ring-2 ring-ink-950 transition-opacity group-hover:opacity-100 focus:opacity-100">×</button>
)}
</div>
);
}
export function UserMessage({ msg, canEdit, onEdit }: { msg: Extract<Message, { role: 'user' }>; canEdit: boolean; onEdit: (text: string) => void }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(msg.text);
const { copied, copy } = useCopy();
if (editing) {
return (
<div className="ml-auto w-full max-w-[85%] rounded-2xl border border-brand-500/40 bg-ink-900 p-3 animate-fade-in">
<textarea autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} rows={3} className="w-full resize-none bg-transparent text-[15px] text-ink-50 outline-none" />
<div className="mt-2 flex justify-end gap-2">
<button onClick={() => { setEditing(false); setDraft(msg.text); }} className="rounded-lg px-3 py-1.5 text-xs text-ink-300 hover:bg-white/5">Cancel</button>
<button
disabled={!draft.trim()}
onClick={() => { setEditing(false); onEdit(draft.trim()); }}
className="rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-ink-950 hover:bg-ink-100 disabled:opacity-40"
>
Save & send
</button>
</div>
</div>
);
}
return (
<div className="group flex flex-col items-end gap-2 animate-fade-up">
{msg.attachments.length > 0 && (
<div className="flex flex-wrap justify-end gap-2">{msg.attachments.map((a) => <AttachmentChip key={a.id} a={a} />)}</div>
)}
<div className="max-w-[85%] whitespace-pre-wrap rounded-2xl rounded-br-md bg-ink-800 px-4 py-2.5 text-[15px] leading-relaxed text-ink-50">{msg.text}</div>
<div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<IconAction label={copied ? 'Copied' : 'Copy'} onClick={() => copy(msg.text)}>{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}</IconAction>
{canEdit && <IconAction label="Edit" onClick={() => setEditing(true)}><Pencil className="h-3.5 w-3.5" /></IconAction>}
</div>
</div>
);
}
function IconAction({ label, onClick, active, children }: { label: string; onClick: () => void; active?: boolean; children: ReactNode }) {
return (
<button onClick={onClick} title={label} aria-label={label} className={cn('grid h-7 w-7 place-items-center rounded-lg transition-colors', active ? 'bg-white/10 text-white' : 'text-ink-500 hover:bg-white/5 hover:text-ink-100')}>
{children}
</button>
);
}
export function AssistantLogo({ spinning }: { spinning?: boolean }) {
return (
<span className="relative grid h-8 w-8 shrink-0 place-items-center rounded-full bg-gradient-to-br from-brand-400 to-sky-600 text-white shadow-[0_0_20px_-4px_rgba(56,189,248,0.6)]">
<Sparkle className={cn('h-4 w-4 fill-current', spinning && 'animate-spin-slow')} />
</span>
);
}
type AssistantProps = {
msg: AssistantMessage;
isLast: boolean;
openArtifact: string | null;
onOpenArtifact: (a: Artifact) => void;
onRegenerate: () => void;
onRate: (r: 'up' | 'down') => void;
onPrompt: (text: string) => void;
};
export function AssistantMessageView({ msg, isLast, openArtifact, onOpenArtifact, onRegenerate, onRate, onPrompt }: AssistantProps) {
const { copied, copy } = useCopy();
const streaming = msg.status === 'streaming';
const lastText = msg.parts.map((p) => p.type).lastIndexOf('text');
const plain = msg.parts.flatMap((p) => (p.type === 'text' ? [p.text] : [])).join('\n\n');
return (
<div className="flex gap-4 animate-fade-up">
<AssistantLogo spinning={streaming} />
<div className="min-w-0 flex-1 space-y-3 pt-1">
{msg.parts.length === 0 && streaming && (
<div className="flex h-6 items-center gap-1">
{[0, 1, 2].map((i) => <span key={i} className="h-1.5 w-1.5 animate-bounce rounded-full bg-ink-400" style={{ animationDelay: `${i * 120}ms` }} />)}
</div>
)}
{msg.parts.map((p, i) => {
switch (p.type) {
case 'thinking': return <ThinkingPart key={i} part={p} />;
case 'tool': return <ToolPart key={i} part={p} />;
case 'text': return <TextPart key={i} part={p} caret={streaming && i === lastText} />;
case 'artifact': return <ArtifactCard key={i} part={p} active={openArtifact === p.artifact.id} onOpen={onOpenArtifact} />;
case 'image': return <ImagePart key={i} part={p} onPrompt={onPrompt} />;
case 'video': return <VideoPart key={i} part={p} />;
case 'error': return <ErrorPart key={i} part={p} onRetry={onRegenerate} />;
}
})}
{msg.status === 'stopped' && (
<div className="inline-flex items-center gap-1.5 rounded-lg bg-white/5 px-2.5 py-1 text-xs text-ink-400"><Square className="h-3 w-3 fill-current" /> Generation stopped</div>
)}
{!streaming && (
<div className={cn('flex items-center gap-0.5 transition-opacity', isLast ? 'opacity-100' : 'opacity-0 hover:opacity-100')}>
<IconAction label={copied ? 'Copied' : 'Copy'} onClick={() => copy(plain)}>{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}</IconAction>
<IconAction label="Good response" active={msg.rating === 'up'} onClick={() => onRate('up')}><ThumbsUp className={cn('h-3.5 w-3.5', msg.rating === 'up' && 'fill-current')} /></IconAction>
<IconAction label="Bad response" active={msg.rating === 'down'} onClick={() => onRate('down')}><ThumbsDown className={cn('h-3.5 w-3.5', msg.rating === 'down' && 'fill-current')} /></IconAction>
{isLast && <IconAction label="Regenerate" onClick={onRegenerate}><RefreshCw className="h-3.5 w-3.5" /></IconAction>}
<span className="ml-2 text-[11px] text-ink-600">{msg.model}</span>
{msg.rating && <span className="ml-2 text-[11px] text-ink-500 animate-fade-in">Thanks for the feedback</span>}
</div>
)}
</div>
</div>
);
}components/niceui/pages/apps/assistant/parts.tsx
import { useEffect, useRef, useState } from 'react';
import {
AlertTriangle, Brain, Check, ChevronDown, Clapperboard, Code2, Download, FileText, Globe, Loader2, Maximize2,
Pause, Play, RotateCcw, Shuffle, Volume2, Wand2, X,
} from 'lucide-react';
import { cn } from '@/lib/cn';
import { Markdown } from '@/components/niceui/ai/stream';
import { Portal, useEscape } from '@/components/niceui/ui/motion/hooks';
import type { Artifact, Part } from './types';
type P<T extends Part['type']> = Extract<Part, { type: T }>;
export function ThinkingPart({ part }: { part: P<'thinking'> }) {
const [open, setOpen] = useState(true);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (part.done) {
setOpen(false);
return;
}
const start = Date.now();
const id = setInterval(() => setElapsed((Date.now() - start) / 1000), 100);
return () => clearInterval(id);
}, [part.done]);
return (
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02]">
<button onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-2 px-3.5 py-2.5 text-left text-[13px]">
<Brain className={cn('h-4 w-4', part.done ? 'text-ink-400' : 'text-brand-400')} />
{part.done ? (
<span className="text-ink-300">Thought for {Math.max(1, Math.round(elapsed || part.seconds))}s</span>
) : (
<span className="shimmer-text font-medium">Thinking… {elapsed.toFixed(1)}s</span>
)}
<ChevronDown className={cn('ml-auto h-4 w-4 text-ink-500 transition-transform duration-300', open && 'rotate-180')} />
</button>
<div className={cn('grid transition-all duration-500 ease-smooth', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className="overflow-hidden">
<p className="border-l-2 border-white/10 mx-3.5 mb-3 pl-3 text-[13px] italic leading-relaxed text-ink-400">{part.text.slice(0, part.shown)}</p>
</div>
</div>
</div>
);
}
const toolIcons: Record<string, typeof Globe> = { web_search: Globe, read_files: FileText, storyboard: Clapperboard };
export function ToolPart({ part }: { part: P<'tool'> }) {
const Icon = toolIcons[part.name] ?? Wand2;
return (
<div className="animate-fade-up">
<div className="flex items-center gap-3 rounded-xl border border-white/[0.06] bg-white/[0.02] px-3.5 py-2.5">
<span className={cn('grid h-7 w-7 place-items-center rounded-lg', part.done ? 'bg-emerald-500/10 text-emerald-400' : 'bg-brand-500/10 text-brand-400')}>
{part.done ? <Check className="h-3.5 w-3.5" /> : <Icon className="h-3.5 w-3.5" />}
</span>
<div className="min-w-0 flex-1">
<div className={cn('text-[13px] font-medium', part.done ? 'text-ink-200' : 'shimmer-text')}>{part.label}</div>
<div className="truncate font-mono text-[11px] text-ink-500">{part.detail}</div>
</div>
{!part.done && <Loader2 className="h-4 w-4 animate-spin text-ink-500" />}
</div>
{part.sources && (
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{part.sources.map((s, i) => (
<div key={s.title} className="group rounded-xl border border-white/[0.06] bg-ink-900/60 p-3 transition-colors hover:border-white/15 animate-fade-up" style={{ animationDelay: `${i * 70}ms`, animationFillMode: 'both' }}>
<div className="flex items-center gap-2 text-[11px] text-ink-500">
<span className="grid h-4 w-4 place-items-center rounded bg-white/10 text-[9px] font-semibold text-ink-200">{i + 1}</span>
{s.domain}
</div>
<div className="mt-1.5 line-clamp-1 text-[13px] font-medium text-ink-100">{s.title}</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-400">{s.snippet}</p>
</div>
))}
</div>
)}
</div>
);
}
export function TextPart({ part, caret }: { part: P<'text'>; caret: boolean }) {
return <Markdown text={part.text.slice(0, part.shown)} caret={caret && part.shown < part.text.length} className="text-[15px] text-ink-200" />;
}
export function ArtifactCard({ part, onOpen, active }: { part: P<'artifact'>; onOpen: (a: Artifact) => void; active: boolean }) {
const lines = part.artifact.code.split('\n').length;
return (
<button
disabled={!part.done}
onClick={() => onOpen(part.artifact)}
className={cn(
'group flex w-full max-w-md items-center gap-3 overflow-hidden rounded-2xl border p-3 text-left transition-all duration-300 animate-fade-up',
active ? 'border-brand-500/50 bg-brand-500/[0.06]' : 'border-white/[0.08] bg-ink-900 hover:border-white/20',
)}
>
<span className="relative grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl bg-ink-800 text-brand-300">
<Code2 className="h-5 w-5" />
{!part.done && <span className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-white/10 to-transparent" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-50">{part.artifact.title}</span>
<span className="block text-xs text-ink-500">{part.done ? `${part.artifact.language.toUpperCase()} · ${lines} lines · Click to open` : 'Writing code…'}</span>
</span>
{part.done ? (
<span className="rounded-lg bg-white/5 px-2.5 py-1 text-xs text-ink-300 transition-colors group-hover:bg-white/10 group-hover:text-white">Open</span>
) : (
<Loader2 className="h-4 w-4 animate-spin text-ink-500" />
)}
</button>
);
}
function Lightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
useEscape(true, onClose);
return (
<Portal>
<div className="fixed inset-0 z-[100] grid place-items-center bg-black/85 p-6 backdrop-blur-sm animate-fade-in" onClick={onClose}>
<img src={src} alt={alt} className="max-h-[85vh] max-w-full rounded-2xl shadow-2xl animate-scale-in" onClick={(e) => e.stopPropagation()} />
<button onClick={onClose} aria-label="Close" className="absolute right-5 top-5 grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white hover:bg-white/20"><X className="h-5 w-5" /></button>
</div>
</Portal>
);
}
export function ImagePart({ part, onPrompt }: { part: P<'image'>; onPrompt: (text: string) => void }) {
const [zoom, setZoom] = useState<string | null>(null);
const done = part.progress >= 100;
return (
<div>
<div className="grid max-w-xl grid-cols-2 gap-2">
{part.urls.map((url, i) => {
const local = Math.max(0, Math.min(100, part.progress * 1.15 - i * 5));
return (
<div key={url} className="group relative aspect-square overflow-hidden rounded-xl bg-ink-800 animate-scale-in" style={{ animationDelay: `${i * 80}ms`, animationFillMode: 'both' }}>
<img
src={url} alt={part.prompt} loading="lazy"
className="h-full w-full object-cover transition-[filter,transform] duration-300"
style={{ filter: done ? 'none' : `blur(${24 - local * 0.22}px) saturate(${0.4 + local / 160})`, transform: done ? undefined : 'scale(1.1)' }}
/>
{!done && (
<>
<div className="absolute inset-0 opacity-40 mix-blend-overlay dither-noise" />
<span className="absolute bottom-2 left-2 rounded-md bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-white backdrop-blur">{Math.round(local)}%</span>
</>
)}
{done && (
<div className="absolute inset-0 flex items-end justify-between bg-gradient-to-t from-black/70 via-transparent to-transparent p-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<button onClick={() => onPrompt(`Create variations of image ${i + 1}: ${part.prompt}`)} className="flex items-center gap-1 rounded-lg bg-white/15 px-2 py-1 text-[11px] font-medium text-white backdrop-blur hover:bg-white/25">
<Shuffle className="h-3 w-3" /> Vary
</button>
<div className="flex gap-1">
<a href={url} target="_blank" rel="noopener noreferrer" aria-label="Download" className="grid h-7 w-7 place-items-center rounded-lg bg-white/15 text-white backdrop-blur hover:bg-white/25"><Download className="h-3.5 w-3.5" /></a>
<button onClick={() => setZoom(url)} aria-label="Expand" className="grid h-7 w-7 place-items-center rounded-lg bg-white/15 text-white backdrop-blur hover:bg-white/25"><Maximize2 className="h-3.5 w-3.5" /></button>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-ink-500">
{done ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{done ? '4 images · 1024 × 1024' : `Generating · step ${Math.round(part.progress * 0.5)}/50`}
</div>
{zoom && <Lightbox src={zoom} alt={part.prompt} onClose={() => setZoom(null)} />}
</div>
);
}
const videoStages = ['Composing keyframes', 'Simulating motion', 'Rendering frames', 'Color grading', 'Encoding'];
export function VideoPart({ part }: { part: P<'video'> }) {
const done = part.progress >= 100;
const [playing, setPlaying] = useState(false);
const [t, setT] = useState(0);
const raf = useRef(0);
useEffect(() => {
if (!playing) return;
let last = performance.now();
const loop = (now: number) => {
const dt = (now - last) / 1000;
last = now;
setT((v) => {
const next = v + dt;
if (next >= part.seconds) {
setPlaying(false);
return part.seconds;
}
return next;
});
raf.current = requestAnimationFrame(loop);
};
raf.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf.current);
}, [playing, part.seconds]);
const toggle = () => {
if (t >= part.seconds) setT(0);
setPlaying((p) => !p);
};
const frac = t / part.seconds;
const stage = videoStages[Math.min(videoStages.length - 1, Math.floor(part.progress / (100 / videoStages.length)))];
return (
<div className="max-w-xl overflow-hidden rounded-2xl border border-white/[0.08] bg-black animate-scale-in">
<div className="relative aspect-video overflow-hidden">
<img
src={part.poster} alt={part.prompt}
className="h-full w-full object-cover"
style={{
filter: done ? 'none' : `blur(${20 - part.progress * 0.16}px) grayscale(${1 - part.progress / 100})`,
transform: `scale(${1.08 + frac * 0.14}) translate(${-frac * 3}%, ${frac * 1.5}%)`,
transition: 'filter 300ms',
}}
/>
{!done && (
<div className="absolute inset-0 grid place-items-center bg-black/30">
<div className="text-center">
<div className="font-mono text-3xl font-semibold tabular-nums text-white">{Math.round(part.progress)}%</div>
<div className="mt-1 text-xs text-white/70">{stage}…</div>
</div>
</div>
)}
{done && !playing && (
<button onClick={toggle} aria-label="Play" className="absolute inset-0 grid place-items-center bg-black/20 transition-colors hover:bg-black/10">
<span className="grid h-14 w-14 place-items-center rounded-full bg-white/90 text-ink-950 shadow-xl transition-transform hover:scale-110"><Play className="ml-0.5 h-6 w-6 fill-current" /></span>
</button>
)}
{done && <span className="absolute left-3 top-3 rounded-md bg-black/50 px-2 py-0.5 font-mono text-[10px] text-white backdrop-blur">1080p · 24fps</span>}
</div>
<div className="flex items-center gap-3 bg-ink-900 px-3 py-2.5">
{done ? (
<>
<button onClick={toggle} aria-label={playing ? 'Pause' : 'Play'} className="text-ink-200 hover:text-white">
{playing ? <Pause className="h-4 w-4" /> : t >= part.seconds ? <RotateCcw className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<input
type="range" min={0} max={part.seconds} step={0.01} value={t}
onChange={(e) => setT(Number(e.target.value))}
aria-label="Seek"
className="h-1 flex-1 accent-brand-500"
/>
<span className="font-mono text-[11px] tabular-nums text-ink-400">0:{String(Math.floor(t)).padStart(2, '0')} / 0:{String(part.seconds).padStart(2, '0')}</span>
<Volume2 className="h-4 w-4 text-ink-500" />
</>
) : (
<div className="h-1 flex-1 overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-gradient-to-r from-brand-500 to-sky-300 transition-[width] duration-200" style={{ width: `${part.progress}%` }} />
</div>
)}
</div>
</div>
);
}
export function ErrorPart({ part, onRetry }: { part: P<'error'>; onRetry: () => void }) {
return (
<div className="flex items-start gap-3 rounded-xl border border-rose-500/25 bg-rose-500/[0.07] p-3.5 animate-fade-up">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-rose-400" />
<div className="flex-1">
<div className="text-sm font-medium text-rose-200">Response interrupted</div>
<p className="mt-0.5 text-[13px] leading-relaxed text-rose-200/70">{part.message}</p>
</div>
<button onClick={onRetry} className="flex shrink-0 items-center gap-1.5 rounded-lg bg-rose-500/15 px-2.5 py-1.5 text-xs font-medium text-rose-100 transition-colors hover:bg-rose-500/25">
<RotateCcw className="h-3.5 w-3.5" /> Retry
</button>
</div>
);
}components/niceui/ai/stream.tsx
import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
/** Streams `text` token-by-token (words + whitespace). */
export function useStream(text: string, { speed = 32, enabled = true }: { speed?: number; enabled?: boolean } = {}) {
const [count, setCount] = useState(0);
const [run, setRun] = useState(0);
const tokens = useRef<string[]>([]);
tokens.current = text.split(/(\s+)/).filter(Boolean);
useEffect(() => {
setCount(0);
}, [text, run]);
useEffect(() => {
if (!enabled) return;
if (count >= tokens.current.length) return;
const jitter = speed * (0.5 + Math.random());
const t = window.setTimeout(() => setCount((c) => c + 2), jitter);
return () => window.clearTimeout(t);
}, [count, enabled, speed, text, run]);
const restart = useCallback(() => setRun((r) => r + 1), []);
const total = tokens.current.length;
return { output: tokens.current.slice(0, count).join(''), done: count >= total, restart, progress: total ? Math.min(1, count / total) : 1 };
}
export function Caret({ className }: { className?: string }) {
return <span aria-hidden className={cn('ml-0.5 inline-block h-[1.05em] w-[7px] translate-y-[3px] animate-blink rounded-[2px] bg-brand-400', className)} />;
}
function inline(text: string) {
return text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((part, i) => {
if (part.startsWith('**') && part.endsWith('**') && part.length > 4)
return <strong key={i} className="font-medium text-ink-50">{part.slice(2, -2)}</strong>;
if (part.startsWith('`') && part.endsWith('`') && part.length > 2)
return <code key={i} className="rounded bg-white/[0.06] px-1 py-px font-mono text-[0.85em] text-brand-200">{part.slice(1, -1)}</code>;
return <Fragment key={i}>{part}</Fragment>;
});
}
/** Minimal markdown: `## heading`, `- bullet`, `1. step`, **bold**, `code`. */
export function Markdown({ text, caret, className }: { text: string; caret?: boolean; className?: string }) {
const blocks = text.split('\n').filter((l, i, arr) => l.trim() !== '' || i === arr.length - 1);
const last = blocks.length - 1;
const tail = (i: number): ReactNode => (caret && i === last ? <Caret /> : null);
return (
<div className={cn('space-y-2 text-sm leading-relaxed text-ink-300', className)}>
{blocks.map((line, i) => {
if (line.startsWith('## '))
return (
<h4 key={i} className="pt-1 text-[15px] font-medium text-ink-50">
{inline(line.slice(3))}
{tail(i)}
</h4>
);
const bullet = line.match(/^(- |\d+\. )(.*)$/);
if (bullet)
return (
<div key={i} className="flex gap-2.5 pl-1">
<span className={cn('shrink-0 select-none', bullet[1] === '- ' ? 'mt-[9px] h-1 w-1 rounded-full bg-brand-400' : 'font-mono text-xs leading-6 text-ink-500')}>
{bullet[1] === '- ' ? '' : bullet[1].trim()}
</span>
<p>
{inline(bullet[2])}
{tail(i)}
</p>
</div>
);
return (
<p key={i}>
{inline(line)}
{tail(i)}
</p>
);
})}
</div>
);
}components/niceui/pages/apps/assistant/ArtifactPanel.tsx
import { useState, type ReactNode } from 'react';
import { Check, Code2, Copy, Download, Eye, Monitor, RotateCw, Smartphone, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { CodeBlock } from '@/components/niceui/ui/CodeBlock';
import type { Artifact } from './types';
export function ArtifactPanel({ artifact, onClose }: { artifact: Artifact; onClose: () => void }) {
const [tab, setTab] = useState<'preview' | 'code'>('preview');
const [device, setDevice] = useState<'desktop' | 'mobile'>('desktop');
const [reload, setReload] = useState(0);
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(artifact.code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
setCopied(false);
}
};
const download = () => {
const url = URL.createObjectURL(new Blob([artifact.code], { type: 'text/html' }));
const a = document.createElement('a');
a.href = url;
a.download = `${artifact.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`;
a.click();
URL.revokeObjectURL(url);
};
return (
<section className="flex h-full min-w-0 flex-col bg-ink-950 animate-slide-in-right">
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-white/[0.06] px-3">
<div className="min-w-0 flex-1 px-1">
<div className="truncate text-sm font-medium text-white">{artifact.title}</div>
<div className="font-mono text-[11px] text-ink-500">{artifact.language} · {artifact.code.split('\n').length} lines</div>
</div>
<div className="flex rounded-lg bg-white/[0.04] p-0.5">
{([['preview', Eye], ['code', Code2]] as const).map(([id, Icon]) => (
<button key={id} onClick={() => setTab(id)} className={cn('flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium capitalize transition-all', tab === id ? 'bg-ink-800 text-white shadow-hairline' : 'text-ink-400 hover:text-ink-100')}>
<Icon className="h-3.5 w-3.5" />{id}
</button>
))}
</div>
<Tool label={copied ? 'Copied' : 'Copy code'} onClick={copy}>{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}</Tool>
<Tool label="Download" onClick={download}><Download className="h-4 w-4" /></Tool>
<Tool label="Close" onClick={onClose}><X className="h-4 w-4" /></Tool>
</header>
{tab === 'preview' ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2">
<div className="flex gap-1.5">{['bg-rose-400/70', 'bg-amber-400/70', 'bg-emerald-400/70'].map((c) => <span key={c} className={cn('h-2.5 w-2.5 rounded-full', c)} />)}</div>
<div className="mx-2 flex-1 truncate rounded-md bg-white/[0.04] px-3 py-1 font-mono text-[11px] text-ink-500">nova.app/artifact/{artifact.id}</div>
<Tool label="Desktop" active={device === 'desktop'} onClick={() => setDevice('desktop')}><Monitor className="h-3.5 w-3.5" /></Tool>
<Tool label="Mobile" active={device === 'mobile'} onClick={() => setDevice('mobile')}><Smartphone className="h-3.5 w-3.5" /></Tool>
<Tool label="Reload" onClick={() => setReload((r) => r + 1)}><RotateCw className="h-3.5 w-3.5" /></Tool>
</div>
<div className="flex min-h-0 flex-1 justify-center overflow-auto bg-ink-900/50 p-4 dot-bg">
<iframe
key={reload}
title={artifact.title}
srcDoc={artifact.code}
sandbox="allow-scripts"
className={cn('h-full rounded-xl border border-white/10 bg-white shadow-card transition-all duration-500 ease-spring', device === 'mobile' ? 'w-[375px] max-w-full' : 'w-full')}
/>
</div>
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto p-4">
<CodeBlock code={artifact.code} filename={`${artifact.id}.html`} language="html" maxHeight={10_000} />
</div>
)}
</section>
);
}
function Tool({ label, onClick, active, children }: { label: string; onClick: () => void; active?: boolean; children: ReactNode }) {
return (
<button onClick={onClick} title={label} aria-label={label} className={cn('grid h-8 w-8 place-items-center rounded-lg transition-colors', active ? 'bg-white/10 text-white' : 'text-ink-400 hover:bg-white/5 hover:text-white')}>
{children}
</button>
);
}components/niceui/ui/CodeBlock.tsx
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { cn } from '@/lib/cn';
const KEYWORDS =
/\b(import|from|export|default|const|let|var|function|return|if|else|for|while|async|await|new|type|interface|extends|true|false|null|undefined|class|def|print)\b/;
type Token = { text: string; cls: string };
function tokenize(line: string): Token[] {
const tokens: Token[] = [];
const re =
/(\/\/.*$|#.*$)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(<\/?[A-Za-z][\w.]*)|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_$][\w$]*\b)|(\s+)|([^\sA-Za-z0-9_$"'`]+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line))) {
const [text, comment, str, tag, num, word] = m;
let cls = 'text-ink-300';
if (comment) cls = 'text-ink-500 italic';
else if (str) cls = 'text-emerald-300';
else if (tag) cls = 'text-brand-300';
else if (num) cls = 'text-amber-300';
else if (word && KEYWORDS.test(word)) cls = 'text-rose-300';
else if (word && /^[A-Z]/.test(word)) cls = 'text-sky-200';
tokens.push({ text, cls });
}
return tokens;
}
type CodeBlockProps = {
code: string;
filename?: string;
language?: string;
showLineNumbers?: boolean;
highlightLines?: number[];
className?: string;
maxHeight?: number;
};
export function CodeBlock({
code,
filename,
language = 'tsx',
showLineNumbers = true,
highlightLines = [],
className,
maxHeight = 480,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const lines = code.replace(/\n$/, '').split('\n');
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch {
setCopied(false);
}
};
return (
<div className={cn('group relative overflow-hidden rounded-xl bg-ink-950 shadow-hairline', className)}>
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2.5">
<div className="flex items-center gap-2 text-xs text-ink-400">
<span className="flex gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-ink-700" />
<span className="h-2.5 w-2.5 rounded-full bg-ink-700" />
<span className="h-2.5 w-2.5 rounded-full bg-ink-700" />
</span>
<span className="ml-2 font-mono">{filename ?? language}</span>
</div>
<button
onClick={copy}
className="focus-ring relative flex h-7 items-center gap-1.5 rounded-md px-2 text-xs text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-100"
aria-label="Copy code"
>
<span className="relative h-3.5 w-3.5">
<Copy className={cn('absolute inset-0 h-3.5 w-3.5 transition-all duration-300', copied ? 'scale-50 opacity-0' : 'scale-100 opacity-100')} />
<Check className={cn('absolute inset-0 h-3.5 w-3.5 text-emerald-400 transition-all duration-300 ease-spring', copied ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} />
</span>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<pre className="overflow-auto py-3 font-mono text-[13px] leading-6" style={{ maxHeight }}>
<code>
{lines.map((line, i) => (
<div
key={i}
className={cn(
'flex px-4',
highlightLines.includes(i + 1) && 'bg-brand-500/10 shadow-[inset_2px_0_0_theme(colors.brand.400)]',
)}
>
{showLineNumbers && <span className="mr-5 w-6 shrink-0 select-none text-right text-ink-600">{i + 1}</span>}
<span className="whitespace-pre">
{tokenize(line).map((t, j) => (
<span key={j} className={t.cls}>
{t.text}
</span>
))}
</span>
</div>
))}
</code>
</pre>
</div>
);
}