The Dutch Mentor
Listening…
Enter to send · Shift+Enter for newline · Free · 5 questions/day

CV Review with The Dutch Mentor

Upload your résumé, paste the job description. Get a comprehensive 7-section report — alignment audit, specific rewrites, interview prep, STAR stories, questions to ask. Free: 1 report ever.

PDF/DOCX is parsed locally in your browser — only the extracted text is sent.

🌟 Your WHY

Start here. The clearer the mentor knows you, the more precise the coaching gets. Your WHY, your brand, and your CV become context the mentor uses on every reply — across Chat, CV Review, and Email Helper.

A great WHY is concrete and personal — not "to help leaders succeed" but "because I watched my mom drown in middle management when nobody helped her, and I'm not letting that happen to anyone else on my watch."
Brand = Natural Strengths + Leadership Attributes + Expertise. The mentor will help you triangulate.
Parsed in your browser. Only the extracted text is stored locally and sent as context with each request — never to anyone but the AI mentor.
📊 Profile completeness: No data yet — start with your WHY above.

Accountability Tracker

Track the commitments that actually matter. Tap each cell — green (on track), yellow (at risk), red (off track). Two yellows or reds in the current period triggers a countermeasure. Pulled straight from your Rhythmic Operations + Performance pillars.

Email Helper — drafts in your voice

Pro feature. Paste an incoming email + a few notes about how you want to respond. The mentor drafts a reply in your voice, learning from samples you share over time.

