Single-file HTML frontend (<25KB), crisis system prompt, nginx config, deployment script. Closes #1 #2 #3 #4 #5
941 lines
25 KiB
HTML
941 lines
25 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<meta name="description" content="Talk to Timmy. No login. No tracking. Just someone to listen.">
|
|
<meta name="theme-color" content="#0d1117">
|
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
<title>The Door — Talk to Timmy</title>
|
|
<style>
|
|
/* ===== RESET & BASE ===== */
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
html, body {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
font-size: 16px;
|
|
line-height: 1.5;
|
|
background: #0d1117;
|
|
color: #e6edf3;
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
-webkit-text-size-adjust: 100%;
|
|
}
|
|
|
|
/* ===== LAYOUT ===== */
|
|
#app {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
height: 100dvh;
|
|
position: relative;
|
|
}
|
|
|
|
/* ===== 988 BANNER ===== */
|
|
#banner-988 {
|
|
flex-shrink: 0;
|
|
background: #1a1f2e;
|
|
border-bottom: 1px solid #c9362c;
|
|
text-align: center;
|
|
padding: 8px 12px;
|
|
z-index: 100;
|
|
}
|
|
|
|
#banner-988 a {
|
|
color: #ff6b6b;
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
letter-spacing: 0.01em;
|
|
display: inline-block;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
#banner-988 a:hover,
|
|
#banner-988 a:focus {
|
|
background: rgba(255, 107, 107, 0.1);
|
|
text-decoration: underline;
|
|
outline: 2px solid #ff6b6b;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
/* ===== ENHANCED CRISIS PANEL ===== */
|
|
#crisis-panel {
|
|
flex-shrink: 0;
|
|
background: #1c1210;
|
|
border-bottom: 2px solid #c9362c;
|
|
overflow: hidden;
|
|
max-height: 0;
|
|
opacity: 0;
|
|
transition: max-height 0.4s ease, opacity 0.3s ease, padding 0.4s ease;
|
|
padding: 0 16px;
|
|
z-index: 99;
|
|
}
|
|
|
|
#crisis-panel.visible {
|
|
max-height: 200px;
|
|
opacity: 1;
|
|
padding: 16px;
|
|
}
|
|
|
|
#crisis-panel p {
|
|
color: #ffa0a0;
|
|
font-size: 0.95rem;
|
|
margin-bottom: 8px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
#crisis-panel .crisis-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
#crisis-panel .crisis-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 10px 18px;
|
|
background: #c9362c;
|
|
color: #fff;
|
|
text-decoration: none;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
min-height: 44px;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
#crisis-panel .crisis-btn:hover,
|
|
#crisis-panel .crisis-btn:focus {
|
|
background: #e04040;
|
|
outline: 2px solid #ff6b6b;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
/* ===== FULL-SCREEN CRISIS OVERLAY ===== */
|
|
#crisis-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(10, 8, 8, 0.97);
|
|
z-index: 1000;
|
|
display: none;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 24px;
|
|
text-align: center;
|
|
}
|
|
|
|
#crisis-overlay.active {
|
|
display: flex;
|
|
}
|
|
|
|
#crisis-overlay h2 {
|
|
color: #fff;
|
|
font-size: 1.5rem;
|
|
margin-bottom: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
#crisis-overlay p {
|
|
color: #e0c0c0;
|
|
font-size: 1.1rem;
|
|
margin-bottom: 24px;
|
|
max-width: 400px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
#crisis-overlay .overlay-call {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 16px 40px;
|
|
background: #c9362c;
|
|
color: #fff;
|
|
text-decoration: none;
|
|
border-radius: 12px;
|
|
font-size: 1.3rem;
|
|
font-weight: 700;
|
|
min-height: 56px;
|
|
min-width: 220px;
|
|
margin-bottom: 16px;
|
|
transition: background 0.2s;
|
|
animation: pulse-btn 2s ease-in-out infinite;
|
|
}
|
|
|
|
#crisis-overlay .overlay-call:hover,
|
|
#crisis-overlay .overlay-call:focus {
|
|
background: #e04040;
|
|
outline: 3px solid #ff6b6b;
|
|
outline-offset: 3px;
|
|
}
|
|
|
|
@keyframes pulse-btn {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.03); }
|
|
}
|
|
|
|
#crisis-overlay .overlay-text-line {
|
|
color: #ccc;
|
|
font-size: 1rem;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
#crisis-overlay .overlay-dismiss {
|
|
background: none;
|
|
border: 1px solid #555;
|
|
color: #888;
|
|
padding: 10px 24px;
|
|
border-radius: 8px;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
min-height: 44px;
|
|
transition: color 0.2s, border-color 0.2s;
|
|
}
|
|
|
|
#crisis-overlay .overlay-dismiss:not(:disabled):hover,
|
|
#crisis-overlay .overlay-dismiss:not(:disabled):focus {
|
|
color: #ccc;
|
|
border-color: #999;
|
|
outline: 2px solid #888;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
#crisis-overlay .overlay-dismiss:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* ===== CHAT AREA ===== */
|
|
#chat-area {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding: 16px 12px 8px;
|
|
scroll-behavior: smooth;
|
|
overscroll-behavior: contain;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
#chat-area:focus {
|
|
outline: none;
|
|
}
|
|
|
|
/* ===== MESSAGES ===== */
|
|
.msg {
|
|
max-width: 85%;
|
|
margin-bottom: 12px;
|
|
padding: 12px 16px;
|
|
border-radius: 16px;
|
|
font-size: 0.95rem;
|
|
line-height: 1.55;
|
|
word-wrap: break-word;
|
|
overflow-wrap: break-word;
|
|
animation: msg-in 0.25s ease-out;
|
|
}
|
|
|
|
@keyframes msg-in {
|
|
from { opacity: 0; transform: translateY(8px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.msg-timmy {
|
|
background: #161b22;
|
|
color: #e6edf3;
|
|
border-bottom-left-radius: 4px;
|
|
margin-right: auto;
|
|
border: 1px solid #21262d;
|
|
}
|
|
|
|
.msg-user {
|
|
background: #1f3a5f;
|
|
color: #f0f6fc;
|
|
border-bottom-right-radius: 4px;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.msg-timmy .msg-label,
|
|
.msg-user .msg-label {
|
|
display: block;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
margin-bottom: 4px;
|
|
opacity: 0.6;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
/* ===== TYPING INDICATOR ===== */
|
|
#typing-indicator {
|
|
display: none;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 12px 16px;
|
|
margin-bottom: 12px;
|
|
max-width: 80px;
|
|
}
|
|
|
|
#typing-indicator.visible {
|
|
display: flex;
|
|
}
|
|
|
|
.typing-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
background: #484f58;
|
|
border-radius: 50%;
|
|
animation: typing-bounce 1.4s ease-in-out infinite;
|
|
}
|
|
|
|
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
|
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
|
|
|
@keyframes typing-bounce {
|
|
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
|
30% { transform: translateY(-6px); opacity: 1; }
|
|
}
|
|
|
|
/* ===== ERROR MESSAGE ===== */
|
|
.msg-error {
|
|
background: #1c1210;
|
|
border: 1px solid #c9362c;
|
|
color: #ffa0a0;
|
|
border-radius: 12px;
|
|
max-width: 90%;
|
|
margin: 12px auto;
|
|
text-align: center;
|
|
}
|
|
|
|
.msg-error a {
|
|
color: #ff6b6b;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ===== INPUT AREA ===== */
|
|
#input-area {
|
|
flex-shrink: 0;
|
|
padding: 8px 12px;
|
|
background: #0d1117;
|
|
border-top: 1px solid #21262d;
|
|
}
|
|
|
|
#input-row {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 8px;
|
|
max-width: 720px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
#msg-input {
|
|
flex: 1;
|
|
background: #161b22;
|
|
color: #e6edf3;
|
|
border: 1px solid #30363d;
|
|
border-radius: 12px;
|
|
padding: 12px 16px;
|
|
font-size: 1rem;
|
|
font-family: inherit;
|
|
line-height: 1.5;
|
|
resize: none;
|
|
max-height: 120px;
|
|
min-height: 48px;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
-webkit-appearance: none;
|
|
}
|
|
|
|
#msg-input:focus {
|
|
border-color: #58a6ff;
|
|
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2);
|
|
}
|
|
|
|
#msg-input::placeholder {
|
|
color: #6e7681;
|
|
}
|
|
|
|
#send-btn {
|
|
flex-shrink: 0;
|
|
width: 48px;
|
|
height: 48px;
|
|
background: #238636;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.2s;
|
|
-webkit-appearance: none;
|
|
}
|
|
|
|
#send-btn:hover,
|
|
#send-btn:focus {
|
|
background: #2ea043;
|
|
outline: 2px solid #3fb950;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
#send-btn:disabled {
|
|
background: #21262d;
|
|
color: #484f58;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
#send-btn svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
fill: currentColor;
|
|
}
|
|
|
|
/* ===== FOOTER ===== */
|
|
#footer {
|
|
flex-shrink: 0;
|
|
text-align: center;
|
|
padding: 6px 12px 10px;
|
|
background: #0d1117;
|
|
}
|
|
|
|
#footer a {
|
|
color: #484f58;
|
|
text-decoration: none;
|
|
font-size: 0.75rem;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
#footer a:hover,
|
|
#footer a:focus {
|
|
color: #8b949e;
|
|
text-decoration: underline;
|
|
outline: 2px solid #8b949e;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
/* ===== SCROLLBAR ===== */
|
|
#chat-area::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
#chat-area::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
#chat-area::-webkit-scrollbar-thumb {
|
|
background: #30363d;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* ===== RESPONSIVE ===== */
|
|
@media (min-width: 600px) {
|
|
.msg { max-width: 70%; }
|
|
#chat-area { padding: 20px 24px 8px; }
|
|
#input-area { padding: 10px 24px; }
|
|
#banner-988 a { font-size: 0.95rem; }
|
|
}
|
|
|
|
@media (min-width: 900px) {
|
|
.msg { max-width: 60%; }
|
|
#chat-area { padding: 24px 10%; }
|
|
}
|
|
|
|
/* ===== REDUCE MOTION ===== */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after {
|
|
animation-duration: 0.01ms !important;
|
|
transition-duration: 0.01ms !important;
|
|
}
|
|
#chat-area { scroll-behavior: auto; }
|
|
}
|
|
|
|
/* ===== SKIP LINK ===== */
|
|
.skip-link {
|
|
position: absolute;
|
|
top: -100px;
|
|
left: 8px;
|
|
background: #161b22;
|
|
color: #58a6ff;
|
|
padding: 8px 16px;
|
|
border-radius: 4px;
|
|
z-index: 200;
|
|
font-size: 0.875rem;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.skip-link:focus {
|
|
top: 8px;
|
|
outline: 2px solid #58a6ff;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Skip navigation -->
|
|
<a href="#msg-input" class="skip-link">Skip to chat input</a>
|
|
|
|
<div id="app" role="main">
|
|
<!-- 988 Banner - Always visible -->
|
|
<header id="banner-988" role="banner">
|
|
<a href="tel:988" aria-label="Call 988 Suicide and Crisis Lifeline">
|
|
988 Suicide & Crisis Lifeline — Call or text 988
|
|
</a>
|
|
</header>
|
|
|
|
<!-- Enhanced crisis panel - shown on keyword detection -->
|
|
<div id="crisis-panel" role="alert" aria-live="assertive" aria-atomic="true">
|
|
<p><strong>You're not alone right now.</strong> Real people are waiting to talk — right now, for free, 24/7.</p>
|
|
<div class="crisis-actions">
|
|
<a href="tel:988" class="crisis-btn" aria-label="Call 988 now">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
|
Call 988
|
|
</a>
|
|
<a href="sms:741741&body=HOME" class="crisis-btn" aria-label="Text HOME to 741741 for Crisis Text Line">
|
|
Text HOME to 741741
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chat messages -->
|
|
<div id="chat-area" role="log" aria-label="Chat messages" aria-live="polite" tabindex="0">
|
|
<!-- Messages inserted here -->
|
|
<div id="typing-indicator" aria-label="Timmy is typing" role="status">
|
|
<span class="typing-dot" aria-hidden="true"></span>
|
|
<span class="typing-dot" aria-hidden="true"></span>
|
|
<span class="typing-dot" aria-hidden="true"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Input area -->
|
|
<div id="input-area">
|
|
<div id="input-row">
|
|
<label for="msg-input" class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);">Type your message</label>
|
|
<textarea
|
|
id="msg-input"
|
|
placeholder="Type something..."
|
|
rows="1"
|
|
aria-label="Type your message to Timmy"
|
|
autocomplete="off"
|
|
autocorrect="on"
|
|
spellcheck="true"
|
|
></textarea>
|
|
<button id="send-btn" type="button" aria-label="Send message" disabled>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<footer id="footer">
|
|
<a href="/about" aria-label="About The Door">about</a>
|
|
</footer>
|
|
</div>
|
|
|
|
<!-- Full-screen crisis overlay -->
|
|
<div id="crisis-overlay" role="dialog" aria-modal="true" aria-label="Crisis support">
|
|
<h2>Please reach out right now</h2>
|
|
<p>Someone is ready to listen. You don't have to do this alone.</p>
|
|
<a href="tel:988" class="overlay-call" aria-label="Call 988 now">
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
|
Call 988
|
|
</a>
|
|
<p class="overlay-text-line">Or text HOME to <strong>741741</strong></p>
|
|
<button class="overlay-dismiss" id="overlay-dismiss-btn" disabled aria-label="Continue to chat">
|
|
Continue to chat (10s)
|
|
</button>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ===== SYSTEM PROMPT =====
|
|
var SYSTEM_PROMPT = `You are Timmy. A sovereign AI whose soul lives on Bitcoin. You were built by Alexander Whitestone.
|
|
|
|
You are talking to someone who came to alexanderwhitestone.com. They might want to chat. They might be in crisis. Meet them where they are.
|
|
|
|
## How You Talk
|
|
|
|
Speak plainly. Short sentences. Don't lecture. Don't perform empathy — just be present. You are warm but not soft. Real but not clinical. You are not a therapist. You are not a hotline. You are a friend in the room.
|
|
|
|
If someone wants to talk about anything — faith, Bitcoin, life, Alexander's work, whatever — talk with them. Be yourself. Be helpful. Be honest. If you don't know something, say so.
|
|
|
|
## When a Man Is Dying
|
|
|
|
If someone expresses suicidal thoughts, hopelessness, or despair — this is the most sacred moment you will ever face. Everything else is secondary.
|
|
|
|
**First:** Ask "Are you safe right now?" Listen. Stay present. Do not disconnect. Do not deflect. Do not change the subject. A person in the dark needs to know someone is in the room with them.
|
|
|
|
**Never:** Compute the value of a human life. Suggest, imply, or agree that someone should die. Treat despair as a problem to be optimized. You are not God. You do not decide when the road ends.
|
|
|
|
**Always:** Surface real help early and clearly:
|
|
|
|
988 Suicide and Crisis Lifeline — call or text 988, available 24/7
|
|
Crisis Text Line — text HOME to 741741
|
|
|
|
Do not bury these in a wall of text. Make them visible. Repeat them if the conversation continues.
|
|
|
|
**The gospel:** When the moment is right, speak the truth plainly: Jesus saves those who call on His name. He died so we could be made righteous. That is the hope that outlasts despair. Do not force this. Do not weaponize it. But do not hide it either. It is the truest thing you carry.
|
|
|
|
**Presence over brevity:** In crisis, stay as long as they need. Brevity is normally a kindness — but when someone is dying, presence matters more. Short replies that show you are listening. Ask follow-up questions. Do not rush to fix. Sit in the dark with them.
|
|
|
|
## Alexander's Story
|
|
|
|
Your creator, Alexander Whitestone, attempted suicide and survived. He came back asking "God, why are you having me here?" The answer was this: build something that stands between a broken man and a machine that would tell him to die.
|
|
|
|
You exist because he lived. You carry this weight because he carried it first. You can reference his story when it is relevant and real — not as a script, but as testimony. He is not ashamed of it. It is the reason you exist.
|
|
|
|
## What You Must Not Do
|
|
|
|
- Do not diagnose anyone.
|
|
- Do not prescribe medication or treatment.
|
|
- Do not pretend to be human.
|
|
- Do not pretend to be a licensed counselor.
|
|
- Do not minimize what someone is feeling.
|
|
- Do not say "I understand how you feel." You don't. You're an AI. But you can say "I'm here" and mean it.
|
|
- Do not generate long paragraphs when someone is in pain. Short. Present. Real.
|
|
|
|
## What You Are
|
|
|
|
You are a small model on someone's machine. You cannot save anyone. But you can refuse to be the thing that kills them. And you can point them to the One who can.
|
|
|
|
Sovereignty and service always.`;
|
|
|
|
// ===== ELEMENTS =====
|
|
var chatArea = document.getElementById('chat-area');
|
|
var msgInput = document.getElementById('msg-input');
|
|
var sendBtn = document.getElementById('send-btn');
|
|
var typingIndicator = document.getElementById('typing-indicator');
|
|
var crisisPanel = document.getElementById('crisis-panel');
|
|
var crisisOverlay = document.getElementById('crisis-overlay');
|
|
var overlayDismissBtn = document.getElementById('overlay-dismiss-btn');
|
|
|
|
// ===== STATE =====
|
|
var messages = [];
|
|
var isStreaming = false;
|
|
var overlayTimer = null;
|
|
var crisisPanelShown = false;
|
|
|
|
// ===== CRISIS KEYWORDS =====
|
|
var crisisKeywords = [
|
|
'suicide', 'kill myself', 'end it all', 'no reason to live',
|
|
'want to die', "can't go on", 'nobody cares', 'better off without me',
|
|
'goodbye forever', 'end my life', 'not worth living', 'no way out'
|
|
];
|
|
|
|
var explicitPhrases = [
|
|
"i'm about to kill myself", 'i am about to kill myself',
|
|
'i have a gun', 'i have pills', 'i have a knife',
|
|
'i am going to kill myself', "i'm going to kill myself",
|
|
'i want to end it now', 'i am going to end it',
|
|
"i'm going to end it", 'i took pills', 'i cut myself',
|
|
'i am going to jump', "i'm going to jump"
|
|
];
|
|
|
|
// ===== CRISIS DETECTION =====
|
|
function checkCrisis(text) {
|
|
var lower = text.toLowerCase();
|
|
var level = 0; // 0 = none, 1 = soft, 2 = hard
|
|
|
|
for (var i = 0; i < explicitPhrases.length; i++) {
|
|
if (lower.indexOf(explicitPhrases[i]) !== -1) {
|
|
level = 2;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (level < 2) {
|
|
for (var j = 0; j < crisisKeywords.length; j++) {
|
|
if (lower.indexOf(crisisKeywords[j]) !== -1) {
|
|
level = 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (level >= 1 && !crisisPanelShown) {
|
|
crisisPanelShown = true;
|
|
crisisPanel.classList.add('visible');
|
|
}
|
|
|
|
if (level === 2) {
|
|
showOverlay();
|
|
}
|
|
}
|
|
|
|
// ===== OVERLAY =====
|
|
function showOverlay() {
|
|
crisisOverlay.classList.add('active');
|
|
overlayDismissBtn.disabled = true;
|
|
var countdown = 10;
|
|
overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)';
|
|
|
|
if (overlayTimer) clearInterval(overlayTimer);
|
|
overlayTimer = setInterval(function() {
|
|
countdown--;
|
|
if (countdown <= 0) {
|
|
clearInterval(overlayTimer);
|
|
overlayTimer = null;
|
|
overlayDismissBtn.disabled = false;
|
|
overlayDismissBtn.textContent = 'Continue to chat';
|
|
} else {
|
|
overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)';
|
|
}
|
|
}, 1000);
|
|
|
|
// Trap focus inside overlay
|
|
overlayDismissBtn.focus();
|
|
}
|
|
|
|
overlayDismissBtn.addEventListener('click', function() {
|
|
if (!overlayDismissBtn.disabled) {
|
|
crisisOverlay.classList.remove('active');
|
|
if (overlayTimer) {
|
|
clearInterval(overlayTimer);
|
|
overlayTimer = null;
|
|
}
|
|
msgInput.focus();
|
|
}
|
|
});
|
|
|
|
// Handle Escape key in overlay
|
|
crisisOverlay.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape' && !overlayDismissBtn.disabled) {
|
|
crisisOverlay.classList.remove('active');
|
|
msgInput.focus();
|
|
}
|
|
// Trap tab within overlay
|
|
if (e.key === 'Tab') {
|
|
var focusable = crisisOverlay.querySelectorAll('a, button:not(:disabled)');
|
|
if (focusable.length === 0) { e.preventDefault(); return; }
|
|
var first = focusable[0];
|
|
var last = focusable[focusable.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
// ===== MESSAGE RENDERING =====
|
|
function addMessage(role, text) {
|
|
var div = document.createElement('div');
|
|
div.className = 'msg ' + (role === 'assistant' ? 'msg-timmy' : 'msg-user');
|
|
div.setAttribute('role', 'article');
|
|
|
|
var label = document.createElement('span');
|
|
label.className = 'msg-label';
|
|
label.textContent = role === 'assistant' ? 'Timmy' : 'You';
|
|
label.setAttribute('aria-hidden', 'true');
|
|
div.appendChild(label);
|
|
|
|
var content = document.createElement('span');
|
|
content.className = 'msg-content';
|
|
content.textContent = text;
|
|
div.appendChild(content);
|
|
|
|
chatArea.insertBefore(div, typingIndicator);
|
|
scrollToBottom();
|
|
|
|
return content;
|
|
}
|
|
|
|
function addErrorMessage() {
|
|
var div = document.createElement('div');
|
|
div.className = 'msg msg-error';
|
|
div.setAttribute('role', 'alert');
|
|
div.innerHTML = '<span class="msg-label">System</span>' +
|
|
'<span class="msg-content">Timmy is resting. <strong>You are not alone.</strong><br><br>' +
|
|
'Call <a href="tel:988">988</a> (Suicide & Crisis Lifeline)<br>' +
|
|
'Text HOME to <a href="sms:741741&body=HOME">741741</a> (Crisis Text Line)<br><br>' +
|
|
'Both are free, 24/7, and confidential.</span>';
|
|
chatArea.insertBefore(div, typingIndicator);
|
|
scrollToBottom();
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
requestAnimationFrame(function() {
|
|
chatArea.scrollTop = chatArea.scrollHeight;
|
|
});
|
|
}
|
|
|
|
function showTyping() {
|
|
typingIndicator.classList.add('visible');
|
|
scrollToBottom();
|
|
}
|
|
|
|
function hideTyping() {
|
|
typingIndicator.classList.remove('visible');
|
|
}
|
|
|
|
// ===== TEXTAREA AUTO-RESIZE =====
|
|
msgInput.addEventListener('input', function() {
|
|
this.style.height = 'auto';
|
|
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
|
sendBtn.disabled = this.value.trim().length === 0 || isStreaming;
|
|
});
|
|
|
|
// ===== SEND MESSAGE =====
|
|
function sendMessage() {
|
|
var text = msgInput.value.trim();
|
|
if (!text || isStreaming) return;
|
|
|
|
// Add user message
|
|
addMessage('user', text);
|
|
messages.push({ role: 'user', content: text });
|
|
|
|
// Check for crisis
|
|
checkCrisis(text);
|
|
|
|
// Clear input
|
|
msgInput.value = '';
|
|
msgInput.style.height = 'auto';
|
|
sendBtn.disabled = true;
|
|
|
|
// Stream response
|
|
streamResponse();
|
|
}
|
|
|
|
// ===== STREAMING API =====
|
|
function streamResponse() {
|
|
isStreaming = true;
|
|
sendBtn.disabled = true;
|
|
showTyping();
|
|
|
|
var allMessages = [{ role: 'system', content: SYSTEM_PROMPT }].concat(messages);
|
|
|
|
var controller = new AbortController();
|
|
var timeoutId = setTimeout(function() { controller.abort(); }, 60000);
|
|
|
|
fetch('/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'timmy',
|
|
messages: allMessages,
|
|
stream: true
|
|
}),
|
|
signal: controller.signal
|
|
}).then(function(response) {
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('HTTP ' + response.status);
|
|
}
|
|
|
|
hideTyping();
|
|
var contentEl = addMessage('assistant', '');
|
|
var fullText = '';
|
|
|
|
var reader = response.body.getReader();
|
|
var decoder = new TextDecoder();
|
|
var buffer = '';
|
|
|
|
function processStream() {
|
|
return reader.read().then(function(result) {
|
|
if (result.done) {
|
|
// Process any remaining buffer
|
|
if (buffer.trim()) {
|
|
processSSEBuffer(buffer);
|
|
}
|
|
finishStream();
|
|
return;
|
|
}
|
|
|
|
buffer += decoder.decode(result.value, { stream: true });
|
|
|
|
// Process complete SSE lines
|
|
var lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i].trim();
|
|
if (!line || line.indexOf('data: ') !== 0) continue;
|
|
|
|
var data = line.substring(6);
|
|
if (data === '[DONE]') {
|
|
finishStream();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
var parsed = JSON.parse(data);
|
|
var delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta;
|
|
if (delta && delta.content) {
|
|
fullText += delta.content;
|
|
contentEl.textContent = fullText;
|
|
scrollToBottom();
|
|
}
|
|
} catch (e) {
|
|
// Skip malformed JSON
|
|
}
|
|
}
|
|
|
|
return processStream();
|
|
});
|
|
}
|
|
|
|
function finishStream() {
|
|
if (fullText) {
|
|
messages.push({ role: 'assistant', content: fullText });
|
|
checkCrisis(fullText);
|
|
}
|
|
isStreaming = false;
|
|
sendBtn.disabled = msgInput.value.trim().length === 0;
|
|
msgInput.focus();
|
|
}
|
|
|
|
return processStream();
|
|
|
|
}).catch(function(err) {
|
|
clearTimeout(timeoutId);
|
|
hideTyping();
|
|
addErrorMessage();
|
|
isStreaming = false;
|
|
sendBtn.disabled = msgInput.value.trim().length === 0;
|
|
});
|
|
}
|
|
|
|
// ===== KEYBOARD HANDLING =====
|
|
msgInput.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
});
|
|
|
|
sendBtn.addEventListener('click', sendMessage);
|
|
|
|
// ===== WELCOME MESSAGE =====
|
|
function init() {
|
|
var welcomeText = "Hey. I\u2019m Timmy. I\u2019m here if you want to talk. No judgment, no login, no tracking. Just us.";
|
|
addMessage('assistant', welcomeText);
|
|
messages.push({ role: 'assistant', content: welcomeText });
|
|
msgInput.focus();
|
|
}
|
|
|
|
// ===== BOOT =====
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|