Eigenes Chat-Interface mit SSE-Streaming gegen das hinterlegte Ollama-Modell, gegroundet in den Bewerbungs-/E-Mail-/Termindaten des Nutzers. - lib/chat.js: streamChat (Ollama stream:true, NDJSON-Token) + buildContextPrompt - chat_threads/chat_messages Tabellen (CASCADE, Index) - Routen: GET /chat, Thread-CRUD, POST /messages (SSE, AbortController) - views/chat.ejs + public/js/chat.js + Floating-Button im Footer - hasApiKey-Gating (503 ohne OLLAMA_API_KEY) Co-Authored-By: Claude <noreply@anthropic.com>
114 lines
6.5 KiB
Plaintext
114 lines
6.5 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<%- include('partials/head') %>
|
|
<style>
|
|
.chat-msg { white-space: pre-wrap; word-break: break-word; }
|
|
#chatLog::-webkit-scrollbar { width: 8px; }
|
|
#chatLog::-webkit-scrollbar-thumb { background: rgba(120,120,120,.4); border-radius: 4px; }
|
|
.typing-dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; display: inline-block; }
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
|
<%- include('partials/header', { hideSettings: true }) %>
|
|
|
|
<main class="container mx-auto px-4 py-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-2xl font-bold text-gray-800 dark:text-white">KI-Bewerbungs-Assistent</h2>
|
|
<button id="newThreadBtn"
|
|
class="px-3 py-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium">
|
|
+ Neuer Chat
|
|
</button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-4 h-[calc(100vh-220px)] min-h-[420px]">
|
|
<!-- Thread sidebar -->
|
|
<aside class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden flex flex-col">
|
|
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">Verläufe</div>
|
|
<ul id="threadList" class="overflow-y-auto flex-1 divide-y divide-gray-100 dark:divide-gray-700">
|
|
<% threads.forEach(function(t) { %>
|
|
<li>
|
|
<a href="/chat?thread=<%= t.id %>"
|
|
data-thread="<%= t.id %>"
|
|
class="threadLink block px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/40 <%= (t.id === activeId) ? 'bg-blue-50 dark:bg-gray-700' : '' %>">
|
|
<div class="truncate font-medium"><%= t.titel || '(ohne Titel)' %></div>
|
|
<div class="text-[11px] text-gray-400"><%= new Date(t.updated_at).toLocaleDateString('de-DE') %></div>
|
|
</a>
|
|
</li>
|
|
<% }); %>
|
|
<% if (!threads.length) { %>
|
|
<li class="px-3 py-4 text-sm text-gray-400 text-center">Noch keine Verläufe.</li>
|
|
<% } %>
|
|
</ul>
|
|
</aside>
|
|
|
|
<!-- Message area -->
|
|
<section class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex flex-col overflow-hidden">
|
|
<div id="chatLog" class="flex-1 overflow-y-auto p-4 space-y-3 text-gray-800 dark:text-gray-100">
|
|
<% if (!activeId) { %>
|
|
<div class="h-full flex items-center justify-center text-gray-400 text-sm text-center px-6">
|
|
Starte einen neuen Chat, um deinen Bewerbungs-Assistenten zu fragen.
|
|
</div>
|
|
<% } else if (!messages.length) { %>
|
|
<div class="h-full flex items-center justify-center text-gray-400 text-sm text-center px-6">
|
|
Stelle die erste Frage zu deinen Bewerbungen.
|
|
</div>
|
|
<% } else { %>
|
|
<% messages.forEach(function(m) { %>
|
|
<div class="flex <%= m.role === 'user' ? 'justify-end' : 'justify-start' %>">
|
|
<div class="max-w-[80%] rounded-lg px-3 py-2 text-sm chat-msg
|
|
<%= m.role === 'user'
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100' %>">
|
|
<%= m.content %>
|
|
</div>
|
|
</div>
|
|
<% }); %>
|
|
<% } %>
|
|
</div>
|
|
|
|
<form id="chatForm" class="border-t border-gray-200 dark:border-gray-700 p-3 flex gap-2"
|
|
<%= activeId ? '' : 'data-needs-thread="1"' %>>
|
|
<textarea id="chatInput" rows="1" autocomplete="off"
|
|
class="flex-1 resize-none rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="Frage stellen … (Enter zum Senden, Shift+Enter = Zeilenumbruch)"
|
|
<%= activeId ? '' : 'disabled' %>></textarea>
|
|
<button type="submit"
|
|
class="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium disabled:opacity-50"
|
|
id="sendBtn" <%= activeId ? '' : 'disabled' %>>Senden</button>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<input type="hidden" id="activeThread" value="<%= activeId || '' %>">
|
|
|
|
<script>
|
|
// Minimal dark-mode toggle: main.js is not loaded on the chat page, so
|
|
// wire the header's toggle + sync the sun/moon icon visibility here.
|
|
(function () {
|
|
var sun = document.getElementById('sunIcon');
|
|
var moon = document.getElementById('moonIcon');
|
|
var isDark = document.documentElement.classList.contains('dark');
|
|
function sync() {
|
|
sun && sun.classList.toggle('hidden', isDark);
|
|
moon && moon.classList.toggle('hidden', !isDark);
|
|
}
|
|
sync();
|
|
var btn = document.getElementById('darkModeToggle');
|
|
if (btn) btn.addEventListener('click', function () {
|
|
isDark = !document.documentElement.classList.toggle('dark');
|
|
// toggle returns whether class is present AFTER toggle; recompute.
|
|
isDark = document.documentElement.classList.contains('dark');
|
|
localStorage.setItem('darkMode', isDark ? 'enabled' : 'disabled');
|
|
sync();
|
|
});
|
|
})();
|
|
window.__CHAT_BOOT__ = {
|
|
activeId: <%= activeId ? JSON.stringify(activeId) : 'null' %>,
|
|
needsThread: <%= activeId ? 'false' : 'true' %>
|
|
};
|
|
</script>
|
|
<script src="/js/chat.js"></script>
|
|
</body>
|
|
</html> |