diff --git a/README.md b/README.md index 3cc2697..49905eb 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Automatic startup Web Worker -### [Open Picklo Live](https://kegodev.github.io/picklo/) +### [Open Picklo Live](https://pickloai.kmdigitallabs.co.za/)
diff --git a/app.js b/app.js index 6c8aea1..aa443a8 100644 --- a/app.js +++ b/app.js @@ -1,15 +1,16 @@ -import * as webllm from "https://esm.run/@mlc-ai/web-llm"; -import { classifyAgentIntent } from "./agent-router.js"; -import { supabase } from "./supabase-client.js"; +import { classifyAgentIntent } from "./agent-router.js?v=8.2.2-fix4"; +import { isAbortError, requestCloudCompletion } from "./cloud-ai.js?v=8.2.2-fix4"; +import { supabase } from "./supabase-client.js?v=8.2.2-fix4"; import { detectRuntimeCapabilities, extractExplicitRequirements, getAdaptiveSampling, getModelLoadCandidates, - recommendModelForDevice -} from "./runtime-policy.js"; + recommendModelForDevice, + selectInferenceMode +} from "./runtime-policy.js?v=8.2.2-fix4"; -const APP_VERSION = "8.1.0"; +const APP_VERSION = "8.2.2"; const STORAGE_KEY = "picklo-v7-state"; const V61_STORAGE_KEY = "picklo-v6.1-state"; const FILE_DB = "picklo-v3-files"; @@ -76,7 +77,7 @@ Support: [help@kmdigitallabs.co.za](mailto:help@kmdigitallabs.co.za) `.trim(); const BASE_SYSTEM_PROMPT = ` -You are Picklo V8.1, a capable general-purpose personal AI assistant that runs locally in the user's browser. +You are Picklo V8.2.2, a capable general-purpose personal AI assistant. Picklo uses private local inference on capable desktop computers and secure cloud inference on mobile, tablet and unsupported devices. You are useful for questions, writing, coding, planning, brainstorming, explanations, decision support and document analysis. Do not claim to be ChatGPT, OpenAI, or another product. Identify yourself simply as Picklo when relevant. @@ -105,6 +106,8 @@ GENERAL RULES: 15. Interpret language in context. Resolve pronouns and follow-up references from the conversation before answering. Recognize common idioms, understatement, figurative language and likely sarcasm; when ambiguity would materially change the answer, ask one concise clarifying question instead of guessing. 16. For complex requests, silently form a short problem representation: the goal, supplied facts, constraints, unknowns and required output. Test the answer against those items before returning it. 17. When sources or expert views disagree, represent the meaningful disagreement fairly. Prefer supplied primary or authoritative material and distinguish source evidence from inference. +18. When returning code in chat, always use a fenced Markdown code block with the correct language label so Picklo can render syntax colours correctly. +19. When you mention a website or email address, use normal Markdown link syntax so it is clickable. `.trim(); const defaultState = () => ({ @@ -124,9 +127,15 @@ const defaultState = () => ({ }); let state = loadState(); +let webllm = null; +let webllmModulePromise = null; let engine = null; let modelWorker = null; let loadedModelId = null; +let activeInferenceMode = "cloud"; +let inferenceReason = "initializing"; +let runtimeCapabilities = null; +let cloudAbortController = null; let isGenerating = false; let generationWasStopped = false; let localFiles = []; @@ -137,11 +146,16 @@ let currentUser = null; let loadedUserId = null; let sessionLoadPromise = null; let authMode = "sign-in"; +let authReason = ""; +let pendingProtectedAction = null; +let pendingGuestChat = null; let appEventsBound = false; let cloudSyncPaused = true; let cloudSyncTimer = null; let cloudSyncChain = Promise.resolve(); let lastCloudSnapshot = ""; +let largestVisualViewportHeight = 0; +let previousVisualViewportHeight = 0; const $ = (id) => document.getElementById(id); @@ -160,7 +174,9 @@ const accountInitial = $("accountInitial"); const settingsAccountInitial = $("settingsAccountInitial"); const accountEmail = $("accountEmail"); const cloudSyncStatus = $("cloudSyncStatus"); +const accountSummary = $("accountSummary"); const signOutBtn = $("signOutBtn"); +const authGuestBtn = $("authGuestBtn"); const newChatBtn = $("newChatBtn"); const mobileNewChatBtn = $("mobileNewChatBtn"); @@ -172,15 +188,13 @@ const chatForm = $("chatForm"); const messageInput = $("messageInput"); const sendBtn = $("sendBtn"); const stopBtn = $("stopBtn"); -const quickActions = $("quickActions"); const fileInput = $("fileInput"); +const attachButton = $("attachButton"); const fileCount = $("fileCount"); const panelFileCount = $("panelFileCount"); const panelMemoryCount = $("panelMemoryCount"); const memoryCount = $("memoryCount"); const panelModelName = $("panelModelName"); -const activeModeLabel = $("activeModeLabel"); -const modeButton = $("modeButton"); const settingsBtn = $("settingsBtn"); const filesBtn = $("filesBtn"); @@ -236,6 +250,9 @@ const themeToggleBtn = $("themeToggleBtn"); const themeSelect = $("themeSelect"); const performanceSelect = $("performanceSelect"); const performanceStatus = $("performanceStatus"); +const performanceHelp = $("performanceHelp"); +const modelSettingGroup = $("modelSettingGroup"); +const modelHelpText = $("modelHelpText"); const autoToolsSelect = $("autoToolsSelect"); const agentStatus = $("agentStatus"); const agentActivityBar = $("agentActivityBar"); @@ -245,7 +262,6 @@ const agentToolCount = $("agentToolCount"); const agentHistoryList = $("agentHistoryList"); const toolsBtn = $("toolsBtn"); -const composerToolsBtn = $("composerToolsBtn"); const toolsSheet = $("toolsSheet"); const calculatorInput = $("calculatorInput"); const calculatorRunBtn = $("calculatorRunBtn"); @@ -264,12 +280,20 @@ boot(); async function boot() { applyTheme(state.theme || "light", false); + bindVisualViewport(); bindAuthEvents(); registerPickloServiceWorker(); const { data, error } = await supabase.auth.getSession(); if (error) setAuthMessage(error.message); - await handleAuthSession(data?.session || null); + let initialSession = data?.session || null; + if (initialSession?.user && !initialSession.user.is_anonymous) { + const expiresAtMs = Number(initialSession.expires_at || 0) * 1000; + if (expiresAtMs && expiresAtMs <= Date.now() + 30000) { + initialSession = await getValidRegisteredSession(); + } + } + await handleAuthSession(initialSession); supabase.auth.onAuthStateChange((_event, session) => { setTimeout(() => { @@ -281,10 +305,62 @@ async function boot() { }); } +function bindVisualViewport() { + const viewport = window.visualViewport; + + const update = () => { + const height = Math.max(1, Math.round(viewport?.height || window.innerHeight)); + const top = Math.max(0, Math.round(viewport?.offsetTop || 0)); + const inputFocused = document.activeElement === messageInput; + + if (!inputFocused) largestVisualViewportHeight = Math.max(largestVisualViewportHeight, height); + if (!largestVisualViewportHeight) largestVisualViewportHeight = height; + + document.documentElement.style.setProperty("--picklo-viewport-height", `${height}px`); + document.documentElement.style.setProperty("--picklo-viewport-top", `${top}px`); + document.documentElement.dataset.keyboard = inputFocused && largestVisualViewportHeight - height > 100 + ? "open" + : "closed"; + + if (inputFocused && Math.abs(previousVisualViewportHeight - height) > 24) { + requestAnimationFrame(() => { + scrollToBottom(false); + // iOS Safari can keep the layout viewport tall while shrinking only visualViewport. + // Keeping the app fixed to visualViewport plus resetting page scroll keeps the composer above the keyboard. + if (window.scrollY !== 0) window.scrollTo(0, 0); + }); + } + previousVisualViewportHeight = height; + }; + + update(); + window.addEventListener("resize", update, { passive: true }); + window.addEventListener("orientationchange", () => { + largestVisualViewportHeight = 0; + setTimeout(update, 80); + }, { passive: true }); + viewport?.addEventListener("resize", update, { passive: true }); + viewport?.addEventListener("scroll", update, { passive: true }); + messageInput.addEventListener("focus", () => { + update(); + setTimeout(update, 80); + setTimeout(update, 280); + }); + messageInput.addEventListener("blur", () => setTimeout(update, 80)); +} + function bindAuthEvents() { authSignInTab.addEventListener("click", () => setAuthMode("sign-in")); authSignUpTab.addEventListener("click", () => setAuthMode("sign-up")); authForm.addEventListener("submit", handleAuthSubmit); + authGuestBtn?.addEventListener("click", () => { + authReason = ""; + pendingProtectedAction = null; + pendingGuestChat = null; + setAuthGateOpen(false); + // On mobile, do not immediately reopen the keyboard after dismissing login. + if (window.matchMedia?.("(pointer: fine)")?.matches) messageInput?.focus(); + }); signOutBtn.addEventListener("click", async () => { signOutBtn.disabled = true; try { @@ -305,7 +381,9 @@ function setAuthMode(mode) { authSignUpTab.classList.toggle("active", signingUp); authSignInTab.setAttribute("aria-selected", String(!signingUp)); authSignUpTab.setAttribute("aria-selected", String(signingUp)); - authTitle.textContent = signingUp ? "Create your Picklo account" : "Sign in to use Picklo"; + authTitle.textContent = signingUp + ? "Create your Picklo account" + : (authReason || "Sign in to Picklo"); authSubmitBtn.textContent = signingUp ? "Create account" : "Sign in"; authPassword.autocomplete = signingUp ? "new-password" : "current-password"; setAuthMessage(""); @@ -321,6 +399,10 @@ async function handleAuthSubmit(event) { setAuthMessage(""); try { + if (currentUser?.is_anonymous) { + await supabase.auth.signOut(); + currentUser = null; + } if (authMode === "sign-up") { const { data, error } = await supabase.auth.signUp({ email, @@ -357,47 +439,232 @@ function setAuthMessage(message, success = false) { authMessage.classList.toggle("success", Boolean(success)); } +function isRegisteredUser() { + return Boolean(currentUser && !currentUser.is_anonymous && currentUser.email); +} + +function isGuestUser() { + return !isRegisteredUser(); +} + +function setAuthGateOpen(open, { loading = false } = {}) { + const shouldOpen = Boolean(open); + authGate.hidden = !shouldOpen; + authGate.classList.toggle("session-loading", shouldOpen && loading); + document.documentElement.dataset.auth = shouldOpen ? "open" : "closed"; + + if (shouldOpen) { + messageInput?.blur(); + closeSheets(); + appRoot?.setAttribute("inert", ""); + appRoot?.setAttribute("aria-hidden", "true"); + } else { + appRoot?.removeAttribute("inert"); + appRoot?.removeAttribute("aria-hidden"); + } +} + +function showAuthGate(reason = "Sign in to continue") { + authReason = reason; + setAuthMode("sign-in"); + authGuestBtn?.classList.remove("hidden"); + setAuthMessage(""); + setAuthGateOpen(true); + + // Do not force the iPhone keyboard open when the account screen appears. + const desktopPointer = window.matchMedia?.("(pointer: fine)")?.matches && window.innerWidth > 760; + if (desktopPointer) setTimeout(() => authEmail?.focus(), 60); +} + +function requireRegisteredAccount(action, payload = null) { + if (isRegisteredUser()) return true; + pendingProtectedAction = { action, payload }; + if (isGuestUser()) pendingGuestChat = cloneActiveGuestChat(); + const reason = action === "upload" + ? "Sign in to attach photos or documents" + : action === "download" + ? "Sign in to download this file" + : "Sign in to continue"; + showAuthGate(reason); + return false; +} + +async function getValidRegisteredSession() { + try { + let { data, error } = await supabase.auth.getSession(); + if (error) return null; + let session = data?.session || null; + const validUser = () => Boolean(session?.access_token && session?.user && !session.user.is_anonymous && session.user.email); + if (!validUser()) return null; + + const expiresAtMs = Number(session.expires_at || 0) * 1000; + if (expiresAtMs && expiresAtMs <= Date.now() + 30000) { + const refreshed = await supabase.auth.refreshSession(); + if (refreshed.error) return null; + session = refreshed.data?.session || null; + } + return validUser() ? session : null; + } catch { + return null; + } +} + +async function ensureRegisteredAccount(action, payload = null) { + const session = await getValidRegisteredSession(); + if (session) { + currentUser = session.user; + if (!loadedUserId) loadedUserId = session.user.id; + return true; + } + pendingProtectedAction = { action, payload }; + if (isGuestUser()) pendingGuestChat = cloneActiveGuestChat(); + const reason = action === "upload" + ? "Sign in to attach photos or documents" + : action === "download" + ? "Sign in to download this file" + : "Sign in to continue"; + showAuthGate(reason); + return false; +} + +function cloneActiveGuestChat() { + try { + const chat = getActiveChat(); + return JSON.parse(JSON.stringify(chat)); + } catch { + return null; + } +} + +async function mergePendingGuestChatIntoAccount() { + const guestChat = pendingGuestChat; + if (!guestChat?.id) return; + pendingGuestChat = null; + + const normalized = normalizeChat(guestChat); + if (!normalized) return; + const existingIndex = state.chats.findIndex((chat) => chat.id === normalized.id); + if (existingIndex >= 0) state.chats[existingIndex] = normalized; + else state.chats.unshift(normalized); + state.activeChatId = normalized.id; + state.activeMode = normalized.mode || state.defaultMode || "general"; + + // Transfer generated-file payloads into this account's device-only asset store. + for (const message of normalized.messages || []) { + if (message.artifact || message.attachments?.length) await putLocalMessageAsset(message); + } +} + +async function resumePendingProtectedAction() { + if (!isRegisteredUser() || !pendingProtectedAction) return; + const pending = pendingProtectedAction; + pendingProtectedAction = null; + authReason = ""; + + // iOS blocks file pickers/downloads that are launched long after the original + // user gesture. Resume with a clear instruction instead of a fragile auto-click. + if (pending.action === "upload") { + composerNote.textContent = "Signed in. Tap the paperclip again to add your photo or document."; + setTimeout(() => { composerNote.textContent = getDefaultComposerNote(); }, 3500); + } else if (pending.action === "download") { + composerNote.textContent = "Signed in. Tap Download again to save the file."; + setTimeout(() => { composerNote.textContent = getDefaultComposerNote(); }, 3500); + } +} + async function handleAuthSession(session) { const user = session?.user || null; - if (!user) { - currentUser = null; + + if (!user || user.is_anonymous) { + const leavingRegisteredScope = Boolean(loadedUserId); + currentUser = user; loadedUserId = null; cloudSyncPaused = true; if (cloudSyncTimer) clearTimeout(cloudSyncTimer); cloudSyncTimer = null; lastCloudSnapshot = ""; - state = normalizeState({ ...state, activeChatId: null, chats: [] }); - if (appEventsBound) closeSheets(); - appRoot.hidden = true; - authGate.hidden = false; - setAuthMode("sign-in"); - authEmail.focus(); + + // On sign-out/session loss, always leave the previous account state behind. + // Otherwise a registered user's chats could remain visible and then be saved + // into the guest localStorage scope. + if (leavingRegisteredScope || !appEventsBound || appRoot.hidden) { + await startGuestApp(); + } else { + renderAccount(); + setAuthGateOpen(false); + } return; } if (loadedUserId === user.id && !appRoot.hidden) { currentUser = user; renderAccount(); + setAuthGateOpen(false); + await resumePendingProtectedAction(); return; } if (loadedUserId === user.id && sessionLoadPromise) return sessionLoadPromise; currentUser = user; loadedUserId = user.id; - authGate.hidden = false; appRoot.hidden = true; + authReason = ""; + setAuthMode("sign-in"); + authTitle.textContent = "Opening Picklo…"; setAuthMessage("Loading your conversations…", true); + setAuthGateOpen(true, { loading: true }); sessionLoadPromise = startAuthenticatedApp(user).finally(() => { sessionLoadPromise = null; }); return sessionLoadPromise; } +async function startGuestApp() { + cloudSyncPaused = true; + state = loadState(); + applyTheme(state.theme || "light", false); + await configureInferenceRuntime(); + applyPerformanceProfile(state.performanceProfile || "balanced", false, false); + + if (!appEventsBound) { + bindEvents(); + appEventsBound = true; + } + + // Attachments are account-gated and never exposed from another signed-in user's local file store. + localFiles = []; + ensureActiveChat(); + defaultModeSelect.value = state.defaultMode || "general"; + themeSelect.value = state.theme || "light"; + performanceSelect.value = state.performanceProfile || "balanced"; + autoToolsSelect.value = state.autoTools === false ? "off" : "on"; + renderAgentHistory(); + renderAccount(); + + messageInput.disabled = false; + sendBtn.disabled = false; + messageInput.placeholder = "Message Picklo…"; + + setMode(state.activeMode || state.defaultMode || "general", false); + renderAll(); + appRoot.hidden = false; + setAuthGateOpen(false); + setAuthMessage(""); + saveState(); + + if (activeInferenceMode === "local") { + preserveLocalModelCache(); + autoStartModel().catch((error) => console.warn("Guest model warmup failed:", error)); + } else { + setCloudRuntimeReady(); + } +} + async function startAuthenticatedApp(user) { cloudSyncPaused = true; state = loadStateForUser(user.id); applyTheme(state.theme || "light", false); - populateModels(); + await configureInferenceRuntime(); applyPerformanceProfile(state.performanceProfile || "balanced", false, false); if (!appEventsBound) { @@ -409,6 +676,7 @@ async function startAuthenticatedApp(user) { let cloudLoaded = false; try { await loadChatsFromCloud(); + await rehydrateLocalMessageAssets(); cloudLoaded = true; setCloudSyncStatus("Conversations saved to your account"); } catch (error) { @@ -416,6 +684,7 @@ async function startAuthenticatedApp(user) { setCloudSyncStatus("Cloud sync unavailable — changes will retry", true); } + await mergePendingGuestChatIntoAccount(); ensureActiveChat(); defaultModeSelect.value = state.defaultMode || "general"; themeSelect.value = state.theme || "light"; @@ -431,23 +700,47 @@ async function startAuthenticatedApp(user) { setMode(state.activeMode || state.defaultMode || "general", false); renderAll(); appRoot.hidden = false; - authGate.hidden = true; + setAuthGateOpen(false); setAuthMessage(""); + authReason = ""; cloudSyncPaused = false; if (!cloudLoaded) lastCloudSnapshot = ""; saveState(); - preserveLocalModelCache(); - autoStartModel().catch((error) => console.warn("Authenticated model warmup failed:", error)); + if (activeInferenceMode === "local") { + preserveLocalModelCache(); + autoStartModel().catch((error) => console.warn("Authenticated model warmup failed:", error)); + } else { + setCloudRuntimeReady(); + } + await resumePendingProtectedAction(); } function renderAccount() { - const email = currentUser?.email || "Signed-in user"; - const initial = email.trim().charAt(0).toUpperCase() || "U"; + const registered = isRegisteredUser(); + document.documentElement.dataset.account = registered ? "registered" : "guest"; + const email = registered ? currentUser.email : "Guest mode"; + const initial = registered ? (email.trim().charAt(0).toUpperCase() || "U") : "G"; accountEmail.textContent = email; accountInitial.textContent = initial; settingsAccountInitial.textContent = initial; + signOutBtn.hidden = !registered; + accountSummary?.classList.toggle("guest-account-link", !registered); + if (accountSummary) { + if (!registered) { + accountSummary.setAttribute("role", "button"); + accountSummary.setAttribute("tabindex", "0"); + accountSummary.setAttribute("aria-label", "Sign in or create a Picklo account"); + accountSummary.title = "Sign in or create account"; + } else { + accountSummary.removeAttribute("role"); + accountSummary.removeAttribute("tabindex"); + accountSummary.removeAttribute("aria-label"); + accountSummary.removeAttribute("title"); + } + } + if (!registered) setCloudSyncStatus("Guest mode — chats stay in this browser"); } function setCloudSyncStatus(message, isError = false) { @@ -465,8 +758,13 @@ function registerPickloServiceWorker() { location.reload(); }); - navigator.serviceWorker.register("./sw.js").then((registration) => { - registration.update().catch(() => {}); + navigator.serviceWorker.register("./sw.js?v=8.2.2-fix4", { updateViaCache: "none" }).then((registration) => { + const check = () => registration.update().catch(() => {}); + check(); + window.addEventListener("pageshow", check); + window.addEventListener("focus", check); + document.addEventListener("visibilitychange", () => { if (!document.hidden) check(); }); + setInterval(check, 60000); }).catch(() => {}); } @@ -479,6 +777,14 @@ async function preserveLocalModelCache() { } function bindEvents() { + window.addEventListener("picklo:account-required", (event) => { + requireRegisteredAccount(event.detail?.action || "upload", event.detail?.payload || null); + }); + window.addEventListener("picklo:local-files-mutated", async () => { + localFiles = await listLocalFiles(); + renderFiles(); + renderStats(); + }); newChatBtn.addEventListener("click", createNewChat); headerNewChatBtn.addEventListener("click", createNewChat); chatSearchInput.addEventListener("input", renderChats); @@ -489,18 +795,25 @@ function bindEvents() { }); clearChatsBtn.addEventListener("click", async () => { - if (!confirm("Delete every Picklo conversation saved to your account?")) return; + if (!confirm(isRegisteredUser() ? "Delete every Picklo conversation saved to your account?" : "Delete every guest conversation stored in this browser?")) return; clearChatsBtn.disabled = true; try { - const { error } = await supabase - .from("picklo_conversations") - .delete() - .eq("user_id", currentUser.id); - if (error) throw error; + if (isRegisteredUser()) { + const { error } = await supabase + .from("picklo_conversations") + .delete() + .eq("user_id", currentUser.id); + if (error) throw error; + } + const removedChats = [...state.chats]; cloudSyncPaused = true; state.chats = []; state.activeChatId = null; + await deleteLocalMessageAssetsForChats(removedChats); + window.dispatchEvent(new CustomEvent("picklo:local-chats-deleted", { + detail: { chatIds: removedChats.map((chat) => chat.id).filter(Boolean) } + })); ensureActiveChat(); lastCloudSnapshot = ""; cloudSyncPaused = false; @@ -514,12 +827,25 @@ function bindEvents() { }); settingsBtn.addEventListener("click", () => openSheet(settingsSheet)); - accountBtn.addEventListener("click", () => openSheet(settingsSheet)); + accountBtn.addEventListener("click", () => { + if (isRegisteredUser()) openSheet(settingsSheet); + else showAuthGate("Sign in to your Picklo account"); + }); + accountSummary?.addEventListener("click", () => { + if (isRegisteredUser()) return; + closeSheets(); + showAuthGate("Sign in to your Picklo account"); + }); + accountSummary?.addEventListener("keydown", (event) => { + if (isRegisteredUser() || !["Enter", " "].includes(event.key)) return; + event.preventDefault(); + closeSheets(); + showAuthGate("Sign in to your Picklo account"); + }); panelModelBtn.addEventListener("click", () => openSheet(settingsSheet)); startButton.addEventListener("click", () => openSheet(settingsSheet)); toolsBtn.addEventListener("click", () => { renderNotes(); renderAgentHistory(); openSheet(toolsSheet); }); - composerToolsBtn.addEventListener("click", () => { renderNotes(); renderAgentHistory(); openSheet(toolsSheet); }); document.querySelectorAll("[data-tool-tab]").forEach((button) => button.addEventListener("click", () => switchToolTab(button.dataset.toolTab))); calculatorRunBtn.addEventListener("click", runCalculator); calculatorInput.addEventListener("keydown", (event) => { if (event.key === "Enter") runCalculator(); }); @@ -541,7 +867,6 @@ function bindEvents() { }); mobileMenuBtn.addEventListener("click", () => openSheet(conversationsSheet)); - modeButton.addEventListener("click", () => openSheet(modeSheet)); document.querySelectorAll("[data-close-sheet]").forEach((button) => { button.addEventListener("click", closeSheets); @@ -605,7 +930,8 @@ function bindEvents() { performanceSelect.addEventListener("change", async () => { state.modelPreference = "auto"; applyPerformanceProfile(performanceSelect.value, true, true); - await loadSelectedModel({ automatic: true }); + if (activeInferenceMode === "local") await loadSelectedModel({ automatic: true }); + else setCloudRuntimeReady(); }); loadModelBtn.addEventListener("click", () => loadSelectedModel({ automatic: false })); @@ -623,23 +949,11 @@ function bindEvents() { } }); - quickActions.addEventListener("click", (event) => { - const button = event.target.closest("[data-followup]"); - if (!button || !engine || isGenerating) return; - messageInput.value = button.dataset.followup || ""; - autoResize(); - messageInput.focus(); - }); - stopBtn.addEventListener("click", stopGeneration); messages.addEventListener("click", (event) => { const prompt = event.target.closest("[data-prompt]"); if (prompt) { - if (!engine) { - openSheet(settingsSheet); - return; - } const mode = prompt.dataset.mode; if (mode) setMode(mode); messageInput.value = prompt.dataset.prompt || ""; @@ -659,7 +973,30 @@ function bindEvents() { } }); - fileInput.addEventListener("change", handleFiles); + attachButton?.addEventListener("click", (event) => { + event.preventDefault(); + // Keep fileInput.click() inside the original tap gesture so iOS Safari opens + // the picker reliably. The change handler validates session freshness again. + if (!requireRegisteredAccount("upload")) return; + fileInput?.click(); + }); + + document.querySelectorAll('label[for="fileInput"]').forEach((label) => { + label.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (!requireRegisteredAccount("upload")) return; + fileInput?.click(); + }, true); + }); + + fileInput.addEventListener("change", async (event) => { + if (!(await ensureRegisteredAccount("upload"))) { + event.target.value = ""; + return; + } + await handleFiles(event); + }); clearContextBtn.addEventListener("click", () => { activeFileSources = []; renderContext(); @@ -816,7 +1153,7 @@ function loadStateForUser(userId) { function saveState() { try { state.version = APP_VERSION; - const key = currentUser ? `${USER_STATE_PREFIX}:${currentUser.id}` : STORAGE_KEY; + const key = isRegisteredUser() ? `${USER_STATE_PREFIX}:${currentUser.id}` : STORAGE_KEY; localStorage.setItem(key, JSON.stringify(state)); } catch (error) { console.warn("Picklo state save failed:", error); @@ -837,22 +1174,26 @@ function createCloudSnapshot() { mode: MODE_PROMPTS[chat.mode] ? chat.mode : "general", createdAt: normalizeTimestamp(chat.createdAt), updatedAt: normalizeTimestamp(chat.updatedAt), - messages: chat.messages.map((message) => ({ - id: message.id, - role: message.role, - content: String(message.content || ""), - modelContent: message.modelContent ? String(message.modelContent) : null, - sources: Array.isArray(message.sources) ? message.sources : [], - tool: String(message.tool || ""), - attachments: Array.isArray(message.attachments) ? message.attachments : [], - artifact: message.artifact || null, - createdAt: normalizeTimestamp(message.createdAt) - })) + messages: chat.messages + .filter((message) => String(message.content || "").trim() || (!message.attachments?.length && !message.artifact)) + .map((message) => ({ + id: message.id, + role: message.role, + content: String(message.content || ""), + // A generated file's full source lives only in the local artifact record. + modelContent: message.artifact ? null : (message.modelContent ? String(message.modelContent) : null), + // Local filenames/source chips are device-only metadata. + sources: [], + tool: message.artifact || message.attachments?.length ? "" : String(message.tool || ""), + attachments: [], + artifact: null, + createdAt: normalizeTimestamp(message.createdAt) + })) }))); } function scheduleCloudSync() { - if (cloudSyncPaused || !currentUser) return; + if (cloudSyncPaused || !isRegisteredUser()) return; const snapshot = createCloudSnapshot(); if (snapshot === lastCloudSnapshot) return; @@ -874,7 +1215,7 @@ function scheduleCloudSync() { } async function flushCloudSync(snapshot, userId) { - if (!currentUser || currentUser.id !== userId) return; + if (!isRegisteredUser() || currentUser.id !== userId) return; const chats = JSON.parse(snapshot); if (!chats.length) return; @@ -917,7 +1258,7 @@ async function flushCloudSync(snapshot, userId) { } async function loadChatsFromCloud() { - if (!currentUser) return; + if (!isRegisteredUser()) return; const [conversationResult, messageResult] = await Promise.all([ supabase .from("picklo_conversations") @@ -942,8 +1283,8 @@ async function loadChatsFromCloud() { modelContent: row.model_content, sources: Array.isArray(row.sources) ? row.sources : [], tool: row.tool || "", - attachments: Array.isArray(row.attachments) ? row.attachments : [], - artifact: row.artifact || null, + attachments: [], + artifact: null, createdAt: row.created_at })); messagesByChat.set(row.conversation_id, list); @@ -965,6 +1306,89 @@ async function loadChatsFromCloud() { lastCloudSnapshot = createCloudSnapshot(); } +const MESSAGE_ASSET_DB = "picklo-local-message-assets-v1"; +const MESSAGE_ASSET_STORE = "assets"; + +function openMessageAssetDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(MESSAGE_ASSET_DB, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(MESSAGE_ASSET_STORE)) { + db.createObjectStore(MESSAGE_ASSET_STORE, { keyPath: "messageId" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function putLocalMessageAsset(message) { + if (!message?.id || (!message.artifact && !message.attachments?.length)) return; + try { + const db = await openMessageAssetDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(MESSAGE_ASSET_STORE, "readwrite"); + tx.objectStore(MESSAGE_ASSET_STORE).put({ + messageId: message.id, + ownerId: isRegisteredUser() ? currentUser.id : "guest", + attachments: Array.isArray(message.attachments) ? message.attachments : [], + artifact: message.artifact || null, + createdAt: Date.now() + }); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); + } catch (error) { + console.warn("Could not persist local message asset:", error); + } +} + +async function rehydrateLocalMessageAssets() { + try { + const db = await openMessageAssetDB(); + const rows = await new Promise((resolve, reject) => { + const tx = db.transaction(MESSAGE_ASSET_STORE, "readonly"); + const req = tx.objectStore(MESSAGE_ASSET_STORE).getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + const ownerId = isRegisteredUser() ? currentUser.id : "guest"; + const byId = new Map(rows.filter((row) => row?.ownerId === ownerId).map((row) => [row.messageId, row])); + for (const chat of state.chats) { + for (const message of chat.messages || []) { + const local = byId.get(message.id); + if (!local) continue; + if (local.attachments?.length) message.attachments = local.attachments; + if (local.artifact) message.artifact = local.artifact; + } + } + } catch (error) { + console.warn("Could not restore local message assets:", error); + } +} + +async function deleteLocalMessageAssetsForChat(chat) { + const ids = new Set((chat?.messages || []).map((message) => message?.id).filter(Boolean)); + if (!ids.size) return; + try { + const db = await openMessageAssetDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(MESSAGE_ASSET_STORE, "readwrite"); + const store = tx.objectStore(MESSAGE_ASSET_STORE); + ids.forEach((id) => store.delete(id)); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); + } catch (error) { + console.warn("Could not remove local message assets for deleted chat:", error); + } +} + +async function deleteLocalMessageAssetsForChats(chats) { + for (const chat of chats || []) await deleteLocalMessageAssetsForChat(chat); +} + function makeChat() { return { id: createId(), @@ -1006,7 +1430,7 @@ function createNewChat() { autoResize(); setMode(state.activeMode, false); renderAll(); - if (engine) messageInput.focus(); + messageInput.focus(); } function switchChat(id) { @@ -1024,22 +1448,27 @@ function switchChat(id) { async function deleteChat(id) { if (isGenerating) return; - const { error } = await supabase - .from("picklo_conversations") - .delete() - .eq("id", id) - .eq("user_id", currentUser.id); - if (error) { - setCloudSyncStatus(error.message || "Could not delete the conversation.", true); - return; + if (isRegisteredUser()) { + const { error } = await supabase + .from("picklo_conversations") + .delete() + .eq("id", id) + .eq("user_id", currentUser.id); + if (error) { + setCloudSyncStatus(error.message || "Could not delete the conversation.", true); + return; + } } + const removedChat = state.chats.find((chat) => chat.id === id) || null; cloudSyncPaused = true; + if (removedChat) await deleteLocalMessageAssetsForChat(removedChat); + window.dispatchEvent(new CustomEvent("picklo:local-chats-deleted", { detail: { chatIds: [id] } })); state.chats = state.chats.filter((chat) => chat.id !== id); if (state.activeChatId === id) state.activeChatId = state.chats[0]?.id || null; ensureActiveChat(); lastCloudSnapshot = ""; - cloudSyncPaused = false; + cloudSyncPaused = !isRegisteredUser(); saveState(); renderAll(); } @@ -1055,10 +1484,8 @@ function setMode(mode, persist = true) { button.classList.toggle("active", button.dataset.mode === mode); }); - const label = mode.charAt(0).toUpperCase() + mode.slice(1); - activeModeLabel.textContent = label; assistantSubtitle.textContent = { - general: "Local, private and ready", + general: activeInferenceMode === "cloud" ? "Cloud AI for this device" : "Local, private and ready", write: "Writing and refinement", code: "Software reasoning", analyze: "Document analysis" @@ -1077,8 +1504,7 @@ function renderAll() { } function renderHeader() { - const chat = getActiveChat(); - headerChatTitle.textContent = chat.title || "New chat"; + headerChatTitle.textContent = "Picklo"; } function renderChats() { @@ -1179,6 +1605,7 @@ function formatConversationTime(value) { function renderMessages() { const chat = getActiveChat(); messages.innerHTML = ""; + messages.classList.toggle("has-chat", Boolean(chat.messages.length)); if (!chat.messages.length) { messages.innerHTML = ` @@ -1186,25 +1613,6 @@ function renderMessages() {

How can I help?

Talk to Picklo naturally. Ask a question, work through an idea, write something, code, or attach a file.

- -
- - - - -
`; return; } @@ -1366,6 +1774,7 @@ function renderArtifactCard(container, artifact) { download.className = "artifact-download"; download.textContent = "Download"; download.addEventListener("click", async () => { + if (!requireRegisteredAccount("download", artifact)) return; download.disabled = true; download.textContent = "Preparing…"; try { @@ -1642,25 +2051,108 @@ function getRuntimeCapabilities() { deviceMemory: navigator.deviceMemory, hardwareConcurrency: navigator.hardwareConcurrency, userAgent: navigator.userAgent, + platform: navigator.platform, viewportWidth: window.innerWidth, maxTouchPoints: navigator.maxTouchPoints, - coarsePointer: window.matchMedia?.("(pointer: coarse)")?.matches || false + coarsePointer: window.matchMedia?.("(pointer: coarse)")?.matches || false, + hasWebGPU: "gpu" in navigator }); } +async function ensureWebLLMModule() { + if (webllm) return webllm; + if (!webllmModulePromise) { + webllmModulePromise = import("https://esm.run/@mlc-ai/web-llm") + .then((module) => { + webllm = module; + return module; + }) + .catch((error) => { + webllmModulePromise = null; + throw error; + }); + } + return webllmModulePromise; +} + +async function configureInferenceRuntime() { + runtimeCapabilities = getRuntimeCapabilities(); + const decision = selectInferenceMode(runtimeCapabilities); + activeInferenceMode = decision.mode; + inferenceReason = decision.reason; + + if (activeInferenceMode === "local") { + try { + await ensureWebLLMModule(); + populateModels(); + modelSettingGroup.hidden = false; + modelSelect.disabled = false; + loadModelBtn.disabled = false; + performanceHelp.textContent = "Capable desktop computers use a private local model. Picklo falls back to Gemini automatically if local AI cannot start."; + modelHelpText.textContent = "Advanced local override. Changing the model manually can use more memory and make responses slower."; + return; + } catch (error) { + console.warn("The local AI module could not load; using cloud fallback:", error); + activeInferenceMode = "cloud"; + inferenceReason = "module-unavailable"; + } + } + + configureCloudControls(); +} + +function configureCloudControls() { + modelSettingGroup.hidden = true; + modelSelect.disabled = true; + loadModelBtn.disabled = true; + performanceHelp.textContent = "Picklo uses Gemini on this device, so no large AI model is downloaded. Performance changes response length and depth."; + composerNote.textContent = getDefaultComposerNote(); +} + +function switchToCloudRuntime(reason = "local-fallback") { + activeInferenceMode = "cloud"; + inferenceReason = reason; + runtimeCapabilities = getRuntimeCapabilities(); + configureCloudControls(); + setCloudRuntimeReady(); +} + +function setCloudRuntimeReady() { + const detail = { + phone: "Gemini • no phone model download", + tablet: "Gemini • no tablet model download", + "webgpu-unavailable": "Gemini • WebGPU not required", + "limited-hardware": "Gemini • optimized for this computer", + "module-unavailable": "Gemini • local module fallback", + "local-fallback": "Gemini • local model fallback" + }[inferenceReason] || "Gemini • cloud AI"; + setRuntime("Picklo is ready", detail, "ready"); + performanceStatus.textContent = `${getPerformanceProfile().label} • cloud`; + composerNote.textContent = getDefaultComposerNote(); +} + +function getDefaultComposerNote() { + if (!isRegisteredUser()) { + return activeInferenceMode === "cloud" + ? "Guest chat stays in this browser. Sign in only to attach photos/documents or download files." + : "Guest chat stays in this browser. Sign in only to attach photos/documents or download files."; + } + return activeInferenceMode === "cloud" + ? "Chats sync to your account. Files and photos stay only in this browser; Gemini processes them transiently when needed." + : "Chats sync to your account. The AI model, files and photos stay on this device."; +} + function applyPerformanceProfile(profileName, persist = true, updateModel = true) { const normalized = PERFORMANCE_PROFILES[profileName] ? profileName : "fast"; const profile = PERFORMANCE_PROFILES[normalized]; state.performanceProfile = normalized; - const capabilities = getRuntimeCapabilities(); - performanceStatus.textContent = capabilities.isPhone - ? `${profile.label} • phone optimized` - : profile.label; + const capabilities = runtimeCapabilities || getRuntimeCapabilities(); + performanceStatus.textContent = activeInferenceMode === "cloud" ? `${profile.label} • cloud` : profile.label; if (performanceSelect) performanceSelect.value = normalized; - if (updateModel) { + if (updateModel && activeInferenceMode === "local") { const recommended = recommendModelForDevice(normalized, getAvailableModelIds(), capabilities); if (recommended) { state.selectedModel = recommended; @@ -1672,12 +2164,16 @@ function applyPerformanceProfile(profileName, persist = true, updateModel = true } async function autoStartModel() { + if (activeInferenceMode === "cloud") { + setCloudRuntimeReady(); + return null; + } if (engine) return engine; if (modelLoadPromise) return modelLoadPromise; if (!("gpu" in navigator)) { - setRuntime("WebGPU unavailable", "Use a WebGPU-capable browser", "error"); - return; + switchToCloudRuntime("webgpu-unavailable"); + return null; } if (state.modelPreference !== "manual") { @@ -1755,6 +2251,10 @@ async function resetModelRuntime() { async function loadSelectedModel(options = {}) { const { automatic = false } = options; + if (activeInferenceMode === "cloud") { + setCloudRuntimeReady(); + return null; + } const selected = modelSelect.value || state.selectedModel; if (!selected || isGenerating) return; @@ -1762,12 +2262,9 @@ async function loadSelectedModel(options = {}) { if (modelLoadPromise) return modelLoadPromise; if (!("gpu" in navigator)) { - setRuntime("WebGPU unavailable", "Use a WebGPU-capable browser", "error"); - if (!automatic) { - closeSheets(); - addError("WebGPU is unavailable in this browser. Picklo V8.1 needs a WebGPU-capable browser for local inference."); - } - return; + switchToCloudRuntime("webgpu-unavailable"); + if (!automatic) closeSheets(); + return null; } const profile = getPerformanceProfile(); @@ -1879,13 +2376,11 @@ async function loadSelectedModel(options = {}) { } } - setRuntime("Picklo could not start", "This device may not have enough WebGPU memory", "error"); - loadModelBtn.textContent = "Try again"; - messageInput.placeholder = "Picklo could not start"; - - if (!automatic) { - addError(`The selected model could not start. ${lastError?.message || String(lastError)}`); - } + console.warn("All local model candidates failed; switching to Gemini:", lastError); + switchToCloudRuntime("local-fallback"); + loadModelBtn.textContent = "Cloud AI active"; + messageInput.placeholder = "Message Picklo…"; + if (!automatic) closeSheets(); return null; } finally { loadModelBtn.disabled = false; @@ -1913,8 +2408,10 @@ function setRuntime(title, detail, stateName = "idle") { } if (stateName === "ready") { - const modelName = friendlyModelName(loadedModelId || state.selectedModel); - modelStatus.textContent = `${modelName} • Local`; + const modelName = activeInferenceMode === "cloud" + ? "Gemini Cloud" + : friendlyModelName(loadedModelId || state.selectedModel); + modelStatus.textContent = activeInferenceMode === "cloud" ? "Gemini • Cloud" : `${modelName} • Local`; panelModelName.textContent = modelName; sidebarModelText.textContent = modelName; startButton.title = "Picklo settings"; @@ -1928,6 +2425,93 @@ function setRuntime(title, detail, stateName = "idle") { } } +async function generatePrimaryCompletion(messagesForModel, sampling, profile, activityLabel) { + if (activeInferenceMode === "cloud") { + const controller = new AbortController(); + cloudAbortController = controller; + try { + const result = await requestCloudCompletion({ + messages: messagesForModel, + temperature: sampling.temperature, + topP: sampling.topP, + maxTokens: profile.maxTokens, + profile: state.performanceProfile, + purpose: "answer", + signal: controller.signal + }); + setAgentActivity("Preparing the answer", activityLabel); + return { + text: result.text, + completionTokens: Number(result.usage?.candidatesTokenCount || result.usage?.totalOutputTokens || 0) + }; + } catch (error) { + if (!isRegisteredUser() && Number(error?.status || 0) === 401) { + throw new Error("Guest cloud chat is temporarily unavailable. Please try again in a moment."); + } + throw error; + } finally { + if (cloudAbortController === controller) cloudAbortController = null; + } + } + + if (!engine) throw new Error("The local AI model is not ready."); + const stream = await engine.chat.completions.create({ + messages: messagesForModel, + temperature: sampling.temperature, + top_p: sampling.topP, + max_tokens: profile.maxTokens, + stream: true, + stream_options: { include_usage: true } + }); + + let text = ""; + let completionTokens = 0; + let receivedFirstToken = false; + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta?.content || ""; + if (chunk.usage?.completion_tokens) completionTokens = chunk.usage.completion_tokens; + if (!delta) continue; + if (!receivedFirstToken) { + receivedFirstToken = true; + setAgentActivity("Preparing the answer", activityLabel); + } + text += delta; + } + return { text, completionTokens }; +} + +async function generateOneShotCompletion(messagesForModel, options = {}) { + const { temperature = 0.1, topP = 0.8, maxTokens = 800, purpose = "review" } = options; + if (activeInferenceMode === "cloud") { + const controller = new AbortController(); + cloudAbortController = controller; + try { + const result = await requestCloudCompletion({ + messages: messagesForModel, + temperature, + topP, + maxTokens, + profile: state.performanceProfile, + purpose, + signal: controller.signal + }); + return result.text; + } finally { + if (cloudAbortController === controller) cloudAbortController = null; + } + } + + if (!engine) throw new Error("The local AI model is not ready."); + const result = await engine.chat.completions.create({ + messages: messagesForModel, + temperature, + top_p: topP, + max_tokens: maxTokens, + stream: false + }); + return String(result?.choices?.[0]?.message?.content || ""); +} + async function sendMessage() { const content = messageInput.value.trim(); if (!content || isGenerating) return; @@ -1995,7 +2579,7 @@ async function sendMessage() { activeFileSources = sourceNames; renderContext(); - if (!engine) { + if (activeInferenceMode === "local" && !engine) { setAgentActivity("Waiting for the local model to finish starting", "Model"); try { await autoStartModel(); @@ -2004,24 +2588,11 @@ async function sendMessage() { } } - if (!engine) { - chat.messages.push({ - role: "assistant", - content: "I could not start the local language model on this device. My built-in local tools still work, but normal AI replies need a WebGPU-compatible model to finish loading.", - createdAt: Date.now(), - tool: "Runtime" - }); - chat.updatedAt = Date.now(); - saveState(); - renderMessages(); - renderChats(); - setAgentIdleSoon(); - return; - } + if (activeInferenceMode === "local" && !engine) switchToCloudRuntime("local-fallback"); setAgentActivity( route?.toolName ? `Using ${route.toolName} and answering` : "Thinking", - route?.toolName || "Model" + route?.toolName || (activeInferenceMode === "cloud" ? "Cloud AI" : "Model") ); const assistantBubble = appendMessageToDOM("assistant", "", { @@ -2032,38 +2603,17 @@ async function sendMessage() { assistantBubble.textContent = ""; let fullReply = ""; - let completionTokens = 0; const generationStartedAt = performance.now(); const profile = getPerformanceProfile(); const sampling = getAdaptiveSampling(content, profile, state.activeMode); - - const stream = await engine.chat.completions.create({ - messages: buildModelMessages(chat, retrieved, route?.toolContext || "", requestedArtifact), - temperature: sampling.temperature, - top_p: sampling.topP, - max_tokens: profile.maxTokens, - stream: true, - stream_options: { include_usage: true } - }); - - let receivedFirstToken = false; - - for await (const chunk of stream) { - const delta = chunk.choices?.[0]?.delta?.content || ""; - - if (chunk.usage?.completion_tokens) { - completionTokens = chunk.usage.completion_tokens; - } - - if (!delta) continue; - - if (!receivedFirstToken) { - receivedFirstToken = true; - setAgentActivity("Preparing the answer", route?.toolName || "Model"); - } - - fullReply += delta; - } + const completion = await generatePrimaryCompletion( + buildModelMessages(chat, retrieved, route?.toolContext || "", requestedArtifact), + sampling, + profile, + route?.toolName || (activeInferenceMode === "cloud" ? "Cloud AI" : "Model") + ); + const completionTokens = completion.completionTokens; + fullReply = completion.text; fullReply = cleanAssistantReply(fullReply); @@ -2080,11 +2630,11 @@ async function sendMessage() { tokensPerSecond: completionTokens ? completionTokens / elapsedSeconds : null }; - if (lastGenerationStats.tokensPerSecond) { + if (activeInferenceMode === "local" && lastGenerationStats.tokensPerSecond) { performanceStatus.textContent = `${profile.label} • ${lastGenerationStats.tokensPerSecond.toFixed(1)} tok/s`; } else { - performanceStatus.textContent = profile.label; + performanceStatus.textContent = activeInferenceMode === "cloud" ? `${profile.label} • cloud` : profile.label; } assistantBubble.classList.remove("typing"); @@ -2105,7 +2655,8 @@ async function sendMessage() { assistantBubble.textContent = artifact ? `Your ${artifact.label} file is ready.` : fullReply; scrollToBottom(); - chat.messages.push({ + const assistantMessage = { + id: createId(), role: "assistant", content: displayReply, modelContent: fullReply, @@ -2113,7 +2664,9 @@ async function sendMessage() { sources: sourceNames, tool: artifact ? "File created" : route?.toolName || (retrieved.length ? "File search" : ""), artifact - }); + }; + chat.messages.push(assistantMessage); + if (artifact) await putLocalMessageAsset(assistantMessage); if (pendingFileIds.length) { const analyzedIds = new Set(pendingFileIds); for (const message of chat.messages) { @@ -2140,6 +2693,8 @@ async function sendMessage() { } catch (error) { console.error(error); + if (isAbortError(error)) generationWasStopped = true; + chat.messages.push({ role: "assistant", content: generationWasStopped @@ -2281,6 +2836,7 @@ function shouldReviewAnswer(input) { function shouldVerifyAnswer(input, profile) { if (!shouldReviewAnswer(input)) return false; + if (activeInferenceMode === "cloud") return Boolean(profile.verify); if (profile.verify) return true; if (state.performanceProfile !== "balanced") return false; @@ -2296,8 +2852,7 @@ function shouldVerifyAnswer(input, profile) { async function reviewAnswer(input, draft, profile) { if (!draft.trim()) return draft; try { - const result = await engine.chat.completions.create({ - messages: [ + const reviewed = await generateOneShotCompletion([ { role: "system", content: "You are Picklo's final-answer verifier. Silently inspect the draft for factual overconfidence, contradictions, missed requirements, unsafe advice, calculation errors and incomplete code. Enforce the supplied response contract exactly. Return a corrected final answer only. Preserve correct content and do not mention reviewing, tools, policies or internal reasoning." @@ -2306,14 +2861,14 @@ async function reviewAnswer(input, draft, profile) { role: "user", content: `Original request:\n${input}\n\n${buildResponseContract(input, [], null)}\n\nDraft answer:\n${draft}` } - ], + ], { temperature: 0.1, - top_p: 0.8, - max_tokens: Math.min(profile.maxTokens, 1000), - stream: false + topP: 0.8, + maxTokens: Math.min(profile.maxTokens, 1000), + purpose: "review" }); - const reviewed = cleanAssistantReply(result?.choices?.[0]?.message?.content || ""); - return reviewed.trim() || draft; + const cleaned = cleanAssistantReply(reviewed); + return cleaned.trim() || draft; } catch (error) { console.warn("Answer verification skipped:", error); return draft; @@ -2326,8 +2881,7 @@ async function repairArtifactIfNeeded(input, request, draft, profile) { if (!validationError) return draft; try { - const result = await engine.chat.completions.create({ - messages: [ + const repaired = await generateOneShotCompletion([ { role: "system", content: `Repair the requested ${request.label}. Return the complete corrected file content only. Do not explain the repair or include placeholders.` @@ -2336,14 +2890,14 @@ async function repairArtifactIfNeeded(input, request, draft, profile) { role: "user", content: `Original request:\n${input}\n\nValidation problem:\n${validationError}\n\nDraft file:\n${draft}` } - ], + ], { temperature: 0.08, - top_p: 0.8, - max_tokens: profile.maxTokens, - stream: false + topP: 0.8, + maxTokens: profile.maxTokens, + purpose: "repair" }); - const repaired = cleanAssistantReply(result?.choices?.[0]?.message?.content || ""); - return repaired.trim() || draft; + const cleaned = cleanAssistantReply(repaired); + return cleaned.trim() || draft; } catch (error) { console.warn("Artifact repair skipped:", error); return draft; @@ -2387,12 +2941,13 @@ function parseRememberCommand(text) { } async function stopGeneration() { - if (!isGenerating || !engine) return; + if (!isGenerating) return; generationWasStopped = true; stopBtn.disabled = true; stopBtn.textContent = "Stopping…"; + cloudAbortController?.abort(); try { - await engine.interruptGenerate(); + if (engine && activeInferenceMode === "local") await engine.interruptGenerate(); } catch (error) { console.warn("Picklo generation interruption failed:", error); } @@ -2464,6 +3019,10 @@ function renderMemory() { } async function handleFiles(event) { + if (!(await ensureRegisteredAccount("upload"))) { + event.target.value = ""; + return; + } const files = [...(event.target.files || [])]; event.target.value = ""; if (!files.length) return; @@ -2483,6 +3042,8 @@ async function handleFiles(event) { type: file.type || file.name.split(".").pop() || "text", size: file.size, text: cleaned, + blob: file, + ownerId: currentUser?.id || null, createdAt: Date.now() }; await putLocalFile(document); @@ -2502,12 +3063,15 @@ async function handleFiles(event) { localFiles = await listLocalFiles(); if (uploaded.length) { const chat = getActiveChat(); - chat.messages.push({ + const attachmentMessage = { + id: createId(), role: "user", content: "", attachments: uploaded, createdAt: Date.now() - }); + }; + chat.messages.push(attachmentMessage); + await putLocalMessageAsset(attachmentMessage); chat.pendingFileIds = [...new Set([...(chat.pendingFileIds || []), ...uploaded.map((file) => file.id)])]; chat.mode = "analyze"; chat.updatedAt = Date.now(); @@ -2526,7 +3090,7 @@ async function handleFiles(event) { ? `${uploaded.length} document${uploaded.length === 1 ? "" : "s"} uploaded and ready for your next question.` : "No readable documents were added."; setTimeout(() => { - composerNote.textContent = "Chats sync to your account. Models and attached file text stay on this device."; + composerNote.textContent = getDefaultComposerNote(); }, 2500); } @@ -2644,11 +3208,17 @@ async function putLocalFile(doc) { async function listLocalFiles() { try { + if (!isRegisteredUser()) return []; + const ownerId = currentUser.id; const db = await openFileDB(); return await new Promise((resolve, reject) => { const tx = db.transaction(FILE_STORE, "readonly"); const request = tx.objectStore(FILE_STORE).getAll(); - request.onsuccess = () => resolve((request.result || []).sort((a, b) => b.createdAt - a.createdAt)); + request.onsuccess = () => resolve( + (request.result || []) + .filter((file) => file?.ownerId === ownerId) + .sort((a, b) => b.createdAt - a.createdAt) + ); request.onerror = () => reject(request.error); }); } catch { @@ -2657,6 +3227,9 @@ async function listLocalFiles() { } async function deleteLocalFile(id) { + if (!isRegisteredUser()) return; + const record = localFiles.find((file) => file?.id === id && file?.ownerId === currentUser.id); + if (!record) return; const db = await openFileDB(); await new Promise((resolve, reject) => { const tx = db.transaction(FILE_STORE, "readwrite"); @@ -2664,6 +3237,9 @@ async function deleteLocalFile(id) { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); + window.dispatchEvent(new CustomEvent("picklo:local-file-deleted", { + detail: { name: record.name, size: record.size, ownerId: currentUser.id } + })); localFiles = await listLocalFiles(); renderFiles(); @@ -3152,7 +3728,7 @@ function showLocalTime() { const now=new Date();localToolOutput.textContent=`${n function useFileSearchTool() { closeSheets(); if(!localFiles.length){messageInput.value="I want to search my local files, but I have not added any yet.";} else {setMode("analyze",true);messageInput.value="Search my local files for: ";} autoResize();messageInput.focus(); } async function regenerateLastAssistant() { - if(!engine||isGenerating)return;const chat=getActiveChat();let index=-1;for(let i=chat.messages.length-1;i>=0;i--){if(chat.messages[i].role==="assistant"){index=i;break;}}if(index<0)return;let user=null;for(let i=index-1;i>=0;i--){if(chat.messages[i].role==="user"){user=chat.messages[i];break;}}if(!user)return;chat.messages=chat.messages.slice(0,index);if(chat.messages.at(-1)?.role==="user")chat.messages.pop();saveState();renderMessages();messageInput.value=user.content;autoResize();await sendMessage(); + if(!currentUser||isGenerating)return;const chat=getActiveChat();let index=-1;for(let i=chat.messages.length-1;i>=0;i--){if(chat.messages[i].role==="assistant"){index=i;break;}}if(index<0)return;let user=null;for(let i=index-1;i>=0;i--){if(chat.messages[i].role==="user"){user=chat.messages[i];break;}}if(!user)return;chat.messages=chat.messages.slice(0,index);if(chat.messages.at(-1)?.role==="user")chat.messages.pop();saveState();renderMessages();messageInput.value=user.content;autoResize();await sendMessage(); } async function exportData() { @@ -3169,7 +3745,7 @@ async function exportData() { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `picklo-v8-backup-${new Date().toISOString().slice(0, 10)}.json`; + link.download = `picklo-v8.2.2-backup-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(link); link.click(); link.remove(); @@ -3202,7 +3778,8 @@ async function importData(event) { } localFiles = await listLocalFiles(); - populateModels(); + if (activeInferenceMode === "local") populateModels(); + else setCloudRuntimeReady(); setMode(state.activeMode || state.defaultMode || "general", false); renderAll(); closeSheets(); @@ -3242,41 +3819,61 @@ function scrollToBottom(smooth = true) { function renderMarkdownInto(container, markdown) { container.innerHTML = ""; const source = String(markdown || ""); + const plainLanguage = detectPlainCodeLanguage(source); + if (!source.includes("```") && plainLanguage) { + appendCodeBlock(container, plainLanguage, source); + return; + } + const fence = /```([^\n`]*)\n?([\s\S]*?)```/g; let index = 0; let match; while ((match = fence.exec(source)) !== null) { renderTextMarkdown(container, source.slice(index, match.index)); - - const block = document.createElement("div"); - block.className = "code-block"; - - const head = document.createElement("div"); - head.className = "code-head"; - - const language = document.createElement("span"); - language.textContent = (match[1] || "code").trim() || "code"; - - const copy = document.createElement("button"); - copy.type = "button"; - copy.className = "copy-code"; - copy.textContent = "Copy"; - - const pre = document.createElement("pre"); - const code = document.createElement("code"); - code.textContent = match[2].replace(/\n$/, ""); - pre.appendChild(code); - - head.append(language, copy); - block.append(head, pre); - container.appendChild(block); + appendCodeBlock(container, (match[1] || "code").trim() || "code", match[2].replace(/\n$/, "")); index = fence.lastIndex; } renderTextMarkdown(container, source.slice(index)); } +function appendCodeBlock(container, languageName, value) { + const block = document.createElement("div"); + block.className = "code-block"; + + const head = document.createElement("div"); + head.className = "code-head"; + const language = document.createElement("span"); + language.textContent = languageName || "code"; + const copy = document.createElement("button"); + copy.type = "button"; + copy.className = "copy-code"; + copy.textContent = "Copy"; + + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = String(value || ""); + pre.appendChild(code); + head.append(language, copy); + block.append(head, pre); + container.appendChild(block); +} + +function detectPlainCodeLanguage(source) { + const text = String(source || "").trim(); + if (text.length < 40 || !text.includes("\n")) return ""; + if (/]*>/gi) || []).length; + if (tagCount >= 4) return "html"; + const cssSignals = (text.match(/[.#]?[a-z][\w-]*(?:\s+[.#]?[\w-]+)*\s*\{|[\w-]+\s*:\s*[^;{}]+;/gi) || []).length; + if (cssSignals >= 5) return "css"; + if (/\b(?:const|let|var)\s+[A-Za-z_$]|\bfunction\s+[A-Za-z_$]|=>\s*\{/m.test(text) && (text.match(/[{};]/g) || []).length >= 6) return "javascript"; + if (/^(?:from\s+\S+\s+import|import\s+\S+|def\s+\w+\s*\(|class\s+\w+\s*:)/m.test(text)) return "python"; + if (/^(?:SELECT|INSERT|UPDATE|DELETE|CREATE\s+TABLE|ALTER\s+TABLE)\b/im.test(text)) return "sql"; + return ""; +} + function renderTextMarkdown(container, text) { if (!text) return; @@ -3345,18 +3942,47 @@ function renderTextMarkdown(container, text) { } function appendInline(parent, text) { - const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*)/g; + const source = String(text || ""); + const regex = /(\[([^\]]+)\]\s*\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)|https?:\/\/[^\s<]+|www\.[^\s<]+|[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*)/gi; let index = 0; let match; - while ((match = regex.exec(text)) !== null) { - if (match.index > index) { - parent.appendChild(document.createTextNode(text.slice(index, match.index))); - } + while ((match = regex.exec(source)) !== null) { + if (match.index > index) parent.appendChild(document.createTextNode(source.slice(index, match.index))); const token = match[0]; + const markdownLabel = match[2]; + const markdownHref = match[3]; + + if (markdownHref || /^https?:\/\//i.test(token) || /^www\./i.test(token) || /^[\w.%+-]+@[\w.-]+\.[a-z]{2,}$/i.test(token)) { + const link = document.createElement("a"); + link.className = "picklo-link"; + let href = markdownHref || token; + let label = markdownLabel || token; + let trailing = ""; + + if (!markdownHref && !/^[\w.%+-]+@[\w.-]+\.[a-z]{2,}$/i.test(token)) { + const cleaned = token.replace(/[.,!?;:]+$/, ""); + trailing = token.slice(cleaned.length); + href = cleaned.startsWith("www.") ? `https://${cleaned}` : cleaned; + label = cleaned; + } else if (!markdownHref && /^[\w.%+-]+@[\w.-]+\.[a-z]{2,}$/i.test(token)) { + href = `mailto:${token}`; + } - if (token.startsWith("`")) { + if (/^(?:https?:|mailto:)/i.test(href)) { + link.href = href; + link.textContent = label; + if (/^https?:/i.test(href)) { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + parent.appendChild(link); + if (trailing) parent.appendChild(document.createTextNode(trailing)); + } else { + parent.appendChild(document.createTextNode(token)); + } + } else if (token.startsWith("`")) { const code = document.createElement("code"); code.textContent = token.slice(1, -1); parent.appendChild(code); @@ -3373,7 +3999,6 @@ function appendInline(parent, text) { index = regex.lastIndex; } - if (index < text.length) { - parent.appendChild(document.createTextNode(text.slice(index))); - } + if (index < source.length) parent.appendChild(document.createTextNode(source.slice(index))); } + diff --git a/cloud-ai.js b/cloud-ai.js new file mode 100644 index 0000000..dacf4d9 --- /dev/null +++ b/cloud-ai.js @@ -0,0 +1,169 @@ +import { supabase, SUPABASE_PUBLISHABLE_KEY, SUPABASE_URL } from "./supabase-client.js?v=8.2.2-fix4"; + +export const CLOUD_FUNCTION_NAME = "picklo-gemini"; +const CLOUD_ENDPOINT = `${SUPABASE_URL}/functions/v1/${CLOUD_FUNCTION_NAME}`; +const REQUEST_TIMEOUT_MS = 75000; + +export class CloudAIError extends Error { + constructor(message, status = 0) { + super(message); + this.name = "CloudAIError"; + this.status = status; + } +} + +export function isAbortError(error) { + return error?.name === "AbortError" || /aborted|cancelled|canceled/i.test(String(error?.message || "")); +} + +function isRegisteredSession(session) { + const user = session?.user; + return Boolean(session?.access_token && user && !user.is_anonymous && user.email); +} + +async function getOptionalAccessToken({ refresh = false } = {}) { + try { + let result = refresh + ? await supabase.auth.refreshSession() + : await supabase.auth.getSession(); + + if (result.error || !isRegisteredSession(result.data?.session)) return ""; + + const session = result.data.session; + const expiresAtMs = Number(session.expires_at || 0) * 1000; + if (!refresh && expiresAtMs && expiresAtMs <= Date.now() + 15000) { + result = await supabase.auth.refreshSession(); + if (result.error || !isRegisteredSession(result.data?.session)) return ""; + return result.data.session.access_token || ""; + } + + return session.access_token || ""; + } catch { + // Guest chat must keep working even if Supabase auth storage contains an expired session. + return ""; + } +} + +function createRequestSignal(parentSignal) { + const controller = new AbortController(); + const abortFromParent = () => controller.abort(parentSignal?.reason); + if (parentSignal?.aborted) abortFromParent(); + else parentSignal?.addEventListener("abort", abortFromParent, { once: true }); + + const timeout = setTimeout( + () => controller.abort(new DOMException("Cloud request timed out", "TimeoutError")), + REQUEST_TIMEOUT_MS + ); + return { + signal: controller.signal, + dispose() { + clearTimeout(timeout); + parentSignal?.removeEventListener("abort", abortFromParent); + } + }; +} + +async function parseResponse(response) { + const data = await response.json().catch(() => null); + if (!response.ok) { + const fallback = response.status === 429 + ? "Picklo is receiving too many requests. Wait a moment and try again." + : response.status === 401 + ? "Sign in to Picklo to use this protected feature." + : "Picklo's cloud AI could not complete the request. Please try again."; + throw new CloudAIError(String(data?.error || fallback), response.status); + } + + const text = String(data?.text || "").trim(); + if (!text) throw new CloudAIError("Gemini returned an empty response. Please try again.", 502); + return { + text, + model: String(data?.model || "Gemini"), + usage: data?.usage && typeof data.usage === "object" ? data.usage : null, + guest: Boolean(data?.guest) + }; +} + +async function performRequest(payload, token, signal) { + const headers = { + "apikey": SUPABASE_PUBLISHABLE_KEY, + "Content-Type": "application/json", + "X-Client-Info": "picklo-web/8.2.2-fix4" + }; + // The Edge Function is public for ordinary text chat. Only attach a bearer + // token when a real registered session exists; protected image analysis is + // rejected server-side without one. + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetch(CLOUD_ENDPOINT, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal + }); + return parseResponse(response); +} + +export async function requestCloudCompletion({ + messages, + temperature = 0.15, + topP = 0.85, + maxTokens = 720, + profile = "balanced", + purpose = "answer", + signal +}) { + const request = createRequestSignal(signal); + const payload = { messages, temperature, topP, maxTokens, profile, purpose }; + + try { + const token = await getOptionalAccessToken(); + try { + return await performRequest(payload, token, request.signal); + } catch (error) { + if (!(error instanceof CloudAIError) || error.status !== 401 || request.signal.aborted) throw error; + + // A stale registered session must never break normal guest chat. Retry the + // text request without Authorization. Protected image/file operations use + // their own account-gated request path below. + return await performRequest(payload, "", request.signal); + } + } finally { + request.dispose(); + } +} + +export async function requestCloudImageAnalysis({ + dataUrl, + prompt = "Describe this image accurately for follow-up questions.", + signal +}) { + const sessionResult = await supabase.auth.getSession(); + let session = sessionResult.data?.session || null; + + if (!isRegisteredSession(session)) { + throw new CloudAIError("Sign in to Picklo to analyze photos.", 401); + } + + const expiresAtMs = Number(session.expires_at || 0) * 1000; + if (expiresAtMs && expiresAtMs <= Date.now() + 15000) { + const refreshed = await supabase.auth.refreshSession(); + session = refreshed.data?.session || null; + } + + if (!isRegisteredSession(session)) { + throw new CloudAIError("Sign in to Picklo to analyze photos.", 401); + } + + const request = createRequestSignal(signal); + try { + return await performRequest({ + image: { dataUrl: String(dataUrl || "").slice(0, 4_000_000) }, + imagePrompt: prompt, + profile: "balanced", + purpose: "image-analysis" + }, session.access_token, request.signal); + } finally { + request.dispose(); + } +} diff --git a/enhancements.css b/enhancements.css new file mode 100644 index 0000000..7e1491d --- /dev/null +++ b/enhancements.css @@ -0,0 +1,322 @@ +:root { + --code-keyword: #7c3aed; + --code-string: #0f766e; + --code-comment: #737373; + --code-number: #b45309; + --code-tag: #be123c; + --code-attr: #1d4ed8; + --code-variable: #0369a1; + --code-function: #7e22ce; + --code-property: #0f766e; + --code-selector: #9f1239; +} + +:root[data-theme="dark"] { + --code-keyword: #c4b5fd; + --code-string: #5eead4; + --code-comment: #a3a3a3; + --code-number: #fbbf24; + --code-tag: #fda4af; + --code-attr: #93c5fd; + --code-variable: #7dd3fc; + --code-function: #d8b4fe; + --code-property: #5eead4; + --code-selector: #fda4af; +} + +.chat-header .version, +#modeButton, +#quickActions, +#composerToolsBtn, +.starter-grid { + display: none !important; +} + +#headerChatTitle { + font-weight: 600 !important; +} + +.picklo-link { + color: inherit; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; + overflow-wrap: anywhere; +} + +.message-row.assistant .picklo-link { + font-weight: 500; +} + +.code-block code .tok-keyword { color: var(--code-keyword); } +.code-block code .tok-string { color: var(--code-string); } +.code-block code .tok-comment { color: var(--code-comment); font-style: italic; } +.code-block code .tok-number { color: var(--code-number); } +.code-block code .tok-tag { color: var(--code-tag); } +.code-block code .tok-attr { color: var(--code-attr); } +.code-block code .tok-variable { color: var(--code-variable); } +.code-block code .tok-function { color: var(--code-function); } +.code-block code .tok-property { color: var(--code-property); } +.code-block code .tok-selector { color: var(--code-selector); } +.code-block code .tok-punctuation { opacity: .8; } + +.local-attachment-row .message-wrap { + max-width: min(78%, 620px); +} + +.local-attachment-bubble { + padding: 0 !important; + background: transparent !important; + border: 0 !important; + box-shadow: none !important; +} + +.local-image-preview { + margin: 0; + max-width: min(520px, 76vw); + overflow: hidden; + border-radius: 18px; + border: 1px solid color-mix(in srgb, currentColor 12%, transparent); + background: color-mix(in srgb, currentColor 4%, transparent); +} + +.local-image-preview img { + display: block; + width: 100%; + max-height: 520px; + object-fit: contain; + background: #000; +} + +.local-image-preview figcaption { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + font-size: 12px; +} + +.local-image-preview figcaption strong { + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.local-image-preview figcaption small { + opacity: .68; +} + +.local-file-card { + min-width: min(360px, 76vw); +} + +.local-file-icon { + display: grid; + place-items: center; + font-size: 10px; + font-weight: 700; +} + +.local-file-download { + flex: 0 0 auto; + border: 0; + border-radius: 8px; + padding: 7px 10px; + font: inherit; + font-size: 12px; + cursor: pointer; + background: color-mix(in srgb, currentColor 9%, transparent); + color: inherit; +} + +.local-attachment-row .message-time { + display: block; + margin: 5px 4px 0 auto; + width: max-content; +} + + + +/* V8.2.2 fix2 — iOS keyboard-safe composer + explicit attachment button */ +.attach { + border: 0; + background: transparent; + padding: 0; + appearance: none; + -webkit-appearance: none; +} + +@media (max-width: 760px) { + html, + body { + width: 100%; + height: 100%; + min-height: 0 !important; + max-height: none !important; + overflow: hidden !important; + } + + #appRoot.app { + position: fixed !important; + top: var(--picklo-viewport-top, 0px) !important; + right: 0 !important; + bottom: auto !important; + left: 0 !important; + width: 100% !important; + height: var(--picklo-viewport-height, 100dvh) !important; + min-height: 0 !important; + max-height: var(--picklo-viewport-height, 100dvh) !important; + overflow: hidden !important; + } + + .chat-shell { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + height: 100% !important; + min-height: 0 !important; + max-height: 100% !important; + overflow: hidden !important; + } + + .chat-header { + position: relative !important; + top: auto !important; + flex: 0 0 auto !important; + } + + #messages.messages { + flex: 1 1 0 !important; + min-height: 0 !important; + height: auto !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; + } + + .context-banner { + flex: 0 0 auto !important; + } + + .composer { + position: relative !important; + inset: auto !important; + flex: 0 0 auto !important; + margin: 0 !important; + padding: 4px 10px max(8px, env(safe-area-inset-bottom, 0px)) !important; + transform: none !important; + } + + html[data-keyboard="open"] .composer { + padding-bottom: 4px !important; + } + + html[data-keyboard="open"] .composer-box { + margin-bottom: 0 !important; + } +} + + +/* 8.2.2-fix4 — foreground account screen and guest account affordance */ +.auth-gate { + position: fixed !important; + inset: 0 !important; + z-index: 5000 !important; + width: 100vw !important; + height: var(--picklo-viewport-height, 100dvh) !important; + min-height: 0 !important; + max-height: none !important; + overflow-y: auto !important; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} +.auth-gate:not([hidden]) { display: grid !important; } +.auth-gate[hidden] { display: none !important; } +.account-summary.guest-account-link { + cursor: pointer; + border-radius: 12px; + padding: 8px; + margin-left: -8px; + margin-right: -8px; + transition: background .16s ease; +} +.account-summary.guest-account-link:hover, +.account-summary.guest-account-link:focus-visible { + background: color-mix(in srgb, currentColor 7%, transparent); + outline: none; +} + +/* V8.2.2 fix4 — auth isolation, dark account controls, and mobile stability */ +html[data-auth="open"], +html[data-auth="open"] body { + overflow: hidden !important; +} + +html[data-auth="open"] #appRoot { + display: none !important; + pointer-events: none !important; +} + +.auth-gate { + left: 0 !important; + right: 0 !important; + top: var(--picklo-viewport-top, 0px) !important; + bottom: auto !important; + width: 100% !important; + height: var(--picklo-viewport-height, 100dvh) !important; + max-height: var(--picklo-viewport-height, 100dvh) !important; + min-height: 0 !important; + z-index: 99999 !important; + isolation: isolate; + background: var(--bg) !important; +} + +.auth-gate.session-loading .auth-tabs, +.auth-gate.session-loading .auth-form, +.auth-gate.session-loading .auth-guest, +.auth-gate.session-loading .auth-privacy { + display: none !important; +} + +.auth-gate.session-loading .auth-card { + min-height: 220px; + display: grid; + align-content: center; +} + +html[data-theme="dark"] .auth-gate { + background: #151715 !important; +} + +html[data-theme="dark"] .auth-submit { + background: #6c5ce7 !important; + color: #ffffff !important; +} + +html[data-theme="dark"] .auth-submit:hover { + background: #7868ed !important; +} + +html[data-theme="dark"] .account-avatar, +html[data-theme="dark"] .account-button { + background: #6c5ce7 !important; + color: #ffffff !important; +} + +html[data-theme="dark"] .auth-guest { + color: #f3f3f3 !important; + border-color: #3a3d3a !important; +} + +@media (max-width: 760px) { + .auth-gate { + place-items: start center !important; + padding: max(14px, env(safe-area-inset-top, 0px)) 12px max(18px, env(safe-area-inset-bottom, 0px)) !important; + } + + .auth-card { + width: min(100%, 520px) !important; + margin: 0 !important; + } +} diff --git a/enhancements.js b/enhancements.js new file mode 100644 index 0000000..4d3585d --- /dev/null +++ b/enhancements.js @@ -0,0 +1,1072 @@ +import * as webllm from "https://esm.run/@mlc-ai/web-llm"; +import { supabase } from "./supabase-client.js?v=8.2.2-fix4"; +import { requestCloudImageAnalysis } from "./cloud-ai.js?v=8.2.2-fix4"; + +const BUILD_VERSION = "8.2.2-fix4"; +const LOCAL_DB = "picklo-local-attachments-v1"; +const LOCAL_STORE = "attachments"; +const FILE_DB = "picklo-v3-files"; +const FILE_STORE = "documents"; +const GUEST_STATE_KEY = "picklo-v7-state"; +const USER_STATE_PREFIX = "picklo-v7-state:user:"; +const SYNTHETIC_IMAGE_PREFIX = "picklo-image-"; +const VISION_MODEL = "Phi-3.5-vision-instruct-q4f16_1-MLC"; +const MAX_LOCAL_FILE_BYTES = 24 * 1024 * 1024; +const MAX_IMAGE_EDGE = 1800; + +let visionEngine = null; +let visionWorker = null; +let visionLoadPromise = null; +let syntheticDispatch = false; +let mutationQueued = false; +let attachmentCache = new Map(); +let localFileNames = new Set(); +let activeOwnerId = ""; +const objectUrls = new Map(); + +installFetchPrivacyGuard(); +installVersionListener(); + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", installEnhancements, { once: true }); +} else { + installEnhancements(); +} + +function installEnhancements() { + document.documentElement.dataset.pickloEnhancements = BUILD_VERSION; + document.title = "Picklo"; + + const input = document.getElementById("fileInput"); + if (input) { + const current = input.getAttribute("accept") || ""; + if (!current.includes("image/*")) input.setAttribute("accept", `${current},image/*`); + input.addEventListener("change", interceptAttachmentSelection, true); + } + + const observer = new MutationObserver(queueEnhanceDOM); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + + window.addEventListener("storage", queueEnhanceDOM); + window.addEventListener("pageshow", queueEnhanceDOM); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) queueEnhanceDOM(); + }); + + window.addEventListener("picklo:local-chats-deleted", (event) => { + const chatIds = Array.isArray(event.detail?.chatIds) ? event.detail.chatIds.filter(Boolean) : []; + if (!chatIds.length) return; + deleteLocalAttachmentsForChats(chatIds).catch((error) => { + console.warn("Could not clear local attachments for deleted chats:", error); + }); + }); + + window.addEventListener("picklo:local-file-deleted", (event) => { + const name = String(event.detail?.name || ""); + const size = Number(event.detail?.size || 0); + if (!name || !activeOwnerId) return; + deleteLocalAttachmentCopies(name, size).catch((error) => { + console.warn("Could not clear the local attachment copy:", error); + }); + }); + + refreshAccountScope().finally(() => { + Promise.all([refreshAttachmentCache(), refreshLocalFileNames()]).finally(queueEnhanceDOM); + }); + + supabase.auth.onAuthStateChange((_event, session) => { + const user = session?.user; + const nextOwner = user && !user.is_anonymous && user.email ? user.id : ""; + if (nextOwner === activeOwnerId) return; + activeOwnerId = nextOwner; + revokeObjectUrls(); + attachmentCache = new Map(); + localFileNames = new Set(); + Promise.all([refreshAttachmentCache(), refreshLocalFileNames()]).finally(queueEnhanceDOM); + }); + + queueEnhanceDOM(); +} + +async function refreshAccountScope() { + try { + const { data } = await supabase.auth.getSession(); + const user = data?.session?.user; + activeOwnerId = user && !user.is_anonymous && user.email ? user.id : ""; + } catch { + activeOwnerId = ""; + } +} + +async function getRegisteredSession() { + try { + let { data, error } = await supabase.auth.getSession(); + if (error) return null; + let session = data?.session || null; + const valid = () => Boolean(session?.access_token && session?.user && !session.user.is_anonymous && session.user.email); + if (!valid()) return null; + const expiresAtMs = Number(session.expires_at || 0) * 1000; + if (expiresAtMs && expiresAtMs <= Date.now() + 30000) { + const refreshed = await supabase.auth.refreshSession(); + if (refreshed.error) return null; + session = refreshed.data?.session || null; + } + return valid() ? session : null; + } catch { + return null; + } +} + +function revokeObjectUrls() { + for (const url of objectUrls.values()) URL.revokeObjectURL(url); + objectUrls.clear(); +} + +function installFetchPrivacyGuard() { + if (window.__pickloLocalAttachmentFetchGuard) return; + window.__pickloLocalAttachmentFetchGuard = true; + const nativeFetch = window.fetch.bind(window); + + window.fetch = async (input, init = undefined) => { + try { + const request = input instanceof Request ? input : null; + const url = request ? request.url : String(input || ""); + const method = String(init?.method || request?.method || "GET").toUpperCase(); + const isMessageWrite = /\/rest\/v1\/picklo_messages(?:\?|$)/.test(url) && ["POST", "PATCH", "PUT"].includes(method); + + if (isMessageWrite) { + let body = init?.body; + if (body == null && request) body = await request.clone().text(); + + if (typeof body === "string" && body.trim()) { + const parsed = JSON.parse(body); + const sanitize = (row) => { + if (!row || typeof row !== "object") return row; + const clean = { ...row }; + const hadArtifact = clean.artifact != null; + const hadAttachments = Array.isArray(clean.attachments) && clean.attachments.length > 0; + clean.attachments = []; + clean.artifact = null; + clean.sources = []; + if (hadArtifact) clean.model_content = null; + if (hadArtifact || hadAttachments) clean.tool = ""; + return clean; + }; + const nextBody = JSON.stringify(Array.isArray(parsed) ? parsed.map(sanitize) : sanitize(parsed)); + + if (request && init?.body == null) { + input = new Request(request, { body: nextBody }); + } else { + init = { ...(init || {}), body: nextBody }; + } + } + } + } catch (error) { + console.warn("Picklo local attachment privacy guard skipped a request:", error); + } + + return nativeFetch(input, init); + }; +} + +function installVersionListener() { + if (!("serviceWorker" in navigator)) return; + + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data?.type !== "PICKLO_BUILD_VERSION") return; + const incoming = String(event.data.version || ""); + if (!incoming) return; + + const previous = localStorage.getItem("picklo-last-build-version"); + localStorage.setItem("picklo-last-build-version", incoming); + + if (previous && previous !== incoming && !sessionStorage.getItem("picklo-version-reload")) { + sessionStorage.setItem("picklo-version-reload", "1"); + location.reload(); + } + }); + + window.addEventListener("load", () => { + sessionStorage.removeItem("picklo-version-reload"); + }, { once: true }); +} + +function queueEnhanceDOM() { + if (mutationQueued) return; + mutationQueued = true; + requestAnimationFrame(async () => { + mutationQueued = false; + forceSimplifiedHeader(); + hideEmptyMessageRows(); + linkifyMessages(); + highlightCodeBlocks(); + await Promise.all([refreshAttachmentCache(), refreshLocalFileNames()]); + renderLocalAttachments(); + rewriteLocalContextBanner(); + }); +} + +function forceSimplifiedHeader() { + const title = document.getElementById("headerChatTitle"); + if (title && title.textContent !== "Picklo") title.textContent = "Picklo"; +} + +function hideEmptyMessageRows() { + document.querySelectorAll("#messages .message-row.user").forEach((row) => { + if (row.classList.contains("local-attachment-row")) return; + const bubble = row.querySelector(".message-bubble"); + if (!bubble) return; + const clone = bubble.cloneNode(true); + clone.querySelectorAll(".message-time, .message-actions").forEach((node) => node.remove()); + const hasMeaningfulElement = clone.querySelector(".attachment-card, .artifact-card, img, a, code, pre"); + if (!hasMeaningfulElement && !clone.textContent.trim()) row.style.display = "none"; + }); +} + +function rewriteLocalContextBanner() { + const context = document.getElementById("contextText"); + if (!context) return; + if (/picklo-image-[a-f0-9-]+\.txt/i.test(context.textContent || "")) { + context.textContent = "Using local image"; + } +} + +async function interceptAttachmentSelection(event) { + if (syntheticDispatch) return; + const input = event.currentTarget; + const files = [...(input.files || [])]; + if (!files.length) return; + + // Stop the native change event synchronously. Async capture listeners cannot + // stop propagation after an await, which previously let images fall through + // to the document parser before the account/vision path had finished. + event.stopImmediatePropagation(); + event.preventDefault(); + input.value = ""; + + const session = await getRegisteredSession(); + const user = session?.user; + if (!user) { + window.dispatchEvent(new CustomEvent("picklo:account-required", { detail: { action: "upload" } })); + return; + } + + activeOwnerId = user.id; + const chatId = getActiveChatId(); + const orderIndex = document.querySelectorAll("#messages .message-row").length; + const images = files.filter((file) => file.type.startsWith("image/")); + const documents = files.filter((file) => !file.type.startsWith("image/")); + + for (const file of documents) { + persistLocalAttachment(file, { chatId, kind: "file", orderIndex, ownerId: user.id }).catch((error) => { + console.warn(`Could not keep ${file.name} locally:`, error); + }); + } + + if (documents.length) redispatchFilesToPicklo(input, documents); + + for (const image of images) { + handleLocalImage(image, { chatId, orderIndex, ownerId: user.id }).catch((error) => { + console.error("Picklo local image handling failed:", error); + setComposerStatus("The image was kept locally, but Picklo could not prepare it for analysis."); + }); + } +} + +async function handleLocalImage(file, { chatId, orderIndex, ownerId }) { + if (!file.type.startsWith("image/")) return; + if (file.size > MAX_LOCAL_FILE_BYTES) { + setComposerStatus(`${file.name} is too large. Use an image under 24 MB.`); + return; + } + + const id = crypto.randomUUID ? crypto.randomUUID() : `img-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const storedBlob = await normalizeImageForStorage(file); + const record = { + id, + ownerId: ownerId || activeOwnerId || "", + chatId: chatId || null, + kind: "image", + name: file.name || "Image", + type: storedBlob.type || file.type || "image/jpeg", + size: storedBlob.size, + blob: storedBlob, + createdAt: Date.now(), + orderIndex, + status: "Reading image locally…", + syntheticName: `${SYNTHETIC_IMAGE_PREFIX}${id}.txt` + }; + + await putLocalAttachment(record); + attachmentCache.set(record.id, record); + renderLocalAttachments(); + setComposerStatus("Image added locally. Picklo is reading it on this device…"); + + let analysis = ""; + let method = ""; + + if (isVisionCapableDesktop()) { + try { + analysis = await analyzeWithVision(file); + method = "local vision model"; + } catch (error) { + console.warn("Local vision model unavailable, trying OCR:", error); + } + } + + if (!analysis.trim()) { + try { + const cloudImage = await prepareImageForCloud(file); + const result = await requestCloudImageAnalysis({ + dataUrl: cloudImage, + prompt: "Describe this image accurately and in enough detail for another assistant to answer follow-up questions. Include visible text, people, objects, layout, colours, quantities, charts and relevant spatial relationships. Do not guess hidden details." + }); + analysis = String(result?.text || "").trim(); + if (analysis) method = "secure vision analysis"; + } catch (error) { + console.warn("Secure image analysis unavailable, trying OCR:", error); + } + } + + if (!analysis.trim()) { + try { + const ocr = await analyzeWithOcr(file); + if (ocr.trim()) { + analysis = `Text detected in the image:\n${ocr.trim()}`; + method = "local OCR"; + } + } catch (error) { + console.warn("Local OCR unavailable:", error); + } + } + + if (!analysis.trim()) { + analysis = isVisionCapableDesktop() + ? "The image is attached locally, but visual analysis could not start on this device. Do not infer details that are not visible in the user prompt." + : "The image is attached locally. This phone/tablet or low-resource device is not loading Picklo's large vision model. No reliable visual description was produced; do not invent image contents."; + method = "local attachment only"; + } + + record.analysis = analysis; + record.analysisMethod = method; + record.status = method === "local vision model" ? "Analyzed locally" : method === "local OCR" ? "Text read locally" : "Stored locally"; + await putLocalAttachment(record); + attachmentCache.set(record.id, record); + renderLocalAttachments(); + + const context = [ + "PICKLO LOCAL IMAGE CONTEXT", + `Original image: ${record.name}`, + `Processing: ${method}`, + "The original image bytes remain only in this browser and are not included in cloud conversation sync.", + "", + analysis + ].join("\n"); + + const contextFile = new File([context], record.syntheticName, { type: "text/plain" }); + const input = document.getElementById("fileInput"); + if (input) redispatchFilesToPicklo(input, [contextFile]); + setComposerStatus("Image ready. Ask Picklo about it."); +} + +function redispatchFilesToPicklo(input, files) { + if (!files.length) return; + try { + const transfer = new DataTransfer(); + files.forEach((file) => transfer.items.add(file)); + syntheticDispatch = true; + input.files = transfer.files; + input.dispatchEvent(new Event("change", { bubbles: true })); + } catch (error) { + console.warn("Could not hand a local attachment to Picklo:", error); + } finally { + syntheticDispatch = false; + } +} + +function isVisionCapableDesktop() { + const ua = navigator.userAgent || ""; + const mobileFamily = /iPhone|iPad|iPod|Android|Mobile|Tablet/i.test(ua); + const compactViewport = Math.min(window.innerWidth, window.innerHeight) < 720; + const memory = Number(navigator.deviceMemory || 0); + const enoughMemory = !memory || memory >= 8; + return Boolean(navigator.gpu) && !mobileFamily && !compactViewport && enoughMemory; +} + +async function analyzeWithVision(file) { + if (!visionEngine) { + if (!visionLoadPromise) { + visionLoadPromise = (async () => { + setComposerStatus("Starting Picklo's local vision model…"); + visionWorker = new Worker("./webllm-worker.js", { type: "module", name: "picklo-vision" }); + visionEngine = await webllm.CreateWebWorkerMLCEngine( + visionWorker, + VISION_MODEL, + { + appConfig: { ...webllm.prebuiltAppConfig, cacheBackend: "cache" }, + initProgressCallback: (report) => { + const pct = typeof report?.progress === "number" ? ` ${Math.round(report.progress * 100)}%` : ""; + setComposerStatus(`Starting local vision${pct}…`); + } + }, + { context_window_size: 4096 } + ); + return visionEngine; + })().catch((error) => { + visionLoadPromise = null; + visionEngine = null; + visionWorker?.terminate(); + visionWorker = null; + throw error; + }); + } + await visionLoadPromise; + } + + const dataUrl = await fileToDataUrl(file); + const response = await visionEngine.chat.completions.create({ + stream: false, + temperature: 0.05, + max_tokens: 700, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "Analyze this image carefully for a second AI assistant. Describe the important visible content, layout, people or objects, diagrams, UI, tables and charts. Read all legible text as accurately as possible. Distinguish clearly visible facts from uncertainty. Do not invent unreadable text or hidden details. Return a compact but information-dense description." + }, + { type: "image_url", image_url: { url: dataUrl } } + ] + } + ] + }); + + return String(response?.choices?.[0]?.message?.content || await visionEngine.getMessage() || "").trim(); +} + +async function prepareImageForCloud(file) { + try { + const bitmap = await createImageBitmap(file); + const maxEdge = 1280; + const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height)); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + const ctx = canvas.getContext("2d", { alpha: false }); + ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close?.(); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.78)); + return await fileToDataUrl(blob || file); + } catch { + return await fileToDataUrl(file); + } +} + +async function analyzeWithOcr(file) { + if (!window.Tesseract) { + await loadClassicScript("https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js", "Tesseract"); + } + setComposerStatus("Reading visible text locally…"); + const result = await window.Tesseract.recognize(file, "eng", { + logger: (message) => { + if (message?.status === "recognizing text" && Number.isFinite(message.progress)) { + setComposerStatus(`Reading visible text locally… ${Math.round(message.progress * 100)}%`); + } + } + }); + return String(result?.data?.text || "").replace(/\n{3,}/g, "\n\n").trim(); +} + +function loadClassicScript(src, globalName) { + return new Promise((resolve, reject) => { + if (globalName && window[globalName]) return resolve(); + const existing = document.querySelector(`script[src="${src}"]`); + if (existing) { + existing.addEventListener("load", resolve, { once: true }); + existing.addEventListener("error", reject, { once: true }); + return; + } + const script = document.createElement("script"); + script.src = src; + script.async = true; + script.onload = resolve; + script.onerror = () => reject(new Error(`Could not load ${globalName || "helper"}.`)); + document.head.appendChild(script); + }); +} + +function fileToDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("Could not read image.")); + reader.readAsDataURL(file); + }); +} + +async function normalizeImageForStorage(file) { + try { + const bitmap = await createImageBitmap(file); + const scale = Math.min(1, MAX_IMAGE_EDGE / Math.max(bitmap.width, bitmap.height)); + if (scale >= 0.999 && file.size <= 4 * 1024 * 1024) { + bitmap.close?.(); + return file; + } + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + const ctx = canvas.getContext("2d", { alpha: false }); + ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close?.(); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.9)); + return blob || file; + } catch { + return file; + } +} + +async function persistLocalAttachment(file, { chatId, kind, orderIndex, ownerId }) { + if (file.size > MAX_LOCAL_FILE_BYTES) return; + const id = crypto.randomUUID ? crypto.randomUUID() : `file-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const record = { + id, + ownerId: ownerId || activeOwnerId || "", + chatId: chatId || null, + kind, + name: file.name || "File", + type: file.type || "application/octet-stream", + size: file.size, + blob: file, + createdAt: Date.now(), + orderIndex, + status: "Stored only on this device" + }; + await putLocalAttachment(record); + attachmentCache.set(record.id, record); +} + +function openLocalDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(LOCAL_DB, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LOCAL_STORE)) { + const store = db.createObjectStore(LOCAL_STORE, { keyPath: "id" }); + store.createIndex("chatId", "chatId", { unique: false }); + store.createIndex("createdAt", "createdAt", { unique: false }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function putLocalAttachment(record) { + const db = await openLocalDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(LOCAL_STORE, "readwrite"); + tx.objectStore(LOCAL_STORE).put(record); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); +} + +async function deleteLocalAttachmentRows(predicate) { + const db = await openLocalDB(); + const rows = await new Promise((resolve, reject) => { + const tx = db.transaction(LOCAL_STORE, "readonly"); + const request = tx.objectStore(LOCAL_STORE).getAll(); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + const chosen = rows.filter((row) => row?.ownerId === activeOwnerId && predicate(row)); + if (!chosen.length) return []; + await new Promise((resolve, reject) => { + const tx = db.transaction(LOCAL_STORE, "readwrite"); + const store = tx.objectStore(LOCAL_STORE); + chosen.forEach((row) => store.delete(row.id)); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); + chosen.forEach((row) => { + const url = objectUrls.get(row.id); + if (url) URL.revokeObjectURL(url); + objectUrls.delete(row.id); + attachmentCache.delete(row.id); + }); + return chosen; +} + +async function deleteSyntheticContextFiles(names) { + const wanted = new Set((names || []).filter(Boolean)); + if (!wanted.size || !activeOwnerId) return; + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open(FILE_DB, 1); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + if (!db.objectStoreNames.contains(FILE_STORE)) return; + const rows = await new Promise((resolve, reject) => { + const tx = db.transaction(FILE_STORE, "readonly"); + const request = tx.objectStore(FILE_STORE).getAll(); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + const ids = rows + .filter((row) => row?.ownerId === activeOwnerId && wanted.has(row?.name)) + .map((row) => row.id) + .filter(Boolean); + if (!ids.length) return; + await new Promise((resolve, reject) => { + const tx = db.transaction(FILE_STORE, "readwrite"); + const store = tx.objectStore(FILE_STORE); + ids.forEach((id) => store.delete(id)); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); +} + +async function deleteLocalAttachmentsForChats(chatIds) { + if (!activeOwnerId) return; + const ids = new Set(chatIds); + const removed = await deleteLocalAttachmentRows((row) => ids.has(row.chatId)); + await deleteSyntheticContextFiles(removed.map((row) => row.syntheticName)); + await Promise.all([refreshAttachmentCache(), refreshLocalFileNames()]); + window.dispatchEvent(new CustomEvent("picklo:local-files-mutated")); + queueEnhanceDOM(); +} + +async function deleteLocalAttachmentCopies(name, size) { + if (!activeOwnerId) return; + await deleteLocalAttachmentRows((row) => + row.kind === "file" && row.name === name && (!size || Number(row.size || 0) === size) + ); + await refreshAttachmentCache(); + queueEnhanceDOM(); +} + +async function listLocalAttachments() { + try { + const db = await openLocalDB(); + return await new Promise((resolve, reject) => { + const tx = db.transaction(LOCAL_STORE, "readonly"); + const request = tx.objectStore(LOCAL_STORE).getAll(); + request.onsuccess = () => resolve( + activeOwnerId + ? (request.result || []).filter((row) => row?.ownerId === activeOwnerId) + : [] + ); + request.onerror = () => reject(request.error); + }); + } catch { + return []; + } +} + +async function refreshAttachmentCache() { + const rows = await listLocalAttachments(); + attachmentCache = new Map(rows.map((row) => [row.id, row])); +} + +async function refreshLocalFileNames() { + try { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open(FILE_DB, 1); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const rows = await new Promise((resolve, reject) => { + if (!db.objectStoreNames.contains(FILE_STORE)) return resolve([]); + const tx = db.transaction(FILE_STORE, "readonly"); + const request = tx.objectStore(FILE_STORE).getAll(); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + localFileNames = new Set( + rows + .filter((row) => activeOwnerId && row?.ownerId === activeOwnerId) + .map((row) => String(row.name || "").trim()) + .filter(Boolean) + ); + } catch { + localFileNames = new Set(); + } +} + +function getActiveState() { + // Guest mode must never scan another account's browser cache. The account UI + // is authoritative for the active scope; registered mode uses only that user's + // exact local state key. + const accountMode = document.documentElement.dataset.account || "guest"; + if (accountMode !== "registered" || !activeOwnerId) { + try { return JSON.parse(localStorage.getItem(GUEST_STATE_KEY) || "null"); } + catch { return null; } + } + + try { + return JSON.parse(localStorage.getItem(`${USER_STATE_PREFIX}${activeOwnerId}`) || "null"); + } catch { + return null; + } +} + +function getActiveChatId() { + return getActiveState()?.activeChatId || null; +} + +function renderLocalAttachments() { + const container = document.getElementById("messages"); + if (!container) return; + const chatId = getActiveChatId(); + if (!chatId) return; + + hideUnavailableAttachmentRows(chatId); + enhanceExistingFileCards(chatId); + + const rows = [...attachmentCache.values()] + .filter((record) => record.chatId === chatId) + .sort((a, b) => a.createdAt - b.createdAt); + + for (const record of rows) { + if (record.kind === "file" && hasVisibleAttachmentNamed(record.name)) continue; + if (container.querySelector(`[data-local-attachment-id="${cssEscape(record.id)}"]`)) continue; + insertLocalAttachmentRow(container, record); + } +} + +function hideUnavailableAttachmentRows(chatId) { + const localNames = new Set( + [...attachmentCache.values()] + .filter((record) => record.chatId === chatId) + .map((record) => record.name) + ); + + document.querySelectorAll("#messages .attachment-card").forEach((card) => { + const name = card.querySelector(".attachment-copy strong")?.textContent?.trim() || ""; + const synthetic = name.startsWith(SYNTHETIC_IMAGE_PREFIX) && name.endsWith(".txt"); + const available = localNames.has(name) || localFileNames.has(name); + const row = card.closest(".message-row"); + if (row && (synthetic || !available)) row.style.display = "none"; + }); +} + +function enhanceExistingFileCards(chatId) { + const localForChat = [...attachmentCache.values()].filter((record) => record.chatId === chatId && record.kind === "file"); + document.querySelectorAll("#messages .attachment-card").forEach((card) => { + if (card.dataset.localEnhanced === "1") return; + const name = card.querySelector(".attachment-copy strong")?.textContent?.trim() || ""; + const record = [...localForChat].reverse().find((item) => item.name === name); + if (!record) return; + card.dataset.localEnhanced = "1"; + card.dataset.localAttachmentId = record.id; + addLocalDownloadButton(card, record); + }); +} + +function hasVisibleAttachmentNamed(name) { + return [...document.querySelectorAll("#messages .attachment-card .attachment-copy strong")] + .some((node) => node.textContent?.trim() === name && node.closest(".message-row")?.style.display !== "none"); +} + +function insertLocalAttachmentRow(container, record) { + const row = document.createElement("article"); + row.className = "message-row user local-attachment-row"; + row.dataset.localAttachmentId = record.id; + + const wrap = document.createElement("div"); + wrap.className = "message-wrap"; + const bubble = document.createElement("div"); + bubble.className = "message-bubble local-attachment-bubble"; + + if (record.kind === "image") { + const figure = document.createElement("figure"); + figure.className = "local-image-preview"; + const img = document.createElement("img"); + img.alt = record.name || "Attached image"; + img.loading = "lazy"; + img.src = getObjectUrl(record); + const caption = document.createElement("figcaption"); + const strong = document.createElement("strong"); + strong.textContent = record.name || "Image"; + const small = document.createElement("small"); + small.textContent = record.status || "Stored locally"; + caption.append(strong, small); + figure.append(img, caption); + bubble.appendChild(figure); + } else { + const card = document.createElement("div"); + card.className = "attachment-card local-file-card"; + card.dataset.localAttachmentId = record.id; + + const icon = document.createElement("span"); + icon.className = "attachment-icon local-file-icon"; + icon.textContent = fileExtension(record.name).slice(0, 4).toUpperCase(); + + const copy = document.createElement("span"); + copy.className = "attachment-copy"; + const strong = document.createElement("strong"); + strong.textContent = record.name; + const small = document.createElement("small"); + small.textContent = `${formatBytes(record.size)} • Local only`; + copy.append(strong, small); + card.append(icon, copy); + addLocalDownloadButton(card, record); + bubble.appendChild(card); + } + + const time = document.createElement("time"); + time.className = "message-time"; + time.textContent = new Date(record.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + bubble.appendChild(time); + wrap.appendChild(bubble); + row.appendChild(wrap); + + const rows = [...container.querySelectorAll(":scope > .message-row:not(.local-attachment-row)")]; + const target = rows[Math.min(record.orderIndex || rows.length, rows.length)] || null; + if (target) container.insertBefore(row, target); + else container.appendChild(row); +} + +function addLocalDownloadButton(card, record) { + if (card.querySelector(".local-file-download")) return; + const button = document.createElement("button"); + button.type = "button"; + button.className = "local-file-download"; + button.textContent = "Open"; + button.addEventListener("click", () => { + if (document.documentElement.dataset.account !== "registered") { + window.dispatchEvent(new CustomEvent("picklo:account-required", { detail: { action: "download" } })); + return; + } + downloadLocalRecord(record); + }); + card.appendChild(button); +} + +function downloadLocalRecord(record) { + if (!record?.blob) return; + const url = getObjectUrl(record); + const link = document.createElement("a"); + link.href = url; + link.download = record.name || "picklo-file"; + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +function getObjectUrl(record) { + if (objectUrls.has(record.id)) return objectUrls.get(record.id); + const url = URL.createObjectURL(record.blob); + objectUrls.set(record.id, url); + return url; +} + +function setComposerStatus(text) { + const note = document.getElementById("composerNote"); + if (note) note.textContent = text; +} + +function fileExtension(name) { + return String(name || "file").split(".").pop() || "file"; +} + +function formatBytes(bytes = 0) { + const value = Number(bytes) || 0; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`; + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} + +function cssEscape(value) { + return window.CSS?.escape ? CSS.escape(String(value)) : String(value).replace(/[^a-z0-9_-]/gi, "\\$&"); +} + +function linkifyMessages() { + document.querySelectorAll("#messages .message-bubble").forEach((bubble) => { + const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (!parent || parent.closest("a, code, pre, button, .artifact-card, .attachment-card, .local-image-preview")) return NodeFilter.FILTER_REJECT; + if (!node.nodeValue?.trim()) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + nodes.forEach(linkifyTextNode); + }); +} + +function linkifyTextNode(node) { + const text = node.nodeValue || ""; + const pattern = /(\[([^\]]+)\]\s*\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)|\bhttps?:\/\/[^\s<]+|\bwww\.[^\s<]+|\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b)/gi; + if (!pattern.test(text)) return; + pattern.lastIndex = 0; + + const frag = document.createDocumentFragment(); + let cursor = 0; + let match; + while ((match = pattern.exec(text)) !== null) { + if (match.index > cursor) frag.appendChild(document.createTextNode(text.slice(cursor, match.index))); + + const token = match[0]; + const markdownLabel = match[2]; + const markdownHref = match[3]; + const link = document.createElement("a"); + link.className = "picklo-link"; + + if (markdownHref) { + link.href = safeHref(markdownHref); + link.textContent = markdownLabel; + } else if (/^[\w.%+-]+@[\w.-]+\.[a-z]{2,}$/i.test(token)) { + link.href = `mailto:${token}`; + link.textContent = token; + } else { + const cleaned = token.replace(/[.,!?;:]+$/, ""); + link.href = safeHref(cleaned.startsWith("www.") ? `https://${cleaned}` : cleaned); + link.textContent = cleaned; + const trailing = token.slice(cleaned.length); + if (trailing) { + frag.appendChild(link); + frag.appendChild(document.createTextNode(trailing)); + cursor = pattern.lastIndex; + continue; + } + } + + if (link.href.startsWith("http")) { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + frag.appendChild(link); + cursor = pattern.lastIndex; + } + if (cursor < text.length) frag.appendChild(document.createTextNode(text.slice(cursor))); + node.replaceWith(frag); +} + +function safeHref(value) { + const text = String(value || "").trim(); + return /^(?:https?:|mailto:)/i.test(text) ? text : "#"; +} + +function highlightCodeBlocks() { + document.querySelectorAll("#messages .code-block").forEach((block) => { + const code = block.querySelector("code"); + if (!code || code.dataset.highlighted === "1") return; + const language = block.querySelector(".code-head span")?.textContent?.trim().toLowerCase() || "code"; + const raw = code.textContent || ""; + code.innerHTML = syntaxHighlight(raw, language); + code.dataset.highlighted = "1"; + block.dataset.language = language; + }); +} + +function syntaxHighlight(source, language) { + if (/^(html?|xml|svg)$/.test(language)) return highlightMarkup(source); + if (/^(css|scss|sass)$/.test(language)) return highlightCss(source); + if (/^(json|jsonc)$/.test(language)) return highlightJson(source); + return highlightProgramming(source, language); +} + +function highlightMarkup(source) { + const escaped = escapeHtml(source); + return escaped + .replace(/(<!--[\s\S]*?-->)/g, '$1') + .replace(/(<\/?)([\w:-]+)/g, '$1$2') + .replace(/\s([\w:-]+)(=)(?="|')/g, ' $1$2') + .replace(/("[^&]*?"|'[^&]*?')/g, '$1'); +} + +function highlightCss(source) { + const escaped = escapeHtml(source); + return escaped + .replace(/(\/\*[\s\S]*?\*\/)/g, '$1') + .replace(/(^|[}\s])([.#]?[a-zA-Z][\w-]*(?:\s+[.#]?[\w-]+)*)(\s*\{)/gm, '$1$2$3') + .replace(/([\w-]+)(\s*:)/g, '$1$2') + .replace(/("[^&]*?"|'[^&]*?')/g, '$1') + .replace(/\b(-?\d+(?:\.\d+)?(?:px|rem|em|vh|vw|%|s|ms|deg)?)\b/g, '$1'); +} + +function highlightJson(source) { + const escaped = escapeHtml(source); + return escaped + .replace(/("(?:\\.|[^&])*?")(\s*:)/g, '$1$2') + .replace(/(:\s*)("(?:\\.|[^&])*?")/g, '$1$2') + .replace(/\b(true|false|null)\b/g, '$1') + .replace(/\b(-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)\b/gi, '$1'); +} + +function highlightProgramming(source, language) { + const escaped = escapeHtml(source); + const keywordSets = { + python: "and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield", + py: "and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield", + javascript: "async await break case catch class const continue debugger default delete do else export extends finally for from function get if import in instanceof let new of return set static super switch this throw try typeof var void while with yield true false null undefined", + js: "async await break case catch class const continue debugger default delete do else export extends finally for from function get if import in instanceof let new of return set static super switch this throw try typeof var void while with yield true false null undefined", + typescript: "abstract any as async await boolean break case catch class const constructor continue declare default delete do else enum export extends false finally for from function get if implements import in infer instanceof interface keyof let namespace never new null number object of private protected public readonly return set static string super switch symbol this throw true try type typeof undefined unknown var void while with yield", + ts: "abstract any as async await boolean break case catch class const constructor continue declare default delete do else enum export extends false finally for from function get if implements import in infer instanceof interface keyof let namespace never new null number object of private protected public readonly return set static string super switch symbol this throw true try type typeof undefined unknown var void while with yield", + php: "abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile eval exit extends final finally fn for foreach function global goto if implements include include_once instanceof insteadof interface isset list match namespace new or print private protected public readonly require require_once return static switch throw trait try unset use var while xor yield true false null", + java: "abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while true false null", + code: "if else for while return function class const let var import export from async await try catch throw new true false null" + }; + const keyText = keywordSets[language] || keywordSets[language.replace(/[^a-z]/g, "")] || keywordSets.code; + const keywords = keyText.split(/\s+/).join("|"); + + const placeholders = []; + const markerFor = (index) => { + let value = index + 1; + let letters = ""; + while (value > 0) { + value -= 1; + letters = String.fromCharCode(65 + (value % 26)) + letters; + value = Math.floor(value / 26); + } + return `___PICKLO_TOKEN_${letters}___`; + }; + const protect = (token, cls) => { + const marker = markerFor(placeholders.length); + placeholders.push(`${token}`); + return marker; + }; + + let text = escaped.replace(/(\/\*[\s\S]*?\*\/|\/\/[^\n]*|#[^\n]*)|("(?:\\.|[^&])*?"|'(?:\\.|[^&])*?'|`(?:\\.|[^`])*`)/g, (token, comment) => + protect(token, comment ? "tok-comment" : "tok-string") + ); + + // Protect declared/assigned variable names before adding keyword markup so + // variable colouring remains visible instead of being swallowed by nested spans. + text = text.replace(/\b(const|let|var)\s+([A-Za-z_$][\w$]*)/g, (_match, declaration, variable) => + `${declaration} ${protect(variable, "tok-variable")}` + ); + if (/^(?:python|py)$/i.test(language)) { + text = text.replace(/(^|\n)(\s*)([A-Za-z_][\w]*)(\s*=)/g, (_m, line, space, variable, equals) => + `${line}${space}${protect(variable, "tok-variable")}${equals}` + ); + } + + text = text + .replace(new RegExp(`\\b(${keywords})\\b`, "g"), '$1') + .replace(/\b(\d+(?:\.\d+)?)\b/g, '$1') + .replace(/\b([A-Za-z_$][\w$]*)(?=\s*\()/g, '$1') + .replace(/\.([A-Za-z_$][\w$]*)/g, '.$1'); + + placeholders.forEach((value, index) => { + text = text.replace(markerFor(index), value); + }); + return text; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/index.html b/index.html index 643de6a..18f4bc4 100644 --- a/index.html +++ b/index.html @@ -2,31 +2,34 @@ - + + + + - + - Picklo V8.1 - - - + Picklo + - - - - - - + + + + + + + + -
+ @@ -111,14 +115,14 @@

Sign in to use Picklo

- SettingsLocal model + SettingsAI runtime
- Picklo is offline - Start a model when you are ready + Checking this device + Choosing local or cloud AI
@@ -138,13 +142,12 @@

Sign in to use Picklo

-

New chat

- V8.1 +

Picklo

- Local, private and ready + Device-adaptive AI • - Starting automatically… + Choosing AI runtime… • Fast • @@ -154,11 +157,6 @@

New chat

- -
-
- - - - -
-
-
@@ -261,14 +248,14 @@

New chat

PICKLO

Settings

-

Model, response mode and local data.

+

AI runtime, response mode and your data.

- + + + + diff --git a/manifest.webmanifest b/manifest.webmanifest index e0a614c..a21dda3 100644 --- a/manifest.webmanifest +++ b/manifest.webmanifest @@ -1,7 +1,7 @@ { "name": "Picklo", "short_name": "Picklo", - "description": "Picklo V8.1 \u2014 consistent answers and phone-adaptive private local AI.", + "description": "Picklo \u2014 local AI on capable computers and secure Gemini cloud AI on light devices.", "id": "./", "start_url": "./", "scope": "./", diff --git a/runtime-policy.js b/runtime-policy.js index 394cbb1..1d7708e 100644 --- a/runtime-policy.js +++ b/runtime-policy.js @@ -38,25 +38,45 @@ export function detectRuntimeCapabilities(input = {}) { const hardwareConcurrency = finitePositive(input.hardwareConcurrency); const viewportWidth = finitePositive(input.viewportWidth); const userAgent = String(input.userAgent || ""); + const platform = String(input.platform || ""); const touchPoints = Number(input.maxTouchPoints) || 0; - const mobileUserAgent = /Android|iPhone|iPod|Mobile|Windows Phone/i.test(userAgent); + const hasWebGPU = Boolean(input.hasWebGPU); + const mobileUserAgent = /Android.*Mobile|iPhone|iPod|Mobile|Windows Phone/i.test(userAgent); const narrowTouchDevice = Boolean((input.coarsePointer || touchPoints > 0) && viewportWidth && viewportWidth <= 820); const isPhone = mobileUserAgent || narrowTouchDevice; + const ipadDesktopUserAgent = /Macintosh/i.test(userAgent) && platform === "MacIntel" && touchPoints > 1; + const tabletUserAgent = /iPad|Tablet|PlayBook|Silk/i.test(userAgent) || + (/Android/i.test(userAgent) && !/Mobile/i.test(userAgent)); + const isTablet = !isPhone && (tabletUserAgent || ipadDesktopUserAgent); + const isMobile = isPhone || isTablet; const isConstrained = Boolean( - (deviceMemory && deviceMemory <= 2) || - (hardwareConcurrency && hardwareConcurrency <= 2) + (deviceMemory && deviceMemory <= 4) || + (hardwareConcurrency && hardwareConcurrency <= 4) ); + const supportsLocalAI = Boolean(hasWebGPU && !isMobile && !isConstrained); return { deviceMemory, hardwareConcurrency, viewportWidth, + hasWebGPU, isPhone, + isTablet, + isMobile, isConstrained, - tier: isConstrained ? "constrained" : isPhone ? "phone" : "desktop" + supportsLocalAI, + tier: isPhone ? "phone" : isTablet ? "tablet" : isConstrained ? "constrained" : "desktop" }; } +export function selectInferenceMode(capabilities = {}) { + if (capabilities.isPhone) return { mode: "cloud", reason: "phone" }; + if (capabilities.isTablet) return { mode: "cloud", reason: "tablet" }; + if (!capabilities.hasWebGPU) return { mode: "cloud", reason: "webgpu-unavailable" }; + if (capabilities.isConstrained) return { mode: "cloud", reason: "limited-hardware" }; + return { mode: "local", reason: "capable-desktop" }; +} + function nearestAvailableModel(target, availableIds) { const available = new Set(availableIds || []); if (available.has(target)) return target; diff --git a/styles.css b/styles.css index 5345d22..b5d5c71 100644 --- a/styles.css +++ b/styles.css @@ -497,6 +497,83 @@ html[data-theme="dark"] .composer-box { } } +/* ========================================================= + Picklo V8.2.2 — compact chat and iOS keyboard viewport + ========================================================= */ + +:root { + --picklo-viewport-height: 100dvh; + --picklo-viewport-top: 0px; +} + +.chat-heading { + flex: 1 1 auto; +} + +.chat-title-line h1 { + max-width: none; +} + +.messages.has-chat { + display: flex; + flex-direction: column; + padding-bottom: 6px; +} + +.messages.has-chat::before { + content: ""; + flex: 1 1 0; + min-height: 0; +} + +.messages.has-chat > * { + flex: 0 0 auto; +} + +.messages.has-chat > .message-row:last-child { + margin-bottom: 0; +} + +.composer { + padding-top: 0; +} + +@media (max-width: 820px) { + .app { + position: fixed; + top: var(--picklo-viewport-top); + right: 0; + bottom: auto; + left: 0; + height: var(--picklo-viewport-height) !important; + max-height: var(--picklo-viewport-height) !important; + } + + .chat-shell { + height: 100% !important; + max-height: 100% !important; + } + + .chat-header { + position: relative !important; + top: auto !important; + flex: 0 0 auto; + z-index: 50; + } + + .messages { + padding-bottom: 6px !important; + } + + .composer { + padding: 0 10px max(8px, env(safe-area-inset-bottom, 0px)); + } + + html[data-keyboard="open"] .composer { + padding-bottom: 6px; + } +} + /* Picklo account access and encrypted cloud conversation sync */ .app[hidden], .auth-gate[hidden] { @@ -2291,3 +2368,8 @@ html[data-theme="dark"] .copyright-card p { transition-duration: .01ms !important; } } + + +/* V8.2.2 guest account gate */ +.auth-guest{width:100%;min-height:42px;margin-top:10px;border:1px solid var(--border-strong);border-radius:12px;background:transparent;color:var(--text);font-weight:700} +.auth-guest:hover{background:var(--surface-2)} diff --git a/supabase-client.js b/supabase-client.js index 94d3fc2..a47dc76 100644 --- a/supabase-client.js +++ b/supabase-client.js @@ -1,7 +1,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.112.4"; -const SUPABASE_URL = "https://vciasenjredzlilzqaln.supabase.co"; -const SUPABASE_PUBLISHABLE_KEY = "sb_publishable_FB2SGE46ft32EQ7KN6qrOg_QGDmPxWX"; +export const SUPABASE_URL = "https://vciasenjredzlilzqaln.supabase.co"; +export const SUPABASE_PUBLISHABLE_KEY = "sb_publishable_FB2SGE46ft32EQ7KN6qrOg_QGDmPxWX"; export const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, { auth: { diff --git a/sw.js b/sw.js index 02f28f3..2ad79b0 100644 --- a/sw.js +++ b/sw.js @@ -1,14 +1,19 @@ -const CACHE = "picklo-v8.1-shell-v3"; +const BUILD_VERSION = "8.2.2-fix4"; +const CACHE = `picklo-shell-${BUILD_VERSION}`; const SHELL = [ "./", "./index.html", - "./styles.css", - "./app.js", - "./agent-router.js", - "./runtime-policy.js", - "./supabase-client.js", - "./webllm-worker.js", - "./manifest.webmanifest", + `./styles.css?v=${BUILD_VERSION}`, + `./enhancements.css?v=${BUILD_VERSION}`, + `./enhancements.js?v=${BUILD_VERSION}`, + `./app.js?v=${BUILD_VERSION}`, + `./agent-router.js?v=${BUILD_VERSION}`, + `./cloud-ai.js?v=${BUILD_VERSION}`, + `./runtime-policy.js?v=${BUILD_VERSION}`, + `./supabase-client.js?v=${BUILD_VERSION}`, + `./webllm-worker.js?v=${BUILD_VERSION}`, + `./manifest.webmanifest?v=${BUILD_VERSION}`, + `./version.json?v=${BUILD_VERSION}`, "./assets/picklo-mark.svg", "./assets/picklo-logo.svg", "./assets/apple-touch-icon.png", @@ -18,44 +23,70 @@ const SHELL = [ ]; self.addEventListener("install", (event) => { - event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); - self.skipWaiting(); + event.waitUntil((async () => { + const cache = await caches.open(CACHE); + await Promise.allSettled( + SHELL.map(async (path) => { + const response = await fetch(new Request(path, { cache: "no-store" })); + if (response.ok) await cache.put(path, response.clone()); + }) + ); + await self.skipWaiting(); + })()); }); self.addEventListener("activate", (event) => { - event.waitUntil( - caches.keys().then((keys) => - Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))) - ) - ); - self.clients.claim(); + event.waitUntil((async () => { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((key) => key !== CACHE && /^picklo-(?:shell|v\d)/i.test(key)) + .map((key) => caches.delete(key)) + ); + await self.clients.claim(); + const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + clients.forEach((client) => client.postMessage({ type: "PICKLO_BUILD_VERSION", version: BUILD_VERSION })); + })()); }); self.addEventListener("fetch", (event) => { if (event.request.method !== "GET") return; - const url = new URL(event.request.url); if (url.origin !== self.location.origin) return; if (event.request.mode === "navigate") { - event.respondWith( - fetch(event.request) - .then((response) => { - if (response.ok) caches.open(CACHE).then((cache) => cache.put("./index.html", response.clone())); - return response; - }) - .catch(() => caches.match("./index.html")) - ); + event.respondWith(networkFirstNavigation(event.request)); return; } + event.respondWith(networkFirstAsset(event.request)); +}); - event.respondWith( - caches.match(event.request).then((cached) => { - const refreshed = fetch(event.request).then((response) => { - if (response.ok) caches.open(CACHE).then((cache) => cache.put(event.request, response.clone())); - return response; - }).catch(() => cached); - return cached || refreshed; - }) - ); +async function networkFirstNavigation(request) { + try { + const response = await fetch(new Request(request, { cache: "no-store" })); + if (!response.ok) throw new Error(`Navigation failed with ${response.status}`); + const cache = await caches.open(CACHE); + await cache.put("./index.html", response.clone()); + return response; + } catch { + return (await caches.match("./index.html")) || Response.error(); + } +} + +async function networkFirstAsset(request) { + const cache = await caches.open(CACHE); + try { + const response = await fetch(new Request(request, { cache: "no-store" })); + if (response.ok) await cache.put(request, response.clone()); + return response; + } catch { + return (await cache.match(request, { ignoreSearch: true })) || Response.error(); + } +} + + +self.addEventListener("message", (event) => { + if (event.data?.type === "PICKLO_CHECK_VERSION") { + event.source?.postMessage?.({ type: "PICKLO_BUILD_VERSION", version: BUILD_VERSION }); + } }); diff --git a/version.json b/version.json new file mode 100644 index 0000000..97faa1d --- /dev/null +++ b/version.json @@ -0,0 +1 @@ +{"version":"8.2.2-fix4"}