secutorpro's picture
🐳 18/04 - 22:02 - corrige tout
6ca4187 verified
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Chat Studio</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
colors: {
brand: {
50: '#f0f4ff',
100: '#dbe4ff',
200: '#bac8ff',
300: '#91a7ff',
400: '#748ffc',
500: '#5c7cfa',
600: '#4c6ef5',
700: '#4263eb',
800: '#3b5bdb',
900: '#364fc2',
}
}
}
}
}
</script>
<style>
* { scrollbar-width: thin; scrollbar-color: #4c6ef5 transparent; }
*::-webkit-scrollbar { width: 6px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: #4c6ef5; border-radius: 10px; }
.chat-message-enter { animation: messageSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
@keyframes messageSlideIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.typing-dot { animation: typingBounce 1.4s infinite ease-in-out both; }
.typing-dot:nth-child(1) { animation-delay: -0.32s; }
.typing-dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes typingBounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.pulse-glow {
animation: pulseGlow 2s ease-in-out infinite;
}
@keyframes pulseGlow {
0%, 100% { box-shadow: 0 0 5px rgba(92, 124, 250, 0.3); }
50% { box-shadow: 0 0 20px rgba(92, 124, 250, 0.6); }
}
.gradient-text {
background: linear-gradient(135deg, #748ffc, #5c7cfa, #4263eb);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sidebar-overlay {
transition: opacity 0.3s ease;
}
.sidebar-slide {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.prose-dark { color: #e2e8f0; }
.prose-dark h1, .prose-dark h2, .prose-dark h3 { color: #f1f5f9; font-weight: 700; margin-top: 1em; margin-bottom: 0.5em; }
.prose-dark h1 { font-size: 1.5em; }
.prose-dark h2 { font-size: 1.3em; }
.prose-dark h3 { font-size: 1.1em; }
.prose-dark p { margin-bottom: 0.75em; line-height: 1.7; }
.prose-dark ul, .prose-dark ol { margin-bottom: 0.75em; padding-left: 1.5em; }
.prose-dark li { margin-bottom: 0.25em; line-height: 1.6; }
.prose-dark ul li { list-style-type: disc; }
.prose-dark ol li { list-style-type: decimal; }
.prose-dark code { background: rgba(92, 124, 250, 0.15); padding: 0.15em 0.4em; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: 0.88em; }
.prose-dark pre { background: #1e1e2e; border: 1px solid #313244; border-radius: 8px; padding: 1em; margin-bottom: 0.75em; overflow-x: auto; }
.prose-dark pre code { background: none; padding: 0; font-size: 0.85em; }
.prose-dark blockquote { border-left: 3px solid #5c7cfa; padding-left: 1em; margin-left: 0; margin-bottom: 0.75em; color: #94a3b8; font-style: italic; }
.prose-dark a { color: #748ffc; text-decoration: underline; }
.prose-dark strong { color: #f1f5f9; font-weight: 600; }
.prose-dark table { width: 100%; border-collapse: collapse; margin-bottom: 0.75em; }
.prose-dark th, .prose-dark td { border: 1px solid #334155; padding: 0.5em; text-align: left; }
.prose-dark th { background: rgba(92, 124, 250, 0.1); font-weight: 600; }
.prose-light { color: #334155; }
.prose-light h1, .prose-light h2, .prose-light h3 { color: #1e293b; font-weight: 700; margin-top: 1em; margin-bottom: 0.5em; }
.prose-light h1 { font-size: 1.5em; }
.prose-light h2 { font-size: 1.3em; }
.prose-light h3 { font-size: 1.1em; }
.prose-light p { margin-bottom: 0.75em; line-height: 1.7; }
.prose-light ul, .prose-light ol { margin-bottom: 0.75em; padding-left: 1.5em; }
.prose-light li { margin-bottom: 0.25em; line-height: 1.6; }
.prose-light ul li { list-style-type: disc; }
.prose-light ol li { list-style-type: decimal; }
.prose-light code { background: rgba(92, 124, 250, 0.1); padding: 0.15em 0.4em; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: 0.88em; color: #4263eb; }
.prose-light pre { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1em; margin-bottom: 0.75em; overflow-x: auto; }
.prose-light pre code { background: none; padding: 0; font-size: 0.85em; color: #334155; }
.prose-light blockquote { border-left: 3px solid #5c7cfa; padding-left: 1em; margin-left: 0; margin-bottom: 0.75em; color: #64748b; font-style: italic; }
.prose-light a { color: #4263eb; text-decoration: underline; }
.prose-light strong { color: #1e293b; font-weight: 600; }
.prose-light table { width: 100%; border-collapse: collapse; margin-bottom: 0.75em; }
.prose-light th, .prose-light td { border: 1px solid #cbd5e1; padding: 0.5em; text-align: left; }
.prose-light th { background: rgba(92, 124, 250, 0.05); font-weight: 600; }
#model-search::-webkit-search-cancel-button { display: none; }
.model-item:hover { background: rgba(92, 124, 250, 0.08); }
.model-item.active { background: rgba(92, 124, 250, 0.15); border-color: #5c7cfa; }
</style>
</head>
<body class="bg-gray-50 dark:bg-[#0a0a0f] text-gray-900 dark:text-gray-100 font-sans h-screen flex overflow-hidden transition-colors duration-300">
<!-- Mobile Sidebar Overlay -->
<div id="sidebar-overlay" class="sidebar-overlay fixed inset-0 bg-black/50 z-30 opacity-0 pointer-events-none lg:hidden" onclick="toggleSidebar()"></div>
<!-- Sidebar -->
<aside id="sidebar" class="sidebar-slide fixed lg:relative z-40 w-80 h-full bg-white dark:bg-[#0f0f18] border-r border-gray-200 dark:border-gray-800/50 flex flex-col transform -translate-x-full lg:translate-x-0">
<!-- Sidebar Header -->
<div class="p-4 border-b border-gray-200 dark:border-gray-800/50">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-brand-700 flex items-center justify-center">
<i data-lucide="bot" class="w-4 h-4 text-white"></i>
</div>
<h1 class="text-lg font-bold gradient-text">AI Chat Studio</h1>
</div>
<button onclick="toggleSidebar()" class="lg:hidden p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<button onclick="newChat()" class="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-700 text-white font-medium transition-all duration-200 hover:shadow-lg hover:shadow-brand-500/25">
<i data-lucide="plus" class="w-4 h-4"></i>
Nouvelle conversation
</button>
</div>
<!-- API Key -->
<div class="p-4 border-b border-gray-200 dark:border-gray-800/50">
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2 block">Clé API OpenRouter</label>
<div class="relative">
<input id="api-key-input" type="password" placeholder="sk-or-v1-..." class="w-full px-3 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-[#12121c] text-sm focus:outline-none focus:border-brand-400 dark:focus:border-brand-500 transition-colors pr-10" oninput="saveApiKey(this.value)">
<button onclick="toggleApiKeyVisibility()" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<i data-lucide="eye" id="api-key-eye" class="w-4 h-4 text-gray-400"></i>
</button>
</div>
<div id="api-key-status" class="flex items-center gap-1.5 mt-1.5 text-xs">
<span class="w-2 h-2 rounded-full bg-red-400" id="api-key-dot"></span>
<span class="text-gray-500 dark:text-gray-400" id="api-key-label">Non configurée</span>
</div>
</div>
<!-- Model Selection -->
<div class="p-4 border-b border-gray-200 dark:border-gray-800/50">
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2 block">Modèle IA</label>
<div class="relative">
<button id="model-dropdown-btn" onclick="toggleModelDropdown()" class="w-full flex items-center justify-between px-3 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-[#12121c] hover:border-brand-400 dark:hover:border-brand-500 transition-all text-sm">
<span id="selected-model-display" class="truncate">Z.AI / GLM 5.1</span>
<i data-lucide="chevron-down" class="w-4 h-4 text-gray-400 flex-shrink-0 ml-2"></i>
</button>
<div id="model-dropdown" class="hidden absolute top-full left-0 right-0 mt-2 bg-white dark:bg-[#12121c] border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 max-h-64 overflow-hidden flex flex-col">
<div class="p-2 border-b border-gray-200 dark:border-gray-700">
<div class="relative">
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input id="model-search" type="search" placeholder="Rechercher un modèle..." class="w-full pl-9 pr-3 py-2 rounded-lg bg-gray-50 dark:bg-[#0a0a0f] border border-gray-200 dark:border-gray-700 text-sm focus:outline-none focus:border-brand-400 transition-colors" oninput="filterModels(this.value)">
</div>
</div>
<div id="model-list" class="overflow-y-auto flex-1 p-1"></div>
</div>
</div>
<div class="flex items-center gap-2 mt-2">
<label class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 cursor-pointer">
<input type="checkbox" id="stream-toggle" checked class="w-3.5 h-3.5 rounded accent-brand-500">
Streaming
</label>
<span class="text-gray-300 dark:text-gray-600">|</span>
<button onclick="toggleTheme()" class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors">
<i data-lucide="sun" class="w-3.5 h-3.5 theme-icon-light hidden"></i>
<i data-lucide="moon" class="w-3.5 h-3.5 theme-icon-dark"></i>
Thème
</button>
</div>
</div>
<!-- Chat History -->
<div class="flex-1 overflow-y-auto p-3 space-y-1" id="chat-history">
</div>
<!-- Sidebar Footer -->
<div class="p-4 border-t border-gray-200 dark:border-gray-800/50">
<div class="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
<i data-lucide="zap" class="w-3.5 h-3.5 text-brand-400"></i>
Propulsé par OpenRouter — Clé API requise
</div>
</div>
</aside>
<!-- Main Chat Area -->
<main class="flex-1 flex flex-col h-full min-w-0">
<!-- Top Bar -->
<header class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800/50 bg-white/80 dark:bg-[#0a0a0f]/80 backdrop-blur-xl">
<div class="flex items-center gap-3">
<button onclick="toggleSidebar()" class="lg:hidden p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<i data-lucide="menu" class="w-5 h-5"></i>
</button>
<div>
<h2 id="chat-title" class="font-semibold text-sm truncate max-w-[200px] sm:max-w-xs md:max-w-md">Nouvelle conversation</h2>
<p id="chat-model-label" class="text-xs text-gray-500 dark:text-gray-400">z-ai/glm-5.1</p>
</div>
</div>
<div class="flex items-center gap-2">
<button onclick="clearChat()" class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400" title="Effacer la conversation">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
<button onclick="exportChat()" class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400 hover:text-brand-500" title="Exporter la conversation">
<i data-lucide="download" class="w-4 h-4"></i>
</button>
</div>
</header>
<!-- Messages Container -->
<div id="messages" class="flex-1 overflow-y-auto px-4 py-6">
<!-- Welcome Screen -->
<div id="welcome-screen" class="flex flex-col items-center justify-center h-full text-center px-4">
<div class="w-20 h-20 rounded-2xl bg-gradient-to-br from-brand-500 to-brand-700 flex items-center justify-center mb-6 pulse-glow">
<i data-lucide="sparkles" class="w-10 h-10 text-white"></i>
</div>
<h2 class="text-2xl sm:text-3xl font-bold gradient-text mb-3">Bienvenue sur AI Chat Studio</h2>
<p class="text-gray-500 dark:text-gray-400 max-w-md mb-8 text-sm sm:text-base">
Discutez avec des centaines de modèles d'IA via OpenRouter. Entrez votre clé API pour commencer.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-lg w-full">
<button onclick="useSuggestion('Explique l informatique quantique simplement')" class="flex items-start gap-3 p-4 rounded-xl border border-gray-200 dark:border-gray-700/50 hover:border-brand-400 dark:hover:border-brand-500 bg-white dark:bg-[#12121c] hover:shadow-md transition-all text-left group">
<i data-lucide="atom" class="w-5 h-5 text-brand-400 mt-0.5 flex-shrink-0 group-hover:scale-110 transition-transform"></i>
<div>
<p class="text-sm font-medium">Informatique quantique</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Expliquée simplement</p>
</div>
</button>
<button onclick="useSuggestion('Écris un poème sur l intelligence artificielle')" class="flex items-start gap-3 p-4 rounded-xl border border-gray-200 dark:border-gray-700/50 hover:border-brand-400 dark:hover:border-brand-500 bg-white dark:bg-[#12121c] hover:shadow-md transition-all text-left group">
<i data-lucide="pen-tool" class="w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0 group-hover:scale-110 transition-transform"></i>
<div>
<p class="text-sm font-medium">Poésie créative</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Sur l'intelligence artificielle</p>
</div>
</button>
<button onclick="useSuggestion('Comment fonctionnent les panneaux solaires ?')" class="flex items-start gap-3 p-4 rounded-xl border border-gray-200 dark:border-gray-700/50 hover:border-brand-400 dark:hover:border-brand-500 bg-white dark:bg-[#12121c] hover:shadow-md transition-all text-left group">
<i data-lucide="sun" class="w-5 h-5 text-amber-400 mt-0.5 flex-shrink-0 group-hover:scale-110 transition-transform"></i>
<div>
<p class="text-sm font-medium">Science</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Panneaux solaires</p>
</div>
</button>
<button onclick="useSuggestion('Donne moi 5 conseils pour être plus productif')" class="flex items-start gap-3 p-4 rounded-xl border border-gray-200 dark:border-gray-700/50 hover:border-brand-400 dark:hover:border-brand-500 bg-white dark:bg-[#12121c] hover:shadow-md transition-all text-left group">
<i data-lucide="lightbulb" class="w-5 h-5 text-emerald-400 mt-0.5 flex-shrink-0 group-hover:scale-110 transition-transform"></i>
<div>
<p class="text-sm font-medium">Productivité</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">5 conseils pratiques</p>
</div>
</button>
</div>
</div>
</div>
<!-- Input Area -->
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-800/50 bg-white/80 dark:bg-[#0a0a0f]/80 backdrop-blur-xl">
<div class="max-w-3xl mx-auto">
<div class="relative flex items-end gap-2 bg-gray-50 dark:bg-[#12121c] border border-gray-200 dark:border-gray-700 rounded-2xl px-4 py-3 focus-within:border-brand-400 dark:focus-within:border-brand-500 transition-colors">
<textarea id="user-input" rows="1" placeholder="Écrivez votre message..." class="flex-1 resize-none bg-transparent outline-none text-sm leading-relaxed max-h-40 placeholder-gray-400 dark:placeholder-gray-500" onkeydown="handleKeyDown(event)" oninput="autoResize(this)"></textarea>
<button id="send-btn" onclick="sendMessage()" class="flex-shrink-0 w-9 h-9 rounded-xl bg-brand-600 hover:bg-brand-700 disabled:bg-gray-300 dark:disabled:bg-gray-700 disabled:cursor-not-allowed text-white flex items-center justify-center transition-all duration-200 hover:shadow-lg hover:shadow-brand-500/25 disabled:shadow-none" disabled>
<i data-lucide="arrow-up" class="w-4 h-4"></i>
</button>
</div>
<p class="text-center text-xs text-gray-400 dark:text-gray-500 mt-2">
OpenRouter — API unifiée pour tous les modèles
</p>
</div>
</div>
</main>
<script>
// ==================== STATE ====================
let currentModel = 'z-ai/glm-5.1';
let conversations = JSON.parse(localStorage.getItem('puter_conversations') || '[]');
let currentConversationId = null;
let isGenerating = false;
let currentAbortController = null;
let apiKey = localStorage.getItem('openrouter_api_key') || '';
// ==================== MODELS ====================
const MODELS = [
{ id: 'z-ai/glm-5.1', name: 'GLM 5.1', provider: 'Z.AI', badge: 'Recommended' },
{ id: 'openai/gpt-oss-120b', name: 'GPT-OSS 120B', provider: 'OpenAI', badge: 'Popular' },
{ id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'Google', badge: 'Fast' },
{ id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'Google' },
{ id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', provider: 'DeepSeek', badge: 'Reasoning' },
{ id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3', provider: 'DeepSeek' },
{ id: 'anthropic/claude-3.7-sonnet', name: 'Claude 3.7 Sonnet', provider: 'Anthropic' },
{ id: 'meta-llama/llama-4-scout', name: 'Llama 4 Scout', provider: 'Meta' },
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick', provider: 'Meta' },
{ id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'Mistral' },
{ id: 'mistralai/mistral-medium-3', name: 'Mistral Medium 3', provider: 'Mistral' },
{ id: 'moonshotai/kimi-k2', name: 'Kimi K2', provider: 'Moonshot' },
{ id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B', provider: 'Qwen' },
{ id: 'qwen/qwen3-max', name: 'Qwen3 Max', provider: 'Qwen' },
{ id: 'qwen/qwen3-coder-plus', name: 'Qwen3 Coder+', provider: 'Qwen', badge: 'Code' },
{ id: 'x-ai/grok-4.20', name: 'Grok 4.20', provider: 'xAI' },
{ id: 'openai/gpt-5.4-pro', name: 'GPT 5.4 Pro', provider: 'OpenAI' },
{ id: 'openai/gpt-5.3-chat', name: 'GPT 5.3 Chat', provider: 'OpenAI' },
{ id: 'openai/o3-deep-research', name: 'O3 Deep Research', provider: 'OpenAI', badge: 'Research' },
{ id: 'google/gemma-4-31b-it', name: 'Gemma 4 31B', provider: 'Google' },
{ id: 'microsoft/phi-4', name: 'Phi-4', provider: 'Microsoft' },
{ id: 'nvidia/llama-3.3-nemotron-super-49b-v1.5', name: 'Nemotron Super 49B', provider: 'NVIDIA' },
{ id: 'amazon/nova-pro-v1', name: 'Nova Pro', provider: 'Amazon' },
{ id: 'minimax/minimax-m2.7', name: 'MiniMax M2.7', provider: 'MiniMax' },
{ id: 'cohere/command-r-plus-08-2024', name: 'Command R+', provider: 'Cohere' },
{ id: 'perplexity/sonar-pro', name: 'Sonar Pro', provider: 'Perplexity' },
{ id: 'bytedance-seed/seed-2.0-lite', name: 'Seed 2.0 Lite', provider: 'ByteDance' },
{ id: 'mistralai/devstral-2512', name: 'Devstral', provider: 'Mistral', badge: 'Code' },
{ id: 'z-ai/glm-5', name: 'GLM 5', provider: 'Z.AI' },
{ id: 'z-ai/glm-5-turbo', name: 'GLM 5 Turbo', provider: 'Z.AI' },
{ id: 'deepseek/deepseek-v3.2', name: 'DeepSeek V3.2', provider: 'DeepSeek' },
{ id: 'deepseek/deepseek-r1-0528', name: 'DeepSeek R1 0528', provider: 'DeepSeek' },
{ id: 'qwen/qwen3.5-397b-a17b', name: 'Qwen3.5 397B', provider: 'Qwen' },
{ id: 'qwen/qwen3-coder-flash', name: 'Qwen3 Coder Flash', provider: 'Qwen', badge: 'Code' },
{ id: 'google/gemini-2.0-flash-001', name: 'Gemini 2.0 Flash', provider: 'Google' },
{ id: 'openai/gpt-4o', name: 'GPT-4o', provider: 'OpenAI' },
{ id: 'openai/gpt-4o-mini-search-preview', name: 'GPT-4o Mini Search', provider: 'OpenAI', badge: 'Search' },
{ id: 'mistralai/mistral-small-3.2-24b-instruct', name: 'Mistral Small 3.2', provider: 'Mistral' },
{ id: 'ibm-granite/granite-4.0-h-micro', name: 'Granite 4.0', provider: 'IBM' },
];
// ==================== API KEY ====================
function saveApiKey(key) {
apiKey = key.trim();
localStorage.setItem('openrouter_api_key', apiKey);
updateApiKeyStatus();
}
function updateApiKeyStatus() {
const dot = document.getElementById('api-key-dot');
const label = document.getElementById('api-key-label');
if (apiKey && apiKey.startsWith('sk-or-')) {
dot.className = 'w-2 h-2 rounded-full bg-green-400';
label.textContent = 'Configurée';
label.className = 'text-green-600 dark:text-green-400';
} else if (apiKey) {
dot.className = 'w-2 h-2 rounded-full bg-yellow-400';
label.textContent = 'Format invalide';
label.className = 'text-yellow-600 dark:text-yellow-400';
} else {
dot.className = 'w-2 h-2 rounded-full bg-red-400';
label.textContent = 'Non configurée';
label.className = 'text-gray-500 dark:text-gray-400';
}
}
function toggleApiKeyVisibility() {
const input = document.getElementById('api-key-input');
const eyeIcon = document.getElementById('api-key-eye');
if (input.type === 'password') {
input.type = 'text';
eyeIcon.setAttribute('data-lucide', 'eye-off');
} else {
input.type = 'password';
eyeIcon.setAttribute('data-lucide', 'eye');
}
lucide.createIcons();
}
// ==================== INIT ====================
function init() {
lucide.createIcons();
renderChatHistory();
renderModelList();
updateThemeIcons();
updateApiKeyStatus();
setupInputListener();
// Load saved API key
if (apiKey) {
document.getElementById('api-key-input').value = apiKey;
}
if (conversations.length > 0) {
loadConversation(conversations[0].id);
}
}
function setupInputListener() {
const input = document.getElementById('user-input');
input.addEventListener('input', () => {
document.getElementById('send-btn').disabled = !input.value.trim() || isGenerating;
});
}
// ==================== THEME ====================
function toggleTheme() {
document.documentElement.classList.toggle('dark');
localStorage.setItem('puter_theme', document.documentElement.classList.contains('dark') ? 'dark' : 'light');
updateThemeIcons();
renderMessages();
}
function updateThemeIcons() {
const isDark = document.documentElement.classList.contains('dark');
document.querySelectorAll('.theme-icon-light').forEach(el => el.classList.toggle('hidden', isDark));
document.querySelectorAll('.theme-icon-dark').forEach(el => el.classList.toggle('hidden', !isDark));
}
// Load saved theme
const savedTheme = localStorage.getItem('puter_theme') || 'dark';
if (savedTheme === 'light') {
document.documentElement.classList.remove('dark');
}
// ==================== SIDEBAR ====================
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const isOpen = !sidebar.classList.contains('-translate-x-full');
if (isOpen) {
sidebar.classList.add('-translate-x-full');
overlay.classList.add('opacity-0', 'pointer-events-none');
overlay.classList.remove('opacity-100');
} else {
sidebar.classList.remove('-translate-x-full');
overlay.classList.remove('opacity-0', 'pointer-events-none');
overlay.classList.add('opacity-100');
}
}
// ==================== MODEL DROPDOWN ====================
let dropdownOpen = false;
function toggleModelDropdown() {
dropdownOpen = !dropdownOpen;
const dropdown = document.getElementById('model-dropdown');
dropdown.classList.toggle('hidden', !dropdownOpen);
if (dropdownOpen) {
document.getElementById('model-search').focus();
}
}
function renderModelList(filter = '') {
const container = document.getElementById('model-list');
const filtered = MODELS.filter(m =>
m.id.toLowerCase().includes(filter.toLowerCase()) ||
m.name.toLowerCase().includes(filter.toLowerCase()) ||
m.provider.toLowerCase().includes(filter.toLowerCase())
);
container.innerHTML = filtered.map(m => `
<button onclick="selectModel('${m.id}')" class="model-item w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${m.id === currentModel ? 'active border border-brand-500/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'}">
<div class="flex flex-col items-start min-w-0">
<span class="font-medium truncate w-full">${m.name}</span>
<span class="text-xs text-gray-400 dark:text-gray-500">${m.provider}</span>
</div>
${m.badge ? `<span class="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 flex-shrink-0 ml-2">${m.badge}</span>` : ''}
</button>
`).join('');
}
function filterModels(query) {
renderModelList(query);
}
function selectModel(modelId) {
currentModel = modelId;
const model = MODELS.find(m => m.id === modelId);
document.getElementById('selected-model-display').textContent = model ? `${model.provider} / ${model.name}` : modelId;
document.getElementById('chat-model-label').textContent = modelId;
toggleModelDropdown();
renderModelList();
}
// Close dropdown on outside click
document.addEventListener('click', (e) => {
const dropdown = document.getElementById('model-dropdown');
const btn = document.getElementById('model-dropdown-btn');
if (dropdownOpen && !dropdown.contains(e.target) && !btn.contains(e.target)) {
dropdownOpen = false;
dropdown.classList.add('hidden');
}
});
// ==================== CHAT HISTORY ====================
function renderChatHistory() {
const container = document.getElementById('chat-history');
if (conversations.length === 0) {
container.innerHTML = `
<div class="flex flex-col items-center justify-center h-full text-center px-4">
<i data-lucide="message-square" class="w-8 h-8 text-gray-300 dark:text-gray-600 mb-2"></i>
<p class="text-xs text-gray-400 dark:text-gray-500">Aucune conversation</p>
</div>
`;
lucide.createIcons();
return;
}
container.innerHTML = conversations.map(conv => `
<button onclick="loadConversation('${conv.id}')" class="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl text-left transition-all group ${conv.id === currentConversationId ? 'bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800/30' : 'hover:bg-gray-100 dark:hover:bg-gray-800/50'}">
<i data-lucide="message-square" class="w-4 h-4 flex-shrink-0 ${conv.id === currentConversationId ? 'text-brand-500' : 'text-gray-400'}"></i>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">${escapeHtml(conv.title)}</p>
<p class="text-xs text-gray-400 dark:text-gray-500 truncate">${conv.model}</p>
</div>
<button onclick="event.stopPropagation(); deleteConversation('${conv.id}')" class="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500 transition-all">
<i data-lucide="trash-2" class="w-3 h-3"></i>
</button>
</button>
`).join('');
lucide.createIcons();
}
function saveConversations() {
localStorage.setItem('puter_conversations', JSON.stringify(conversations));
}
function newChat() {
currentConversationId = null;
document.getElementById('chat-title').textContent = 'Nouvelle conversation';
renderChatHistory();
renderMessages();
document.getElementById('user-input').focus();
if (window.innerWidth < 1024) toggleSidebar();
}
function loadConversation(id) {
currentConversationId = id;
const conv = conversations.find(c => c.id === id);
if (conv) {
currentModel = conv.model;
selectModel(conv.model);
document.getElementById('chat-title').textContent = conv.title;
renderMessages();
}
renderChatHistory();
if (window.innerWidth < 1024) toggleSidebar();
}
function deleteConversation(id) {
conversations = conversations.filter(c => c.id !== id);
saveConversations();
if (currentConversationId === id) {
currentConversationId = null;
document.getElementById('chat-title').textContent = 'Nouvelle conversation';
renderMessages();
}
renderChatHistory();
}
// ==================== MESSAGES ====================
function getCurrentConversation() {
return conversations.find(c => c.id === currentConversationId);
}
function renderMessages() {
const container = document.getElementById('messages');
const conv = getCurrentConversation();
if (!conv || conv.messages.length === 0) {
document.getElementById('welcome-screen').style.display = 'flex';
container.querySelectorAll('.message-bubble').forEach(el => el.remove());
return;
}
document.getElementById('welcome-screen').style.display = 'none';
container.querySelectorAll('.message-bubble').forEach(el => el.remove());
const isDark = document.documentElement.classList.contains('dark');
const proseClass = isDark ? 'prose-dark' : 'prose-light';
conv.messages.forEach(msg => {
const div = document.createElement('div');
div.className = 'message-bubble chat-message-enter max-w-3xl mx-auto mb-4';
if (msg.role === 'user') {
div.innerHTML = `
<div class="flex justify-end gap-3">
<div class="max-w-[85%] sm:max-w-[70%]">
<div class="bg-brand-600 text-white rounded-2xl rounded-br-md px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(msg.content)}</div>
</div>
<div class="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
<i data-lucide="user" class="w-4 h-4 text-brand-600 dark:text-brand-300"></i>
</div>
</div>
`;
} else {
div.innerHTML = `
<div class="flex justify-start gap-3">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-brand-500 to-brand-700 flex items-center justify-center flex-shrink-0">
<i data-lucide="bot" class="w-4 h-4 text-white"></i>
</div>
<div class="max-w-[85%] sm:max-w-[70%] min-w-0">
<div class="bg-white dark:bg-[#12121c] border border-gray-200 dark:border-gray-700/50 rounded-2xl rounded-bl-md px-4 py-3 text-sm leading-relaxed ${proseClass}">${renderMarkdown(msg.content)}</div>
</div>
</div>
`;
}
container.appendChild(div);
});
lucide.createIcons();
scrollToBottom();
}
function addMessageToUI(role, content) {
const container = document.getElementById('messages');
const isDark = document.documentElement.classList.contains('dark');
const proseClass = isDark ? 'prose-dark' : 'prose-light';
const div = document.createElement('div');
div.className = 'message-bubble chat-message-enter max-w-3xl mx-auto mb-4';
if (role === 'user') {
div.innerHTML = `
<div class="flex justify-end gap-3">
<div class="max-w-[85%] sm:max-w-[70%]">
<div class="bg-brand-600 text-white rounded-2xl rounded-br-md px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(content)}</div>
</div>
<div class="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
<i data-lucide="user" class="w-4 h-4 text-brand-600 dark:text-brand-300"></i>
</div>
</div>
`;
} else {
div.innerHTML = `
<div class="flex justify-start gap-3">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-brand-500 to-brand-700 flex items-center justify-center flex-shrink-0">
<i data-lucide="bot" class="w-4 h-4 text-white"></i>
</div>
<div class="max-w-[85%] sm:max-w-[70%] min-w-0">
<div class="ai-response-content bg-white dark:bg-[#12121c] border border-gray-200 dark:border-gray-700/50 rounded-2xl rounded-bl-md px-4 py-3 text-sm leading-relaxed ${proseClass}">${renderMarkdown(content)}</div>
</div>
</div>
`;
}
container.appendChild(div);
lucide.createIcons();
scrollToBottom();
return div;
}
function addTypingIndicator() {
const container = document.getElementById('messages');
const div = document.createElement('div');
div.id = 'typing-indicator';
div.className = 'message-bubble chat-message-enter max-w-3xl mx-auto mb-4';
div.innerHTML = `
<div class="flex justify-start gap-3">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-brand-500 to-brand-700 flex items-center justify-center flex-shrink-0">
<i data-lucide="bot" class="w-4 h-4 text-white"></i>
</div>
<div class="bg-white dark:bg-[#12121c] border border-gray-200 dark:border-gray-700/50 rounded-2xl rounded-bl-md px-4 py-3">
<div class="flex items-center gap-1.5">
<div class="typing-dot w-2 h-2 rounded-full bg-brand-400"></div>
<div class="typing-dot w-2 h-2 rounded-full bg-brand-400"></div>
<div class="typing-dot w-2 h-2 rounded-full bg-brand-400"></div>
</div>
</div>
</div>
`;
container.appendChild(div);
lucide.createIcons();
scrollToBottom();
}
function removeTypingIndicator() {
const el = document.getElementById('typing-indicator');
if (el) el.remove();
}
function updateStreamingMessage(content) {
const el = document.querySelector('.ai-response-content');
if (el) {
el.innerHTML = renderMarkdown(content);
scrollToBottom();
}
}
// ==================== OPENROUTER API ====================
async function callOpenRouter(messages, model, stream) {
if (!apiKey) {
throw new Error('Clé API non configurée. Ajoutez votre clé OpenRouter dans la barre latérale.');
}
const url = 'https://openrouter.ai/api/v1/chat/completions';
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.href,
'X-Title': 'AI Chat Studio'
},
body: JSON.stringify({
model: model,
messages: messages,
stream: stream
})
};
if (stream) {
currentAbortController = new AbortController();
options.signal = currentAbortController.signal;
}
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.error?.message || `Erreur HTTP ${response.status}`;
throw new Error(errorMessage);
}
return response;
}
function parseSSEStream(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
return {
async *[Symbol.asyncIterator]() {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content;
if (content) yield content;
} catch (e) {
// Skip malformed JSON chunks
}
}
}
}
};
}
// ==================== SEND MESSAGE ====================
async function sendMessage() {
const input = document.getElementById('user-input');
const prompt = input.value.trim();
if (!prompt || isGenerating) return;
if (!apiKey) {
addMessageToUI('assistant', '⚠️ **Clé API non configurée.** Ajoutez votre clé OpenRouter dans la barre latérale pour commencer.');
return;
}
isGenerating = true;
document.getElementById('send-btn').disabled = true;
input.value = '';
autoResize(input);
// Hide welcome screen
document.getElementById('welcome-screen').style.display = 'none';
// Create conversation if needed
if (!currentConversationId) {
const id = 'conv_' + Date.now();
const title = prompt.slice(0, 40) + (prompt.length > 40 ? '...' : '');
conversations.unshift({
id,
title,
model: currentModel,
messages: [],
createdAt: Date.now()
});
currentConversationId = id;
document.getElementById('chat-title').textContent = title;
saveConversations();
renderChatHistory();
}
const conv = getCurrentConversation();
conv.messages.push({ role: 'user', content: prompt });
// Add user message to UI
addMessageToUI('user', prompt);
// Build messages array for API
const apiMessages = conv.messages.map(m => ({
role: m.role,
content: m.content
}));
const useStream = document.getElementById('stream-toggle').checked;
addTypingIndicator();
try {
if (useStream) {
const response = await callOpenRouter(apiMessages, currentModel, true);
removeTypingIndicator();
let fullContent = '';
const isDark = document.documentElement.classList.contains('dark');
const proseClass = isDark ? 'prose-dark' : 'prose-light';
// Add empty AI message bubble
const msgDiv = document.createElement('div');
msgDiv.className = 'message-bubble chat-message-enter max-w-3xl mx-auto mb-4';
msgDiv.innerHTML = `
<div class="flex justify-start gap-3">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-brand-500 to-brand-700 flex items-center justify-center flex-shrink-0">
<i data-lucide="bot" class="w-4 h-4 text-white"></i>
</div>
<div class="max-w-[85%] sm:max-w-[70%] min-w-0">
<div class="ai-response-content bg-white dark:bg-[#12121c] border border-gray-200 dark:border-gray-700/50 rounded-2xl rounded-bl-md px-4 py-3 text-sm leading-relaxed ${proseClass}"></div>
</div>
</div>
`;
document.getElementById('messages').appendChild(msgDiv);
lucide.createIcons();
const stream = parseSSEStream(response);
for await (const chunk of stream) {
fullContent += chunk;
updateStreamingMessage(fullContent);
}
conv.messages.push({ role: 'assistant', content: fullContent });
} else {
const response = await callOpenRouter(apiMessages, currentModel, false);
const data = await response.json();
removeTypingIndicator();
const content = data.choices?.[0]?.message?.content || 'Aucune réponse reçue.';
conv.messages.push({ role: 'assistant', content });
addMessageToUI('assistant', content);
}
saveConversations();
} catch (error) {
removeTypingIndicator();
if (error.name === 'AbortError') {
// User cancelled, don't show error
} else {
const errorContent = `⚠️ **Erreur :** ${error.message || 'Une erreur est survenue lors de la communication avec l\'API.'}`;
addMessageToUI('assistant', errorContent);
}
} finally {
isGenerating = false;
currentAbortController = null;
document.getElementById('send-btn').disabled = !document.getElementById('user-input').value.trim();
}
}
// ==================== UTILITIES ====================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderMarkdown(text) {
if (!text) return '';
try {
return marked.parse(text, { breaks: true, gfm: true });
} catch (e) {
return escapeHtml(text);
}
}
function scrollToBottom() {
const container = document.getElementById('messages');
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
}
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function useSuggestion(text) {
document.getElementById('user-input').value = text;
document.getElementById('send-btn').disabled = false;
sendMessage();
}
function clearChat() {
if (!currentConversationId) return;
const conv = getCurrentConversation();
if (conv) {
conv.messages = [];
saveConversations();
renderMessages();
}
}
function exportChat() {
const conv = getCurrentConversation();
if (!conv || conv.messages.length === 0) return;
let text = `# ${conv.title}\n\nModèle : ${conv.model}\nDate : ${new Date(conv.createdAt).toLocaleString('fr-FR')}\n\n---\n\n`;
conv.messages.forEach(msg => {
const role = msg.role === 'user' ? '👤 Vous' : '🤖 Assistant';
text += `### ${role}\n\n${msg.content}\n\n---\n\n`;
});
const blob = new Blob([text], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${conv.title.replace(/[^a-zA-Z0-9]/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
}
// ==================== INIT ====================
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>