415 lines
16 KiB
HTML
415 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>AI Chat Assistant</title>
|
|
<!-- Tailwind CSS for styling -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<!-- Lucide Icons -->
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
<!-- Marked.js for Markdown parsing -->
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<!-- Highlight.js for syntax highlighting -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
<style>
|
|
.scroll-smooth {
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
/* Custom scrollbar */
|
|
::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: #4b5563;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: #6b7280;
|
|
}
|
|
|
|
.message-animation {
|
|
animation: fadeIn 0.2s ease-out;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
/* Message content styling */
|
|
.message-content {
|
|
overflow-wrap: break-word;
|
|
word-wrap: break-word;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.message-content pre {
|
|
overflow-x: auto;
|
|
margin: 0.5rem 0;
|
|
padding: 1rem;
|
|
background-color: #1f2937;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid #374151;
|
|
}
|
|
|
|
.message-content code {
|
|
font-family: 'Courier New', Courier, monospace;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.message-content pre code {
|
|
background: transparent;
|
|
padding: 0;
|
|
border-radius: 0;
|
|
}
|
|
|
|
.message-content p {
|
|
margin: 0.5rem 0;
|
|
}
|
|
|
|
.message-content p:first-child {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.message-content p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.message-content ul,
|
|
.message-content ol {
|
|
margin: 0.5rem 0;
|
|
padding-left: 1.5rem;
|
|
}
|
|
|
|
.message-content li {
|
|
margin: 0.25rem 0;
|
|
}
|
|
|
|
.message-content blockquote {
|
|
border-left: 3px solid #4b5563;
|
|
padding-left: 1rem;
|
|
margin: 0.5rem 0;
|
|
color: #9ca3af;
|
|
}
|
|
|
|
/* Inline code */
|
|
.message-content :not(pre)>code {
|
|
background-color: #374151;
|
|
padding: 0.125rem 0.375rem;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body class="bg-gray-900 text-gray-100 font-sans h-screen flex flex-col overflow-hidden">
|
|
|
|
<!-- Header -->
|
|
<header class="flex items-center justify-between px-6 py-4 bg-gray-800 border-b border-gray-700 shadow-sm z-10">
|
|
<div class="flex items-center gap-2">
|
|
<div class="p-2 bg-indigo-600 rounded-lg shadow-blue-200 shadow-lg">
|
|
<i data-lucide="bot" class="text-white w-6 h-6"></i>
|
|
</div>
|
|
<div>
|
|
<h1 class="text-lg font-bold tracking-tight text-gray-100 leading-none">AI Assistant</h1>
|
|
<span class="text-[10px] text-green-500 font-medium uppercase tracking-tighter">● Online</span>
|
|
</div>
|
|
</div>
|
|
<button id="clear-btn"
|
|
class="p-2 text-gray-400 hover:text-red-400 hover:bg-gray-700 rounded-full transition-all"
|
|
title="Clear chat">
|
|
<i data-lucide="trash-2" class="w-5 h-5"></i>
|
|
</button>
|
|
</header>
|
|
|
|
<!-- Message display area -->
|
|
<main id="chat-window" class="flex-1 overflow-y-auto px-4 py-8 scroll-smooth">
|
|
<div id="messages-container" class="w-full space-y-6">
|
|
<!-- Messages will be inserted here -->
|
|
</div>
|
|
|
|
<!-- Loading indicator (initially hidden) -->
|
|
<div id="loading-indicator" class="hidden w-full mt-6">
|
|
<div class="flex gap-4">
|
|
<div
|
|
class="flex-shrink-0 w-8 h-8 rounded-full bg-gray-700 border border-gray-600 flex items-center justify-center shadow-sm">
|
|
<i data-lucide="bot" class="text-indigo-400 w-4 h-4"></i>
|
|
</div>
|
|
<div
|
|
class="bg-gray-800 border border-gray-700 px-4 py-3 rounded-2xl rounded-tl-none shadow-sm flex items-center gap-3">
|
|
<div class="flex gap-1">
|
|
<span
|
|
class="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]"></span>
|
|
<span
|
|
class="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]"></span>
|
|
<span class="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce"></span>
|
|
</div>
|
|
<span class="text-gray-400 text-xs font-medium">AI is thinking...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error display area -->
|
|
<div id="error-container" class="hidden w-full mt-6">
|
|
<!-- Error content will be inserted here -->
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Input area -->
|
|
<footer class="bg-gray-800 border-t border-gray-700 p-4">
|
|
<div class="w-full px-2">
|
|
<div
|
|
class="relative flex items-end gap-2 bg-gray-700 border border-gray-600 rounded-2xl p-1.5 focus-within:ring-2 focus-within:ring-indigo-500 focus-within:bg-gray-700 transition-all shadow-inner">
|
|
<textarea id="user-input" placeholder="Type your message..." rows="1"
|
|
class="flex-1 bg-transparent border-none focus:ring-0 text-sm py-2 px-3 resize-none max-h-60 min-h-[40px] outline-none text-gray-100 placeholder-gray-400"></textarea>
|
|
<button id="send-btn" class="p-2.5 rounded-xl transition-all text-gray-300 disabled:cursor-not-allowed"
|
|
disabled>
|
|
<i data-lucide="send" id="send-icon" class="w-5 h-5"></i>
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="flex justify-between items-center mt-3 px-1 text-[10px] text-gray-400 font-medium uppercase tracking-widest">
|
|
<span id="model-name">Model: loading...</span>
|
|
<span id="api-status">API Status: Ready</span>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
|
|
<script>
|
|
const API_URL = 'https://lmstudio.yasue.org/v1/chat/completions';
|
|
|
|
// Read API key from URL query parameter
|
|
function getApiKeyFromUrl() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
return urlParams.get('apikey');
|
|
}
|
|
|
|
const API_KEY = getApiKeyFromUrl();
|
|
const MODEL = 'openai/gpt-oss-20b';
|
|
|
|
let messages = [
|
|
{ role: 'assistant', content: 'Hello! How can I help you today?' }
|
|
];
|
|
let isLoading = false;
|
|
|
|
const chatWindow = document.getElementById('chat-window');
|
|
const container = document.getElementById('messages-container');
|
|
const userInput = document.getElementById('user-input');
|
|
const sendBtn = document.getElementById('send-btn');
|
|
const clearBtn = document.getElementById('clear-btn');
|
|
const loadingIndicator = document.getElementById('loading-indicator');
|
|
const errorContainer = document.getElementById('error-container');
|
|
const modelLabel = document.getElementById('model-name');
|
|
|
|
modelLabel.textContent = `Model: ${MODEL}`;
|
|
|
|
// Configure marked.js
|
|
marked.setOptions({
|
|
breaks: true,
|
|
gfm: true,
|
|
headerIds: false,
|
|
mangle: false
|
|
});
|
|
|
|
// アイコンの初期化
|
|
function initIcons() {
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// Format message content
|
|
function formatMessage(content, isUser) {
|
|
if (isUser) {
|
|
// User messages: preserve formatting but allow line breaks
|
|
return `<div class="message-content whitespace-pre-wrap">${escapeHtml(content)}</div>`;
|
|
} else {
|
|
// AI messages: parse as Markdown
|
|
try {
|
|
const html = marked.parse(content);
|
|
return `<div class="message-content">${html}</div>`;
|
|
} catch (e) {
|
|
console.error('Markdown parsing error:', e);
|
|
return `<div class="message-content whitespace-pre-wrap">${escapeHtml(content)}</div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Escape HTML to prevent XSS
|
|
function escapeHtml(text) {
|
|
const map = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
};
|
|
return text.replace(/[&<>"']/g, m => map[m]);
|
|
}
|
|
|
|
// メッセージのレンダリング
|
|
function renderMessages() {
|
|
container.innerHTML = '';
|
|
messages.forEach((msg) => {
|
|
const isUser = msg.role === 'user';
|
|
const formattedContent = formatMessage(msg.content, isUser);
|
|
const messageDiv = document.createElement('div');
|
|
messageDiv.className = `flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} message-animation`;
|
|
messageDiv.innerHTML = `
|
|
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center shadow-sm ${isUser ? 'bg-indigo-600' : 'bg-gray-700 border border-gray-600'}">
|
|
<i data-lucide="${isUser ? 'user' : 'bot'}" class="${isUser ? 'text-white' : 'text-indigo-400'} w-4 h-4"></i>
|
|
</div>
|
|
<div class="flex flex-col flex-1">
|
|
<div class="px-4 py-2.5 rounded-2xl shadow-sm text-sm w-full overflow-hidden ${isUser ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-gray-800 text-gray-100 border border-gray-700 rounded-tl-none'}">
|
|
${formattedContent}
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(messageDiv);
|
|
});
|
|
|
|
// Apply syntax highlighting to code blocks
|
|
document.querySelectorAll('pre code').forEach((block) => {
|
|
hljs.highlightElement(block);
|
|
});
|
|
|
|
initIcons();
|
|
scrollToBottom();
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
}
|
|
|
|
function showError(message) {
|
|
errorContainer.innerHTML = `
|
|
<div class="flex flex-col gap-2 p-4 bg-red-900 bg-opacity-30 border border-red-800 rounded-xl text-red-400">
|
|
<div class="flex items-center gap-2 font-bold text-sm">
|
|
<i data-lucide="alert-circle" class="w-4 h-4"></i>
|
|
<span>An error occurred</span>
|
|
</div>
|
|
<p class="text-xs opacity-90 leading-relaxed">${message}</p>
|
|
${message.includes('Failed to fetch') ? `
|
|
<div class="mt-2 text-[10px] bg-red-950 bg-opacity-50 p-2 rounded flex gap-2 items-start">
|
|
<i data-lucide="info" class="w-3 h-3 mt-0.5 flex-shrink-0"></i>
|
|
<span>Hint: API calls from external domains are being blocked by the browser. Set the Access-Control-Allow-Origin header on the server side, or try using a CORS-disabling browser extension during development.</span>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
errorContainer.classList.remove('hidden');
|
|
initIcons();
|
|
scrollToBottom();
|
|
}
|
|
|
|
async function handleSend() {
|
|
const content = userInput.value.trim();
|
|
if (!content || isLoading) return;
|
|
|
|
// ユーザーメッセージを追加
|
|
const userMsg = { role: 'user', content };
|
|
messages.push(userMsg);
|
|
|
|
userInput.value = '';
|
|
userInput.style.height = 'auto';
|
|
isLoading = true;
|
|
errorContainer.classList.add('hidden');
|
|
updateUIState();
|
|
renderMessages();
|
|
|
|
try {
|
|
const response = await fetch(API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Api-Key': API_KEY,
|
|
},
|
|
mode: 'cors',
|
|
body: JSON.stringify({
|
|
model: MODEL,
|
|
messages: messages,
|
|
temperature: 0.7,
|
|
max_tokens: 10000
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.error?.message || `API error (status: ${response.status})`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data.choices && data.choices[0]?.message) {
|
|
messages.push(data.choices[0].message);
|
|
} else {
|
|
throw new Error('Invalid response format.');
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
showError(err.message === 'Failed to fetch' ? 'Failed to connect to API. Please check CORS restrictions.' : err.message);
|
|
} finally {
|
|
isLoading = false;
|
|
updateUIState();
|
|
renderMessages();
|
|
}
|
|
}
|
|
|
|
function updateUIState() {
|
|
const isEmpty = userInput.value.trim() === '';
|
|
sendBtn.disabled = isLoading || isEmpty;
|
|
|
|
if (sendBtn.disabled) {
|
|
sendBtn.classList.replace('text-white', 'text-gray-300');
|
|
sendBtn.classList.remove('bg-indigo-600', 'hover:bg-indigo-700', 'shadow-md');
|
|
} else {
|
|
sendBtn.classList.replace('text-gray-300', 'text-white');
|
|
sendBtn.classList.add('bg-indigo-600', 'hover:bg-indigo-700', 'shadow-md');
|
|
}
|
|
|
|
loadingIndicator.classList.toggle('hidden', !isLoading);
|
|
}
|
|
|
|
// イベントリスナー
|
|
userInput.addEventListener('input', () => {
|
|
userInput.style.height = 'auto';
|
|
userInput.style.height = userInput.scrollHeight + 'px';
|
|
updateUIState();
|
|
});
|
|
|
|
userInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
});
|
|
|
|
sendBtn.addEventListener('click', handleSend);
|
|
|
|
clearBtn.addEventListener('click', () => {
|
|
messages = [{ role: 'assistant', content: 'Chat has been reset. How can I help you?' }];
|
|
errorContainer.classList.add('hidden');
|
|
renderMessages();
|
|
});
|
|
|
|
// Initial render
|
|
renderMessages();
|
|
initIcons();
|
|
</script>
|
|
</body>
|
|
|
|
</html> |