Files
sune/index.html
2025-08-13 16:51:31 -07:00

227 lines
12 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>
<script src="https://cdn.tailwindcss.com"></script>
<style>
: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">
<div class="flex flex-col h-dvh max-h-dvh">
<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>
<button id="modelBadge" class="text-xs font-medium px-3 py-1.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 transition">
<span id="modelName">openai/gpt-4o</span>
</button>
<div class="flex items-center gap-2">
<span class="text-[11px] text-gray-400" id="status">offline</span>
</div>
</div>
</header><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">
<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>
</main>
<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">
<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>
<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">
<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>
</div>
</div> <script>
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'),
};
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: [], busy: false, controller: null };
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);
state.messages.push({ role, content });
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');
}
async function askOpenRouter() {
const apiKey = store.apiKey;
const model = store.model;
if (!apiKey) {
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) throw new Error(await res.text() || ('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) {
const tips = [ 'Tip: set an OpenRouter API key (bottom-left) to go truly online.', 'Pro move: click the model badge to change 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}`;
}
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;
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' }));
});
el.input.addEventListener('input', () => {
el.input.style.height = 'auto';
const h = Math.min(el.input.scrollHeight, 160);
el.input.style.height = h + 'px';
});
el.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
el.composer.requestSubmit();
}
});
el.clearBtn.addEventListener('click', () => clearChat(false));
el.newChatBtn.addEventListener('click', () => clearChat(true));
el.setApiBtn.addEventListener('click', () => {
const current = store.apiKey ? '********' : '';
const input = prompt('Enter OpenRouter API key (stored locally):', current);
if (input === null) return;
store.apiKey = input === '********' ? store.apiKey : (input.trim());
alert(store.apiKey ? 'API key saved locally.' : 'API key cleared.');
});
el.modelBadge.addEventListener('click', () => {
const input = prompt('Enter model name (e.g., openai/gpt-4o):', store.model);
if (input === null) return;
store.model = input.trim() || 'openai/gpt-4o';
});
updateStatus();
el.modelName.textContent = store.model;
</script></body>
</html>