Files
sune/index.html
2025-08-13 16:24:14 -07:00

310 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>ChatGPT — Mobile Clone (Light)</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* iOS safe area for the input dock */
:root { --safe-bottom: env(safe-area-inset-bottom); }
::-webkit-scrollbar { height: 8px; width: 8px; }
::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 999px; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>
</head>
<body class="bg-white text-gray-900 selection:bg-amber-200/60">
<!-- App Shell -->
<div class="flex flex-col h-dvh max-h-dvh">
<!-- Header -->
<header class="sticky top-0 z-10 bg-white/80 backdrop-blur border-b border-gray-200">
<div class="mx-auto w-full max-w-2xl px-4 py-3 flex items-center justify-between">
<button id="newChatBtn" class="text-sm font-medium px-3 py-1.5 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition">
New chat
</button>
<div class="text-sm text-gray-600 truncate" id="modelBadge">
Model: <span class="font-semibold" id="modelName">openai/gpt-4o</span>
</div>
<div class="flex items-center gap-2">
<span class="text-[11px] text-gray-400" id="status">offline</span>
</div>
</div>
</header><!-- Messages -->
<main id="chat" class="flex-1 overflow-y-auto no-scrollbar">
<div id="messages" class="mx-auto w-full max-w-2xl px-4 py-4 sm:py-6 space-y-4">
<!-- Placeholder intro -->
<div class="flex gap-3">
<div class="shrink-0 h-8 w-8 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center">🤖</div>
<div class="bg-gray-100 rounded-2xl px-4 py-3 text-[15px] leading-relaxed">
<div class="font-semibold text-gray-800 mb-1">Hello!</div>
Im a lightweight ChatGPT-style demo. Type below to chat. Set an OpenRouter API key (bottom-left) to go online; otherwise Ill reply locally.
</div>
</div>
</div>
<div class="h-24"></div> <!-- spacer so last message isn't hidden under input dock -->
</main>
<!-- Input Dock -->
<footer class="sticky bottom-0 bg-gradient-to-t from-white via-white/95 to-white/40 pt-3 pb-[calc(12px+var(--safe-bottom))] border-t border-gray-200">
<div class="mx-auto w-full max-w-2xl px-4">
<form id="composer" class="group relative flex items-end gap-2">
<textarea id="input" rows="1" placeholder="Send a message" spellcheck="true"
class="flex-1 resize-none rounded-2xl border border-gray-300 bg-white px-4 py-3 text-[15px] leading-6 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-300/80 focus:border-gray-300 max-h-40"></textarea>
<button id="sendBtn" type="submit" aria-label="Send"
class="shrink-0 rounded-2xl bg-amber-500 text-white h-10 w-10 inline-flex items-center justify-center shadow-sm hover:bg-amber-600 active:scale-[.98] transition disabled:opacity-40 disabled:cursor-not-allowed">
<!-- Send icon -->
<svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</form>
<div class="mt-2 flex items-center justify-between text-xs text-gray-500">
<div class="flex items-center gap-1.5">
<kbd class="px-1.5 py-0.5 rounded border bg-gray-50">Enter</kbd>
to send • <kbd class="px-1.5 py-0.5 rounded border bg-gray-50">Shift</kbd>+<kbd class="px-1.5 py-0.5 rounded border bg-gray-50">Enter</kbd> for newline
</div>
<button id="clearBtn" class="underline decoration-dotted hover:text-gray-700">Clear</button>
</div>
</div>
</footer>
<!-- Corner Controls (bottom-left) -->
<div class="fixed bottom-[calc(68px+var(--safe-bottom))] left-3 flex flex-col gap-2">
<button id="setApiBtn" title="Set OpenRouter API key" class="h-10 w-10 rounded-full bg-gray-100 border border-gray-200 shadow-sm hover:bg-gray-200 active:scale-[.98] transition flex items-center justify-center">
<!-- User icon -->
<svg viewBox="0 0 24 24" class="h-5 w-5 text-gray-700" fill="none" stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6.75a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 20.25a8.25 8.25 0 0115 0"/>
</svg>
</button>
<button id="setModelBtn" title="Set model" class="h-10 w-10 rounded-full bg-emerald-50 border border-emerald-200 shadow-sm hover:bg-emerald-100 active:scale-[.98] transition flex items-center justify-center">
<!-- Bot icon -->
<svg viewBox="0 0 24 24" class="h-5 w-5 text-emerald-700" fill="none" stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6M4 11h16v4a5 5 0 01-5 5H9a5 5 0 01-5-5v-4z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 3h6v4H9z"/>
<circle cx="9" cy="14" r="1"/><circle cx="15" cy="14" r="1"/>
</svg>
</button>
</div>
</div> <script>
// === State ===
const el = {
chat: document.getElementById('chat'),
messages: document.getElementById('messages'),
composer: document.getElementById('composer'),
input: document.getElementById('input'),
sendBtn: document.getElementById('sendBtn'),
clearBtn: document.getElementById('clearBtn'),
newChatBtn: document.getElementById('newChatBtn'),
status: document.getElementById('status'),
modelName: document.getElementById('modelName'),
modelBadge: document.getElementById('modelBadge'),
setApiBtn: document.getElementById('setApiBtn'),
setModelBtn: document.getElementById('setModelBtn'),
};
const store = {
get apiKey() { return localStorage.getItem('openrouter_api_key') || ''; },
set apiKey(v) { localStorage.setItem('openrouter_api_key', v || ''); updateStatus(); },
get model() { return localStorage.getItem('openrouter_model') || 'openai/gpt-4o'; },
set model(v) { localStorage.setItem('openrouter_model', v || 'openai/gpt-4o'); el.modelName.textContent = store.model; },
};
const state = {
messages: [], // {role: 'user'|'assistant'|'system', content: string}
busy: false,
controller: null,
};
// === UI Helpers ===
function addMessage(role, content) {
const row = document.createElement('div');
row.className = 'flex gap-3';
const avatar = document.createElement('div');
avatar.className = 'shrink-0 h-8 w-8 rounded-full flex items-center justify-center ' + (role === 'user' ? 'bg-blue-100 text-blue-700' : 'bg-emerald-100 text-emerald-700');
avatar.textContent = role === 'user' ? '🧑' : '🤖';
const bubble = document.createElement('div');
bubble.className = 'rounded-2xl px-4 py-3 text-[15px] leading-relaxed whitespace-pre-wrap ' + (role === 'user' ? 'bg-blue-50 border border-blue-100' : 'bg-gray-100');
bubble.textContent = content;
row.appendChild(avatar);
row.appendChild(bubble);
el.messages.appendChild(row);
// Keep internal transcript (for API calls)
state.messages.push({ role, content });
// Auto-scroll
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' }));
return bubble;
}
function addThinkingBubble() {
const row = document.createElement('div');
row.className = 'flex gap-3';
const avatar = document.createElement('div');
avatar.className = 'shrink-0 h-8 w-8 rounded-full flex items-center justify-center bg-emerald-100 text-emerald-700';
avatar.textContent = '🤖';
const bubble = document.createElement('div');
bubble.className = 'rounded-2xl px-4 py-3 text-[15px] leading-relaxed bg-gray-100 text-gray-700';
bubble.innerHTML = '<span class="inline-flex items-center gap-1">Thinking<span class="inline-block w-1 h-1 bg-gray-500 rounded-full animate-bounce [animation-delay:-0.2s]"></span><span class="inline-block w-1 h-1 bg-gray-500 rounded-full animate-bounce [animation-delay:-0.1s]"></span><span class="inline-block w-1 h-1 bg-gray-500 rounded-full animate-bounce"></span></span>';
row.appendChild(avatar);
row.appendChild(bubble);
el.messages.appendChild(row);
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight }));
return bubble;
}
function clearChat(intro = true) {
state.messages = [];
el.messages.innerHTML = '';
if (intro) {
const introRow = document.createElement('div');
introRow.className = 'flex gap-3';
introRow.innerHTML = `
<div class="shrink-0 h-8 w-8 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center">🤖</div>
<div class="bg-gray-100 rounded-2xl px-4 py-3 text-[15px] leading-relaxed">
<div class="font-semibold text-gray-800 mb-1">New chat</div>
You're in a fresh conversation. Ask away.
</div>`;
el.messages.appendChild(introRow);
}
}
function updateStatus() {
const online = !!store.apiKey;
el.status.textContent = online ? 'online' : 'offline';
el.status.className = 'text-[11px] ' + (online ? 'text-emerald-600' : 'text-gray-400');
}
// === Comms ===
async function askOpenRouter() {
const apiKey = store.apiKey;
const model = store.model;
if (!apiKey) {
// Local demo reply
return {
ok: true,
text: localDemoReply(state.messages[state.messages.length - 1]?.content || '')
};
}
try {
state.controller = new AbortController();
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey,
},
body: JSON.stringify({
model,
messages: state.messages.filter(m => m.role !== 'system'),
stream: false,
}),
signal: state.controller.signal,
});
if (!res.ok) {
const err = await res.text();
throw new Error(err || ('HTTP ' + res.status));
}
const data = await res.json();
const choice = data.choices?.[0]?.message?.content?.trim() || '(no content)';
return { ok: true, text: choice };
} catch (e) {
console.error(e);
return { ok: false, text: 'Request failed. Using local demo instead.\n\n' + localDemoReply(state.messages[state.messages.length - 1]?.content || '') };
} finally {
state.controller = null;
}
}
function localDemoReply(prompt) {
// Very tiny offline echo with a dash of personality
const tips = [
'Tip: set an OpenRouter API key (bottom-left) to go truly online.',
'Pro move: tap the bot icon to pick a different model.',
'New chats are stateless here—no history is kept.',
];
const tip = tips[Math.floor(Math.random() * tips.length)];
const mirrored = prompt.split(/\s+/).slice(0, 24).join(' ');
return `Local demo mode. You said: "${mirrored}"\n\n${tip}`;
}
// === Events ===
el.composer.addEventListener('submit', async (e) => {
e.preventDefault();
if (state.busy) return;
const text = el.input.value.trim();
if (!text) return;
el.input.value = '';
el.input.style.height = 'auto';
addMessage('user', text);
state.busy = true;
el.sendBtn.disabled = true;
const thinking = addThinkingBubble();
const res = await askOpenRouter();
thinking.textContent = res.text;
// Replace thinking bubble with final assistant bubble style
thinking.className = 'rounded-2xl px-4 py-3 text-[15px] leading-relaxed bg-gray-100 text-gray-800 whitespace-pre-wrap';
state.messages[state.messages.length - 1] = { role: 'assistant', content: res.text };
el.sendBtn.disabled = false;
state.busy = false;
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' }));
});
// Auto-resize textarea
el.input.addEventListener('input', () => {
el.input.style.height = 'auto';
const h = Math.min(el.input.scrollHeight, 160); // cap at 160px
el.input.style.height = h + 'px';
});
// Enter to send, Shift+Enter for new line
el.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
el.composer.requestSubmit();
}
});
// Clear chat
el.clearBtn.addEventListener('click', () => clearChat(false));
// New chat (fresh intro)
el.newChatBtn.addEventListener('click', () => clearChat(true));
// Set API key
el.setApiBtn.addEventListener('click', () => {
const current = store.apiKey ? '********' : '';
const input = prompt('Enter OpenRouter API key (stored locally):', current);
if (input === null) return; // cancel
store.apiKey = input === '********' ? store.apiKey : (input.trim());
alert(store.apiKey ? 'API key saved locally.' : 'API key cleared.');
});
// Set Model
el.setModelBtn.addEventListener('click', () => {
const input = prompt('Enter model name (e.g., openai/gpt-4o):', store.model);
if (input === null) return;
const name = input.trim() || 'openai/gpt-4o';
store.model = name;
});
// Init
updateStatus();
el.modelName.textContent = store.model;
</script></body>
</html>