mirror of
https://github.com/multipleof4/sune.git
synced 2026-01-13 16:17:55 +00:00
367 lines
15 KiB
HTML
367 lines
15 KiB
HTML
<!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-black/10">
|
||
<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">
|
||
<!-- New chat = icon only -->
|
||
<button id="newChatBtn" class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="New chat">
|
||
<svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12M6 12h12"/>
|
||
</svg>
|
||
</button>
|
||
<!-- Robot icon (clickable to change model) -->
|
||
<button id="modelBadge" class="h-8 w-8 rounded-full bg-black text-white border border-black hover:bg-black/90 transition flex items-center justify-center" title="Change model">
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6a2 2 0 012 2v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9a2 2 0 012-2zm3-4v3m-3 4h.01M15 10h.01"/>
|
||
</svg>
|
||
</button>
|
||
<!-- API key badge -->
|
||
<button id="apiBadge" title="Set OpenRouter API key" class="h-8 min-w-8 px-3 rounded-full border text-xs font-medium inline-flex items-center justify-center gap-1 transition bg-gray-100 text-gray-700 border-gray-200 hover:bg-gray-200">
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a4 4 0 11-7.999.001A4 4 0 0115 7zm-.293 6.707L9 19.414V22h2.586l5.707-5.707a1 1 0 000-1.414l-1.586-1.586a1 1 0 00-1.414 0z"/>
|
||
</svg>
|
||
<span id="statusText" class="sr-only">offline</span>
|
||
</button>
|
||
</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">
|
||
<div class="flex gap-3">
|
||
<div class="shrink-0 h-8 w-8 rounded-full bg-gray-200 text-gray-900 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>
|
||
I’m wired to OpenRouter. Click the key badge to set an API key, or hardcode one below if you insist. Enter adds a newline.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="h-24"></div>
|
||
</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-black/20 focus:border-gray-300 max-h-40"></textarea>
|
||
<button id="sendBtn" type="submit" aria-label="Send"
|
||
class="shrink-0 rounded-2xl bg-black text-white h-10 w-10 inline-flex items-center justify-center shadow-sm hover:bg-black/90 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-end text-xs text-gray-500">
|
||
<div class="flex items-center gap-3">
|
||
<button id="stopBtn" class="underline decoration-dotted hover:text-gray-700 hidden">Stop</button>
|
||
<button id="clearBtn" class="underline decoration-dotted hover:text-gray-700">Clear</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
</div> <script>
|
||
// === Configuration (fill in only if you *really* want to hardcode) ===
|
||
const DEFAULT_MODEL = 'openai/gpt-4o';
|
||
const DEFAULT_API_KEY = '';// ← paste here if you insist (not recommended)
|
||
|
||
// === Elements ===
|
||
const el = {
|
||
chat: document.getElementById('chat'),
|
||
messages: document.getElementById('messages'),
|
||
composer: document.getElementById('composer'),
|
||
input: document.getElementById('input'),
|
||
sendBtn: document.getElementById('sendBtn'),
|
||
stopBtn: document.getElementById('stopBtn'),
|
||
clearBtn: document.getElementById('clearBtn'),
|
||
newChatBtn: document.getElementById('newChatBtn'),
|
||
modelBadge: document.getElementById('modelBadge'),
|
||
apiBadge: document.getElementById('apiBadge'),
|
||
statusText: document.getElementById('statusText'),
|
||
};
|
||
|
||
// === Local storage ===
|
||
const store = {
|
||
get apiKey() { return localStorage.getItem('openrouter_api_key') || DEFAULT_API_KEY || ''; },
|
||
set apiKey(v) { localStorage.setItem('openrouter_api_key', v || ''); updateStatus(); },
|
||
get model() { return localStorage.getItem('openrouter_model') || DEFAULT_MODEL; },
|
||
set model(v) { localStorage.setItem('openrouter_model', v || DEFAULT_MODEL); },
|
||
};
|
||
|
||
// === Helpers ===
|
||
const getModelShort = () => {
|
||
const m = store.model || '';
|
||
const name = m.includes('/') ? m.split('/').pop() : m;
|
||
return name;
|
||
};
|
||
|
||
// === Runtime state ===
|
||
const state = { messages: [], 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-gray-900 text-white' : 'bg-gray-200 text-gray-900');
|
||
avatar.textContent = role === 'user' ? '🧑' : '🤖';
|
||
|
||
const right = document.createElement('div');
|
||
right.className = 'flex flex-col';
|
||
|
||
if (role !== 'user') {
|
||
const name = document.createElement('div');
|
||
name.className = 'text-xs font-medium text-gray-500 mb-0.5';
|
||
name.textContent = getModelShort();
|
||
right.appendChild(name);
|
||
}
|
||
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'rounded-2xl px-4 py-3 text-[15px] leading-relaxed whitespace-pre-wrap ' + (role === 'user' ? 'bg-gray-50 border border-gray-200' : 'bg-gray-100');
|
||
bubble.textContent = content;
|
||
|
||
right.appendChild(bubble);
|
||
row.appendChild(avatar);
|
||
row.appendChild(right);
|
||
|
||
el.messages.appendChild(row);
|
||
state.messages.push({ role, content });
|
||
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' }));
|
||
return bubble;
|
||
}
|
||
|
||
function addAssistantBubbleStreaming() {
|
||
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-gray-200 text-gray-900';
|
||
avatar.textContent = '🤖';
|
||
|
||
const right = document.createElement('div');
|
||
right.className = 'flex flex-col';
|
||
|
||
const name = document.createElement('div');
|
||
name.className = 'text-xs font-medium text-gray-500 mb-0.5';
|
||
name.textContent = getModelShort();
|
||
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'rounded-2xl px-4 py-3 text-[15px] leading-relaxed bg-gray-100 text-gray-800 whitespace-pre-wrap';
|
||
bubble.textContent = '';
|
||
|
||
right.appendChild(name);
|
||
right.appendChild(bubble);
|
||
row.appendChild(avatar);
|
||
row.appendChild(right);
|
||
|
||
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-gray-200 text-gray-900 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.apiBadge.className = 'h-8 min-w-8 px-3 rounded-full border text-xs font-medium inline-flex items-center justify-center gap-1 transition ' + (online
|
||
? 'bg-black text-white border-black hover:bg-black/90'
|
||
: 'bg-gray-100 text-gray-700 border-gray-200 hover:bg-gray-200');
|
||
el.statusText.textContent = online ? 'online' : 'offline';
|
||
}
|
||
|
||
// === Networking ===
|
||
async function askOpenRouterStreaming(onDelta) {
|
||
const apiKey = store.apiKey;
|
||
const model = store.model;
|
||
if (!apiKey) {
|
||
const text = localDemoReply(state.messages[state.messages.length - 1]?.content || '');
|
||
onDelta(text, true);
|
||
return { ok: true, text };
|
||
}
|
||
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: true,
|
||
}),
|
||
signal: state.controller.signal,
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errText = await res.text().catch(() => '');
|
||
throw new Error(errText || ('HTTP ' + res.status));
|
||
}
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder('utf-8');
|
||
let buffer = '';
|
||
let full = '';
|
||
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
let idx;
|
||
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
||
const chunk = buffer.slice(0, idx).trim();
|
||
buffer = buffer.slice(idx + 2);
|
||
if (!chunk) continue;
|
||
if (chunk.startsWith('data:')) {
|
||
const data = chunk.slice(5).trim();
|
||
if (data === '[DONE]') continue;
|
||
try {
|
||
const json = JSON.parse(data);
|
||
const delta = json.choices?.[0]?.delta?.content ?? '';
|
||
if (delta) {
|
||
full += delta;
|
||
onDelta(delta, false);
|
||
}
|
||
const finish = json.choices?.[0]?.finish_reason;
|
||
if (finish) {
|
||
onDelta('', true);
|
||
}
|
||
} catch { /* ignore partial JSON */ }
|
||
}
|
||
}
|
||
}
|
||
return { ok: true, text: full };
|
||
} catch (e) {
|
||
console.error(e);
|
||
const msg = String(e?.message || e);
|
||
let hint = 'Request failed.';
|
||
if (/401|unauthorized/i.test(msg)) hint = 'Unauthorized (check API key).';
|
||
else if (/429|rate/i.test(msg)) hint = 'Rate limited (slow down or upgrade).';
|
||
else if (/access|forbidden|403/i.test(msg)) hint = 'Forbidden (model or key scope).';
|
||
const fallback = `\n\n${hint}\nSwitching to local demo.\n\n` + localDemoReply(state.messages[state.messages.length - 1]?.content || '');
|
||
onDelta(fallback, true);
|
||
return { ok: false, text: fallback };
|
||
} finally {
|
||
state.controller = null;
|
||
}
|
||
}
|
||
|
||
function localDemoReply(prompt) {
|
||
const tips = [
|
||
'Tip: click the key badge to set your OpenRouter API key.',
|
||
'Click the robot to change the 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;
|
||
el.stopBtn.classList.remove('hidden');
|
||
|
||
const assistantBubble = addAssistantBubbleStreaming();
|
||
|
||
await askOpenRouterStreaming((delta, done) => {
|
||
assistantBubble.textContent += delta;
|
||
if (done) {
|
||
el.sendBtn.disabled = false;
|
||
el.stopBtn.classList.add('hidden');
|
||
state.busy = false;
|
||
state.messages.push({ role: 'assistant', content: assistantBubble.textContent });
|
||
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' }));
|
||
}
|
||
});
|
||
});
|
||
|
||
// Stop streaming
|
||
el.stopBtn.addEventListener('click', () => {
|
||
if (state.controller) state.controller.abort();
|
||
el.stopBtn.classList.add('hidden');
|
||
el.sendBtn.disabled = false;
|
||
state.busy = false;
|
||
});
|
||
|
||
// Auto-resize textarea
|
||
el.input.addEventListener('input', () => {
|
||
el.input.style.height = 'auto';
|
||
const h = Math.min(el.input.scrollHeight, 160);
|
||
el.input.style.height = h + 'px';
|
||
});
|
||
|
||
// Clear / New
|
||
el.clearBtn.addEventListener('click', () => clearChat(false));
|
||
el.newChatBtn.addEventListener('click', () => clearChat(true));
|
||
|
||
// Model badge click (robot)
|
||
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() || DEFAULT_MODEL;
|
||
});
|
||
|
||
// API badge click
|
||
el.apiBadge.addEventListener('click', () => {
|
||
const currentMasked = store.apiKey ? '********' : '';
|
||
const input = prompt('Enter OpenRouter API key (stored locally):', currentMasked);
|
||
if (input === null) return;
|
||
store.apiKey = input === '********' ? store.apiKey : (input.trim());
|
||
alert(store.apiKey ? 'API key saved locally.' : 'API key cleared.');
|
||
});
|
||
|
||
// Init
|
||
function initFromQuery() {
|
||
const url = new URL(location.href);
|
||
const key = url.searchParams.get('key');
|
||
const model = url.searchParams.get('model');
|
||
if (key) store.apiKey = key;
|
||
if (model) store.model = model;
|
||
}
|
||
|
||
function init() {
|
||
initFromQuery();
|
||
updateStatus();
|
||
}
|
||
|
||
init();
|
||
</script></body>
|
||
</html>
|