import { html, css, LitElement } from "../../assets/lit-core-2.7.4.min.js"; export class MainView extends LitElement { static styles = css` * { font-family: var(--font); cursor: default; user-select: none; box-sizing: border-box; } :host { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-xl) var(--space-lg); } .form-wrapper { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: var(--space-md); } .page-title { font-size: var(--font-size-xl); font-weight: var(--font-weight-semibold); color: var(--text-primary); margin-bottom: var(--space-xs); } .page-title .mode-suffix { opacity: 0.5; } .page-subtitle { font-size: var(--font-size-sm); color: var(--text-muted); margin-bottom: var(--space-md); } /* ── Form controls ── */ .form-group { display: flex; flex-direction: column; gap: var(--space-xs); } .form-label { font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; } input, select, textarea { background: var(--bg-elevated); color: var(--text-primary); border: 1px solid var(--border); padding: 10px 12px; width: 100%; border-radius: var(--radius-sm); font-size: var(--font-size-sm); font-family: var(--font); transition: border-color var(--transition), box-shadow var(--transition); } input:hover:not(:focus), select:hover:not(:focus), textarea:hover:not(:focus) { border-color: var(--text-muted); } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } input::placeholder, textarea::placeholder { color: var(--text-muted); } input.error { border-color: var(--danger, #ef4444); } select { cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23999' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right 8px center; background-repeat: no-repeat; background-size: 14px; padding-right: 28px; } textarea { resize: vertical; min-height: 80px; line-height: var(--line-height); } .form-hint { font-size: var(--font-size-xs); color: var(--text-muted); } .form-hint a, .form-hint span.link { color: var(--accent); text-decoration: none; cursor: pointer; } .form-hint span.link:hover { text-decoration: underline; } .whisper-label-row { display: flex; align-items: center; gap: 6px; } .whisper-spinner { width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: whisper-spin 0.8s linear infinite; } @keyframes whisper-spin { to { transform: rotate(360deg); } } /* ── Whisper download progress ── */ .whisper-progress-container { margin-top: 8px; padding: 8px 10px; background: var(--bg-elevated, rgba(255, 255, 255, 0.05)); border-radius: var(--radius-sm, 6px); border: 1px solid var(--border, rgba(255, 255, 255, 0.1)); } .whisper-progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 11px; color: var(--text-secondary, #999); } .whisper-progress-file { font-family: var(--font-mono, monospace); font-size: 10px; color: var(--text-secondary, #999); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } .whisper-progress-pct { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--accent, #6cb4ee); } .whisper-progress-track { height: 4px; background: var(--border, rgba(255, 255, 255, 0.1)); border-radius: 2px; overflow: hidden; } .whisper-progress-bar { height: 100%; background: var(--accent, #6cb4ee); border-radius: 2px; transition: width 0.3s ease; min-width: 0; } .whisper-progress-size { margin-top: 4px; font-size: 10px; color: var(--text-tertiary, #666); text-align: right; } /* ── Start button ── */ .start-button { position: relative; overflow: hidden; background: #e8e8e8; color: #111111; border: none; padding: 12px var(--space-md); border-radius: var(--radius-sm); font-size: var(--font-size-base); font-weight: var(--font-weight-semibold); cursor: pointer; width: 100%; display: flex; align-items: center; justify-content: center; gap: var(--space-sm); } .start-button canvas.btn-aurora { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; } .start-button canvas.btn-dither { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; opacity: 0.1; mix-blend-mode: overlay; pointer-events: none; image-rendering: pixelated; } .start-button .btn-label { position: relative; z-index: 2; display: flex; align-items: center; gap: var(--space-sm); } .start-button:hover { opacity: 0.9; } .start-button.disabled { opacity: 0.5; cursor: not-allowed; } .start-button.disabled:hover { opacity: 0.5; } .shortcut-hint { display: inline-flex; align-items: center; gap: 2px; opacity: 0.5; font-family: var(--font-mono); } /* ── Divider ── */ .divider { display: flex; align-items: center; gap: var(--space-md); margin: var(--space-sm) 0; } .divider-line { flex: 1; height: 1px; background: var(--border); } .divider-text { font-size: var(--font-size-xs); color: var(--text-muted); text-transform: lowercase; } /* ── Mode switch links ── */ .mode-links { display: flex; justify-content: center; gap: var(--space-lg); } .mode-link { font-size: var(--font-size-sm); color: var(--text-secondary); cursor: pointer; background: none; border: none; padding: 0; transition: color var(--transition); } .mode-link:hover { color: var(--text-primary); } /* ── Mode option cards ── */ .mode-cards { display: flex; gap: var(--space-sm); } .mode-card { flex: 1; display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--bg-elevated); cursor: pointer; transition: border-color 0.2s, background 0.2s; } .mode-card:hover { border-color: var(--text-muted); background: var(--bg-hover); } .mode-card-title { font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); color: var(--text-primary); } .mode-card-desc { font-size: var(--font-size-xs); color: var(--text-muted); line-height: var(--line-height); } /* ── Title row with help ── */ .title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-xs); } .title-row .page-title { margin-bottom: 0; } .help-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 4px; border-radius: var(--radius-sm); transition: color 0.2s; display: flex; align-items: center; } .help-btn:hover { color: var(--text-secondary); } .help-btn * { pointer-events: none; } /* ── Help content ── */ .help-content { display: flex; flex-direction: column; gap: var(--space-md); max-height: 500px; overflow-y: auto; } .help-section { display: flex; flex-direction: column; gap: 4px; } .help-section-title { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); color: var(--text-primary); } .help-section-text { font-size: var(--font-size-xs); color: var(--text-secondary); line-height: var(--line-height); } .help-code { font-family: var(--font-mono); font-size: 11px; background: var(--bg-hover); padding: 6px 8px; border-radius: var(--radius-sm); color: var(--text-primary); display: block; } .help-link { color: var(--accent); cursor: pointer; text-decoration: none; } .help-link:hover { text-decoration: underline; } .help-models { display: flex; flex-direction: column; gap: 2px; } .help-model { font-size: var(--font-size-xs); color: var(--text-secondary); display: flex; justify-content: space-between; } .help-model-name { font-family: var(--font-mono); font-size: 11px; color: var(--text-primary); } .help-divider { border: none; border-top: 1px solid var(--border); margin: 0; } .help-warn { font-size: var(--font-size-xs); color: var(--warning); line-height: var(--line-height); } `; static properties = { onStart: { type: Function }, onExternalLink: { type: Function }, selectedProfile: { type: String }, onProfileChange: { type: Function }, isInitializing: { type: Boolean }, whisperDownloading: { type: Boolean }, whisperProgress: { type: Object }, // Internal state _mode: { state: true }, _token: { state: true }, _geminiKey: { state: true }, _groqKey: { state: true }, _openaiKey: { state: true }, _openaiCompatibleApiKey: { state: true }, _openaiCompatibleBaseUrl: { state: true }, _openaiCompatibleModel: { state: true }, _availableModels: { state: true }, _loadingModels: { state: true }, _manualModelInput: { state: true }, _responseProvider: { state: true }, _tokenError: { state: true }, _keyError: { state: true }, // Local AI state _ollamaHost: { state: true }, _ollamaModel: { state: true }, _whisperModel: { state: true }, _customWhisperModel: { state: true }, _showLocalHelp: { state: true }, }; constructor() { super(); this.onStart = () => {}; this.onExternalLink = () => {}; this.selectedProfile = "interview"; this.onProfileChange = () => {}; this.isInitializing = false; this.whisperDownloading = false; this.whisperProgress = null; this._mode = "byok"; this._token = ""; this._geminiKey = ""; this._groqKey = ""; this._openaiKey = ""; this._openaiCompatibleApiKey = ""; this._openaiCompatibleBaseUrl = ""; this._openaiCompatibleModel = ""; this._availableModels = []; this._loadingModels = false; this._manualModelInput = false; this._responseProvider = "gemini"; this._tokenError = false; this._keyError = false; this._showLocalHelp = false; this._ollamaHost = "http://127.0.0.1:11434"; this._ollamaModel = "llama3.1"; this._whisperModel = "Xenova/whisper-small"; this._customWhisperModel = ""; this._animId = null; this._time = 0; this._mouseX = -1; this._mouseY = -1; this.boundKeydownHandler = this._handleKeydown.bind(this); this._loadFromStorage(); } async _loadFromStorage() { try { const [prefs, creds] = await Promise.all([ cheatingDaddy.storage.getPreferences(), cheatingDaddy.storage.getCredentials().catch(() => ({})), ]); this._mode = prefs.providerMode || "byok"; // Load keys this._token = ""; this._geminiKey = (await cheatingDaddy.storage.getApiKey().catch(() => "")) || ""; this._groqKey = (await cheatingDaddy.storage.getGroqApiKey().catch(() => "")) || ""; this._openaiKey = creds.openaiKey || ""; // Load OpenAI-compatible config const openaiConfig = await cheatingDaddy.storage .getOpenAICompatibleConfig() .catch(() => ({})); this._openaiCompatibleApiKey = openaiConfig.apiKey || ""; this._openaiCompatibleBaseUrl = openaiConfig.baseUrl || ""; this._openaiCompatibleModel = openaiConfig.model || ""; // Load response provider preference this._responseProvider = prefs.responseProvider || "gemini"; // Load local AI settings this._ollamaHost = prefs.ollamaHost || "http://127.0.0.1:11434"; this._ollamaModel = prefs.ollamaModel || "llama3.1"; this._whisperModel = prefs.whisperModel || "Xenova/whisper-small"; // If the saved model isn't one of the presets, it's a custom HF model const presets = [ "Xenova/whisper-tiny", "Xenova/whisper-base", "Xenova/whisper-small", "Xenova/whisper-medium", ]; if (!presets.includes(this._whisperModel)) { this._customWhisperModel = this._whisperModel; this._whisperModel = "__custom__"; } this.requestUpdate(); // Auto-load models if OpenAI-compatible is selected and URL is set if ( this._responseProvider === "openai-compatible" && this._openaiCompatibleBaseUrl ) { this._loadModels(); } } catch (e) { console.error("Error loading MainView storage:", e); } } connectedCallback() { super.connectedCallback(); document.addEventListener("keydown", this.boundKeydownHandler); } disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener("keydown", this.boundKeydownHandler); if (this._animId) cancelAnimationFrame(this._animId); if (this._loadModelsTimeout) clearTimeout(this._loadModelsTimeout); } updated(changedProperties) { super.updated(changedProperties); if (changedProperties.has("_mode")) { // Stop old animation when switching modes if (this._animId) { cancelAnimationFrame(this._animId); this._animId = null; } } } _initButtonAurora() { const btn = this.shadowRoot.querySelector(".start-button"); const aurora = this.shadowRoot.querySelector("canvas.btn-aurora"); const dither = this.shadowRoot.querySelector("canvas.btn-dither"); if (!aurora || !dither || !btn) return; // Mouse tracking this._mouseX = -1; this._mouseY = -1; btn.addEventListener("mousemove", (e) => { const rect = btn.getBoundingClientRect(); this._mouseX = (e.clientX - rect.left) / rect.width; this._mouseY = (e.clientY - rect.top) / rect.height; }); btn.addEventListener("mouseleave", () => { this._mouseX = -1; this._mouseY = -1; }); // Dither const blockSize = 8; const cols = Math.ceil(aurora.offsetWidth / blockSize); const rows = Math.ceil(aurora.offsetHeight / blockSize); dither.width = cols; dither.height = rows; const dCtx = dither.getContext("2d"); const img = dCtx.createImageData(cols, rows); for (let i = 0; i < img.data.length; i += 4) { const v = Math.random() > 0.5 ? 255 : 0; img.data[i] = v; img.data[i + 1] = v; img.data[i + 2] = v; img.data[i + 3] = 255; } dCtx.putImageData(img, 0, 0); // Aurora const ctx = aurora.getContext("2d"); const scale = 0.4; aurora.width = Math.floor(aurora.offsetWidth * scale); aurora.height = Math.floor(aurora.offsetHeight * scale); const blobs = [ { color: [120, 160, 230], x: 0.1, y: 0.3, vx: 0.25, vy: 0.2, phase: 0 }, { color: [150, 120, 220], x: 0.8, y: 0.5, vx: -0.2, vy: 0.25, phase: 1.5, }, { color: [200, 140, 210], x: 0.5, y: 0.6, vx: 0.18, vy: -0.22, phase: 3.0, }, { color: [100, 190, 190], x: 0.3, y: 0.7, vx: 0.3, vy: 0.15, phase: 4.5 }, { color: [220, 170, 130], x: 0.7, y: 0.4, vx: -0.22, vy: -0.25, phase: 6.0, }, ]; const draw = () => { this._time += 0.008; const w = aurora.width; const h = aurora.height; const maxDim = Math.max(w, h); ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, w, h); const hovering = this._mouseX >= 0; for (const blob of blobs) { const t = this._time; const cx = (blob.x + Math.sin(t * blob.vx + blob.phase) * 0.4) * w; const cy = (blob.y + Math.cos(t * blob.vy + blob.phase * 0.7) * 0.4) * h; const r = maxDim * 0.45; let boost = 1; if (hovering) { const dx = cx / w - this._mouseX; const dy = cy / h - this._mouseY; const dist = Math.sqrt(dx * dx + dy * dy); boost = 1 + 2.5 * Math.max(0, 1 - dist / 0.6); } const a0 = Math.min(1, 0.18 * boost); const a1 = Math.min(1, 0.08 * boost); const a2 = Math.min(1, 0.02 * boost); const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); grad.addColorStop( 0, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a0})`, ); grad.addColorStop( 0.3, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a1})`, ); grad.addColorStop( 0.6, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a2})`, ); grad.addColorStop( 1, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0)`, ); ctx.fillStyle = grad; ctx.fillRect(0, 0, w, h); } this._animId = requestAnimationFrame(draw); }; draw(); } _handleKeydown(e) { const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; if ((isMac ? e.metaKey : e.ctrlKey) && e.key === "Enter") { e.preventDefault(); this._handleStart(); } } // ── Persistence ── async _saveMode(mode) { this._mode = mode; this._keyError = false; await cheatingDaddy.storage.updatePreference("providerMode", mode); this.requestUpdate(); } async _saveGeminiKey(val) { this._geminiKey = val; this._keyError = false; await cheatingDaddy.storage.setApiKey(val); this.requestUpdate(); } async _saveGroqKey(val) { this._groqKey = val; await cheatingDaddy.storage.setGroqApiKey(val); this.requestUpdate(); } async _saveOpenaiKey(val) { this._openaiKey = val; try { const creds = await cheatingDaddy.storage .getCredentials() .catch(() => ({})); await cheatingDaddy.storage.setCredentials({ ...creds, openaiKey: val }); } catch (e) {} this.requestUpdate(); } async _saveOpenAICompatibleApiKey(val) { this._openaiCompatibleApiKey = val; await cheatingDaddy.storage.setOpenAICompatibleConfig( val, this._openaiCompatibleBaseUrl, this._openaiCompatibleModel, ); this.requestUpdate(); // Auto-load models when both key and URL are set this._debouncedLoadModels(); } async _saveOpenAICompatibleBaseUrl(val) { this._openaiCompatibleBaseUrl = val; await cheatingDaddy.storage.setOpenAICompatibleConfig( this._openaiCompatibleApiKey, val, this._openaiCompatibleModel, ); this.requestUpdate(); // Auto-load models when both key and URL are set this._debouncedLoadModels(); } async _saveOpenAICompatibleModel(val) { this._openaiCompatibleModel = val; await cheatingDaddy.storage.setOpenAICompatibleConfig( this._openaiCompatibleApiKey, this._openaiCompatibleBaseUrl, val, ); this.requestUpdate(); } async _saveResponseProvider(val) { this._responseProvider = val; await cheatingDaddy.storage.updatePreference("responseProvider", val); this.requestUpdate(); // Auto-load models when switching to openai-compatible if (val === "openai-compatible" && this._openaiCompatibleBaseUrl) { this._loadModels(); } } async _loadModels() { if ( this._responseProvider !== "openai-compatible" || !this._openaiCompatibleBaseUrl ) { return; } this._loadingModels = true; this._availableModels = []; this.requestUpdate(); try { let modelsUrl = this._openaiCompatibleBaseUrl.trim(); modelsUrl = modelsUrl.replace(/\/$/, ""); if (!modelsUrl.includes("/models")) { modelsUrl = modelsUrl.includes("/v1") ? modelsUrl + "/models" : modelsUrl + "/v1/models"; } console.log("Loading models from:", modelsUrl); const headers = { "Content-Type": "application/json", }; if (this._openaiCompatibleApiKey) { headers["Authorization"] = `Bearer ${this._openaiCompatibleApiKey}`; } const response = await fetch(modelsUrl, { headers }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); if (data.data && Array.isArray(data.data)) { this._availableModels = data.data .map((m) => m.id || m.model || m.name) .filter(Boolean); } else if (Array.isArray(data)) { this._availableModels = data .map((m) => m.id || m.model || m.name || m) .filter(Boolean); } console.log("Loaded models:", this._availableModels.length); if ( this._availableModels.length > 0 && !this._availableModels.includes(this._openaiCompatibleModel) ) { await this._saveOpenAICompatibleModel(this._availableModels[0]); } } catch (error) { console.log("Could not load models:", error.message); this._availableModels = []; } finally { this._loadingModels = false; this.requestUpdate(); } } _debouncedLoadModels() { if (this._loadModelsTimeout) { clearTimeout(this._loadModelsTimeout); } this._loadModelsTimeout = setTimeout(() => { this._loadModels(); }, 500); } _toggleManualInput() { this._manualModelInput = !this._manualModelInput; this.requestUpdate(); } async _saveOllamaHost(val) { this._ollamaHost = val; await cheatingDaddy.storage.updatePreference("ollamaHost", val); this.requestUpdate(); } async _saveOllamaModel(val) { this._ollamaModel = val; await cheatingDaddy.storage.updatePreference("ollamaModel", val); this.requestUpdate(); } async _saveWhisperModel(val) { this._whisperModel = val; if (val === "__custom__") { // Don't save yet — wait for the custom input this.requestUpdate(); return; } this._customWhisperModel = ""; await cheatingDaddy.storage.updatePreference("whisperModel", val); this.requestUpdate(); } async _saveCustomWhisperModel(val) { this._customWhisperModel = val.trim(); if (this._customWhisperModel) { await cheatingDaddy.storage.updatePreference( "whisperModel", this._customWhisperModel, ); } this.requestUpdate(); } _formatBytes(bytes) { if (!bytes || bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return (bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0) + " " + units[i]; } _renderWhisperProgress() { const p = this.whisperProgress; if (!p) return ""; const pct = Math.round(p.progress || 0); const fileName = p.file ? p.file.split("/").pop() : ""; return html`
ollama pull ${this._ollamaModel}
first
ollama serve
ollama pull gemma3:4b