Files
xianren_studio/ui/src/pages/ChatPage.tsx
T

863 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
api,
ChatDoneEvent,
ChatErrorEvent,
ChatMessageUpdatedEvent,
ChatParams,
ChatTokenEvent,
MessageVersion,
onEvent,
} from "../api";
import { useStore } from "../store";
import Icon from "../components/Icon";
interface Attachment {
id: string;
kind: "image" | "text";
name: string;
size: number;
dataUrl?: string;
text?: string;
}
interface LocalMessage {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
images?: string[];
tokensIn?: number | null;
tokensOut?: number | null;
tokensEstimated?: boolean;
elapsedMs?: number | null;
firstTokenMs?: number | null;
}
const defaultParams: ChatParams = {
temperature: 0.7,
top_p: 0.9,
max_tokens: 2048,
ctx_size: 4096,
ngl: 99,
};
const TEXT_EXTENSIONS = new Set([
"txt", "md", "markdown", "json", "csv", "log", "py", "js", "ts", "tsx", "jsx",
"rs", "c", "cpp", "h", "hpp", "go", "java", "kt", "html", "css", "xml", "yaml",
"yml", "toml", "ini", "sh", "ps1", "bat", "sql", "env", "gitignore",
]);
const MAX_IMAGE_SIZE = 3 * 1024 * 1024;
const MAX_TEXT_SIZE = 100 * 1024;
export default function ChatPage() {
const models = useStore((s) => s.models);
const conversations = useStore((s) => s.conversations);
const refreshConversations = useStore((s) => s.refreshConversations);
const engine = useStore((s) => s.engine);
const deployStates = useStore((s) => s.deployStates);
const deployProgress = useStore((s) => s.deployProgress);
const [activeConvId, setActiveConvId] = useState<string | null>(null);
const [messages, setMessages] = useState<LocalMessage[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [params, setParams] = useState<ChatParams>(defaultParams);
const [selectedModelId, setSelectedModelId] = useState<string>("");
const [rightOpen, setRightOpen] = useState(
() => localStorage.getItem("xianren-right-open") !== "0",
);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingDraft, setEditingDraft] = useState("");
const [versionsFor, setVersionsFor] = useState<Record<string, MessageVersion[]>>({});
const [attachments, setAttachments] = useState<Attachment[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const engineRunning = engine?.running ?? false;
const engineModel = engine?.model ?? null;
const selectedIsRemote = useMemo(
() => models.find((m) => m.id === selectedModelId)?.kind === "remote",
[models, selectedModelId],
);
const engineLabel = useMemo(() => {
if (!models.length) return "无可用模型";
const m = models.find((x) => x.id === selectedModelId);
return m ? `${m.file_name}${m.kind === "remote" ? " · 在线API" : " · 本地"}` : "选择模型";
}, [models, selectedModelId]);
function toggleRight() {
setRightOpen((v) => {
const next = !v;
localStorage.setItem("xianren-right-open", next ? "1" : "0");
return next;
});
}
useEffect(() => {
if (models.length > 0 && !selectedModelId) {
setSelectedModelId(models[0].id);
}
}, [models, selectedModelId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
useEffect(() => {
const un1 = onEvent<ChatTokenEvent>("chat://token", (e) => {
if (e.conversation_id !== activeConvId) return;
setMessages((prev) => {
const last = prev[prev.length - 1];
if (!last || last.role !== "assistant") {
return [
...prev,
{ id: `tmp-${Date.now()}`, role: "assistant", content: e.text, streaming: true },
];
}
const updated = [...prev];
updated[updated.length - 1] = {
...last,
content: last.content + e.text,
streaming: true,
};
return updated;
});
});
const un2 = onEvent<ChatDoneEvent>("chat://done", (e) => {
if (e.conversation_id !== activeConvId) return;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last && last.role === "assistant" && last.streaming) {
updated[updated.length - 1] = {
id: e.message_id,
role: "assistant",
content: e.content,
tokensIn: e.tokens_in,
tokensOut: e.tokens_out,
tokensEstimated: e.tokens_estimated,
elapsedMs: e.elapsed_ms,
firstTokenMs: e.first_token_ms,
};
}
return updated;
});
setStreaming(false);
refreshConversations();
});
const un3 = onEvent<ChatErrorEvent>("chat://error", (e) => {
if (e.conversation_id !== activeConvId) return;
setStreaming(false);
setError(e.message);
});
const un4 = onEvent<ChatMessageUpdatedEvent>("chat://message-updated", (e) => {
if (e.conversation_id !== activeConvId) return;
const m = e.message;
setMessages((prev) =>
prev.map((x) =>
x.id === m.id
? {
...x,
content: m.content,
tokensIn: m.tokens_in,
tokensOut: m.tokens_out,
elapsedMs: m.elapsed_ms,
firstTokenMs: m.first_token_ms,
images: m.images,
}
: x,
),
);
});
return () => {
un1.then((f) => f());
un2.then((f) => f());
un3.then((f) => f());
un4.then((f) => f());
};
}, [activeConvId, refreshConversations]);
function appendStreamingPlaceholder() {
setMessages((prev) => [
...prev,
{ id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true },
]);
}
async function handleSend() {
const textParts = attachments.filter((a) => a.kind === "text");
let content = input.trim();
if (textParts.length > 0) {
const blocks = textParts.map(
(a) => `\n\n--- 附件:${a.name} ---\n\`\`\`\n${a.text}\n\`\`\``,
);
content += blocks.join("\n");
}
const images = attachments
.filter((a) => a.kind === "image" && a.dataUrl)
.map((a) => a.dataUrl!);
if (!content.trim() && images.length === 0) return;
if (streaming) return;
if (!selectedModelId) {
setError("请先在模型管理添加一个模型");
return;
}
setError(null);
setInput("");
setAttachments([]);
let convId = activeConvId;
if (!convId) {
const conv = await api.createConversation(content.slice(0, 30) || "新会话");
convId = conv.id;
setActiveConvId(convId);
await refreshConversations();
}
const tempUserId = `user-${Date.now()}`;
setMessages((prev) => [
...prev,
{ id: tempUserId, role: "user", content, images },
{ id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true },
]);
setStreaming(true);
try {
const result = await api.chatSend({
conversation_id: convId,
model_id: selectedModelId,
content,
params,
images,
});
if (result?.message_id) {
setMessages((prev) =>
prev.map((m) => (m.id === tempUserId ? { ...m, id: result.message_id } : m)),
);
}
} catch (e) {
setStreaming(false);
setError(String(e));
}
}
async function handleRegenerate(msg: LocalMessage) {
if (!activeConvId || streaming) return;
setError(null);
const idx = messages.findIndex((m) => m.id === msg.id);
if (idx < 0) return;
setMessages((prev) => [...prev.slice(0, idx)]);
appendStreamingPlaceholder();
setStreaming(true);
try {
await api.regenerateMessage(activeConvId, msg.id, params);
} catch (e) {
setStreaming(false);
setError(String(e));
}
}
async function handleEditSave(msg: LocalMessage) {
if (!activeConvId || streaming) return;
const content = editingDraft.trim();
if (!content) return;
setError(null);
setEditingId(null);
const idx = messages.findIndex((m) => m.id === msg.id);
setMessages((prev) =>
prev.map((m, i) => (i === idx ? { ...m, content } : m)).slice(0, idx + 1),
);
appendStreamingPlaceholder();
setStreaming(true);
try {
await api.editMessage(activeConvId, msg.id, content, params);
} catch (e) {
setStreaming(false);
setError(String(e));
}
}
async function handleToggleVersions(msg: LocalMessage) {
try {
const list = await api.listMessageVersions(msg.id);
setVersionsFor((prev) => ({ ...prev, [msg.id]: list }));
} catch (e) {
alert(String(e));
}
}
async function handleApplyVersion(msg: LocalMessage, version: MessageVersion) {
if (!activeConvId) return;
try {
await api.applyMessageVersion(activeConvId, msg.id, version.id);
setVersionsFor((prev) => ({ ...prev, [msg.id]: [] }));
} catch (e) {
alert(String(e));
}
}
async function handleFiles(files: FileList | File[]) {
for (const file of Array.from(files)) {
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
if (file.type.startsWith("image/") || ["png", "jpg", "jpeg", "webp", "gif", "bmp"].includes(ext)) {
if (file.size > MAX_IMAGE_SIZE) {
setError(`图片 ${file.name} 超过 3MB,已跳过`);
continue;
}
const dataUrl = await readAsDataUrl(file);
setAttachments((prev) => [
...prev,
{ id: crypto.randomUUID(), kind: "image", name: file.name, size: file.size, dataUrl },
]);
} else if (file.type.startsWith("text/") || TEXT_EXTENSIONS.has(ext)) {
if (file.size > MAX_TEXT_SIZE) {
setError(`文件 ${file.name} 超过 100KB,已跳过`);
continue;
}
const text = await file.text();
setAttachments((prev) => [
...prev,
{ id: crypto.randomUUID(), kind: "text", name: file.name, size: file.size, text },
]);
} else {
setError(`不支持的文件类型:${file.name}(支持图片与文本类文件)`);
}
}
}
async function handleNewConversation() {
const conv = await api.createConversation("新会话");
setActiveConvId(conv.id);
setMessages([]);
setError(null);
setVersionsFor({});
await refreshConversations();
}
async function handleLoadConversation(id: string) {
setActiveConvId(id);
const msgs = await api.getMessages(id);
setMessages(
msgs.map((m) => ({
id: m.id,
role: m.role as "user" | "assistant",
content: m.content,
images: m.images,
tokensIn: m.tokens_in,
tokensOut: m.tokens_out,
elapsedMs: m.elapsed_ms,
firstTokenMs: m.first_token_ms,
})),
);
setError(null);
setVersionsFor({});
}
async function handleDeleteConversation(id: string) {
await api.deleteConversation(id);
if (activeConvId === id) {
setActiveConvId(null);
setMessages([]);
}
await refreshConversations();
}
return (
<div className="flex h-full">
<div className="flex w-60 flex-col border-r border-border bg-panel p-3">
<button className="btn-primary mb-3 w-full" onClick={handleNewConversation}>
+ 新会话
</button>
<div className="flex-1 space-y-1 overflow-auto">
{conversations.map((c) => (
<div
key={c.id}
className={`group flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm ${
activeConvId === c.id
? "bg-panel-2 text-white"
: "text-slate-300 hover:bg-panel-2/60"
}`}
onClick={() => handleLoadConversation(c.id)}
>
<span className="truncate">{c.title}</span>
<button
className="ml-1 hidden text-xs text-slate-500 hover:text-red-400 group-hover:block"
onClick={(e) => {
e.stopPropagation();
handleDeleteConversation(c.id);
}}
>
×
</button>
</div>
))}
</div>
</div>
<div className="flex flex-1 flex-col">
<div className="flex-1 space-y-4 overflow-auto p-6">
{messages.length === 0 && !error ? (
<div className="mt-24 text-center text-sm text-slate-500">
<div className="mb-2 text-3xl">💬</div>
选择左侧会话,或直接开始新的对话。
<div className="mt-1 text-xs text-slate-600">
支持上传/粘贴图片与文本文件(本地文本模型仅支持文本文件)
</div>
</div>
) : null}
{messages.map((m) => (
<MessageBubble
key={m.id}
message={m}
streaming={streaming}
editingId={editingId}
editingDraft={editingDraft}
versions={versionsFor[m.id] ?? null}
onEditStart={(msg) => {
setEditingId(msg.id);
setEditingDraft(msg.content);
}}
onEditDraftChange={setEditingDraft}
onEditCancel={() => setEditingId(null)}
onEditSave={handleEditSave}
onRegenerate={handleRegenerate}
onToggleVersions={handleToggleVersions}
onApplyVersion={handleApplyVersion}
/>
))}
{error ? (
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
) : null}
<div ref={bottomRef} />
</div>
<div className="border-t border-border bg-panel p-4">
{attachments.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((a) => (
<div
key={a.id}
className="flex items-center gap-1.5 rounded-lg border border-border bg-panel-2 px-2 py-1 text-xs"
>
{a.kind === "image" ? (
<img src={a.dataUrl} className="h-6 w-6 rounded object-cover" alt={a.name} />
) : (
<Icon name="paperclip" className="h-3.5 w-3.5 text-slate-400" />
)}
<span className="max-w-[160px] truncate">{a.name}</span>
<button
className="text-slate-500 hover:text-red-400"
onClick={() =>
setAttachments((prev) => prev.filter((x) => x.id !== a.id))
}
>
×
</button>
</div>
))}
</div>
) : null}
<div className="flex items-end gap-3">
<button
className="mb-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-panel-2 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
onClick={() => fileInputRef.current?.click()}
title="上传图片或文本文件"
>
<Icon name="paperclip" className="h-4 w-4" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
accept="image/*,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.rs,.c,.cpp,.h,.hpp,.go,.java,.html,.css,.xml,.yaml,.yml,.toml,.ini,.sh,.ps1,.bat,.sql"
onChange={(e) => {
if (e.target.files) handleFiles(e.target.files);
e.target.value = "";
}}
/>
<textarea
className="input min-h-[72px] flex-1 resize-none"
placeholder={`向模型提问…(可粘贴图片/文件)`}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
onPaste={(e) => {
const files = e.clipboardData?.files;
if (files && files.length > 0) {
e.preventDefault();
handleFiles(files);
}
}}
/>
<button
className="btn-primary h-[72px] px-6"
onClick={handleSend}
disabled={streaming || (!input.trim() && attachments.length === 0)}
>
{streaming ? "生成中…" : "发送"}
</button>
</div>
</div>
</div>
{rightOpen ? (
<div className="flex w-72 flex-col overflow-y-auto border-l border-border bg-panel p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs uppercase text-slate-400">参数面板</span>
<button
className="rounded p-1 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
onClick={toggleRight}
title="收起参数面板"
>
<Icon name="chevron-right" />
</button>
</div>
<div className="mb-1 text-xs text-slate-400">当前模型</div>
<select
className="input w-full"
value={selectedModelId}
onChange={(e) => setSelectedModelId(e.target.value)}
>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.file_name}
{m.kind === "remote" ? "API" : ""}
{engineRunning && engineModel === m.file_name ? " ●已部署" : ""}
</option>
))}
</select>
{selectedIsRemote ? (
<div className="mt-2 rounded-lg border border-sky-500/30 bg-sky-500/10 px-3 py-1.5 text-xs text-sky-300">
在线 API 模型,无需本地引擎
</div>
) : (
<div className="mt-2 flex gap-2">
<button
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
onClick={() =>
api.engineStart(selectedModelId, params).catch((e) => alert(String(e)))
}
disabled={engineRunning}
>
启动引擎
</button>
<button
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
onClick={() => api.engineStop().catch((e) => alert(String(e)))}
disabled={!engineRunning}
>
停止引擎
</button>
</div>
)}
{deployStates[selectedModelId] === "loading" ? (
<div className="mt-2">
<div className="h-1.5 overflow-hidden rounded-full bg-panel-2">
<div
className="h-full rounded-full bg-gradient-to-r from-accent to-accent-2 transition-all duration-300"
style={{ width: `${deployProgress[selectedModelId]?.percent ?? 8}%` }}
/>
</div>
<div className="mt-1 text-[10px] text-slate-400">
{deployProgress[selectedModelId]?.stage ?? "启动引擎进程"}{" "}
{deployProgress[selectedModelId]?.percent ?? 8}%
</div>
</div>
) : null}
<div className="mt-1 truncate text-xs text-slate-500">{engineLabel}</div>
<div className="mt-4 border-t border-border pt-3">
<div className="mb-3 text-xs uppercase text-slate-400">部署参数</div>
<ParamSlider
label="上下文长度 ctx_size"
value={params.ctx_size}
min={512}
max={32768}
step={512}
onChange={(v) => setParams({ ...params, ctx_size: v })}
/>
<ParamSlider
label="GPU 层数 ngl99=全部)"
value={params.ngl}
min={0}
max={99}
step={1}
onChange={(v) => setParams({ ...params, ngl: v })}
/>
</div>
<div className="mt-4 border-t border-border pt-3">
<div className="mb-3 text-xs uppercase text-slate-400">请求参数</div>
<ParamSlider
label="temperature"
value={params.temperature}
min={0}
max={2}
step={0.05}
onChange={(v) => setParams({ ...params, temperature: v })}
/>
<ParamSlider
label="top_p"
value={params.top_p}
min={0}
max={1}
step={0.05}
onChange={(v) => setParams({ ...params, top_p: v })}
/>
<ParamSlider
label="max_tokens"
value={params.max_tokens}
min={64}
max={8192}
step={64}
onChange={(v) => setParams({ ...params, max_tokens: v })}
/>
</div>
</div>
) : (
<div className="flex w-9 flex-col items-center border-l border-border bg-panel py-2">
<button
className="rounded p-1.5 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
onClick={toggleRight}
title="展开参数面板"
>
<Icon name="chevron-left" />
</button>
<span className="mt-3 whitespace-nowrap text-[10px] text-slate-500 [writing-mode:vertical-rl]">
参数
</span>
</div>
)}
</div>
);
}
function MessageBubble({
message,
streaming,
editingId,
editingDraft,
versions,
onEditStart,
onEditDraftChange,
onEditCancel,
onEditSave,
onRegenerate,
onToggleVersions,
onApplyVersion,
}: {
message: LocalMessage;
streaming: boolean;
editingId: string | null;
editingDraft: string;
versions: MessageVersion[] | null;
onEditStart: (m: LocalMessage) => void;
onEditDraftChange: (v: string) => void;
onEditCancel: () => void;
onEditSave: (m: LocalMessage) => void;
onRegenerate: (m: LocalMessage) => void;
onToggleVersions: (m: LocalMessage) => void;
onApplyVersion: (m: LocalMessage, v: MessageVersion) => void;
}) {
const isUser = message.role === "user";
const editing = editingId === message.id;
return (
<div className={`group relative flex ${isUser ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed ${
isUser
? "bg-gradient-to-br from-accent to-accent-2 text-white"
: "markdown-body border border-border bg-panel"
}`}
>
{message.images && message.images.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2">
{message.images.map((src, i) => (
<img
key={i}
src={src}
alt={`attachment-${i}`}
className="max-h-40 rounded-lg border border-white/20 object-cover"
/>
))}
</div>
) : null}
{isUser && editing ? (
<div className="w-[420px]">
<textarea
className="input min-h-[100px] resize-y bg-white/10 text-white"
value={editingDraft}
autoFocus
onChange={(e) => onEditDraftChange(e.target.value)}
/>
<div className="mt-2 flex gap-2">
<button
className="rounded-lg bg-white/20 px-3 py-1 text-xs hover:bg-white/30"
onClick={() => onEditSave(message)}
disabled={streaming || !editingDraft.trim()}
>
提交
</button>
<button
className="rounded-lg bg-white/10 px-3 py-1 text-xs hover:bg-white/20"
onClick={onEditCancel}
>
取消
</button>
</div>
</div>
) : isUser ? (
<>
<div className="whitespace-pre-wrap">{message.content}</div>
<div
className={`mt-1 flex justify-end transition-opacity ${
streaming ? "opacity-0" : "opacity-0 group-hover:opacity-100"
}`}
>
<button
className="rounded px-1.5 py-0.5 text-[10px] text-white/60 hover:bg-white/10 hover:text-white"
onClick={() => onEditStart(message)}
title="编辑并重新提交(清空之后的生成)"
>
编辑
</button>
</div>
</>
) : message.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.content}</ReactMarkdown>
) : (
<span className="inline-flex items-center gap-1 text-slate-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
思考中…
</span>
)}
{!isUser && !message.streaming && message.content ? (
<>
<MessageStats message={message} />
<div className="mt-2 flex items-center gap-3 text-xs text-slate-500">
<button
className="hover:text-slate-200"
onClick={() => onRegenerate(message)}
disabled={streaming}
>
重新生成
</button>
<button className="hover:text-slate-200" onClick={() => onToggleVersions(message)}>
版本{versions && versions.length > 0 ? `${versions.length}` : ""}
</button>
</div>
{versions && versions.length > 0 ? (
<div className="mt-2 space-y-1 rounded-lg border border-border bg-panel-2/70 p-2">
{versions.map((v) => (
<div
key={v.id}
className="flex items-center justify-between gap-2 rounded px-2 py-1 hover:bg-panel-2"
>
<span className="text-xs text-slate-400">
版本 {v.seq} · {v.created_at}
{v.tokens_out != null ? ` · ${v.tokens_out} tokens` : ""}
</span>
<button
className="text-xs text-slate-300 hover:text-white"
onClick={() => onApplyVersion(message, v)}
>
恢复
</button>
</div>
))}
</div>
) : null}
</>
) : null}
</div>
</div>
);
}
function MessageStats({ message }: { message: LocalMessage }) {
const items: string[] = [];
if (message.firstTokenMs != null) items.push(`首字 ${fmtMs(message.firstTokenMs)}`);
if (message.tokensOut != null) {
items.push(`${message.tokensEstimated ? "≈ " : ""}${message.tokensOut} tokens`);
}
if (
message.tokensOut != null &&
message.elapsedMs != null &&
message.elapsedMs > 0
) {
items.push(`${((message.tokensOut / (message.elapsedMs / 1000))).toFixed(1)} tok/s`);
}
if (message.elapsedMs != null) items.push(`共 ${fmtMs(message.elapsedMs)}`);
if (items.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-500">
{items.map((item) => (
<span key={item}>{item}</span>
))}
</div>
);
}
function fmtMs(ms: number) {
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(2)} s`;
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function ParamSlider({
label,
value,
min,
max,
step,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (v: number) => void;
}) {
return (
<label className="mb-3 block">
<div className="mb-1 flex items-center justify-between text-xs text-slate-400">
<span>{label}</span>
<span className="font-mono text-slate-300">{value}</span>
</div>
<input
type="range"
className="w-full accent-indigo-500"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
);
}