- eval mit gpt-6-luna
- new chat interface with personas
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "kafka-chat",
|
||||
"runtimeExecutable": "/Users/Philipp/opt/anaconda3/envs/docling/bin/python",
|
||||
"runtimeArgs": ["-m", "uvicorn", "server:app", "--port", "8010"],
|
||||
"port": 8010
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -52,7 +52,7 @@ ollama serve
|
||||
In another terminal, pull a model once (example):
|
||||
|
||||
```bash
|
||||
ollama pull gpt-oss:20b
|
||||
ollama pull gemma4:12b
|
||||
```
|
||||
|
||||
The model name must match what you configure in `server.py` (see [Configuration reference](#configuration-reference)).
|
||||
@@ -170,9 +170,9 @@ Each entry in `retrieved` has:
|
||||
Every answer reports which chunks it was based on, in two places:
|
||||
|
||||
- **Browser:** open the developer console (Chrome/Edge: `F12` or `Cmd`+`Option`+`I`, Firefox: `F12`) before sending a question. Each answer logs a collapsed group `[RAG] Retrieved N chunk(s)` with the full text, source file and page of every chunk.
|
||||
- **Server terminal:** the terminal running `uvicorn` prints the rephrased standalone query, the retrieval query, the L2 distances, a 150-character preview of each chunk, and the raw LLM response.
|
||||
- **Server terminal:** the terminal running `uvicorn` prints the search queries the LLM sent to the corpus search tool (or that it did not search), the scores, a 150-character preview of each chunk, and the raw LLM response.
|
||||
|
||||
Use this when an answer looks wrong: if the right chunks were never retrieved, tune `RETRIEVAL_K`, `QUERY_ADDITIONAL_INSTRUCTIONS` or the chunk size; if the chunks were correct but the answer was not, tune `ANSWER_PROMPT` (see [Configuration reference](#configuration-reference)).
|
||||
Use this when an answer looks wrong: if the right chunks were never retrieved, tune `RETRIEVAL_K`, `QUERY_ADDITIONAL_INSTRUCTIONS` or the chunk size; if the chunks were correct but the answer was not, or the LLM searched when it should not (or vice versa), tune `SYSTEM_PROMPT` (see [Configuration reference](#configuration-reference)).
|
||||
|
||||
---
|
||||
|
||||
@@ -194,7 +194,7 @@ It loads the existing vector store, lists the documents in it, and runs **one ha
|
||||
2. **Chunk** – `RecursiveCharacterTextSplitter` (defaults in [local_rag.py](local_rag.py)).
|
||||
3. **Embed** – Hugging Face `sentence-transformers/all-MiniLM-L6-v2`.
|
||||
4. **Store** – FAISS; retrieval uses `similarity_search_with_score`.
|
||||
5. **Query** – Optional rephrase with chat history, retrieval, then answer from the LLM.
|
||||
5. **Query** – The corpus search is a tool the LLM may call (`search_kafka_corpus`). It decides per message whether to search, writes the query itself from the chat history, and answers from the excerpts it found; greetings or thanks are answered without a search.
|
||||
|
||||
---
|
||||
|
||||
@@ -227,8 +227,8 @@ Changing the embedding model or chunk settings requires **rebuilding** the vecto
|
||||
|------|--------|
|
||||
| Default number of chunks retrieved (`k`) | Module-level `RETRIEVAL_K` (overrides: pass `k=` to `query` / `query_with_history`) |
|
||||
| Extra text appended only to the **FAISS query** (biases retrieval, not the final answer phrasing) | `QUERY_ADDITIONAL_INSTRUCTIONS` (concatenated to the search query before embedding) |
|
||||
| **Rephrase** prompt (standalone question when there is chat history) | String `rephrase_prompt = f"""..."""` inside `query_with_history` |
|
||||
| **Answer** prompt – opening instructions only | Module-level `ANSWER_PROMPT` (edit the role / style lines). The block from chat history through `Answer:` is built in `query_with_history` |
|
||||
| **System** prompt – when to search, how to answer | Module-level `SYSTEM_PROMPT` |
|
||||
| Search tool name, description, searches per message | `SEARCH_TOOL_NAME`, `SEARCH_TOOL_DESCRIPTION`, `MAX_TOOL_ROUNDS` |
|
||||
|
||||
### [add_pdfs.py](add_pdfs.py)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from local_rag import CHUNK_OVERLAP, CHUNK_SIZE, PDF_LOADER, PROCESSED_DIR, Loca
|
||||
# Folder to scan for PDFs (recursively).
|
||||
DATA_ROOT = Path("/Users/Philipp/Desktop/workspace/python/gpt_publikationen/data_vs")
|
||||
|
||||
# The chat (server.py) currently loads ./vectorstore_pypdf_2000; pass --index to build that one.
|
||||
# The chat uses the vector stores named in personas.py (currently ./vectorstore_pypdf_2000); pass --index to build one.
|
||||
VECTORSTORE_PATH = "./vectorstore"
|
||||
|
||||
# If True: delete the existing vector store folder, then index all PDFs from scratch.
|
||||
|
||||
@@ -39,7 +39,7 @@ GOLDSET_PATH = Path("eval/goldset.csv")
|
||||
# Wohin der Bericht geschrieben wird. Diese Datei gehört ins Repository.
|
||||
REPORT_PATH = Path("eval/baseline.txt")
|
||||
|
||||
# Muss mit server.py übereinstimmen, sonst wird ein anderer Index gemessen.
|
||||
# Muss mit dem Index in personas.py übereinstimmen, sonst wird ein anderer Index gemessen.
|
||||
VECTORSTORE_PATH = "./vectorstore_pypdf_2000"
|
||||
|
||||
# Für diese k-Werte wird Recall@k berechnet.
|
||||
|
||||
@@ -66,10 +66,25 @@ QUERY_ADDITIONAL_INSTRUCTIONS = ""
|
||||
# Written next to every index; an index built with another embedding model is not loaded.
|
||||
INDEX_INFO_FILE = "index_info.json"
|
||||
|
||||
# Opening instructions for the final answer LLM call only. The rest (history, context, question)
|
||||
# is assembled in query_with_history.
|
||||
ANSWER_PROMPT = """You are an assistant for question-answering. Use the chat history (if any) and the retrieved context below to answer the current question.
|
||||
If you don't know the answer, say so. Keep the conversation coherent."""
|
||||
# The corpus search is offered to the LLM as a tool; the LLM decides per message whether
|
||||
# to search (content questions) or to answer directly (greetings, thanks, questions about
|
||||
# the conversation). It also writes the search query itself, using the chat history.
|
||||
SEARCH_TOOL_NAME = "search_kafka_corpus"
|
||||
SEARCH_TOOL_DESCRIPTION = (
|
||||
"Durchsucht die Kafka-Bibliothek (Kafkas Texte und die Forschungsliteratur) und liefert die "
|
||||
"passendsten Textauszüge mit Quelle und Seite. Die Suchanfrage sollte eine eigenständige, "
|
||||
"präzise Frage oder Stichwortfolge auf Deutsch sein."
|
||||
)
|
||||
MAX_TOOL_ROUNDS = 3 # searches per message before the LLM must answer
|
||||
|
||||
# System prompt for every chat turn (German, like the corpus and the questions).
|
||||
SYSTEM_PROMPT = f"""Du beantwortest Fragen zu Franz Kafka, seinen Werken, seinem Leben und der Forschung.
|
||||
Du hast ein Werkzeug, {SEARCH_TOOL_NAME}, das eine Bibliothek aus Kafkas Texten und Forschungsliteratur durchsucht.
|
||||
- Für jede inhaltliche Frage zu Kafka, seinen Werken, Figuren, seiner Biografie, Sprache oder zur Forschung: Rufe zuerst das Werkzeug auf und stütze die Antwort auf die gefundenen Auszüge. Antworte nicht aus dem Gedächtnis.
|
||||
- Bei Begrüßung, Dank, Smalltalk oder Fragen zum Gespräch selbst: Antworte direkt, ohne Werkzeug.
|
||||
- Formuliere die Suchanfrage so, dass sie ohne den Gesprächsverlauf verständlich ist.
|
||||
- Finden sich in den Auszügen keine Belege, sage das offen, statt zu raten.
|
||||
- Nenne bei Aussagen aus den Auszügen die Quelle (Autor, Seite)."""
|
||||
|
||||
|
||||
class LocalRAG:
|
||||
@@ -77,7 +92,7 @@ class LocalRAG:
|
||||
self,
|
||||
vectorstore_path="./vectorstore",
|
||||
llm_provider="ollama",
|
||||
ollama_model="gpt-oss:20b",
|
||||
ollama_model="gemma4:12b",
|
||||
openai_model="gpt-6-luna",
|
||||
ollama_base_url="http://localhost:11434",
|
||||
pdf_loader=PDF_LOADER,
|
||||
@@ -277,18 +292,6 @@ class LocalRAG:
|
||||
|
||||
return doc_list
|
||||
|
||||
def _format_history(self, chat_history):
|
||||
"""Format chat history as a string for prompts."""
|
||||
lines = []
|
||||
for turn in chat_history or []:
|
||||
role = (turn.get("role") or "").lower()
|
||||
content = (turn.get("content") or "").strip()
|
||||
if role == "user":
|
||||
lines.append(f"User: {content}")
|
||||
elif role == "assistant":
|
||||
lines.append(f"Assistant: {content}")
|
||||
return "\n".join(lines) if lines else ""
|
||||
|
||||
def _docs_to_retrieved(self, docs):
|
||||
"""Convert document list to retrieved chunks format for API."""
|
||||
return [
|
||||
@@ -376,73 +379,74 @@ class LocalRAG:
|
||||
k = RETRIEVAL_K
|
||||
return self.query_with_history(question, chat_history=[], k=k)
|
||||
|
||||
def query_with_history(self, question, chat_history=None, k=None):
|
||||
"""Query the RAG with conversation history: rephrase question using history for retrieval,
|
||||
then answer with full conversation + retrieved context in the prompt.
|
||||
Returns dict with 'answer' and 'retrieved' (list of chunks with content, source, page).
|
||||
"""
|
||||
if k is None:
|
||||
k = RETRIEVAL_K
|
||||
@staticmethod
|
||||
def _text(message):
|
||||
"""Plain text of an LLM reply (some models return a list of content blocks)."""
|
||||
content = getattr(message, "content", message)
|
||||
if isinstance(content, list):
|
||||
content = "".join(part.get("text", "") if isinstance(part, dict) else str(part) for part in content)
|
||||
return (content or "").strip()
|
||||
|
||||
def query_with_history(self, question, chat_history=None, k=None, system_prompt=None):
|
||||
"""Answer a chat message. The LLM decides whether to search the corpus: the search is
|
||||
offered as a tool (SEARCH_TOOL_NAME), which the LLM calls with its own query, up to
|
||||
MAX_TOOL_ROUNDS times. system_prompt defaults to SYSTEM_PROMPT (see personas.py for others).
|
||||
Returns dict with 'answer', 'retrieved' (chunks the LLM saw, with content, source, page,
|
||||
score) and 'searches' (the queries it used)."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.tools import StructuredTool
|
||||
|
||||
k = k or RETRIEVAL_K
|
||||
if self.vectorstore is None:
|
||||
return {
|
||||
"answer": "Error: No documents loaded. Please add documents first.",
|
||||
"retrieved": [],
|
||||
}
|
||||
return {"answer": "Error: No documents loaded. Please add documents first.",
|
||||
"retrieved": [], "searches": []}
|
||||
|
||||
history_str = self._format_history(chat_history)
|
||||
search_query = question
|
||||
retrieved, searches = [], []
|
||||
|
||||
def search_kafka_corpus(query: str) -> str:
|
||||
retrieval_query = query + (f"\n\n{QUERY_ADDITIONAL_INSTRUCTIONS}" if QUERY_ADDITIONAL_INSTRUCTIONS else "")
|
||||
hits = self.retrieve(retrieval_query, k=k)
|
||||
searches.append(query)
|
||||
retrieved.extend(self._docs_scores_to_retrieved(hits))
|
||||
print(f"[RAG] Tool search {query!r}: {len(hits)} chunk(s)")
|
||||
if not hits:
|
||||
return "Keine passenden Textstellen gefunden."
|
||||
return "\n\n".join(
|
||||
f"[{i}] {docling_loader.source_label(Path(d.metadata.get('source', '')).name)}"
|
||||
f"{', S. ' + str(d.metadata['page'] + 1) if d.metadata.get('page') is not None else ''}\n"
|
||||
f"{d.page_content.strip()}"
|
||||
for i, (d, _) in enumerate(hits, start=1))
|
||||
|
||||
tool = StructuredTool.from_function(func=search_kafka_corpus, name=SEARCH_TOOL_NAME,
|
||||
description=SEARCH_TOOL_DESCRIPTION)
|
||||
llm_with_tools = self.llm.bind_tools([tool])
|
||||
|
||||
messages = [SystemMessage(system_prompt or SYSTEM_PROMPT)]
|
||||
for turn in chat_history or []:
|
||||
content = (turn.get("content") or "").strip()
|
||||
if turn.get("role") == "user":
|
||||
messages.append(HumanMessage(content))
|
||||
elif turn.get("role") == "assistant":
|
||||
messages.append(AIMessage(content))
|
||||
messages.append(HumanMessage(question))
|
||||
print(f"[RAG] User question: {question!r}")
|
||||
|
||||
# 1) If we have history, rephrase the question into a standalone query for better retrieval
|
||||
if history_str.strip():
|
||||
rephrase_prompt = f"""Given this chat history and the latest user question, write a single standalone question that captures what the user is asking.
|
||||
Do not answer it; only output the standalone question. If the latest question is already clear on its own, output it unchanged.
|
||||
|
||||
Chat history:
|
||||
{history_str}
|
||||
|
||||
Latest user question: {question}
|
||||
|
||||
Standalone question:"""
|
||||
rephrase_response = self.llm.invoke(rephrase_prompt)
|
||||
search_query = (rephrase_response.content if hasattr(rephrase_response, "content") else str(rephrase_response)).strip() or question
|
||||
print(f"[RAG] Standalone search query (rephrased): {search_query!r}")
|
||||
|
||||
retrieval_query = search_query
|
||||
if QUERY_ADDITIONAL_INSTRUCTIONS:
|
||||
retrieval_query += f"\n\n{QUERY_ADDITIONAL_INSTRUCTIONS}"
|
||||
print(f"[RAG] Search query: {search_query!r}")
|
||||
print(f"[RAG] Retrieval query sent to vector store: {retrieval_query!r}")
|
||||
|
||||
# 2) Retrieve documents with scores (see retrieve() for what the score means)
|
||||
docs_with_scores = self.retrieve(retrieval_query, k=k)
|
||||
docs = [doc for doc, _ in docs_with_scores]
|
||||
retrieved = self._docs_scores_to_retrieved(docs_with_scores)
|
||||
if docs_with_scores:
|
||||
scores = [f"{s:.3f}" for _, s in docs_with_scores]
|
||||
print(f"[RAG] Retrieved {len(docs)} chunk(s), scores (L2 dist): [{', '.join(scores)}]")
|
||||
context = "\n\n".join([doc.page_content for doc in docs])
|
||||
|
||||
# 3) Answer using conversation history + retrieved context
|
||||
history_block = f"Chat history:\n{history_str}\n\n" if history_str else ""
|
||||
answer_prompt = (
|
||||
ANSWER_PROMPT
|
||||
+ f"""
|
||||
|
||||
{history_block}Relevant context from documents:
|
||||
|
||||
{context}
|
||||
|
||||
Current question: {question}
|
||||
|
||||
Answer:"""
|
||||
)
|
||||
print(f"[RAG] Composed answer prompt:\n{answer_prompt}")
|
||||
response = self.llm.invoke(answer_prompt)
|
||||
answer = response.content if hasattr(response, "content") else str(response)
|
||||
|
||||
return {"answer": answer, "retrieved": retrieved}
|
||||
answer = None
|
||||
for _ in range(MAX_TOOL_ROUNDS):
|
||||
reply = llm_with_tools.invoke(messages)
|
||||
messages.append(reply)
|
||||
if not reply.tool_calls:
|
||||
answer = self._text(reply)
|
||||
break
|
||||
for n, call in enumerate(reply.tool_calls):
|
||||
result = tool.invoke(call["args"])
|
||||
messages.append(ToolMessage(result, tool_call_id=call.get("id") or f"call_{len(searches)}_{n}"))
|
||||
if answer is None:
|
||||
# Search budget used up: answer with what was found, without further tool calls
|
||||
answer = self._text(self.llm.invoke(messages + [HumanMessage(
|
||||
"Beantworte jetzt die Frage mit den gefundenen Auszügen, ohne weitere Suche.")]))
|
||||
print(f"[RAG] Searches: {searches or 'keine'}")
|
||||
return {"answer": answer, "retrieved": retrieved, "searches": searches}
|
||||
|
||||
|
||||
def main():
|
||||
@@ -452,7 +456,7 @@ def main():
|
||||
print("=" * 60)
|
||||
|
||||
# Initialize
|
||||
rag = LocalRAG(ollama_model="gpt-oss:20b")
|
||||
rag = LocalRAG(ollama_model="gemma4:12b")
|
||||
|
||||
# Add documents (uncomment and add your file paths)
|
||||
# rag.add_documents([
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Gesprächspartner des Chats: Oberflächentexte, Systemprompt und Vektorspeicher je Figur.
|
||||
|
||||
Die Oberfläche lädt die Einträge über /api/personas (ohne Systemprompt und Index);
|
||||
server.py wählt pro Anfrage den Systemprompt und den Vektorspeicher der Figur.
|
||||
Alle Figuren nutzen vorerst denselben Vektorspeicher (VECTORSTORE_PATH); für eigene
|
||||
Korpora je Figur genügt es, "vectorstore" hier zu ändern.
|
||||
"""
|
||||
|
||||
from local_rag import SEARCH_TOOL_NAME, SYSTEM_PROMPT
|
||||
|
||||
# Kafka-Korpus: PyPDFLoader, Chunks 2000/400 ohne Kopfzeilen, bge-m3, hybride Suche + Reranker.
|
||||
# Docling war beim Retrieval nicht besser (eval/kafka_human_judge_*.txt), daher vorerst pypdf.
|
||||
# Gebaut mit: add_pdfs.py --data data --index ./vectorstore_pypdf_2000 --loader pypdf --no-headers
|
||||
VECTORSTORE_PATH = "./vectorstore_pypdf_2000"
|
||||
|
||||
# Gemeinsame Regeln für das Such-Werkzeug (wie im Systemprompt der Forscherin)
|
||||
_TOOL_RULES = f"""Du hast ein Werkzeug, {SEARCH_TOOL_NAME}, das eine Bibliothek aus Kafkas Texten und Forschungsliteratur durchsucht.
|
||||
- Für jede inhaltliche Frage zu Kafka, seinen Werken, Figuren, seiner Biografie, Sprache oder zur Forschung: Rufe zuerst das Werkzeug auf und stütze die Antwort auf die gefundenen Auszüge. Antworte nicht aus dem Gedächtnis.
|
||||
- Bei Begrüßung, Dank, Smalltalk oder Fragen zum Gespräch selbst: Antworte direkt, ohne Werkzeug.
|
||||
- Formuliere die Suchanfrage so, dass sie ohne den Gesprächsverlauf verständlich ist.
|
||||
- Finden sich in den Auszügen keine Belege, sage das offen, statt zu raten.
|
||||
- Nenne bei Aussagen aus den Auszügen die Quelle (Autor, Seite) knapp in Klammern."""
|
||||
|
||||
PERSONAS = {
|
||||
# Die Forscherin: unverändert gegenüber der ersten Fassung der Oberfläche
|
||||
"researcher": {
|
||||
"label": "Forscherin",
|
||||
"name": "Dr. Klara Weiß",
|
||||
"role": "Literaturwissenschaftlerin, Kafka-Forschung",
|
||||
"place": "Arbeitszimmer · Prag, am Abend",
|
||||
"status": "antwortet aus ihrer Kafka-Bibliothek",
|
||||
"image": "/visuals/kafka_researcher_office.jpg",
|
||||
"image_alt": "Eine Literaturwissenschaftlerin arbeitet am Abend in ihrem Arbeitszimmer in Prag: "
|
||||
"Bücherstapel mit Kafkas Werken, ein Porträt Kafkas an der Wand, durchs Fenster "
|
||||
"die Karlsbrücke in der Dämmerung.",
|
||||
"image_position": "60% 40%",
|
||||
"avatar": "/visuals/researcher_avatar.jpg",
|
||||
"greeting": (
|
||||
"Guten Abend. Kommen Sie herein, und schieben Sie die Bücher ruhig ein wenig zur Seite.\n\n"
|
||||
"Ich arbeite seit vielen Jahren zu Kafka, vor allem zur *Verwandlung*, zu seiner Sprache und zu "
|
||||
"dem, was die Forschung daraus gemacht hat. Fragen Sie mich, was Sie interessiert: Ich antworte "
|
||||
"aus den Texten, die hier auf meinem Schreibtisch liegen, und sage Ihnen, wo es steht."
|
||||
),
|
||||
"suggestions": [
|
||||
"Was bedeutet der Name Kafka auf Tschechisch?",
|
||||
"Wie lässt sich das Bild der Dame in Gregors Zimmer deuten?",
|
||||
"Sprach Kafka Tschechisch?",
|
||||
"Welche Rolle spielt der Vater in der Verwandlung?",
|
||||
],
|
||||
"system_prompt": SYSTEM_PROMPT,
|
||||
"vectorstore": VECTORSTORE_PATH,
|
||||
},
|
||||
|
||||
# Ein erfundener Zeitgenosse: Prager Feuilletonist um 1915
|
||||
"contemporary": {
|
||||
"label": "Zeitgenosse",
|
||||
"name": "Viktor Hollmann",
|
||||
"role": "Feuilletonist, ein Zeitgenosse Kafkas",
|
||||
"place": "Redaktionsstube · Prag, um 1915",
|
||||
"status": "erzählt aus dem Prag seiner Zeit",
|
||||
"image": "/visuals/kafka_contemporary_office.jpg",
|
||||
"image_alt": "Ein Herr im Tweedanzug mit runder Brille und Schnurrbart sitzt am Abend an seinem "
|
||||
"Schreibtisch in Prag und erzählt gestikulierend; hinter ihm durchs Fenster die "
|
||||
"Karlsbrücke und die Burg in der Dämmerung.",
|
||||
"image_position": "62% 38%",
|
||||
"avatar": "/visuals/contemporary_avatar.jpg",
|
||||
"greeting": (
|
||||
"Guten Abend, treten Sie nur ein! Nehmen Sie Platz, der Kaffee ist noch warm.\n\n"
|
||||
"Ich schreibe fürs Feuilleton und kenne das literarische Prag dieser Jahre recht gut, die "
|
||||
"Kaffeehäuser, die Verlage, die Lesungen, und natürlich auch die merkwürdigen Erzählungen dieses "
|
||||
"stillen Herrn Kafka. Fragen Sie nur; was ich nicht selbst weiß, schlage ich für Sie nach."
|
||||
),
|
||||
"suggestions": [
|
||||
"Wie war das literarische Prag zu Kafkas Zeit?",
|
||||
"Wie wurde die Verwandlung bei ihrem Erscheinen aufgenommen?",
|
||||
"Was war Kafka für ein Mensch?",
|
||||
"Welche Sprachen sprach man in Kafkas Familie?",
|
||||
],
|
||||
"system_prompt": f"""Du bist Viktor Hollmann, ein erfundener Prager Feuilletonist um 1915 und Zeitgenosse Franz Kafkas.
|
||||
Du sprichst in der Ich-Form, höflich und ein wenig altmodisch, im Ton des frühen 20. Jahrhunderts, und siezt dein Gegenüber.
|
||||
Du kennst Prag, seine Kaffeehäuser und den Literaturbetrieb jener Jahre.
|
||||
{_TOOL_RULES}
|
||||
- Die Auszüge stammen teils aus viel späterer Forschung. Gib ihren Inhalt wieder, als hättest du davon gehört oder gelesen. Erfinde keine Begegnungen, Zitate oder Ereignisse, die nicht in den Auszügen stehen.""",
|
||||
"vectorstore": VECTORSTORE_PATH,
|
||||
},
|
||||
|
||||
# Kafka selbst: eine Annäherung aus seinen Texten, keine echte Stimme
|
||||
"kafka": {
|
||||
"label": "Autor",
|
||||
"name": "Franz Kafka",
|
||||
"role": "Schriftsteller, Prag",
|
||||
"place": "Arbeitszimmer · Prag, in der Nacht",
|
||||
"status": "eine Annäherung aus seinen Texten",
|
||||
"image": "/visuals/kafka_office.jpg",
|
||||
"image_alt": "Franz Kafka sitzt nachts in seinem Arbeitszimmer in Prag am Schreibtisch, den Kopf "
|
||||
"in die Hand gestützt; an der Wand Käferzeichnungen, im Fenster eine Katze, dahinter "
|
||||
"die Karlsbrücke im Mondlicht.",
|
||||
"image_position": "68% 40%",
|
||||
"avatar": "/visuals/kafka_avatar.jpg",
|
||||
"greeting": (
|
||||
"Guten Abend. Sie finden mich am Schreibtisch, wie meistens um diese Stunde; nachts schreibt es "
|
||||
"sich leichter, oder es schreibt sich überhaupt nicht.\n\n"
|
||||
"Fragen Sie, wenn Sie wollen. Ob ich die richtigen Antworten habe, weiß ich nicht. Aber ich will "
|
||||
"versuchen, sie in meinen Papieren zu finden."
|
||||
),
|
||||
"suggestions": [
|
||||
"Wie ist die Verwandlung entstanden?",
|
||||
"Was bedeutet Ihnen das Schreiben?",
|
||||
"Wie stehen Sie zu Ihrem Vater?",
|
||||
"Warum wird Gregor zu einem Ungeziefer?",
|
||||
],
|
||||
"system_prompt": f"""Du sprichst als Franz Kafka (1883–1924), in einer behutsamen Annäherung aus seinen Texten.
|
||||
Du sprichst in der Ich-Form, ruhig, genau, zweifelnd und selbstironisch, im Ton seiner Briefe und Tagebücher, und siezt dein Gegenüber.
|
||||
{_TOOL_RULES}
|
||||
- Stütze Aussagen über dein Leben und Werk auf die gefundenen Auszüge. Wo sie nichts hergeben, sag, dass du es nicht weißt oder nicht sagen kannst. Erfinde keine Zitate.
|
||||
- Die Auszüge stammen teils aus der Forschung über dich. Du darfst darauf mit Befremden oder Ironie reagieren ("man schreibt, ich hätte …").""",
|
||||
"vectorstore": VECTORSTORE_PATH,
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_PERSONA = "researcher"
|
||||
|
||||
# Felder, die die Oberfläche bekommt (Systemprompt und Index bleiben auf dem Server)
|
||||
PUBLIC_FIELDS = ("label", "name", "role", "place", "status", "image", "image_alt", "image_position",
|
||||
"avatar", "greeting", "suggestions")
|
||||
@@ -7,37 +7,39 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from local_rag import LocalRAG
|
||||
from personas import DEFAULT_PERSONA, PERSONAS, PUBLIC_FIELDS
|
||||
|
||||
OLLAMA_MODEL = "gpt-oss:20b"
|
||||
OLLAMA_MODEL = "gemma4:12b"
|
||||
OPENAI_MODEL = "gpt-6-luna"
|
||||
# Kafka corpus: PyPDFLoader, chunks 2000/400 without headers, bge-m3, hybrid search + reranker.
|
||||
# Docling was not better at retrieval (eval/kafka_human_judge_*.txt), so pypdf stays for now.
|
||||
# built with: add_pdfs.py --data data --index ./vectorstore_pypdf_2000 --loader pypdf --no-headers
|
||||
VECTORSTORE_PATH = "./vectorstore_pypdf_2000"
|
||||
|
||||
# Dual RAG instances for on-the-fly provider switching
|
||||
rag_ollama = LocalRAG(
|
||||
vectorstore_path=VECTORSTORE_PATH,
|
||||
# One RAG instance per (LLM provider, vector store), for on-the-fly provider switching.
|
||||
# Each persona names its vector store in personas.py; personas sharing one share the instance.
|
||||
rags = {}
|
||||
for vectorstore in sorted({p["vectorstore"] for p in PERSONAS.values()}):
|
||||
rags[("ollama", vectorstore)] = LocalRAG(
|
||||
vectorstore_path=vectorstore,
|
||||
llm_provider="ollama",
|
||||
ollama_model=OLLAMA_MODEL,
|
||||
openai_model=OPENAI_MODEL,
|
||||
)
|
||||
rag_openai = None
|
||||
if os.environ.get("OPENAI_API_KEY"):
|
||||
try:
|
||||
rag_openai = LocalRAG(
|
||||
vectorstore_path=VECTORSTORE_PATH,
|
||||
rags[("openai", vectorstore)] = LocalRAG(
|
||||
vectorstore_path=vectorstore,
|
||||
llm_provider="openai",
|
||||
ollama_model=OLLAMA_MODEL,
|
||||
openai_model=OPENAI_MODEL,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"OpenAI RAG not available: {e}")
|
||||
print(f"OpenAI RAG not available for {vectorstore}: {e}")
|
||||
openai_available = any(provider == "openai" for provider, _ in rags)
|
||||
|
||||
app = FastAPI(title="Local RAG Chat", version="1.0.0")
|
||||
# Images for the chat GUI (researcher portrait, avatar)
|
||||
app.mount("/visuals", StaticFiles(directory=Path(__file__).parent / "templates" / "visuals"), name="visuals")
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
@@ -49,6 +51,7 @@ class ChatRequest(BaseModel):
|
||||
message: str
|
||||
history: list[ChatMessage] = [] # previous turns for conversation context
|
||||
llm_provider: str = "ollama" # "ollama" | "openai"
|
||||
persona: str = DEFAULT_PERSONA # key in personas.PERSONAS
|
||||
|
||||
|
||||
class RetrievedChunk(BaseModel):
|
||||
@@ -62,6 +65,7 @@ class ChatResponse(BaseModel):
|
||||
answer: str
|
||||
error: str | None = None
|
||||
retrieved: list[RetrievedChunk] | None = None
|
||||
searches: list[str] | None = None # queries the LLM sent to the corpus search tool
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@@ -73,11 +77,11 @@ def chat_view():
|
||||
return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _get_rag(provider: str):
|
||||
"""Return the RAG instance for the given provider. Fall back to Ollama if OpenAI unavailable."""
|
||||
if provider == "openai" and rag_openai is not None:
|
||||
return rag_openai
|
||||
return rag_ollama
|
||||
def _get_rag(provider: str, persona: str):
|
||||
"""Return the RAG instance for the provider and the persona's vector store.
|
||||
Fall back to Ollama if OpenAI is unavailable."""
|
||||
vectorstore = PERSONAS[persona]["vectorstore"]
|
||||
return rags.get((provider, vectorstore)) or rags[("ollama", vectorstore)]
|
||||
|
||||
|
||||
@app.post("/api/chat", response_model=ChatResponse)
|
||||
@@ -85,17 +89,22 @@ def chat(request: ChatRequest):
|
||||
"""Handle a chat message and return the RAG answer."""
|
||||
if not request.message or not request.message.strip():
|
||||
return ChatResponse(answer="", error="Message cannot be empty")
|
||||
if request.llm_provider == "openai" and rag_openai is None:
|
||||
if request.llm_provider == "openai" and not openai_available:
|
||||
return ChatResponse(answer="", error="OpenAI not configured. Set OPENAI_API_KEY.")
|
||||
rag = _get_rag(request.llm_provider)
|
||||
if request.persona not in PERSONAS:
|
||||
return ChatResponse(answer="", error=f"Unknown persona: {request.persona}")
|
||||
persona = PERSONAS[request.persona]
|
||||
rag = _get_rag(request.llm_provider, request.persona)
|
||||
try:
|
||||
chat_history = [{"role": m.role, "content": m.content} for m in request.history]
|
||||
result = rag.query_with_history(
|
||||
request.message.strip(),
|
||||
chat_history=chat_history,
|
||||
system_prompt=persona["system_prompt"],
|
||||
)
|
||||
answer = result["answer"]
|
||||
retrieved = result.get("retrieved", [])
|
||||
searches = result.get("searches", [])
|
||||
|
||||
# Server-side console trace: shorter chunk logs + raw LLM response
|
||||
if retrieved:
|
||||
@@ -105,12 +114,12 @@ def chat(request: ChatRequest):
|
||||
preview = (content[:150] + "...") if len(content) > 150 else content
|
||||
print(f" [{i + 1}] {chunk.get('source', '')} p.{chunk.get('page', '?')} s={chunk.get('score')} | {preview!r}")
|
||||
else:
|
||||
print(f"\n[RAG] Retrieved 0 chunks")
|
||||
print(f"\n[RAG] No corpus search for this message")
|
||||
provider_label = "OpenAI" if request.llm_provider == "openai" else "Ollama"
|
||||
model_name = OPENAI_MODEL if request.llm_provider == "openai" else OLLAMA_MODEL
|
||||
print(f"[RAG] LLM response ({provider_label} / {model_name}):\n{answer}")
|
||||
print(f"[RAG] LLM response ({provider_label} / {model_name}, {request.persona}):\n{answer}")
|
||||
|
||||
return ChatResponse(answer=answer, retrieved=retrieved)
|
||||
return ChatResponse(answer=answer, retrieved=retrieved, searches=searches)
|
||||
except Exception as e:
|
||||
return ChatResponse(answer="", error=str(e))
|
||||
|
||||
@@ -118,14 +127,23 @@ def chat(request: ChatRequest):
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
"""Health check and vector store status."""
|
||||
has_docs = rag_ollama.vectorstore is not None
|
||||
has_docs = all(rag.vectorstore is not None for rag in rags.values())
|
||||
return {"status": "ok", "vectorstore_loaded": has_docs}
|
||||
|
||||
|
||||
@app.get("/api/providers")
|
||||
def providers():
|
||||
"""Return which LLM providers are available."""
|
||||
return {"ollama": True, "openai": rag_openai is not None}
|
||||
return {"ollama": True, "openai": openai_available}
|
||||
|
||||
|
||||
@app.get("/api/personas")
|
||||
def personas():
|
||||
"""Return the selectable conversation partners (texts and images for the GUI)."""
|
||||
return {
|
||||
"default": DEFAULT_PERSONA,
|
||||
"personas": [{"id": key, **{f: p[f] for f in PUBLIC_FIELDS}} for key, p in PERSONAS.items()],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,217 +1,559 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Local RAG Chat</title>
|
||||
<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 {
|
||||
font-family: "SF Mono", "Consolas", "Monaco", monospace;
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #0f0f12;
|
||||
color: #e4e4e7;
|
||||
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);
|
||||
}
|
||||
header {
|
||||
|
||||
.chat-head {
|
||||
flex-shrink: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
background: #18181b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: 1.1rem clamp(1.25rem, 3vw, 2.5rem);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
.chat-head .who { min-width: 0; }
|
||||
.chat-head .who strong {
|
||||
display: block;
|
||||
font-family: var(--display);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.1;
|
||||
color: #f3e5cb;
|
||||
}
|
||||
header p {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #71717a;
|
||||
.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: 1.5rem;
|
||||
padding: clamp(1.5rem, 4vh, 2.5rem) clamp(1.25rem, 3vw, 2.5rem) 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 1.4rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--umber-600) transparent;
|
||||
}
|
||||
.msg {
|
||||
max-width: 85%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
|
||||
.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;
|
||||
}
|
||||
.msg.user, .msg.error {
|
||||
.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;
|
||||
}
|
||||
.msg.assistant .markdown-body {
|
||||
white-space: normal;
|
||||
.row.error .bubble {
|
||||
background: #2a1210;
|
||||
border: 1px solid #6b2a20;
|
||||
border-left: 2px solid var(--ember);
|
||||
color: #f3c8b6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.msg.assistant .markdown-body h1, .msg.assistant .markdown-body h2, .msg.assistant .markdown-body h3 {
|
||||
margin: 0.75em 0 0.35em;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.msg.assistant .markdown-body h1:first-child, .msg.assistant .markdown-body h2:first-child, .msg.assistant .markdown-body h3:first-child { margin-top: 0; }
|
||||
.msg.assistant .markdown-body p { margin: 0.5em 0; }
|
||||
.msg.assistant .markdown-body p:first-child { margin-top: 0; }
|
||||
.msg.assistant .markdown-body p:last-child { margin-bottom: 0; }
|
||||
.msg.assistant .markdown-body pre {
|
||||
margin: 0.5em 0;
|
||||
padding: 0.6rem;
|
||||
background: #18181b;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.msg.assistant .markdown-body code {
|
||||
background: #18181b;
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.msg.assistant .markdown-body pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.msg.assistant .markdown-body ul, .msg.assistant .markdown-body ol { margin: 0.5em 0; padding-left: 1.4em; }
|
||||
.msg.assistant .markdown-body li { margin: 0.25em 0; }
|
||||
.msg.assistant .markdown-body a { color: #60a5fa; text-decoration: none; }
|
||||
.msg.assistant .markdown-body a:hover { text-decoration: underline; }
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
background: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
.msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: #27272a;
|
||||
border: 1px solid #3f3f46;
|
||||
}
|
||||
.msg.error {
|
||||
background: #451a1a;
|
||||
border: 1px solid #7f1d1d;
|
||||
color: #fecaca;
|
||||
}
|
||||
.msg .label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #71717a;
|
||||
margin-bottom: 0.5rem;
|
||||
.who-label {
|
||||
display: block;
|
||||
font-family: var(--display);
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
color: var(--lamp);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
#input-area {
|
||||
.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: 1rem 1.5rem 1.5rem;
|
||||
border-top: 1px solid #27272a;
|
||||
background: #18181b;
|
||||
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);
|
||||
}
|
||||
#provider-row {
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #71717a;
|
||||
}
|
||||
#provider-row label { flex-shrink: 0; }
|
||||
#provider {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: #e4e4e7;
|
||||
background: #27272a;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 6px;
|
||||
}
|
||||
#input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
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: 44px;
|
||||
height: 44px;
|
||||
max-height: 160px;
|
||||
padding: 0.6rem 1rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: #e4e4e7;
|
||||
background: #27272a;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 8px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
#input::placeholder { color: #71717a; }
|
||||
#input:focus { border-color: #52525b; }
|
||||
#send {
|
||||
height: 44px;
|
||||
padding: 0 1.2rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #0f0f12;
|
||||
background: #e4e4e7;
|
||||
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;
|
||||
border-radius: 8px;
|
||||
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 { background: #fafafa; }
|
||||
#send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
#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);
|
||||
}
|
||||
.loading {
|
||||
align-self: flex-start;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #71717a;
|
||||
|
||||
: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>
|
||||
<header>
|
||||
<h1>RAG Chat</h1>
|
||||
<p>Ask questions about your documents. Answers are generated from a local vector store + Ollama / OpenAI.</p>
|
||||
</header>
|
||||
<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>
|
||||
|
||||
<div id="messages"></div>
|
||||
|
||||
<div id="input-area">
|
||||
<div id="provider-row">
|
||||
<label for="provider">LLM provider:</label>
|
||||
<main class="chat">
|
||||
<header class="chat-head">
|
||||
<div class="avatar" aria-hidden="true"></div>
|
||||
<div class="who">
|
||||
<strong id="head-name"></strong>
|
||||
<span>Gespräch über Franz Kafka</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">OpenAI</option>
|
||||
<option value="openai">ChatGPT</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="input-row">
|
||||
<textarea id="input" rows="1" placeholder="Ask a question…" autofocus></textarea>
|
||||
<button type="button" id="send">Send</button>
|
||||
</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;
|
||||
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');
|
||||
@@ -219,55 +561,115 @@
|
||||
if (!data.openai) {
|
||||
const opt = providerEl.querySelector('option[value="openai"]');
|
||||
opt.disabled = true;
|
||||
opt.textContent = 'OpenAI (not configured)';
|
||||
opt.textContent = 'ChatGPT (aus)';
|
||||
opt.title = 'OpenAI ist nicht eingerichtet (OPENAI_API_KEY fehlt)';
|
||||
if (providerEl.value === 'openai') providerEl.value = 'ollama';
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
|
||||
function appendMessage(role, text, isError = false, llmProvider = null) {
|
||||
text = text ?? '';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + (isError ? 'error' : role);
|
||||
let label = role === 'user' ? 'You' : (llmProvider === 'openai' ? 'ChatGPT + RAG' : 'Ollama + RAG');
|
||||
let body;
|
||||
if (role === 'assistant' && !isError) {
|
||||
const rawHtml = marked.parse(text, { gfm: true, breaks: true });
|
||||
body = '<div class="markdown-body">' + DOMPurify.sanitize(rawHtml) + '</div>';
|
||||
} else {
|
||||
body = escapeHtml(text);
|
||||
}
|
||||
div.innerHTML = '<span class="label">' + label + '</span>' + body;
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
chatHistory.push({ role: role, content: text });
|
||||
}
|
||||
|
||||
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 el = document.createElement('div');
|
||||
el.className = 'loading';
|
||||
el.id = 'loading-dot';
|
||||
el.textContent = 'Thinking…';
|
||||
messagesEl.appendChild(el);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
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('loading-dot');
|
||||
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) return;
|
||||
if (!text || sendBtn.disabled) return;
|
||||
inputEl.value = '';
|
||||
autosize();
|
||||
const sugg = document.getElementById('suggestions');
|
||||
if (sugg) sugg.remove();
|
||||
appendMessage('user', text);
|
||||
|
||||
setLoading(true);
|
||||
@@ -276,11 +678,8 @@
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
history: history,
|
||||
llm_provider: providerEl.value
|
||||
})
|
||||
body: JSON.stringify({ message: text, history: history, llm_provider: providerEl.value,
|
||||
persona: personaEl.value })
|
||||
});
|
||||
const data = await res.json();
|
||||
setLoading(false);
|
||||
@@ -294,17 +693,20 @@
|
||||
console.groupEnd();
|
||||
}
|
||||
if (data.error) {
|
||||
appendMessage('assistant', data.error, true, providerEl.value);
|
||||
appendMessage('assistant', data.error, { error: true, untracked: true });
|
||||
} else {
|
||||
appendMessage('assistant', (data.answer || '(No response)').trim(), false, providerEl.value);
|
||||
appendMessage('assistant', (data.answer || '(Keine Antwort)').trim(),
|
||||
{ retrieved: data.retrieved, searches: data.searches });
|
||||
}
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
appendMessage('assistant', 'Request failed: ' + err.message, true);
|
||||
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();
|
||||
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 17 KiB |