free.ly / script.js
arudradey's picture
Update script.js
94df454 verified
// ═══════════════════════════════════════════════
// PIXELAI CHAT v2.1 β€” script.js
// API-Ready Β· Chat History Β· Minimal Pixel Design
// ═══════════════════════════════════════════════
(function () {
"use strict";
// ── Quick DOM selector ──
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
// ── DOM Elements ──
const cursor = $("#cursor");
const sidebar = $("#sidebar");
const sidebarOverlay= $("#sidebarOverlay");
const menuBtn = $("#menuBtn");
const sidebarClose = $("#sidebarClose");
const newChatBtn = $("#newChatBtn");
const chatHistoryList = $("#chatHistoryList");
const chatArea = $("#chatArea");
const chatInput = $("#chatInput");
const sendBtn = $("#sendBtn");
const typingEl = $("#typing");
const typingAvatar = $("#typingAvatar");
const welcomeEl = $("#welcome");
const pixelArtEl = $("#pixelArt");
const quickActions = $("#quickActions");
const currentChatTitle = $("#currentChatTitle");
const statusDot = $("#statusDot");
const statusText = $("#statusText");
const toastContainer = $("#toastContainer");
const floatingContainer = $("#floatingPixels");
// API settings
const apiProviderEl = $("#apiProvider");
const apiKeyEl = $("#apiKey");
const apiModelEl = $("#apiModel");
const apiEndpointEl = $("#apiEndpoint");
const systemPromptEl = $("#systemPrompt");
const saveApiBtn = $("#saveApiBtn");
const apiStatusEl = $("#apiStatus");
const toggleApiKeyBtn= $("#toggleApiKey");
// Dialog
const dialogOverlay = $("#dialogOverlay");
const dialogTitle = $("#dialogTitle");
const dialogMsg = $("#dialogMsg");
const dialogCancel = $("#dialogCancel");
const dialogConfirm = $("#dialogConfirm");
// Buttons
const exportBtn = $("#exportBtn");
const clearAllBtn = $("#clearAllBtn");
// ── State ──
let currentChatId = null;
let isProcessing = false;
let abortController = null;
// ═══════════════════════════════
// STORAGE
// ═══════════════════════════════
const KEYS = {
CHATS: "pixelai_chats",
ACTIVE: "pixelai_active_chat",
CONFIG: "pixelai_api_config",
};
function load(key, fallback) {
try {
const d = localStorage.getItem(key);
return d ? JSON.parse(d) : fallback;
} catch { return fallback; }
}
function save(key, data) {
try { localStorage.setItem(key, JSON.stringify(data)); }
catch (e) { console.error("Storage error:", e); }
}
// ═══════════════════════════════
// CHAT DATA
// ═══════════════════════════════
function allChats() { return load(KEYS.CHATS, {}); }
function saveAllChats(c) { save(KEYS.CHATS, c); }
function getChat(id) { return allChats()[id] || null; }
function saveChat(id, chat) {
const c = allChats();
c[id] = chat;
saveAllChats(c);
}
function deleteChat(id) {
const c = allChats();
delete c[id];
saveAllChats(c);
}
function genId() {
return "chat_" + Date.now() + "_" + Math.random().toString(36).substr(2, 6);
}
function createChat() {
const id = genId();
const chat = {
id,
title: "New Chat",
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
saveChat(id, chat);
return chat;
}
function chatTitle(chat) {
if (chat.messages.length > 0) {
const first = chat.messages.find((m) => m.role === "user");
if (first) {
const t = first.content.substring(0, 45);
return t + (first.content.length > 45 ? "..." : "");
}
}
return "New Chat";
}
function fmtDate(ts) {
const diff = Date.now() - ts;
if (diff < 60000) return "Just now";
if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago";
if (diff < 604800000) return Math.floor(diff / 86400000) + "d ago";
return new Date(ts).toLocaleDateString();
}
function getTime() {
return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function escHtml(s) {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// ═══════════════════════════════
// RENDER SIDEBAR HISTORY
// ═══════════════════════════════
function renderHistory() {
const chats = allChats();
const sorted = Object.values(chats).sort((a, b) => b.updatedAt - a.updatedAt);
if (!sorted.length) {
chatHistoryList.innerHTML =
'<div class="history-empty">No chats yet.<br>Start a new conversation!</div>';
return;
}
chatHistoryList.innerHTML = sorted.map((ch) => `
<div class="history-item ${ch.id === currentChatId ? "active" : ""}" data-id="${ch.id}">
<span class="history-item-icon">β—ˆ</span>
<div class="history-item-content" data-action="load" data-id="${ch.id}">
<div class="history-item-title">${escHtml(chatTitle(ch))}</div>
<div class="history-item-date">${fmtDate(ch.updatedAt)} Β· ${ch.messages.length} msgs</div>
</div>
<button class="history-item-delete" data-action="delete" data-id="${ch.id}" title="Delete chat">βœ•</button>
</div>
`).join("");
}
// ═══════════════════════════════
// LOAD / NEW CHAT
// ═══════════════════════════════
function loadChat(id) {
const chat = getChat(id);
if (!chat) return;
currentChatId = id;
save(KEYS.ACTIVE, id);
chatArea.innerHTML = "";
if (chat.messages.length === 0) {
// Show welcome
const w = document.createElement("div");
w.className = "welcome";
w.innerHTML = `
<div class="pixel-art-display" id="pixelArtInner"></div>
<h2>WELCOME TO<br>PIXELAI CHAT</h2>
<p>Your retro-futuristic AI companion.<br>Open the <span class="highlight">☰ MENU</span> to configure your API key.</p>
<div class="welcome-arrow">← Click the menu button</div>
`;
chatArea.appendChild(w);
buildPixelArt(w.querySelector(".pixel-art-display"));
quickActions.style.display = "flex";
} else {
quickActions.style.display = "none";
chat.messages.forEach((msg) => {
appendMsg(msg.content, msg.role === "user", msg.time, false, msg.role === "error");
});
chatArea.scrollTop = chatArea.scrollHeight;
}
currentChatTitle.textContent = chatTitle(chat);
renderHistory();
closeSidebar();
}
function startNewChat() {
const chat = createChat();
loadChat(chat.id);
toast("New chat created!", "success");
}
// ═══════════════════════════════
// API CONFIG
// ═══════════════════════════════
const DEFAULT_CFG = {
provider: "openai",
apiKey: "",
model: "gpt-4o-mini",
endpoint: "",
systemPrompt: "You are a helpful AI assistant. Keep responses concise and clear.",
};
function loadCfg() { return load(KEYS.CONFIG, { ...DEFAULT_CFG }); }
function saveCfg(c) { save(KEYS.CONFIG, c); }
function populateSettings() {
const c = loadCfg();
apiProviderEl.value = c.provider;
apiKeyEl.value = c.apiKey;
apiModelEl.value = c.model;
apiEndpointEl.value = c.endpoint;
systemPromptEl.value = c.systemPrompt;
updateDefaults(c.provider);
}
function updateDefaults(provider) {
const map = {
openai: { model: "gpt-4o-mini", ep: "https://api.openai.com/v1/chat/completions" },
anthropic: { model: "claude-3-5-sonnet-20241022", ep: "https://api.anthropic.com/v1/messages" },
google: { model: "gemini-1.5-flash", ep: "" },
custom: { model: "", ep: "" },
};
const d = map[provider] || map.openai;
apiModelEl.placeholder = d.model || "model name";
apiEndpointEl.placeholder = d.ep || "https://your-api.com/v1/chat";
}
// ═══════════════════════════════
// API CALL
// ═══════════════════════════════
function buildRequest(config, messages) {
const p = config.provider;
if (p === "openai" || p === "custom") {
return {
url: config.endpoint || "https://api.openai.com/v1/chat/completions",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${config.apiKey}`,
},
body: {
model: config.model,
messages: [
{ role: "system", content: config.systemPrompt },
...messages.map((m) => ({ role: m.role, content: m.content })),
],
max_tokens: 2048,
temperature: 0.7,
},
parse: (d) => {
if (d.choices?.[0]) return d.choices[0].message.content;
throw new Error(d.error?.message || "Invalid API response");
},
};
}
if (p === "anthropic") {
return {
url: config.endpoint || "https://api.anthropic.com/v1/messages",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey,
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
},
body: {
model: config.model,
max_tokens: 2048,
system: config.systemPrompt,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
},
parse: (d) => {
if (d.content?.[0]) return d.content[0].text;
throw new Error(d.error?.message || "Invalid API response");
},
};
}
if (p === "google") {
return {
url: `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`,
headers: { "Content-Type": "application/json" },
body: {
system_instruction: { parts: [{ text: config.systemPrompt }] },
contents: messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
})),
generationConfig: { maxOutputTokens: 2048, temperature: 0.7 },
},
parse: (d) => {
if (d.candidates?.[0]?.content?.parts?.[0])
return d.candidates[0].content.parts[0].text;
throw new Error(d.error?.message || "Invalid Gemini response");
},
};
}
throw new Error("Unknown provider: " + p);
}
async function callAPI(messages) {
const config = loadCfg();
if (!config.apiKey) {
throw new Error("No API key! Open ☰ MENU β†’ API Settings to configure.");
}
const req = buildRequest(config, messages);
abortController = new AbortController();
const res = await fetch(req.url, {
method: "POST",
headers: req.headers,
body: JSON.stringify(req.body),
signal: abortController.signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message || `API Error ${res.status}: ${res.statusText}`);
}
return req.parse(await res.json());
}
// ═══════════════════════════════
// SEND MESSAGE
// ═══════════════════════════════
async function sendMessage() {
const text = chatInput.value.trim();
if (!text || isProcessing) return;
isProcessing = true;
setUI("loading");
// Hide welcome
const w = chatArea.querySelector(".welcome");
if (w) w.remove();
quickActions.style.display = "none";
// Ensure active chat
if (!currentChatId) {
const ch = createChat();
currentChatId = ch.id;
save(KEYS.ACTIVE, currentChatId);
}
const time = getTime();
const userMsg = { role: "user", content: text, time };
appendMsg(text, true, time, true);
addMsgToChat(currentChatId, userMsg);
chatInput.value = "";
chatInput.focus();
showTyping();
try {
const chat = getChat(currentChatId);
const reply = await callAPI(chat.messages);
hideTyping();
const aiTime = getTime();
const aiMsg = { role: "assistant", content: reply, time: aiTime };
appendMsg(reply, false, aiTime, true);
addMsgToChat(currentChatId, aiMsg);
currentChatTitle.textContent = chatTitle(getChat(currentChatId));
renderHistory();
setUI("ready");
} catch (err) {
hideTyping();
if (err.name === "AbortError") {
toast("Request cancelled.", "info");
} else {
appendMsg("⚠ " + err.message, false, getTime(), true, true);
toast(err.message, "error");
}
setUI("error");
setTimeout(() => setUI("ready"), 3000);
}
isProcessing = false;
}
function addMsgToChat(id, msg) {
const chat = getChat(id);
if (!chat) return;
chat.messages.push(msg);
chat.updatedAt = Date.now();
chat.title = chatTitle(chat);
saveChat(id, chat);
}
// ═══════════════════════════════
// DOM: APPEND MESSAGE
// ═══════════════════════════════
function appendMsg(text, isUser, time, animate = true, isError = false) {
const msg = document.createElement("div");
msg.className = `message ${isUser ? "user" : "ai"} ${isError ? "error" : ""}`;
if (!animate) msg.style.animation = "none";
const av = document.createElement("div");
av.className = "avatar";
createAvatar(av, !isUser);
const bubble = document.createElement("div");
bubble.className = "bubble";
const msgSpan = document.createElement("span");
msgSpan.className = "msg-text";
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = time;
bubble.appendChild(msgSpan);
bubble.appendChild(timeSpan);
msg.appendChild(av);
msg.appendChild(bubble);
chatArea.appendChild(msg);
if (!isUser && animate && !isError) {
typewriter(msgSpan, text);
} else {
msgSpan.textContent = text;
}
chatArea.scrollTop = chatArea.scrollHeight;
}
function typewriter(el, text) {
let i = 0;
function tick() {
if (i < text.length) {
el.textContent += text[i++];
chatArea.scrollTop = chatArea.scrollHeight;
setTimeout(tick, 14);
}
}
tick();
}
// ═══════════════════════════════
// UI HELPERS
// ═══════════════════════════════
function showTyping() { typingEl.classList.add("active"); chatArea.scrollTop = chatArea.scrollHeight; }
function hideTyping() { typingEl.classList.remove("active"); }
function setUI(state) {
const map = {
ready: { cls: "status-dot", label: "READY", dis: false },
loading: { cls: "status-dot loading", label: "THINKING", dis: true },
error: { cls: "status-dot error", label: "ERROR", dis: false },
};
const s = map[state] || map.ready;
statusDot.className = s.cls;
statusText.textContent = s.label;
chatInput.disabled = s.dis;
sendBtn.disabled = s.dis;
}
// ═══════════════════════════════
// TOAST
// ═══════════════════════════════
function toast(msg, type = "info") {
const t = document.createElement("div");
t.className = `toast ${type}`;
t.textContent = msg;
toastContainer.appendChild(t);
setTimeout(() => t.remove(), 3200);
}
// ═══════════════════════════════
// CONFIRM DIALOG
// ═══════════════════════════════
function confirm(title, message) {
return new Promise((resolve) => {
dialogTitle.textContent = title;
dialogMsg.textContent = message;
dialogOverlay.classList.add("active");
function done(val) {
dialogOverlay.classList.remove("active");
dialogCancel.removeEventListener("click", onNo);
dialogConfirm.removeEventListener("click", onYes);
resolve(val);
}
function onNo() { done(false); }
function onYes() { done(true); }
dialogCancel.addEventListener("click", onNo);
dialogConfirm.addEventListener("click", onYes);
});
}
// ═══════════════════════════════
// SIDEBAR
// ═══════════════════════════════
function openSidebar() {
sidebar.classList.add("open");
sidebarOverlay.classList.add("active");
renderHistory();
}
function closeSidebar() {
sidebar.classList.remove("open");
sidebarOverlay.classList.remove("active");
}
// ═══════════════════════════════
// EXPORT
// ═══════════════════════════════
function exportChats() {
const data = JSON.stringify(allChats(), null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `pixelai_export_${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
toast("Chats exported!", "success");
}
// ═══════════════════════════════
// PIXEL ART & AVATARS
// ═══════════════════════════════
const ROBOT = [
0,0,1,1,1,1,0,0,
0,1,0,1,1,0,1,0,
0,1,1,1,1,1,1,0,
0,1,2,1,1,2,1,0,
0,1,1,1,1,1,1,0,
0,0,1,0,0,1,0,0,
0,1,1,1,1,1,1,0,
0,1,0,0,0,0,1,0,
];
const ART_C = ["transparent", "#00ff88", "#ff00aa"];
function buildPixelArt(container) {
if (!container) return;
container.innerHTML = "";
ROBOT.forEach((v) => {
const p = document.createElement("div");
p.className = "p";
p.style.background = ART_C[v];
container.appendChild(p);
});
}
function createAvatar(container, isAI) {
const ai = [0,1,1,1,1,0, 1,0,1,1,0,1, 1,1,1,1,1,1, 1,2,1,1,2,1, 1,1,1,1,1,1, 0,1,0,0,1,0];
const user = [0,0,1,1,0,0, 0,1,1,1,1,0, 1,2,1,1,2,1, 1,1,1,1,1,1, 0,1,2,2,1,0, 0,0,1,1,0,0];
const pat = isAI ? ai : user;
const cols = isAI ? ["transparent","#00ff88","#ff00aa"] : ["transparent","#ff00aa","#00ff88"];
container.innerHTML = "";
pat.forEach((v) => {
const p = document.createElement("div");
p.className = "avatar-pixel";
p.style.background = cols[v];
container.appendChild(p);
});
}
// ═══════════════════════════════
// CUSTOM CURSOR
// ═══════════════════════════════
let trailN = 0;
document.addEventListener("mousemove", (e) => {
cursor.style.left = (e.clientX - 2) + "px";
cursor.style.top = (e.clientY - 2) + "px";
if (++trailN % 3 === 0) {
const t = document.createElement("div");
t.className = "cursor-trail";
t.style.left = (e.clientX - 2) + "px";
t.style.top = (e.clientY - 2) + "px";
document.body.appendChild(t);
setTimeout(() => { t.style.opacity = "0"; }, 80);
setTimeout(() => t.remove(), 500);
}
});
document.addEventListener("mousedown", () => cursor.classList.add("click"));
document.addEventListener("mouseup", () => cursor.classList.remove("click"));
// ═══════════════════════════════
// FLOATING PIXELS
// ═══════════════════════════════
function spawnPixel() {
const p = document.createElement("div");
p.className = "floating-pixel";
p.style.left = Math.random() * 100 + "%";
p.style.animationDuration = (8 + Math.random() * 15) + "s";
const size = (2 + Math.random() * 4) + "px";
p.style.width = p.style.height = size;
p.style.background = Math.random() > 0.5 ? "#00ff88" : "#ff00aa";
floatingContainer.appendChild(p);
p.addEventListener("animationend", () => { p.remove(); spawnPixel(); });
}
for (let i = 0; i < 18; i++) setTimeout(spawnPixel, Math.random() * 5000);
// ═══════════════════════════════
// LOGO GLITCH
// ═══════════════════════════════
const logoText = $(".logo-text");
setInterval(() => {
if (Math.random() > 0.95 && logoText) {
logoText.style.textShadow = `
${(Math.random()*4-2).toFixed(1)}px 0 rgba(255,0,170,0.7),
${(Math.random()*4-2).toFixed(1)}px 0 rgba(0,255,136,0.7)`;
setTimeout(() => {
logoText.style.textShadow = "2px 2px 0 rgba(0,255,136,0.15)";
}, 100);
}
}, 200);
// ═══════════════════════════════
// EVENT LISTENERS
// ═══════════════════════════════
// Send message
sendBtn.addEventListener("click", sendMessage);
chatInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
// Quick actions
quickActions.addEventListener("click", (e) => {
const btn = e.target.closest(".quick-btn");
if (btn) { chatInput.value = btn.dataset.prompt; sendMessage(); }
});
// Sidebar open/close
menuBtn.addEventListener("click", openSidebar);
sidebarClose.addEventListener("click", closeSidebar);
sidebarOverlay.addEventListener("click", closeSidebar);
// New chat
newChatBtn.addEventListener("click", startNewChat);
// History list clicks
chatHistoryList.addEventListener("click", async (e) => {
const target = e.target.closest("[data-action]");
if (!target) return;
const action = target.dataset.action;
const id = target.dataset.id;
if (action === "load") {
loadChat(id);
} else if (action === "delete") {
const ok = await confirm("Delete Chat", "Permanently delete this chat?");
if (ok) {
deleteChat(id);
if (id === currentChatId) startNewChat();
else renderHistory();
toast("Chat deleted.", "info");
}
}
});
// Export
exportBtn.addEventListener("click", exportChats);
// Clear all
clearAllBtn.addEventListener("click", async () => {
const ok = await confirm("Clear All", "Delete ALL chat history? This cannot be undone.");
if (ok) {
saveAllChats({});
startNewChat();
toast("All chats cleared.", "info");
}
});
// API provider change
apiProviderEl.addEventListener("change", () => updateDefaults(apiProviderEl.value));
// Toggle API key
toggleApiKeyBtn.addEventListener("click", () => {
const show = apiKeyEl.type === "password";
apiKeyEl.type = show ? "text" : "password";
toggleApiKeyBtn.textContent = show ? "β—Ž" : "β—‰";
});
// Save API config
saveApiBtn.addEventListener("click", () => {
const cfg = {
provider: apiProviderEl.value,
apiKey: apiKeyEl.value.trim(),
model: apiModelEl.value.trim() || DEFAULT_CFG.model,
endpoint: apiEndpointEl.value.trim(),
systemPrompt: systemPromptEl.value.trim() || DEFAULT_CFG.systemPrompt,
};
saveCfg(cfg);
apiStatusEl.textContent = "βœ“ Saved!";
apiStatusEl.style.color = "var(--accent)";
setTimeout(() => { apiStatusEl.textContent = ""; }, 2500);
toast("API config saved!", "success");
});
// Keyboard: Escape
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (sidebar.classList.contains("open")) closeSidebar();
if (dialogOverlay.classList.contains("active")) {
dialogOverlay.classList.remove("active");
}
if (isProcessing && abortController) {
abortController.abort();
isProcessing = false;
hideTyping();
setUI("ready");
}
}
});
// ═══════════════════════════════
// INIT
// ═══════════════════════════════
function init() {
buildPixelArt(pixelArtEl);
createAvatar(typingAvatar, true);
populateSettings();
const lastId = load(KEYS.ACTIVE, null);
const chats = allChats();
if (lastId && chats[lastId]) {
loadChat(lastId);
} else if (Object.keys(chats).length) {
const latest = Object.values(chats).sort((a, b) => b.updatedAt - a.updatedAt);
loadChat(latest[0].id);
} else {
startNewChat();
}
chatInput.focus();
console.log(
"%cβ—ˆ PixelAI v2.1 Ready β—ˆ",
"color:#00ff88; font-size:14px; font-family:monospace; background:#0a0a0a; padding:8px 16px;"
);
}
init();
})();