| <!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"> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div class="flex-1 overflow-y-auto p-3 space-y-1" id="chat-history"> |
| </div> |
|
|
| |
| <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 class="flex-1 flex flex-col h-full min-w-0"> |
| |
| <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> |
|
|
| |
| <div id="messages" class="flex-1 overflow-y-auto px-4 py-6"> |
| |
| <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> |
|
|
| |
| <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> |
| |
| 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') || ''; |
| |
| |
| 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' }, |
| ]; |
| |
| |
| 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(); |
| } |
| |
| |
| function init() { |
| lucide.createIcons(); |
| renderChatHistory(); |
| renderModelList(); |
| updateThemeIcons(); |
| updateApiKeyStatus(); |
| setupInputListener(); |
| |
| |
| 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; |
| }); |
| } |
| |
| |
| 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)); |
| } |
| |
| |
| const savedTheme = localStorage.getItem('puter_theme') || 'dark'; |
| if (savedTheme === 'light') { |
| document.documentElement.classList.remove('dark'); |
| } |
| |
| |
| 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'); |
| } |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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'); |
| } |
| }); |
| |
| |
| 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(); |
| } |
| |
| |
| 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(); |
| } |
| } |
| |
| |
| 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) { |
| |
| } |
| } |
| } |
| } |
| }; |
| } |
| |
| |
| 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); |
| |
| |
| document.getElementById('welcome-screen').style.display = 'none'; |
| |
| |
| 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 }); |
| |
| |
| addMessageToUI('user', prompt); |
| |
| |
| 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'; |
| |
| |
| 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') { |
| |
| } 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(); |
| } |
| } |
| |
| |
| 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); |
| } |
| |
| |
| document.addEventListener('DOMContentLoaded', init); |
| </script> |
|
|
| </body> |
| </html> |