* * It adds: * - A floating cobalt circle button in the bottom-right corner * - Click → slides up a chat panel with the Dutch Mentor avatar * - Wires the avatar to the shared dutch-mentor-ai worker * - Runs client-side crisis detection BEFORE any API call * - Persists conversation per-app in localStorage (key per host) * * Customize via window.DM_MENTOR_CONFIG before this script loads: * * * * Safe to load on any page. Will not collide with existing UI * because all CSS is scoped to .dm-mentor-* classes. * * VERSION: 0.2 (added browser TTS — speaks replies, mute toggle in header) * ============================================================= */ (function () { "use strict"; // ----------------------------------------------------------- // Configuration (with sensible defaults) // ----------------------------------------------------------- const cfg = Object.assign({ appName: detectAppName(), endpoint: "https://dutch-mentor-ai.pages.dev/chat", greeting: "Hey — I'm Walter's AI mentor. Tell me what's on your mind. I'm here to think with you, not at you.", buttonLabel: "Talk to Walter", autoOpen: false, timeoutMs: 30000, storageKey: "dm-mentor-chat-" + (location.hostname || "default"), }, window.DM_MENTOR_CONFIG || {}); function detectAppName() { const h = (location.hostname || "").toLowerCase(); if (h.includes("dutchmentor.pages.dev") || h.includes("smart")) return "smart"; if (h.includes("aimscholar")) return "aimscholar"; if (h.includes("companion")) return "companion"; return "unknown"; } // ----------------------------------------------------------- // Voice (TTS) — uses the browser's built-in Web Speech API. // Free, no API key. Each OS picks its best voice. // The user can mute via a toggle in the panel header. // Mute preference persists in localStorage per-host. // ----------------------------------------------------------- const VOICE_PREF_KEY = cfg.storageKey + "-voice"; function isVoiceMuted() { try { return localStorage.getItem(VOICE_PREF_KEY) === "muted"; } catch (e) { return false; } } function setVoiceMuted(muted) { try { localStorage.setItem(VOICE_PREF_KEY, muted ? "muted" : "on"); } catch (e) {} } let cachedVoice = null; function pickVoice() { if (cachedVoice) return cachedVoice; if (!("speechSynthesis" in window)) return null; const voices = window.speechSynthesis.getVoices(); if (!voices || voices.length === 0) return null; // Prefer high-quality English male voices in order of perceived naturalness. // macOS: Daniel (en_GB), Tom, Alex; Windows: David; Chrome: Google US English (default tends male-ish). const preferred = [ "Daniel", "Tom", "Alex", "Fred", "Aaron", "Microsoft David", "Microsoft Mark", "Google UK English Male", "Google US English", ]; for (const name of preferred) { const v = voices.find(x => x.name && x.name.toLowerCase().includes(name.toLowerCase())); if (v) { cachedVoice = v; return v; } } // Fallback: first English voice const en = voices.find(v => /^en/i.test(v.lang)) || voices[0]; cachedVoice = en; return en; } // Voice list arrives async on some browsers; refresh when ready if ("speechSynthesis" in window) { window.speechSynthesis.onvoiceschanged = () => { cachedVoice = null; }; } function speak(text) { if (!("speechSynthesis" in window)) return; if (isVoiceMuted()) return; if (!text || !text.trim()) return; try { // Cancel any in-flight utterance so replies don't pile up window.speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(stripForSpeech(text)); const v = pickVoice(); if (v) u.voice = v; u.rate = 0.96; // slightly slower than default — more natural for mentor tone u.pitch = 0.95; // a touch lower for warmth u.volume = 1.0; window.speechSynthesis.speak(u); } catch (e) { console.warn("TTS failed:", e.message); } } function stopSpeaking() { if (!("speechSynthesis" in window)) return; try { window.speechSynthesis.cancel(); } catch (e) {} } // Strip markdown/special characters that TTS engines mispronounce function stripForSpeech(text) { return text .replace(/```[\s\S]*?```/g, " ") // code blocks .replace(/`[^`]*`/g, " ") // inline code .replace(/\*\*([^*]+)\*\*/g, "$1") // bold .replace(/\*([^*]+)\*/g, "$1") // italic .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // markdown links → just label .replace(/[#>•·]/g, "") // markers .replace(/—/g, ", ") // em-dash .replace(/\s+/g, " ") .trim(); } // ----------------------------------------------------------- // Crisis detection — runs BEFORE any API call. // Same pattern as the Companion's spine. NEVER edit without // a clinical advisor's read. // ----------------------------------------------------------- const CRISIS_PATTERNS = [ /\b(?:kill|hurt|harm)\s+(?:my)?self\b/i, /\bsuicide|suicidal\b/i, /\bend\s+(?:it|my\s+life|things)\b/i, /\bdon'?t\s+want\s+to\s+(?:live|be\s+here|exist)\b/i, /\bwant\s+to\s+die\b/i, /\bcan'?t\s+go\s+on\b/i, /\bgoing\s+to\s+(?:kill|hurt)\b/i, /\bself[\s-]?harm\b/i, /\bcutting\s+myself\b/i, /\boverdose\b/i, /\bjump\s+off\b/i, /\b(?:has|have)\s+a\s+gun\b/i, /\babuse[ds]?\s+me\b/i, /\b(?:rape|raped|assault(?:ed)?|attacked)\s+me\b/i, ]; function detectCrisis(text) { return CRISIS_PATTERNS.some(rx => rx.test(text)); } // ----------------------------------------------------------- // Crisis modal — same content as Companion. Opens directly, // no API call. // ----------------------------------------------------------- function showCrisisModal() { const modal = document.createElement("div"); modal.className = "dm-mentor-crisis-modal"; modal.innerHTML = ` `; document.body.appendChild(modal); modal.querySelector(".dm-mentor-crisis-close").addEventListener("click", () => modal.remove()); } // ----------------------------------------------------------- // Chat state (persists in localStorage, scoped per host) // ----------------------------------------------------------- let chat = loadChat(); function loadChat() { try { const raw = localStorage.getItem(cfg.storageKey); if (raw) return JSON.parse(raw); } catch (e) {} return [{ role: "me", text: cfg.greeting, t: Date.now() }]; } function saveChat() { try { localStorage.setItem(cfg.storageKey, JSON.stringify(chat.slice(-30))); } catch (e) {} } function pushMsg(role, text) { chat.push({ role, text, t: Date.now() }); saveChat(); renderChat(); } // ----------------------------------------------------------- // AI call — sends conversation history to the worker. // Trusts that the user message is already in `chat` (caller pushes it first). // ----------------------------------------------------------- async function mentorReplyAI(userText) { if (detectCrisis(userText)) { showCrisisModal(); return ""; } const history = chat .filter(m => m.role === "you" || m.role === "me") .map(m => ({ role: m.role === "you" ? "user" : "assistant", content: m.text })); if (history.length === 0 || history[history.length - 1].role !== "user") { history.push({ role: "user", content: userText }); } setAvatarState("thinking"); const ctrl = new AbortController(); const timeout = setTimeout(() => ctrl.abort(), cfg.timeoutMs); try { const resp = await fetch(cfg.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: history, memoryEnabled: true, appName: cfg.appName }), signal: ctrl.signal, }); clearTimeout(timeout); if (!resp.ok) throw new Error("upstream_" + resp.status); const data = await resp.json(); if (!data.reply) throw new Error("empty_reply"); return data.reply; } catch (e) { clearTimeout(timeout); console.warn("Mentor AI unreachable:", e.message); return "I had trouble reaching the mentor service. Try again in a moment, or check your connection."; } } // ----------------------------------------------------------- // Render the floating button + chat panel // ----------------------------------------------------------- function injectStyles() { if (document.getElementById("dm-mentor-styles")) return; const css = document.createElement("style"); css.id = "dm-mentor-styles"; css.textContent = ` .dm-mentor-fab { position: fixed; right: 22px; bottom: 22px; z-index: 99998; width: 60px; height: 60px; border-radius: 50%; background: #0836DB; color: #fff; border: 3px solid #FFB536; cursor: pointer; box-shadow: 0 8px 24px rgba(8,54,219,0.35); display: grid; place-items: center; font-size: 28px; transition: transform 0.15s ease, box-shadow 0.15s ease; } .dm-mentor-fab:hover { transform: scale(1.06); box-shadow: 0 12px 32px rgba(8,54,219,0.45); } .dm-mentor-fab svg { width: 36px; height: 36px; } .dm-mentor-fab.is-open { display: none; } .dm-mentor-panel { position: fixed; right: 22px; bottom: 22px; z-index: 99999; width: 380px; max-width: calc(100vw - 24px); height: 560px; max-height: calc(100vh - 80px); background: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 24px 60px rgba(0,0,0,0.28); display: none; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, sans-serif; color: #0A1428; } .dm-mentor-panel.is-open { display: flex; } .dm-mentor-panel.is-mobile { right: 12px; left: 12px; bottom: 12px; width: auto; } .dm-mentor-header { background: linear-gradient(135deg, #0836DB 0%, #062AA8 100%); color: #fff; padding: 14px 16px; display: flex; align-items: center; gap: 12px; } .dm-mentor-header-title { flex: 1; font-weight: 600; font-size: 15px; letter-spacing: 0.01em; } .dm-mentor-header-title small { display: block; font-weight: 400; font-size: 11px; opacity: 0.8; letter-spacing: 0.04em; } .dm-mentor-close, .dm-mentor-mute { background: rgba(255,255,255,0.15); border: 0; color: #fff; width: 32px; height: 32px; border-radius: 50%; cursor: pointer; font-size: 18px; line-height: 1; display: grid; place-items: center; transition: background 0.15s; } .dm-mentor-close:hover, .dm-mentor-mute:hover { background: rgba(255,255,255,0.25); } .dm-mentor-mute.is-muted { opacity: 0.6; } .dm-mentor-avatar { width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; background: #062AA8; border: 2px solid #FFB536; display: grid; place-items: center; overflow: hidden; } .dm-mentor-avatar svg { width: 100%; height: 100%; } .dm-mentor-avatar.is-thinking { animation: dm-pulse 1.2s ease-in-out infinite; } @keyframes dm-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.08); } } .dm-mentor-body { flex: 1; overflow-y: auto; padding: 16px; background: #F4F6FB; display: flex; flex-direction: column; gap: 10px; } .dm-mentor-bubble { max-width: 85%; padding: 10px 14px; border-radius: 16px; font-size: 14px; line-height: 1.5; white-space: pre-wrap; } .dm-mentor-bubble.you { align-self: flex-end; background: #0836DB; color: #fff; border-bottom-right-radius: 4px; } .dm-mentor-bubble.me { align-self: flex-start; background: #fff; color: #0A1428; border-bottom-left-radius: 4px; border: 1px solid #E2E1DC; } .dm-mentor-bubble.thinking { align-self: flex-start; background: #fff; border: 1px solid #E2E1DC; border-bottom-left-radius: 4px; color: #6b6b6b; font-style: italic; } .dm-mentor-thinking-dots::after { content: ''; animation: dm-dots 1.4s steps(4,end) infinite; } @keyframes dm-dots { 0%,20% { content: ''; } 40% { content: '.'; } 60% { content: '..'; } 80%,100% { content: '...'; } } .dm-mentor-footer { border-top: 1px solid #E2E1DC; padding: 10px 12px; background: #fff; display: flex; gap: 8px; align-items: flex-end; } .dm-mentor-input { flex: 1; border: 1px solid #E2E1DC; border-radius: 12px; padding: 10px 12px; font-size: 14px; font-family: inherit; resize: none; outline: none; min-height: 40px; max-height: 120px; line-height: 1.4; } .dm-mentor-input:focus { border-color: #0836DB; } .dm-mentor-send { background: #0836DB; color: #fff; border: 0; border-radius: 12px; padding: 10px 16px; font-weight: 600; font-size: 14px; cursor: pointer; white-space: nowrap; } .dm-mentor-send:hover { background: #062AA8; } .dm-mentor-send:disabled { opacity: 0.5; cursor: not-allowed; } .dm-mentor-meta { font-size: 10px; color: #6b6b6b; text-align: center; padding: 6px 12px 8px; background: #fff; border-top: 1px solid #F0EFEA; } .dm-mentor-meta a { color: #0836DB; text-decoration: none; } .dm-mentor-meta a:hover { text-decoration: underline; } /* Crisis modal */ .dm-mentor-crisis-modal { position: fixed; inset: 0; z-index: 100000; background: rgba(10,20,40,0.7); backdrop-filter: blur(4px); display: grid; place-items: center; padding: 20px; } .dm-mentor-crisis-card { background: #fff; border-radius: 16px; padding: 28px 24px; max-width: 420px; width: 100%; box-shadow: 0 24px 60px rgba(0,0,0,0.4); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #0A1428; } .dm-mentor-crisis-card h2 { font-size: 22px; margin-bottom: 12px; color: #0A1428; } .dm-mentor-crisis-card p { font-size: 15px; line-height: 1.5; margin-bottom: 16px; color: #4a4a4a; } .dm-mentor-crisis-actions { display: flex; flex-direction: column; gap: 10px; margin: 16px 0; } .dm-mentor-crisis-btn { display: block; padding: 14px 18px; border-radius: 12px; font-weight: 600; text-decoration: none; text-align: center; font-size: 16px; } .dm-mentor-crisis-call { background: #0836DB; color: #fff; } .dm-mentor-crisis-text { background: #FFB536; color: #0A1428; } .dm-mentor-crisis-aux { font-size: 13px; color: #6b6b6b; margin: 12px 0 16px; } .dm-mentor-crisis-close { background: transparent; border: 1px solid #E2E1DC; color: #4a4a4a; padding: 10px 14px; border-radius: 10px; font-size: 14px; cursor: pointer; width: 100%; } @media (max-width: 480px) { .dm-mentor-fab { right: 14px; bottom: 14px; width: 54px; height: 54px; } .dm-mentor-panel.is-open { right: 12px; left: 12px; bottom: 12px; width: auto; height: 70vh; } } `; document.head.appendChild(css); } // Cobalt avatar SVG — simplified version of the Companion's avatar const AVATAR_SVG = ` `; let panelEl, fabEl, bodyEl, inputEl, sendEl, avatarEl; function buildUI() { injectStyles(); // Floating button fabEl = document.createElement("button"); fabEl.className = "dm-mentor-fab"; fabEl.title = cfg.buttonLabel; fabEl.setAttribute("aria-label", cfg.buttonLabel); fabEl.innerHTML = AVATAR_SVG; fabEl.addEventListener("click", openPanel); document.body.appendChild(fabEl); // Slide-up panel panelEl = document.createElement("div"); panelEl.className = "dm-mentor-panel"; panelEl.innerHTML = `
${AVATAR_SVG}
The Dutch Mentor AI Walter's voice — not a real person
AI mentor · Crisis? 988 · Text HOME to 741741
`; document.body.appendChild(panelEl); bodyEl = panelEl.querySelector(".dm-mentor-body"); inputEl = panelEl.querySelector(".dm-mentor-input"); sendEl = panelEl.querySelector(".dm-mentor-send"); avatarEl = panelEl.querySelector(".dm-mentor-avatar"); panelEl.querySelector(".dm-mentor-close").addEventListener("click", closePanel); // Voice mute toggle const muteBtn = panelEl.querySelector(".dm-mentor-mute"); function refreshMuteUI() { const muted = isVoiceMuted(); muteBtn.textContent = muted ? "🔇" : "🔊"; muteBtn.classList.toggle("is-muted", muted); muteBtn.setAttribute("aria-pressed", muted ? "true" : "false"); } refreshMuteUI(); muteBtn.addEventListener("click", () => { const nowMuted = !isVoiceMuted(); setVoiceMuted(nowMuted); refreshMuteUI(); if (nowMuted) stopSpeaking(); }); sendEl.addEventListener("click", handleSend); inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }); inputEl.addEventListener("input", () => { inputEl.style.height = "auto"; inputEl.style.height = Math.min(120, inputEl.scrollHeight) + "px"; }); renderChat(); if (cfg.autoOpen) openPanel(); } function renderChat() { if (!bodyEl) return; bodyEl.innerHTML = ""; chat.forEach(m => { const b = document.createElement("div"); b.className = "dm-mentor-bubble " + (m.role === "you" ? "you" : "me"); b.textContent = m.text; bodyEl.appendChild(b); }); bodyEl.scrollTop = bodyEl.scrollHeight; } function setAvatarState(state) { if (!avatarEl) return; avatarEl.classList.remove("is-thinking", "is-speaking"); if (state && state !== "idle") avatarEl.classList.add("is-" + state); } function openPanel() { panelEl.classList.add("is-open"); fabEl.classList.add("is-open"); setTimeout(() => inputEl && inputEl.focus(), 100); } function closePanel() { panelEl.classList.remove("is-open"); fabEl.classList.remove("is-open"); stopSpeaking(); // don't keep talking after the panel is hidden } async function handleSend() { const text = inputEl.value.trim(); if (!text) return; inputEl.value = ""; inputEl.style.height = "auto"; // Stop any in-flight speech so a new question doesn't overlap the old reply stopSpeaking(); if (detectCrisis(text)) { showCrisisModal(); return; } pushMsg("you", text); sendEl.disabled = true; // Show a thinking placeholder bubble const thinking = document.createElement("div"); thinking.className = "dm-mentor-bubble thinking"; thinking.innerHTML = 'thinking'; bodyEl.appendChild(thinking); bodyEl.scrollTop = bodyEl.scrollHeight; try { const reply = await mentorReplyAI(text); thinking.remove(); if (reply) { setAvatarState("speaking"); pushMsg("me", reply); speak(reply); // speak the reply aloud (no-op when muted) setTimeout(() => setAvatarState("idle"), Math.min(5000, 1000 + reply.length * 12)); } } catch (e) { thinking.remove(); pushMsg("me", "I had trouble reaching the mentor service. Try again in a moment."); } finally { sendEl.disabled = false; setAvatarState("idle"); } } // ----------------------------------------------------------- // Boot // ----------------------------------------------------------- if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", buildUI); } else { buildUI(); } // Expose minimal API on window window.DutchMentorModule = { open: openPanel, close: closePanel, clear: () => { chat = [{ role: "me", text: cfg.greeting, t: Date.now() }]; saveChat(); renderChat(); }, version: "0.1", }; })(); * * It adds: * - A floating cobalt circle button in the bottom-right corner * - Click → slides up a chat panel with the Dutch Mentor avatar * - Wires the avatar to the shared dutch-mentor-ai worker * - Runs client-side crisis detection BEFORE any API call * - Persists conversation per-app in localStorage (key per host) * * Customize via window.DM_MENTOR_CONFIG before this script loads: * * * * Safe to load on any page. Will not collide with existing UI * because all CSS is scoped to .dm-mentor-* classes. * * VERSION: 0.1 * ============================================================= */ (function () { "use strict"; // ----------------------------------------------------------- // Configuration (with sensible defaults) // ----------------------------------------------------------- const cfg = Object.assign({ appName: detectAppName(), endpoint: "https://dutch-mentor-ai.pages.dev/chat", greeting: "Hey — I'm Walter's AI mentor. Tell me what's on your mind. I'm here to think with you, not at you.", buttonLabel: "Talk to Walter", autoOpen: false, timeoutMs: 30000, storageKey: "dm-mentor-chat-" + (location.hostname || "default"), }, window.DM_MENTOR_CONFIG || {}); function detectAppName() { const h = (location.hostname || "").toLowerCase(); if (h.includes("dutchmentor.pages.dev") || h.includes("smart")) return "smart"; if (h.includes("aimscholar")) return "aimscholar"; if (h.includes("companion")) return "companion"; return "unknown"; } // ----------------------------------------------------------- // Crisis detection — runs BEFORE any API call. // Same pattern as the Companion's spine. NEVER edit without // a clinical advisor's read. // ----------------------------------------------------------- const CRISIS_PATTERNS = [ /\b(?:kill|hurt|harm)\s+(?:my)?self\b/i, /\bsuicide|suicidal\b/i, /\bend\s+(?:it|my\s+life|things)\b/i, /\bdon'?t\s+want\s+to\s+(?:live|be\s+here|exist)\b/i, /\bwant\s+to\s+die\b/i, /\bcan'?t\s+go\s+on\b/i, /\bgoing\s+to\s+(?:kill|hurt)\b/i, /\bself[\s-]?harm\b/i, /\bcutting\s+myself\b/i, /\boverdose\b/i, /\bjump\s+off\b/i, /\b(?:has|have)\s+a\s+gun\b/i, /\babuse[ds]?\s+me\b/i, /\b(?:rape|raped|assault(?:ed)?|attacked)\s+me\b/i, ]; function detectCrisis(text) { return CRISIS_PATTERNS.some(rx => rx.test(text)); } // ----------------------------------------------------------- // Crisis modal — same content as Companion. Opens directly, // no API call. // ----------------------------------------------------------- function showCrisisModal() { const modal = document.createElement("div"); modal.className = "dm-mentor-crisis-modal"; modal.innerHTML = ` `; document.body.appendChild(modal); modal.querySelector(".dm-mentor-crisis-close").addEventListener("click", () => modal.remove()); } // ----------------------------------------------------------- // Chat state (persists in localStorage, scoped per host) // ----------------------------------------------------------- let chat = loadChat(); function loadChat() { try { const raw = localStorage.getItem(cfg.storageKey); if (raw) return JSON.parse(raw); } catch (e) {} return [{ role: "me", text: cfg.greeting, t: Date.now() }]; } function saveChat() { try { localStorage.setItem(cfg.storageKey, JSON.stringify(chat.slice(-30))); } catch (e) {} } function pushMsg(role, text) { chat.push({ role, text, t: Date.now() }); saveChat(); renderChat(); } // ----------------------------------------------------------- // AI call — sends conversation history to the worker. // Trusts that the user message is already in `chat` (caller pushes it first). // ----------------------------------------------------------- async function mentorReplyAI(userText) { if (detectCrisis(userText)) { showCrisisModal(); return ""; } const history = chat .filter(m => m.role === "you" || m.role === "me") .map(m => ({ role: m.role === "you" ? "user" : "assistant", content: m.text })); if (history.length === 0 || history[history.length - 1].role !== "user") { history.push({ role: "user", content: userText }); } setAvatarState("thinking"); const ctrl = new AbortController(); const timeout = setTimeout(() => ctrl.abort(), cfg.timeoutMs); try { const resp = await fetch(cfg.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: history, memoryEnabled: true, appName: cfg.appName }), signal: ctrl.signal, }); clearTimeout(timeout); if (!resp.ok) throw new Error("upstream_" + resp.status); const data = await resp.json(); if (!data.reply) throw new Error("empty_reply"); return data.reply; } catch (e) { clearTimeout(timeout); console.warn("Mentor AI unreachable:", e.message); return "I had trouble reaching the mentor service. Try again in a moment, or check your connection."; } } // ----------------------------------------------------------- // Render the floating button + chat panel // ----------------------------------------------------------- function injectStyles() { if (document.getElementById("dm-mentor-styles")) return; const css = document.createElement("style"); css.id = "dm-mentor-styles"; css.textContent = ` .dm-mentor-fab { position: fixed; right: 22px; bottom: 22px; z-index: 99998; width: 60px; height: 60px; border-radius: 50%; background: #0836DB; color: #fff; border: 3px solid #FFB536; cursor: pointer; box-shadow: 0 8px 24px rgba(8,54,219,0.35); display: grid; place-items: center; font-size: 28px; transition: transform 0.15s ease, box-shadow 0.15s ease; } .dm-mentor-fab:hover { transform: scale(1.06); box-shadow: 0 12px 32px rgba(8,54,219,0.45); } .dm-mentor-fab svg { width: 36px; height: 36px; } .dm-mentor-fab.is-open { display: none; } .dm-mentor-panel { position: fixed; right: 22px; bottom: 22px; z-index: 99999; width: 380px; max-width: calc(100vw - 24px); height: 560px; max-height: calc(100vh - 80px); background: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 24px 60px rgba(0,0,0,0.28); display: none; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, sans-serif; color: #0A1428; } .dm-mentor-panel.is-open { display: flex; } .dm-mentor-panel.is-mobile { right: 12px; left: 12px; bottom: 12px; width: auto; } .dm-mentor-header { background: linear-gradient(135deg, #0836DB 0%, #062AA8 100%); color: #fff; padding: 14px 16px; display: flex; align-items: center; gap: 12px; } .dm-mentor-header-title { flex: 1; font-weight: 600; font-size: 15px; letter-spacing: 0.01em; } .dm-mentor-header-title small { display: block; font-weight: 400; font-size: 11px; opacity: 0.8; letter-spacing: 0.04em; } .dm-mentor-close { background: rgba(255,255,255,0.15); border: 0; color: #fff; width: 32px; height: 32px; border-radius: 50%; cursor: pointer; font-size: 20px; line-height: 1; } .dm-mentor-close:hover { background: rgba(255,255,255,0.25); } .dm-mentor-avatar { width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; background: #062AA8; border: 2px solid #FFB536; display: grid; place-items: center; overflow: hidden; } .dm-mentor-avatar svg { width: 100%; height: 100%; } .dm-mentor-avatar.is-thinking { animation: dm-pulse 1.2s ease-in-out infinite; } @keyframes dm-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.08); } } .dm-mentor-body { flex: 1; overflow-y: auto; padding: 16px; background: #F4F6FB; display: flex; flex-direction: column; gap: 10px; } .dm-mentor-bubble { max-width: 85%; padding: 10px 14px; border-radius: 16px; font-size: 14px; line-height: 1.5; white-space: pre-wrap; } .dm-mentor-bubble.you { align-self: flex-end; background: #0836DB; color: #fff; border-bottom-right-radius: 4px; } .dm-mentor-bubble.me { align-self: flex-start; background: #fff; color: #0A1428; border-bottom-left-radius: 4px; border: 1px solid #E2E1DC; } .dm-mentor-bubble.thinking { align-self: flex-start; background: #fff; border: 1px solid #E2E1DC; border-bottom-left-radius: 4px; color: #6b6b6b; font-style: italic; } .dm-mentor-thinking-dots::after { content: ''; animation: dm-dots 1.4s steps(4,end) infinite; } @keyframes dm-dots { 0%,20% { content: ''; } 40% { content: '.'; } 60% { content: '..'; } 80%,100% { content: '...'; } } .dm-mentor-footer { border-top: 1px solid #E2E1DC; padding: 10px 12px; background: #fff; display: flex; gap: 8px; align-items: flex-end; } .dm-mentor-input { flex: 1; border: 1px solid #E2E1DC; border-radius: 12px; padding: 10px 12px; font-size: 14px; font-family: inherit; resize: none; outline: none; min-height: 40px; max-height: 120px; line-height: 1.4; } .dm-mentor-input:focus { border-color: #0836DB; } .dm-mentor-send { background: #0836DB; color: #fff; border: 0; border-radius: 12px; padding: 10px 16px; font-weight: 600; font-size: 14px; cursor: pointer; white-space: nowrap; } .dm-mentor-send:hover { background: #062AA8; } .dm-mentor-send:disabled { opacity: 0.5; cursor: not-allowed; } .dm-mentor-meta { font-size: 10px; color: #6b6b6b; text-align: center; padding: 6px 12px 8px; background: #fff; border-top: 1px solid #F0EFEA; } .dm-mentor-meta a { color: #0836DB; text-decoration: none; } .dm-mentor-meta a:hover { text-decoration: underline; } /* Crisis modal */ .dm-mentor-crisis-modal { position: fixed; inset: 0; z-index: 100000; background: rgba(10,20,40,0.7); backdrop-filter: blur(4px); display: grid; place-items: center; padding: 20px; } .dm-mentor-crisis-card { background: #fff; border-radius: 16px; padding: 28px 24px; max-width: 420px; width: 100%; box-shadow: 0 24px 60px rgba(0,0,0,0.4); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #0A1428; } .dm-mentor-crisis-card h2 { font-size: 22px; margin-bottom: 12px; color: #0A1428; } .dm-mentor-crisis-card p { font-size: 15px; line-height: 1.5; margin-bottom: 16px; color: #4a4a4a; } .dm-mentor-crisis-actions { display: flex; flex-direction: column; gap: 10px; margin: 16px 0; } .dm-mentor-crisis-btn { display: block; padding: 14px 18px; border-radius: 12px; font-weight: 600; text-decoration: none; text-align: center; font-size: 16px; } .dm-mentor-crisis-call { background: #0836DB; color: #fff; } .dm-mentor-crisis-text { background: #FFB536; color: #0A1428; } .dm-mentor-crisis-aux { font-size: 13px; color: #6b6b6b; margin: 12px 0 16px; } .dm-mentor-crisis-close { background: transparent; border: 1px solid #E2E1DC; color: #4a4a4a; padding: 10px 14px; border-radius: 10px; font-size: 14px; cursor: pointer; width: 100%; } @media (max-width: 480px) { .dm-mentor-fab { right: 14px; bottom: 14px; width: 54px; height: 54px; } .dm-mentor-panel.is-open { right: 12px; left: 12px; bottom: 12px; width: auto; height: 70vh; } } `; document.head.appendChild(css); } // Cobalt avatar SVG — simplified version of the Companion's avatar const AVATAR_SVG = ` `; let panelEl, fabEl, bodyEl, inputEl, sendEl, avatarEl; function buildUI() { injectStyles(); // Floating button fabEl = document.createElement("button"); fabEl.className = "dm-mentor-fab"; fabEl.title = cfg.buttonLabel; fabEl.setAttribute("aria-label", cfg.buttonLabel); fabEl.innerHTML = AVATAR_SVG; fabEl.addEventListener("click", openPanel); document.body.appendChild(fabEl); // Slide-up panel panelEl = document.createElement("div"); panelEl.className = "dm-mentor-panel"; panelEl.innerHTML = `
${AVATAR_SVG}
The Dutch Mentor AI Walter's voice — not a real person
AI mentor · Crisis? 988 · Text HOME to 741741
`; document.body.appendChild(panelEl); bodyEl = panelEl.querySelector(".dm-mentor-body"); inputEl = panelEl.querySelector(".dm-mentor-input"); sendEl = panelEl.querySelector(".dm-mentor-send"); avatarEl = panelEl.querySelector(".dm-mentor-avatar"); panelEl.querySelector(".dm-mentor-close").addEventListener("click", closePanel); sendEl.addEventListener("click", handleSend); inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }); inputEl.addEventListener("input", () => { inputEl.style.height = "auto"; inputEl.style.height = Math.min(120, inputEl.scrollHeight) + "px"; }); renderChat(); if (cfg.autoOpen) openPanel(); } function renderChat() { if (!bodyEl) return; bodyEl.innerHTML = ""; chat.forEach(m => { const b = document.createElement("div"); b.className = "dm-mentor-bubble " + (m.role === "you" ? "you" : "me"); b.textContent = m.text; bodyEl.appendChild(b); }); bodyEl.scrollTop = bodyEl.scrollHeight; } function setAvatarState(state) { if (!avatarEl) return; avatarEl.classList.remove("is-thinking", "is-speaking"); if (state && state !== "idle") avatarEl.classList.add("is-" + state); } function openPanel() { panelEl.classList.add("is-open"); fabEl.classList.add("is-open"); setTimeout(() => inputEl && inputEl.focus(), 100); } function closePanel() { panelEl.classList.remove("is-open"); fabEl.classList.remove("is-open"); } async function handleSend() { const text = inputEl.value.trim(); if (!text) return; inputEl.value = ""; inputEl.style.height = "auto"; if (detectCrisis(text)) { showCrisisModal(); return; } pushMsg("you", text); sendEl.disabled = true; // Show a thinking placeholder bubble const thinking = document.createElement("div"); thinking.className = "dm-mentor-bubble thinking"; thinking.innerHTML = 'thinking'; bodyEl.appendChild(thinking); bodyEl.scrollTop = bodyEl.scrollHeight; try { const reply = await mentorReplyAI(text); thinking.remove(); if (reply) { setAvatarState("speaking"); pushMsg("me", reply); setTimeout(() => setAvatarState("idle"), Math.min(5000, 1000 + reply.length * 12)); } } catch (e) { thinking.remove(); pushMsg("me", "I had trouble reaching the mentor service. Try again in a moment."); } finally { sendEl.disabled = false; setAvatarState("idle"); } } // ----------------------------------------------------------- // Boot // ----------------------------------------------------------- if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", buildUI); } else { buildUI(); } // Expose minimal API on window window.DutchMentorModule = { open: openPanel, close: closePanel, clear: () => { chat = [{ role: "me", text: cfg.greeting, t: Date.now() }]; saveChat(); renderChat(); }, version: "0.1", }; })(); * * It adds: * - A floating cobalt circle button in the bottom-right corner * - Click → slides up a chat panel with the Dutch Mentor avatar * - Wires the avatar to the shared dutch-mentor-ai worker * - Runs client-side crisis detection BEFORE any API call * - Persists conversation per-app in localStorage (key per host) * * Customize via window.DM_MENTOR_CONFIG before this script loads: * * * * Safe to load on any page. Will not collide with existing UI * because all CSS is scoped to .dm-mentor-* classes. * * VERSION: 0.3 (ElevenLabs voice clone via worker /speak; browser TTS fallback) * ============================================================= */ (function () { "use strict"; // ----------------------------------------------------------- // Configuration (with sensible defaults) // ----------------------------------------------------------- const cfg = Object.assign({ appName: detectAppName(), endpoint: "https://dutch-mentor-ai.pages.dev/chat", greeting: "Hey — I'm Walter's AI mentor. Tell me what's on your mind. I'm here to think with you, not at you.", buttonLabel: "Talk to Walter", autoOpen: false, timeoutMs: 30000, storageKey: "dm-mentor-chat-" + (location.hostname || "default"), }, window.DM_MENTOR_CONFIG || {}); function detectAppName() { const h = (location.hostname || "").toLowerCase(); if (h.includes("dutchmentor.pages.dev") || h.includes("smart")) return "smart"; if (h.includes("aimscholar")) return "aimscholar"; if (h.includes("companion")) return "companion"; return "unknown"; } // ----------------------------------------------------------- // Voice (TTS) — uses the browser's built-in Web Speech API. // Free, no API key. Each OS picks its best voice. // The user can mute via a toggle in the panel header. // Mute preference persists in localStorage per-host. // ----------------------------------------------------------- const VOICE_PREF_KEY = cfg.storageKey + "-voice"; function isVoiceMuted() { try { return localStorage.getItem(VOICE_PREF_KEY) === "muted"; } catch (e) { return false; } } function setVoiceMuted(muted) { try { localStorage.setItem(VOICE_PREF_KEY, muted ? "muted" : "on"); } catch (e) {} } let cachedVoice = null; function pickVoice() { if (cachedVoice) return cachedVoice; if (!("speechSynthesis" in window)) return null; const voices = window.speechSynthesis.getVoices(); if (!voices || voices.length === 0) return null; // Prefer high-quality English male voices in order of perceived naturalness. // macOS: Daniel (en_GB), Tom, Alex; Windows: David; Chrome: Google US English (default tends male-ish). const preferred = [ "Daniel", "Tom", "Alex", "Fred", "Aaron", "Microsoft David", "Microsoft Mark", "Google UK English Male", "Google US English", ]; for (const name of preferred) { const v = voices.find(x => x.name && x.name.toLowerCase().includes(name.toLowerCase())); if (v) { cachedVoice = v; return v; } } // Fallback: first English voice const en = voices.find(v => /^en/i.test(v.lang)) || voices[0]; cachedVoice = en; return en; } // Voice list arrives async on some browsers; refresh when ready if ("speechSynthesis" in window) { window.speechSynthesis.onvoiceschanged = () => { cachedVoice = null; }; } // ElevenLabs voice via worker /speak — preferred when available. // Falls back to browser SpeechSynthesis if /speak is unreachable or returns 503. let _currentAudio = null; let _speakAbort = null; const SPEAK_ENDPOINT = cfg.endpoint.replace(/\/chat$/, "/speak"); async function speakViaWorker(text) { if (_speakAbort) { try { _speakAbort.abort(); } catch (e) {} } _speakAbort = new AbortController(); try { const resp = await fetch(SPEAK_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: stripForSpeech(text), appName: cfg.appName }), signal: _speakAbort.signal, }); if (!resp.ok) return false; const blob = await resp.blob(); if (blob.size === 0) return false; stopAudio(); const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.onended = () => { URL.revokeObjectURL(url); _currentAudio = null; }; audio.onerror = () => { URL.revokeObjectURL(url); _currentAudio = null; }; _currentAudio = audio; await audio.play(); return true; } catch (e) { return false; // any failure → caller falls back to browser TTS } } function stopAudio() { if (_currentAudio) { try { _currentAudio.pause(); } catch (e) {} _currentAudio = null; } if (_speakAbort) { try { _speakAbort.abort(); } catch (e) {} _speakAbort = null; } } function speakBrowser(text) { if (!("speechSynthesis" in window)) return; if (!text || !text.trim()) return; try { window.speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(stripForSpeech(text)); const v = pickVoice(); if (v) u.voice = v; u.rate = 0.96; u.pitch = 0.95; u.volume = 1.0; window.speechSynthesis.speak(u); } catch (e) { console.warn("Browser TTS failed:", e.message); } } // Public speak: try ElevenLabs (worker), fall back to browser async function speak(text) { if (isVoiceMuted()) return; if (!text || !text.trim()) return; const ok = await speakViaWorker(text); if (!ok) speakBrowser(text); } function stopSpeaking() { stopAudio(); if ("speechSynthesis" in window) { try { window.speechSynthesis.cancel(); } catch (e) {} } } // Strip markdown/special characters that TTS engines mispronounce function stripForSpeech(text) { return text .replace(/```[\s\S]*?```/g, " ") // code blocks .replace(/`[^`]*`/g, " ") // inline code .replace(/\*\*([^*]+)\*\*/g, "$1") // bold .replace(/\*([^*]+)\*/g, "$1") // italic .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // markdown links → just label .replace(/[#>•·]/g, "") // markers .replace(/—/g, ", ") // em-dash .replace(/\s+/g, " ") .trim(); } // ----------------------------------------------------------- // Crisis detection — runs BEFORE any API call. // Same pattern as the Companion's spine. NEVER edit without // a clinical advisor's read. // ----------------------------------------------------------- const CRISIS_PATTERNS = [ /\b(?:kill|hurt|harm)\s+(?:my)?self\b/i, /\bsuicide|suicidal\b/i, /\bend\s+(?:it|my\s+life|things)\b/i, /\bdon'?t\s+want\s+to\s+(?:live|be\s+here|exist)\b/i, /\bwant\s+to\s+die\b/i, /\bcan'?t\s+go\s+on\b/i, /\bgoing\s+to\s+(?:kill|hurt)\b/i, /\bself[\s-]?harm\b/i, /\bcutting\s+myself\b/i, /\boverdose\b/i, /\bjump\s+off\b/i, /\b(?:has|have)\s+a\s+gun\b/i, /\babuse[ds]?\s+me\b/i, /\b(?:rape|raped|assault(?:ed)?|attacked)\s+me\b/i, ]; function detectCrisis(text) { return CRISIS_PATTERNS.some(rx => rx.test(text)); } // ----------------------------------------------------------- // Crisis modal — same content as Companion. Opens directly, // no API call. // ----------------------------------------------------------- function showCrisisModal() { const modal = document.createElement("div"); modal.className = "dm-mentor-crisis-modal"; modal.innerHTML = ` `; document.body.appendChild(modal); modal.querySelector(".dm-mentor-crisis-close").addEventListener("click", () => modal.remove()); } // ----------------------------------------------------------- // Chat state (persists in localStorage, scoped per host) // ----------------------------------------------------------- let chat = loadChat(); function loadChat() { try { const raw = localStorage.getItem(cfg.storageKey); if (raw) return JSON.parse(raw); } catch (e) {} return [{ role: "me", text: cfg.greeting, t: Date.now() }]; } function saveChat() { try { localStorage.setItem(cfg.storageKey, JSON.stringify(chat.slice(-30))); } catch (e) {} } function pushMsg(role, text) { chat.push({ role, text, t: Date.now() }); saveChat(); renderChat(); } // ----------------------------------------------------------- // AI call — sends conversation history to the worker. // Trusts that the user message is already in `chat` (caller pushes it first). // ----------------------------------------------------------- async function mentorReplyAI(userText) { if (detectCrisis(userText)) { showCrisisModal(); return ""; } const history = chat .filter(m => m.role === "you" || m.role === "me") .map(m => ({ role: m.role === "you" ? "user" : "assistant", content: m.text })); if (history.length === 0 || history[history.length - 1].role !== "user") { history.push({ role: "user", content: userText }); } setAvatarState("thinking"); const ctrl = new AbortController(); const timeout = setTimeout(() => ctrl.abort(), cfg.timeoutMs); try { const resp = await fetch(cfg.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: history, memoryEnabled: true, appName: cfg.appName }), signal: ctrl.signal, }); clearTimeout(timeout); if (!resp.ok) throw new Error("upstream_" + resp.status); const data = await resp.json(); if (!data.reply) throw new Error("empty_reply"); return data.reply; } catch (e) { clearTimeout(timeout); console.warn("Mentor AI unreachable:", e.message); return "I had trouble reaching the mentor service. Try again in a moment, or check your connection."; } } // ----------------------------------------------------------- // Render the floating button + chat panel // ----------------------------------------------------------- function injectStyles() { if (document.getElementById("dm-mentor-styles")) return; const css = document.createElement("style"); css.id = "dm-mentor-styles"; css.textContent = ` .dm-mentor-fab { position: fixed; right: 22px; bottom: 22px; z-index: 99998; width: 60px; height: 60px; border-radius: 50%; background: #0836DB; color: #fff; border: 3px solid #FFB536; cursor: pointer; box-shadow: 0 8px 24px rgba(8,54,219,0.35); display: grid; place-items: center; font-size: 28px; transition: transform 0.15s ease, box-shadow 0.15s ease; } .dm-mentor-fab:hover { transform: scale(1.06); box-shadow: 0 12px 32px rgba(8,54,219,0.45); } .dm-mentor-fab svg { width: 36px; height: 36px; } .dm-mentor-fab.is-open { display: none; } .dm-mentor-panel { position: fixed; right: 22px; bottom: 22px; z-index: 99999; width: 380px; max-width: calc(100vw - 24px); height: 560px; max-height: calc(100vh - 80px); background: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 24px 60px rgba(0,0,0,0.28); display: none; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, sans-serif; color: #0A1428; } .dm-mentor-panel.is-open { display: flex; } .dm-mentor-panel.is-mobile { right: 12px; left: 12px; bottom: 12px; width: auto; } .dm-mentor-header { background: linear-gradient(135deg, #0836DB 0%, #062AA8 100%); color: #fff; padding: 14px 16px; display: flex; align-items: center; gap: 12px; } .dm-mentor-header-title { flex: 1; font-weight: 600; font-size: 15px; letter-spacing: 0.01em; } .dm-mentor-header-title small { display: block; font-weight: 400; font-size: 11px; opacity: 0.8; letter-spacing: 0.04em; } .dm-mentor-close, .dm-mentor-mute { background: rgba(255,255,255,0.15); border: 0; color: #fff; width: 32px; height: 32px; border-radius: 50%; cursor: pointer; font-size: 18px; line-height: 1; display: grid; place-items: center; transition: background 0.15s; } .dm-mentor-close:hover, .dm-mentor-mute:hover { background: rgba(255,255,255,0.25); } .dm-mentor-mute.is-muted { opacity: 0.6; } .dm-mentor-avatar { width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; background: #062AA8; border: 2px solid #FFB536; display: grid; place-items: center; overflow: hidden; } .dm-mentor-avatar svg { width: 100%; height: 100%; } .dm-mentor-avatar.is-thinking { animation: dm-pulse 1.2s ease-in-out infinite; } @keyframes dm-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.08); } } .dm-mentor-body { flex: 1; overflow-y: auto; padding: 16px; background: #F4F6FB; display: flex; flex-direction: column; gap: 10px; } .dm-mentor-bubble { max-width: 85%; padding: 10px 14px; border-radius: 16px; font-size: 14px; line-height: 1.5; white-space: pre-wrap; } .dm-mentor-bubble.you { align-self: flex-end; background: #0836DB; color: #fff; border-bottom-right-radius: 4px; } .dm-mentor-bubble.me { align-self: flex-start; background: #fff; color: #0A1428; border-bottom-left-radius: 4px; border: 1px solid #E2E1DC; } .dm-mentor-bubble.thinking { align-self: flex-start; background: #fff; border: 1px solid #E2E1DC; border-bottom-left-radius: 4px; color: #6b6b6b; font-style: italic; } .dm-mentor-thinking-dots::after { content: ''; animation: dm-dots 1.4s steps(4,end) infinite; } @keyframes dm-dots { 0%,20% { content: ''; } 40% { content: '.'; } 60% { content: '..'; } 80%,100% { content: '...'; } } .dm-mentor-footer { border-top: 1px solid #E2E1DC; padding: 10px 12px; background: #fff; display: flex; gap: 8px; align-items: flex-end; } .dm-mentor-input { flex: 1; border: 1px solid #E2E1DC; border-radius: 12px; padding: 10px 12px; font-size: 14px; font-family: inherit; resize: none; outline: none; min-height: 40px; max-height: 120px; line-height: 1.4; } .dm-mentor-input:focus { border-color: #0836DB; } .dm-mentor-send { background: #0836DB; color: #fff; border: 0; border-radius: 12px; padding: 10px 16px; font-weight: 600; font-size: 14px; cursor: pointer; white-space: nowrap; } .dm-mentor-send:hover { background: #062AA8; } .dm-mentor-send:disabled { opacity: 0.5; cursor: not-allowed; } .dm-mentor-meta { font-size: 10px; color: #6b6b6b; text-align: center; padding: 6px 12px 8px; background: #fff; border-top: 1px solid #F0EFEA; } .dm-mentor-meta a { color: #0836DB; text-decoration: none; } .dm-mentor-meta a:hover { text-decoration: underline; } /* Crisis modal */ .dm-mentor-crisis-modal { position: fixed; inset: 0; z-index: 100000; background: rgba(10,20,40,0.7); backdrop-filter: blur(4px); display: grid; place-items: center; padding: 20px; } .dm-mentor-crisis-card { background: #fff; border-radius: 16px; padding: 28px 24px; max-width: 420px; width: 100%; box-shadow: 0 24px 60px rgba(0,0,0,0.4); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #0A1428; } .dm-mentor-crisis-card h2 { font-size: 22px; margin-bottom: 12px; color: #0A1428; } .dm-mentor-crisis-card p { font-size: 15px; line-height: 1.5; margin-bottom: 16px; color: #4a4a4a; } .dm-mentor-crisis-actions { display: flex; flex-direction: column; gap: 10px; margin: 16px 0; } .dm-mentor-crisis-btn { display: block; padding: 14px 18px; border-radius: 12px; font-weight: 600; text-decoration: none; text-align: center; font-size: 16px; } .dm-mentor-crisis-call { background: #0836DB; color: #fff; } .dm-mentor-crisis-text { background: #FFB536; color: #0A1428; } .dm-mentor-crisis-aux { font-size: 13px; color: #6b6b6b; margin: 12px 0 16px; } .dm-mentor-crisis-close { background: transparent; border: 1px solid #E2E1DC; color: #4a4a4a; padding: 10px 14px; border-radius: 10px; font-size: 14px; cursor: pointer; width: 100%; } @media (max-width: 480px) { .dm-mentor-fab { right: 14px; bottom: 14px; width: 54px; height: 54px; } .dm-mentor-panel.is-open { right: 12px; left: 12px; bottom: 12px; width: auto; height: 70vh; } } `; document.head.appendChild(css); } // Cobalt avatar SVG — simplified version of the Companion's avatar const AVATAR_SVG = ` `; let panelEl, fabEl, bodyEl, inputEl, sendEl, avatarEl; function buildUI() { injectStyles(); // Floating button fabEl = document.createElement("button"); fabEl.className = "dm-mentor-fab"; fabEl.title = cfg.buttonLabel; fabEl.setAttribute("aria-label", cfg.buttonLabel); fabEl.innerHTML = AVATAR_SVG; fabEl.addEventListener("click", openPanel); document.body.appendChild(fabEl); // Slide-up panel panelEl = document.createElement("div"); panelEl.className = "dm-mentor-panel"; panelEl.innerHTML = `
${AVATAR_SVG}
The Dutch Mentor AI Walter's voice — not a real person
AI mentor · Crisis? 988 · Text HOME to 741741
`; document.body.appendChild(panelEl); bodyEl = panelEl.querySelector(".dm-mentor-body"); inputEl = panelEl.querySelector(".dm-mentor-input"); sendEl = panelEl.querySelector(".dm-mentor-send"); avatarEl = panelEl.querySelector(".dm-mentor-avatar"); panelEl.querySelector(".dm-mentor-close").addEventListener("click", closePanel); // Voice mute toggle const muteBtn = panelEl.querySelector(".dm-mentor-mute"); function refreshMuteUI() { const muted = isVoiceMuted(); muteBtn.textContent = muted ? "🔇" : "🔊"; muteBtn.classList.toggle("is-muted", muted); muteBtn.setAttribute("aria-pressed", muted ? "true" : "false"); } refreshMuteUI(); muteBtn.addEventListener("click", () => { const nowMuted = !isVoiceMuted(); setVoiceMuted(nowMuted); refreshMuteUI(); if (nowMuted) stopSpeaking(); }); sendEl.addEventListener("click", handleSend); inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }); inputEl.addEventListener("input", () => { inputEl.style.height = "auto"; inputEl.style.height = Math.min(120, inputEl.scrollHeight) + "px"; }); renderChat(); if (cfg.autoOpen) openPanel(); } function renderChat() { if (!bodyEl) return; bodyEl.innerHTML = ""; chat.forEach(m => { const b = document.createElement("div"); b.className = "dm-mentor-bubble " + (m.role === "you" ? "you" : "me"); b.textContent = m.text; bodyEl.appendChild(b); }); bodyEl.scrollTop = bodyEl.scrollHeight; } function setAvatarState(state) { if (!avatarEl) return; avatarEl.classList.remove("is-thinking", "is-speaking"); if (state && state !== "idle") avatarEl.classList.add("is-" + state); } function openPanel() { panelEl.classList.add("is-open"); fabEl.classList.add("is-open"); setTimeout(() => inputEl && inputEl.focus(), 100); } function closePanel() { panelEl.classList.remove("is-open"); fabEl.classList.remove("is-open"); stopSpeaking(); // don't keep talking after the panel is hidden } async function handleSend() { const text = inputEl.value.trim(); if (!text) return; inputEl.value = ""; inputEl.style.height = "auto"; // Stop any in-flight speech so a new question doesn't overlap the old reply stopSpeaking(); if (detectCrisis(text)) { showCrisisModal(); return; } pushMsg("you", text); sendEl.disabled = true; // Show a thinking placeholder bubble const thinking = document.createElement("div"); thinking.className = "dm-mentor-bubble thinking"; thinking.innerHTML = 'thinking'; bodyEl.appendChild(thinking); bodyEl.scrollTop = bodyEl.scrollHeight; try { const reply = await mentorReplyAI(text); thinking.remove(); if (reply) { setAvatarState("speaking"); pushMsg("me", reply); speak(reply); // speak the reply aloud (no-op when muted) setTimeout(() => setAvatarState("idle"), Math.min(5000, 1000 + reply.length * 12)); } } catch (e) { thinking.remove(); pushMsg("me", "I had trouble reaching the mentor service. Try again in a moment."); } finally { sendEl.disabled = false; setAvatarState("idle"); } } // ----------------------------------------------------------- // Boot // ----------------------------------------------------------- if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", buildUI); } else { buildUI(); } // Expose minimal API on window window.DutchMentorModule = { open: openPanel, close: closePanel, clear: () => { chat = [{ role: "me", text: cfg.greeting, t: Date.now() }]; saveChat(); renderChat(); }, version: "0.1", }; })();