720 lines
24 KiB
HTML
720 lines
24 KiB
HTML
<!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>
|