feat: add static file server, backend process manager, and base frontend components for local AI image generation
This commit is contained in:
1 parent
f1e7c29a5d
commit
2e24396b35
3 files changed
+657
-72
No files matched your search
@@ -35,6 +35,7 @@ const MODEL_LIBRARY = [
|
||||
resolution: "1024x1024",
|
||||
notes: "Photorealistic SDXL model tuned for 4-8 step Lightning generation.",
|
||||
url: "https://huggingface.co/RunDiffusion/Juggernaut-XL-Lightning/resolve/main/Juggernaut_RunDiffusionPhoto2_Lightning_4Steps.safetensors",
|
||||
recommendedTiers: ["Mid", "High"],
|
||||
},
|
||||
{
|
||||
name: "DreamShaper XL Lightning",
|
||||
@@ -44,6 +45,7 @@ const MODEL_LIBRARY = [
|
||||
resolution: "1024x1024",
|
||||
notes: "All-around SDXL model for fantasy art, renders, illustration, and stylized images.",
|
||||
url: "https://huggingface.co/Lykon/dreamshaper-xl-lightning/resolve/main/DreamShaperXL_Lightning.safetensors",
|
||||
recommendedTiers: ["Mid", "High"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -58,6 +60,7 @@ const MODEL_LIBRARY = [
|
||||
resolution: "512x512",
|
||||
notes: "General-purpose SD 1.5 model for illustration, anime, and semi-realistic portraits.",
|
||||
url: "https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors",
|
||||
recommendedTiers: ["Low"],
|
||||
},
|
||||
{
|
||||
name: "CyberRealistic V8",
|
||||
@@ -67,6 +70,7 @@ const MODEL_LIBRARY = [
|
||||
resolution: "512x512",
|
||||
notes: "High-fidelity realism and photorealism in the SD 1.5 ecosystem.",
|
||||
url: "https://huggingface.co/cyberdelia/CyberRealistic/resolve/main/CyberRealistic_V8_FP16.safetensors",
|
||||
recommendedTiers: ["Low"],
|
||||
},
|
||||
{
|
||||
name: "ReV Animated v1.2.2",
|
||||
@@ -76,6 +80,7 @@ const MODEL_LIBRARY = [
|
||||
resolution: "512x512",
|
||||
notes: "Semi-realistic, 2.5D, anime, fantasy, and stylized digital art.",
|
||||
url: "https://huggingface.co/danbrown/RevAnimated-v1-2-2/resolve/main/rev-animated-v1-2-2.safetensors",
|
||||
recommendedTiers: ["Low"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -93,6 +98,7 @@ const TEXT_MODEL_LIBRARY = [
|
||||
resolution: "N/A",
|
||||
notes: "Extremely fast, lightweight assistant, perfect for low RAM/VRAM machines.",
|
||||
url: "https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-0.5b-instruct-q4_k_m.gguf",
|
||||
recommendedTiers: ["Low"],
|
||||
},
|
||||
{
|
||||
name: "SmolLM2 1.7B Instruct Q4_K_M",
|
||||
@@ -102,6 +108,42 @@ const TEXT_MODEL_LIBRARY = [
|
||||
resolution: "N/A",
|
||||
notes: "Excellent lightweight assistant with strong logic, reasoning, and prompt expansion capabilities.",
|
||||
url: "https://huggingface.co/HuggingFaceTB/SmolLM2-1.7B-Instruct-GGUF/resolve/main/smollm2-1.7b-instruct-q4_k_m.gguf",
|
||||
recommendedTiers: ["Low"],
|
||||
},
|
||||
{
|
||||
name: "Qwen2.5-Coder-7B-Instruct",
|
||||
filename: "qwen2.5-coder-7b-instruct-q4_k_m.gguf",
|
||||
format: "GGUF",
|
||||
size: "4.7 GB",
|
||||
approxSize: "4.7 GB",
|
||||
resolution: "N/A",
|
||||
notes: "Highly intelligent coding and text assistant. Recommended for mid/high tier systems.",
|
||||
url: "https://huggingface.co/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/qwen2.5-coder-7b-instruct-q4_k_m.gguf",
|
||||
recommendedTiers: ["Mid", "High"],
|
||||
},
|
||||
{
|
||||
name: "Llama-3.1-8B-Instruct",
|
||||
filename: "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
|
||||
format: "GGUF",
|
||||
size: "4.9 GB",
|
||||
approxSize: "4.9 GB",
|
||||
resolution: "N/A",
|
||||
notes: "Excellent general-purpose text model. Recommended for high-tier systems.",
|
||||
url: "https://huggingface.co/lmstudio-community/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
|
||||
recommendedTiers: ["High"],
|
||||
},
|
||||
{
|
||||
name: "LLaVA 1.5 7B",
|
||||
filename: "ggml-model-q4_k.gguf",
|
||||
format: "GGUF",
|
||||
size: "4.5 GB",
|
||||
approxSize: "4.5 GB",
|
||||
resolution: "N/A",
|
||||
notes: "Multimodal model capable of understanding images. Downloads companion vision projector automatically.",
|
||||
url: "https://huggingface.co/mys/ggml_llava-v1.5-7b/resolve/main/ggml-model-q4_k.gguf",
|
||||
projectorUrl: "https://huggingface.co/mys/ggml_llava-v1.5-7b/resolve/main/mmproj-model-f16.gguf",
|
||||
projectorFilename: "mmproj-model-f16.gguf",
|
||||
recommendedTiers: ["Mid", "High"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -143,6 +185,20 @@ const COREML_MODEL_LIBRARY = [
|
||||
},
|
||||
];
|
||||
|
||||
const getHardwareTier = (specs) => {
|
||||
if (!specs) return "Low";
|
||||
const ram = Number(specs.ram_total_gb) || 0;
|
||||
const vram = Number(specs.gpu_vram_gb) || 0;
|
||||
|
||||
if (vram >= 12 || (vram >= 8 && ram >= 32)) {
|
||||
return "High";
|
||||
} else if (vram >= 6 || ram >= 16) {
|
||||
return "Mid";
|
||||
} else {
|
||||
return "Low";
|
||||
}
|
||||
};
|
||||
|
||||
function ModelManager({
|
||||
activeModel,
|
||||
setActiveModel,
|
||||
@@ -288,32 +344,6 @@ function ModelManager({
|
||||
}
|
||||
};
|
||||
|
||||
const startProgressPolling = (modelId) => {
|
||||
setDownloadingModelId(modelId);
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const status = await getDownloadProgress();
|
||||
if (status.active) {
|
||||
setDownloadProgress(status.progress === -1 ? 0 : status.progress);
|
||||
setDownloadSpeed(`${status.speed} • ETA ${status.eta}s`);
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setDownloadingModelId(null);
|
||||
if (status.error) {
|
||||
if (!String(status.error).toLowerCase().includes("cancelled")) {
|
||||
showAlert({ title: "Download Failed", message: status.error, danger: true });
|
||||
}
|
||||
} else {
|
||||
fetchModels();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Polling error:", e);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const downloadByUrl = async (url, expectedFilename) => {
|
||||
if (downloadingModelId) return;
|
||||
|
||||
@@ -352,6 +382,40 @@ function ModelManager({
|
||||
}
|
||||
};
|
||||
|
||||
const startProgressPolling = (modelId) => {
|
||||
setDownloadingModelId(modelId);
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const status = await getDownloadProgress();
|
||||
if (status.active) {
|
||||
setDownloadProgress(status.progress === -1 ? 0 : status.progress);
|
||||
setDownloadSpeed(`${status.speed} • ETA ${status.eta}s`);
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setDownloadingModelId(null);
|
||||
if (status.error) {
|
||||
if (!String(status.error).toLowerCase().includes("cancelled")) {
|
||||
showAlert({ title: "Download Failed", message: status.error, danger: true });
|
||||
}
|
||||
} else {
|
||||
fetchModels();
|
||||
if (modelId === "ggml-model-q4_k.gguf") {
|
||||
setTimeout(() => {
|
||||
downloadByUrl(
|
||||
"https://huggingface.co/mys/ggml_llava-v1.5-7b/resolve/main/mmproj-model-f16.gguf",
|
||||
"mmproj-model-f16.gguf"
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Polling error:", e);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleUrlDownloadSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!downloadUrl.trim()) return;
|
||||
@@ -809,6 +873,8 @@ function ModelManager({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Active Model Status Tonal Box */}
|
||||
{activeModelType === "image" && activeModel && (
|
||||
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", background: "var(--md-sys-color-primary-container)", color: "var(--md-sys-color-on-primary-container)" }}>
|
||||
@@ -1006,10 +1072,27 @@ function ModelManager({
|
||||
{section.items.map((model) => {
|
||||
const installed = modelNames.includes(model.filename);
|
||||
const downloading = downloadingModelId === model.filename || downloadingModelId === `${model.filename}.zip`;
|
||||
const systemTier = getHardwareTier(specs);
|
||||
const isRecommended = model.recommendedTiers && model.recommendedTiers.includes(systemTier);
|
||||
return (
|
||||
<div key={model.filename} style={{ display: "flex", flexDirection: "column", gap: "10px", padding: "14px", background: "var(--md-sys-color-surface-variant)", border: "1px solid var(--md-sys-color-outline-variant)", borderRadius: "var(--md-shape-corner-medium)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
<span style={{ fontWeight: 700 }}>{model.name}</span>
|
||||
<span style={{ fontWeight: 700, display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" }}>
|
||||
{model.name}
|
||||
{isRecommended && (
|
||||
<span style={{
|
||||
fontSize: "0.68rem",
|
||||
color: "#2e7d32",
|
||||
background: "#e8f5e9",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
fontWeight: "bold",
|
||||
border: "1px solid #c8e6c9"
|
||||
}}>
|
||||
[Recommended Fit]
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
|
||||
{model.format} • approx. {model.approxSize} {model.resolution !== "N/A" && `• ${model.resolution}`}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bot, LoaderCircle, Send, Trash2, Square, History, Plus } from "lucide-react";
|
||||
import { Bot, LoaderCircle, Send, Trash2, Square, History, Plus, Paperclip, X } from "lucide-react";
|
||||
import {
|
||||
chatWithLlm,
|
||||
getDownloadProgress,
|
||||
@@ -30,6 +30,64 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
|
||||
const completedDownloadRef = useRef("");
|
||||
const loadingModelRef = useRef(null);
|
||||
|
||||
const [attachments, setAttachments] = useState([]);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const isImage = (file) => {
|
||||
return /\.(jpe?g|png|webp)$/i.test(file.name) || file.type.startsWith("image/");
|
||||
};
|
||||
|
||||
const isTextFile = (file) => {
|
||||
return /\.(txt|md|csv|js|jsx|ts|tsx|py|json|css|html|java|cpp|c|h|rs|go|sh|bat|xml|yaml|yml)$/i.test(file.name) || file.type.startsWith("text/");
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
files.forEach((file) => {
|
||||
if (isImage(file)) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Math.random().toString(36).substring(7),
|
||||
file,
|
||||
type: "image",
|
||||
name: file.name,
|
||||
dataUrl: event.target.result,
|
||||
},
|
||||
]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else if (isTextFile(file)) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Math.random().toString(36).substring(7),
|
||||
file,
|
||||
type: "document",
|
||||
name: file.name,
|
||||
content: event.target.result,
|
||||
},
|
||||
]);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
showAlert({
|
||||
title: "Unsupported File",
|
||||
message: `File "${file.name}" is not supported. Please select an image (JPEG, PNG, WEBP) or a text/code file.`,
|
||||
danger: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadingModelRef.current = loadingModel;
|
||||
}, [loadingModel]);
|
||||
@@ -179,7 +237,12 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
|
||||
setSelectedModel(conv.model);
|
||||
}
|
||||
// Try to compute approximate token usage from messages
|
||||
const total = conv.messages.reduce((sum, m) => sum + m.content.split(/\s+/).length, 0);
|
||||
const total = conv.messages.reduce((sum, m) => {
|
||||
const text = Array.isArray(m.content)
|
||||
? m.content.map(c => c.text || "").join(" ")
|
||||
: (m.content || "");
|
||||
return sum + text.split(/\s+/).length;
|
||||
}, 0);
|
||||
setTokenUsage({
|
||||
prompt_tokens: Math.round(total * 0.7),
|
||||
completion_tokens: Math.round(total * 0.3),
|
||||
@@ -204,7 +267,8 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isBusy || !status.ready) return;
|
||||
const hasAttachments = attachments.length > 0;
|
||||
if ((!text && !hasAttachments) || isBusy || !status.ready) return;
|
||||
|
||||
let convId = activeConversationId;
|
||||
let isNew = false;
|
||||
@@ -214,12 +278,40 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
const nextMessages = [...messages, { role: "user", content: text }];
|
||||
// Delimited context blocks for document attachments
|
||||
let documentContext = "";
|
||||
attachments.filter(att => att.type === "document").forEach((att) => {
|
||||
documentContext += `\n[Attached File: ${att.name}]\n${att.content}\n`;
|
||||
});
|
||||
|
||||
const combinedText = documentContext ? `${text}\n\n${documentContext}`.trim() : text;
|
||||
const imageAttachments = attachments.filter(att => att.type === "image");
|
||||
|
||||
let userMessageContent;
|
||||
if (imageAttachments.length > 0) {
|
||||
userMessageContent = [
|
||||
{
|
||||
type: "text",
|
||||
text: combinedText
|
||||
},
|
||||
...imageAttachments.map((img) => ({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: img.dataUrl
|
||||
}
|
||||
}))
|
||||
];
|
||||
} else {
|
||||
userMessageContent = combinedText;
|
||||
}
|
||||
|
||||
const nextMessages = [...messages, { role: "user", content: userMessageContent }];
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setIsBusy(true);
|
||||
|
||||
const firstTitle = isNew ? (text.slice(0, 26) + (text.length > 26 ? "..." : "")) : null;
|
||||
const displayTitleText = text || (imageAttachments.length > 0 ? "Sent Image" : "Sent File");
|
||||
const firstTitle = isNew ? (displayTitleText.slice(0, 26) + (displayTitleText.length > 26 ? "..." : "")) : null;
|
||||
saveConversationState(convId, nextMessages, selectedModel, firstTitle);
|
||||
|
||||
try {
|
||||
@@ -238,6 +330,9 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
|
||||
setTokenUsage(response.usage);
|
||||
}
|
||||
saveConversationState(convId, finalMessages, selectedModel);
|
||||
|
||||
// Clean up attached files on success
|
||||
setAttachments([]);
|
||||
} catch (err) {
|
||||
const finalMessages = [...nextMessages, { role: "assistant", content: `Error: ${err.message}`, error: true }];
|
||||
setMessages(finalMessages);
|
||||
@@ -510,7 +605,30 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
|
||||
{messages.map((message, index) => (
|
||||
<div key={`${message.role}-${index}`} className={`chat-message ${message.role} ${message.error ? "error" : ""}`}>
|
||||
<strong>{message.role === "user" ? "You" : "Local AI"}</strong>
|
||||
<div>{message.content}</div>
|
||||
<div>
|
||||
{Array.isArray(message.content) ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{message.content.map((item, idx) => {
|
||||
if (item.type === "text") {
|
||||
return <span key={idx} style={{ whiteSpace: "pre-wrap" }}>{item.text}</span>;
|
||||
}
|
||||
if (item.type === "image_url") {
|
||||
return (
|
||||
<img
|
||||
key={idx}
|
||||
src={item.image_url.url}
|
||||
alt="Attached image"
|
||||
style={{ maxWidth: "240px", maxHeight: "180px", objectFit: "contain", borderRadius: "6px", marginTop: "4px" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ whiteSpace: "pre-wrap" }}>{message.content}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isBusy && status.ready && <div className="chat-thinking"><LoaderCircle className="progress-spinner" size={16} /> Generating...</div>}
|
||||
@@ -519,22 +637,99 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-composer">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder={status.ready ? "Message your local model..." : "Select and load a GGUF model above to begin"}
|
||||
disabled={!status.ready || isBusy}
|
||||
/>
|
||||
<button className="m3-btn m3-btn-filled" onClick={sendMessage} disabled={!input.trim() || !status.ready || isBusy}>
|
||||
<Send size={17} /> Send
|
||||
</button>
|
||||
<div className="chat-composer" style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{attachments.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", paddingBottom: "8px" }}>
|
||||
{attachments.map((att) => (
|
||||
<div key={att.id} style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 28px 6px 8px",
|
||||
background: "var(--md-sys-color-surface-variant)",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: "6px",
|
||||
fontSize: "0.8rem",
|
||||
maxWidth: "200px"
|
||||
}}>
|
||||
{att.type === "image" ? (
|
||||
<img src={att.dataUrl} alt={att.name} style={{ width: "24px", height: "24px", objectFit: "cover", borderRadius: "3px" }} />
|
||||
) : (
|
||||
<span style={{ fontWeight: 600 }}>📄</span>
|
||||
)}
|
||||
<span style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
color: "var(--md-sys-color-on-surface-variant)"
|
||||
}} title={att.name}>
|
||||
{att.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setAttachments(prev => prev.filter(item => item.id !== att.id))}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "4px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--md-sys-color-error)",
|
||||
cursor: "pointer",
|
||||
padding: "2px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: "10px", width: "100%" }}>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: "none" }}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
className="m3-btn m3-btn-tonal"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ padding: "0 12px", height: "48px", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
disabled={!status.ready || isBusy}
|
||||
title="Attach files or images"
|
||||
>
|
||||
<Paperclip size={17} />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder={status.ready ? "Message your local model..." : "Select and load a GGUF model above to begin"}
|
||||
disabled={!status.ready || isBusy}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
className="m3-btn m3-btn-filled"
|
||||
onClick={sendMessage}
|
||||
disabled={(!input.trim() && attachments.length === 0) || !status.ready || isBusy}
|
||||
style={{ height: "48px" }}
|
||||
>
|
||||
<Send size={17} /> Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user