Action Commit

This commit is contained in:
github-actions
2025-08-14 00:03:59 +00:00
parent 4d65ac0510
commit 6e1f53350d

View File

@@ -14,58 +14,65 @@
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>
</head>
<body class="bg-white text-gray-900 selection:bg-amber-200/60">
<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">
<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">
<!-- Model badge (click to change model) -->
<button id="modelBadge" class="text-xs font-medium px-3 py-1.5 rounded-full bg-black text-white border border-black hover:bg-black/90 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>
<!-- API key badge (click to set key); color reflects online/offline -->
<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">
<!-- Key icon -->
<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><main id="chat" class="flex-1 overflow-y-auto no-scrollbar">
</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-emerald-100 text-emerald-700 flex items-center justify-center">🤖</div>
<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 a lightweight ChatGPT-style demo. Type below to chat. Set an OpenRouter API key (bottom-left) to go online; otherwise Ill reply locally.
Im a lightweight ChatGPT-style demo. Click the key badge to set an OpenRouter API key; otherwise Ill reply locally. 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-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>
<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-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
<span>Enter</span> 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>
<!-- Bottom-left controls removed (no longer needed) -->
</div> <script>
// === Elements ===
const el = {
chat: document.getElementById('chat'),
messages: document.getElementById('messages'),
@@ -74,12 +81,13 @@
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'),
apiBadge: document.getElementById('apiBadge'),
statusText: document.getElementById('statusText'),
};
// === Local storage ===
const store = {
get apiKey() { return localStorage.getItem('openrouter_api_key') || ''; },
set apiKey(v) { localStorage.setItem('openrouter_api_key', v || ''); updateStatus(); },
@@ -87,16 +95,18 @@
set model(v) { localStorage.setItem('openrouter_model', v || 'openai/gpt-4o'); el.modelName.textContent = store.model; },
};
// === 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-blue-100 text-blue-700' : 'bg-emerald-100 text-emerald-700');
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 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.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;
row.appendChild(avatar);
row.appendChild(bubble);
@@ -110,7 +120,7 @@
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.className = 'shrink-0 h-8 w-8 rounded-full flex items-center justify-center bg-gray-200 text-gray-900';
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';
@@ -128,17 +138,21 @@
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>`;
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.status.textContent = online ? 'online' : 'offline';
el.status.className = 'text-[11px] ' + (online ? 'text-emerald-600' : 'text-gray-400');
// Toggle badge colors
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 askOpenRouter() {
const apiKey = store.apiKey;
const model = store.model;
@@ -160,18 +174,21 @@
} 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;
}
} 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 tips = [
'Tip: click the key badge to set your OpenRouter API key.',
'Model is clickable at the top—tap the model badge to change it.',
'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;
@@ -179,47 +196,52 @@
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' }));
});
// 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';
});
el.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
el.composer.requestSubmit();
}
});
// Do NOT send on Enter — Enter inserts newline by default; no keydown override here.
// Clear / New
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.');
});
// Model badge click
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';
});
// 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
updateStatus();
el.modelName.textContent = store.model;
</script></body>