Files
sune/docs/index.html
github-actions 5200a4c980 Action Commit
2025-08-14 01:28:43 +00:00

367 lines
15 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-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>
Im 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>