Files
localRAG/templates/chat.html
T
2026-10-08 16:08:00 +02:00

720 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Im Arbeitszimmer · Gespräch über Kafka</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;0,600;1,500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
/* Farben aus dem Bild: Nacht im Arbeitszimmer, Lampenlicht, Papier, Prager Dämmerung */
:root {
--ink: #0d0a08;
--umber-900: #14100c;
--umber-800: #1c1611;
--umber-700: #261e18;
--umber-600: #382b21;
--sepia: #8a6852;
--paper: #ecdcc0;
--paper-dim: #b9a586;
--paper-faint:#7d6c58;
--lamp: #f2b45a;
--lamp-deep: #c98a3a;
--lamp-glow: rgba(242, 180, 90, 0.16);
--dusk: #5d6280;
--ember: #d9824a;
--line: rgba(236, 220, 192, 0.09);
--avatar: url("/visuals/researcher_avatar.jpg"); /* wird je Gesprächspartner gesetzt */
--display: "Cormorant Garamond", "Cormorant", Georgia, serif;
--serif: "Source Serif 4", "Source Serif Pro", Georgia, serif;
--mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
overflow: hidden;
background: var(--ink);
color: var(--paper);
font-family: var(--serif);
-webkit-font-smoothing: antialiased;
display: grid;
grid-template-columns: minmax(380px, 46%) 1fr;
}
/* ---------- Bild ---------- */
.portrait {
position: relative;
overflow: hidden;
background: var(--umber-900);
}
.portrait img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 60% 40%;
transform: scale(1.02);
transition: opacity .35s ease;
}
.portrait img.switching { opacity: 0; }
/* sanfter Übergang ins Gespräch und Abdunklung unten für die Bildunterschrift */
.portrait::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(to right, transparent 72%, var(--ink) 100%),
linear-gradient(to top, rgba(13,10,8,0.92) 0%, rgba(13,10,8,0.55) 22%, transparent 48%),
radial-gradient(ellipse at 92% 54%, rgba(242,180,90,0.10), transparent 40%);
pointer-events: none;
}
.portrait figcaption {
position: absolute;
left: clamp(1.5rem, 4vw, 3rem);
right: 3rem;
bottom: clamp(1.5rem, 5vh, 3rem);
z-index: 1;
}
.place {
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--lamp);
margin: 0 0 0.6rem;
}
.portrait h1 {
font-family: var(--display);
font-weight: 600;
font-size: clamp(2.4rem, 4.2vw, 3.6rem);
line-height: 1;
letter-spacing: -0.01em;
margin: 0;
color: #f6ead3;
text-shadow: 0 2px 24px rgba(0,0,0,0.6);
}
.role {
font-family: var(--display);
font-style: italic;
font-size: clamp(1.1rem, 1.6vw, 1.35rem);
color: var(--paper-dim);
margin: 0.5rem 0 0;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
color: var(--paper-dim);
}
.status::before {
content: "";
width: 7px; height: 7px;
border-radius: 50%;
background: var(--lamp);
box-shadow: 0 0 10px 2px rgba(242,180,90,0.55);
}
/* ---------- Gespräch ---------- */
.chat {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
height: 100vh;
background:
radial-gradient(ellipse at 100% 0%, rgba(93,98,128,0.10), transparent 45%),
radial-gradient(ellipse at 0% 100%, rgba(242,180,90,0.05), transparent 50%),
var(--ink);
}
.chat-head {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.9rem;
padding: 1.1rem clamp(1.25rem, 3vw, 2.5rem);
border-bottom: 1px solid var(--line);
}
.chat-head .who { min-width: 0; }
.chat-head .who strong {
display: block;
font-family: var(--display);
font-weight: 600;
font-size: 1.3rem;
line-height: 1.1;
color: #f3e5cb;
}
.chat-head .who span {
white-space: nowrap;
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.06em;
color: var(--paper-faint);
}
.selectors { margin-left: auto; display: flex; align-items: center; gap: 1.1rem; }
.model {
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.06em;
color: var(--paper-faint);
}
.model select {
font: inherit;
font-size: 0.72rem;
color: var(--paper);
background: var(--umber-800);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.25rem 0.55rem;
cursor: pointer;
}
.avatar {
flex-shrink: 0;
width: 44px; height: 44px;
border-radius: 50%;
background: var(--avatar) center / cover, var(--umber-700);
box-shadow: 0 0 0 1.5px var(--lamp-deep), 0 0 18px rgba(242,180,90,0.18);
}
.avatar.small { width: 36px; height: 36px; }
#messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: clamp(1.5rem, 4vh, 2.5rem) clamp(1.25rem, 3vw, 2.5rem) 1rem;
display: flex;
flex-direction: column;
gap: 1.4rem;
scrollbar-width: thin;
scrollbar-color: var(--umber-600) transparent;
}
.row { display: flex; gap: 0.85rem; align-items: flex-start; max-width: 46rem; }
.row.assistant { align-self: flex-start; }
.row.user { align-self: flex-end; justify-content: flex-end; }
.bubble {
min-width: 0;
padding: 1rem 1.25rem;
border-radius: 4px 14px 14px 14px;
font-size: 1.02rem;
line-height: 1.65;
word-break: break-word;
}
.row.assistant .bubble {
background: linear-gradient(160deg, #241c15, #19130e);
border: 1px solid var(--line);
border-left: 2px solid var(--lamp-deep);
color: var(--paper);
box-shadow: 0 10px 30px -18px rgba(0,0,0,0.9);
}
.row.user .bubble {
border-radius: 14px 4px 14px 14px;
background: var(--lamp-glow);
border: 1px solid rgba(242,180,90,0.28);
color: #f6e9d1;
white-space: pre-wrap;
}
.row.error .bubble {
background: #2a1210;
border: 1px solid #6b2a20;
border-left: 2px solid var(--ember);
color: #f3c8b6;
white-space: pre-wrap;
}
.who-label {
display: block;
font-family: var(--display);
font-style: italic;
font-size: 1rem;
color: var(--lamp);
margin-bottom: 0.3rem;
}
.row.user .who-label { color: var(--paper-dim); text-align: right; }
/* Markdown in Antworten */
.md p { margin: 0.55em 0; }
.md p:first-child { margin-top: 0; }
.md p:last-child { margin-bottom: 0; }
.md h1, .md h2, .md h3 {
font-family: var(--display);
font-weight: 600;
font-size: 1.25em;
margin: 0.9em 0 0.3em;
color: #f3e5cb;
}
.md h1:first-child, .md h2:first-child, .md h3:first-child { margin-top: 0; }
.md ul, .md ol { margin: 0.55em 0; padding-left: 1.3em; }
.md li { margin: 0.25em 0; }
.md li::marker { color: var(--lamp-deep); }
.md strong { color: #f6e9d1; font-weight: 600; }
.md em { color: #f0dfbf; }
.md blockquote {
margin: 0.8em 0;
padding: 0.1em 0 0.1em 1em;
border-left: 2px solid var(--sepia);
color: var(--paper-dim);
font-style: italic;
}
.md a { color: var(--lamp); text-decoration-thickness: 1px; text-underline-offset: 3px; }
.md code {
font-family: var(--mono);
font-size: 0.85em;
background: rgba(0,0,0,0.35);
padding: 0.1em 0.35em;
border-radius: 3px;
}
.md pre { overflow-x: auto; background: rgba(0,0,0,0.35); padding: 0.7rem; border-radius: 6px; }
.md pre code { background: none; padding: 0; }
/* Quellen unter einer Antwort */
.sources {
margin-top: 0.9rem;
padding-top: 0.7rem;
border-top: 1px dashed rgba(236,220,192,0.14);
font-family: var(--mono);
font-size: 0.72rem;
color: var(--paper-faint);
}
.sources summary {
cursor: pointer;
list-style: none;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--paper-dim);
}
.sources summary::-webkit-details-marker { display: none; }
.sources summary::before { content: "▸ "; color: var(--lamp-deep); }
.sources[open] summary::before { content: "▾ "; }
.sources ol { margin: 0.6rem 0 0; padding-left: 1.4rem; }
.sources li { margin: 0.3rem 0; line-height: 1.45; }
.sources .src-title { color: var(--paper-dim); }
.sources .src-page { color: var(--lamp-deep); white-space: nowrap; }
.sources .searched { margin: 0.6rem 0 0; font-style: italic; font-family: var(--serif); font-size: 0.85rem; }
/* Beispielfragen */
.suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.2rem 0 0 calc(44px + 0.85rem);
max-width: 46rem;
}
.suggestions button {
font-family: var(--serif);
font-size: 0.9rem;
font-style: italic;
color: var(--paper-dim);
background: transparent;
border: 1px solid rgba(236,220,192,0.16);
border-radius: 999px;
padding: 0.45rem 0.95rem;
cursor: pointer;
transition: border-color .2s, color .2s, background .2s;
}
.suggestions button:hover {
color: var(--paper);
border-color: var(--lamp-deep);
background: rgba(242,180,90,0.06);
}
/* Ladeanzeige: Lampe glimmt */
.thinking .bubble { font-style: italic; color: var(--paper-dim); }
.thinking .pending { display: flex; align-items: center; gap: 0.7rem; }
.glow {
width: 9px; height: 9px;
border-radius: 50%;
background: var(--lamp);
box-shadow: 0 0 12px 3px rgba(242,180,90,0.5);
animation: glow 1.6s ease-in-out infinite;
}
@keyframes glow {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.1); }
}
/* ---------- Eingabe ---------- */
.composer {
flex-shrink: 0;
padding: 0.9rem clamp(1.25rem, 3vw, 2.5rem) 1.3rem;
border-top: 1px solid var(--line);
background: linear-gradient(to top, var(--umber-900), transparent);
}
.field {
display: flex;
align-items: flex-end;
gap: 0.6rem;
padding: 0.45rem 0.45rem 0.45rem 1.1rem;
background: var(--umber-800);
border: 1px solid rgba(236,220,192,0.12);
border-radius: 18px;
transition: border-color .2s, box-shadow .2s;
}
.field:focus-within {
border-color: rgba(242,180,90,0.5);
box-shadow: 0 0 0 4px rgba(242,180,90,0.07);
}
#input {
flex: 1;
min-height: 2.6rem;
max-height: 10rem;
padding: 0.6rem 0;
font-family: var(--serif);
font-size: 1.02rem;
line-height: 1.45;
color: var(--paper);
background: transparent;
border: none;
outline: none;
resize: none;
}
#input::placeholder { color: var(--paper-faint); font-style: italic; }
#send {
flex-shrink: 0;
width: 2.6rem; height: 2.6rem;
display: grid;
place-items: center;
border: none;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #ffd08a, var(--lamp) 45%, var(--lamp-deep));
color: #2a1a0b;
cursor: pointer;
box-shadow: 0 0 18px rgba(242,180,90,0.35);
transition: transform .15s, opacity .2s;
}
#send:hover { transform: translateY(-1px); }
#send:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.hint {
margin: 0.55rem 0.3rem 0;
font-family: var(--mono);
font-size: 0.64rem;
letter-spacing: 0.05em;
color: var(--paper-faint);
}
:focus-visible { outline: 2px solid var(--lamp); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.glow { animation: none; opacity: 0.8; }
#send, .suggestions button { transition: none; }
}
/* ---------- schmale Bildschirme: Bild als Kopf ---------- */
@media (max-width: 900px) {
body { display: flex; flex-direction: column; overflow: hidden; }
.portrait { flex: 0 0 auto; height: 36vh; min-height: 220px; }
.portrait::after {
background:
linear-gradient(to top, var(--ink) 0%, rgba(13,10,8,0.6) 35%, transparent 70%);
}
.portrait figcaption { bottom: 1rem; left: 1.1rem; }
.portrait h1 { font-size: 2rem; }
.role { font-size: 1rem; margin-top: 0.25rem; }
.status, .place { display: none; }
.chat { height: auto; flex: 1; min-height: 0; }
.chat-head { padding: 0.45rem 1rem; justify-content: flex-end; }
.chat-head .avatar, .chat-head .who { display: none; }
/* kompakte Menüs rechts, Beschriftung nur für Screenreader */
.selectors { margin-left: auto; gap: 0.5rem; }
.model { font-size: 0; }
.model select { font-size: 0.72rem; }
.hint { display: none; }
#messages { padding: 1rem; }
.row { max-width: 100%; }
.avatar.small { width: 30px; height: 30px; }
.suggestions { margin-left: 0; }
.bubble { font-size: 0.98rem; padding: 0.85rem 1rem; }
.composer { padding: 0.7rem 1rem 1rem; }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
</head>
<body>
<figure class="portrait" style="margin:0">
<img id="portrait-img" src="/visuals/kafka_researcher_office.jpg" alt="" />
<figcaption>
<p class="place" id="persona-place"></p>
<h1 id="persona-name"></h1>
<p class="role" id="persona-role"></p>
<span class="status" id="persona-status"></span>
</figcaption>
</figure>
<main class="chat">
<header class="chat-head">
<div class="avatar" aria-hidden="true"></div>
<div class="who">
<strong id="head-name"></strong>
<span id="head-topic"></span>
</div>
<div class="selectors">
<label class="model">
Gegenüber
<select id="persona"></select>
</label>
<label class="model">
Modell
<select id="provider">
<option value="ollama">Ollama</option>
<option value="openai">ChatGPT</option>
</select>
</label>
</div>
</header>
<div id="messages" aria-live="polite"></div>
<div class="composer">
<div class="field">
<textarea id="input" rows="1" autofocus></textarea>
<button type="button" id="send" aria-label="Frage senden">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h13M13 6l6 6-6 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<p class="hint">Enter sendet · Shift + Enter für eine neue Zeile · Antworten stützen sich auf die Texte im lokalen Korpus</p>
</div>
</main>
<script>
// Gesprächspartner (Texte, Bilder) kommen vom Server: personas.py, /api/personas
let PERSONAS = {};
let PERSONA = null;
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const providerEl = document.getElementById('provider');
const personaEl = document.getElementById('persona');
const portraitEl = document.getElementById('portrait-img');
const chatHistory = [];
// Gesprächspartner wechseln: Bild, Texte und Avatar tauschen, Gespräch neu beginnen
function applyPersona(id) {
PERSONA = PERSONAS[id];
personaEl.value = id;
document.title = PERSONA.name + ' · Gespräch über Kafka';
document.documentElement.style.setProperty('--avatar', 'url("' + PERSONA.avatar + '")');
document.getElementById('persona-place').textContent = PERSONA.place;
document.getElementById('persona-name').textContent = PERSONA.name;
document.getElementById('persona-role').textContent = PERSONA.role;
document.getElementById('persona-status').textContent = PERSONA.status;
document.getElementById('head-name').textContent = PERSONA.name;
document.getElementById('head-topic').textContent = PERSONA.topic;
inputEl.placeholder = 'Ihre Frage an ' + PERSONA.name + ' …';
portraitEl.classList.add('switching');
const img = new Image();
img.onload = () => {
portraitEl.src = PERSONA.image;
portraitEl.alt = PERSONA.image_alt;
portraitEl.style.objectPosition = PERSONA.image_position;
portraitEl.classList.remove('switching');
};
img.src = PERSONA.image;
messagesEl.innerHTML = '';
chatHistory.length = 0;
// Begrüßung (nicht Teil des Gesprächsverlaufs, der an das Modell geht)
appendMessage('assistant', PERSONA.greeting, { untracked: true });
showSuggestions();
messagesEl.scrollTop = 0;
}
(async function initPersonas() {
const res = await fetch('/api/personas');
const data = await res.json();
data.personas.forEach(p => {
PERSONAS[p.id] = p;
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.label;
personaEl.appendChild(opt);
});
applyPersona(data.default);
})();
personaEl.addEventListener('change', () => applyPersona(personaEl.value));
(async function initProviders() {
try {
const res = await fetch('/api/providers');
const data = await res.json();
if (!data.openai) {
const opt = providerEl.querySelector('option[value="openai"]');
opt.disabled = true;
opt.textContent = 'ChatGPT (aus)';
opt.title = 'OpenAI ist nicht eingerichtet (OPENAI_API_KEY fehlt)';
if (providerEl.value === 'openai') providerEl.value = 'ollama';
}
} catch (_) {}
})();
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: true }));
}
// "Binder,_Hartmut_KafkaHandbuch_Band2_AText.pdf" -> "Binder, Hartmut: Kafka Handbuch – Band2 – A Text"
function sourceLabel(path) {
const stem = (path || '').split('/').pop().replace(/\.pdf$/i, '').replace(/[_ .]+$/, '');
const tokens = stem.split('_').filter(Boolean);
const authors = [];
let i = 0;
while (i < tokens.length && tokens[i].endsWith(',')) {
authors.push(tokens[i].slice(0, -1) + (tokens[i + 1] ? ', ' + tokens[i + 1] : ''));
i += 2;
}
const title = tokens.slice(i).map(t => t.replace(/([a-zäöüß])([A-ZÄÖÜ])/g, '$1 $2')).join(' – ');
if (!authors.length) return title || stem;
return authors.join('; ') + (title ? ': ' + title : '');
}
// Quellen, die das Gegenüber über das Such-Werkzeug nachgeschlagen hat (keine Suche: keine Liste)
function sourcesHtml(retrieved, searches) {
if (!retrieved || !retrieved.length) return '';
const items = retrieved.map(c => {
const page = c.page != null ? 'S. ' + (c.page + 1) : '';
return '<li><span class="src-title">' + escapeHtml(sourceLabel(c.source)) + '</span> ' +
'<span class="src-page">' + page + '</span></li>';
}).join('');
const searched = (searches && searches.length)
? '<p class="searched">nachgeschlagen: ' + searches.map(q => '„' + escapeHtml(q) + '“').join(', ') + '</p>'
: '';
return '<details class="sources"><summary>Quellen (' + retrieved.length + ')</summary>' + searched +
'<ol>' + items + '</ol></details>';
}
function addRow(kind, innerHtml, label) {
const row = document.createElement('div');
row.className = 'row ' + kind;
const avatar = kind === 'user' ? '' : '<div class="avatar small" aria-hidden="true"></div>';
row.innerHTML = avatar + '<div class="bubble"><span class="who-label">' + label + '</span>' + innerHtml + '</div>';
messagesEl.appendChild(row);
messagesEl.scrollTop = messagesEl.scrollHeight;
return row;
}
function appendMessage(role, text, opts = {}) {
text = text ?? '';
if (opts.error) {
addRow('error assistant', escapeHtml(text), 'Hinweis');
} else if (role === 'assistant') {
addRow('assistant', '<div class="md">' + renderMarkdown(text) + '</div>' + sourcesHtml(opts.retrieved, opts.searches),
escapeHtml(PERSONA.name));
} else {
addRow('user', escapeHtml(text), 'Sie');
}
if (!opts.untracked) chatHistory.push({ role: role, content: text });
}
function setLoading(show) {
if (show) {
const row = addRow('assistant thinking', '<span class="pending"><span class="glow" aria-hidden="true"></span>überlegt …</span>',
escapeHtml(PERSONA.name));
row.id = 'thinking';
} else {
const el = document.getElementById('thinking');
if (el) el.remove();
}
sendBtn.disabled = show;
personaEl.disabled = show; // während einer Antwort nicht wechseln
}
function showSuggestions() {
const box = document.createElement('div');
box.className = 'suggestions';
box.id = 'suggestions';
PERSONA.suggestions.forEach(q => {
const b = document.createElement('button');
b.type = 'button';
b.textContent = q;
b.addEventListener('click', () => { inputEl.value = q; send(); });
box.appendChild(b);
});
messagesEl.appendChild(box);
}
function autosize() {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
}
async function send() {
const text = inputEl.value.trim();
if (!text || sendBtn.disabled) return;
inputEl.value = '';
autosize();
const sugg = document.getElementById('suggestions');
if (sugg) sugg.remove();
appendMessage('user', text);
setLoading(true);
try {
const history = chatHistory.slice(0, -1);
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, history: history, llm_provider: providerEl.value,
persona: personaEl.value })
});
const data = await res.json();
setLoading(false);
if (data.retrieved && data.retrieved.length > 0) {
console.groupCollapsed('[RAG] Retrieved ' + data.retrieved.length + ' chunk(s)');
data.retrieved.forEach(function (chunk, i) {
console.group('Chunk ' + (i + 1) + ' | ' + (chunk.source || '') + (chunk.page != null ? ' p.' + chunk.page : ''));
console.log(chunk.content);
console.groupEnd();
});
console.groupEnd();
}
if (data.error) {
appendMessage('assistant', data.error, { error: true, untracked: true });
} else {
appendMessage('assistant', (data.answer || '(Keine Antwort)').trim(),
{ retrieved: data.retrieved, searches: data.searches });
}
} catch (err) {
setLoading(false);
appendMessage('assistant', 'Die Anfrage ist fehlgeschlagen: ' + err.message, { error: true, untracked: true });
}
inputEl.focus();
}
sendBtn.addEventListener('click', send);
inputEl.addEventListener('input', autosize);
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
});
</script>
</body>
</html>