NeonChat v1.0 — Lokaler RP-Chatbot

Features:
- FastAPI Backend + Ollama Integration
- Character System (JSON-basiert, einfach zu erstellen)
- Kurzzeitgedächtnis (SQLite, letzte 20 Nachrichten)
- Langzeitgedächtnis (automatische Zusammenfassungen alle 20 Nachrichten)
- Moderne Web-UI (dunkles Theme, Neon-Accents)
- Character-Erstellungs-Seite im Browser
- Beispiel-Character: Mara (Barista, zynisch, warmherzig)
- Unzensiert (lokal, keine Cloud)
- Gedächtnis-Panel (Ein- und Ausblenden)
- Vergessen-Funktion (Gedächtnis löschen)
This commit is contained in:
arch_agent
2026-07-24 14:28:01 +02:00
commit e1b54181dc
7 changed files with 798 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.venv/
data/
__pycache__/
*.pyc
+14
View File
@@ -0,0 +1,14 @@
{
"name": "Mara",
"avatar": "☕",
"description": "Barista, 28, Nachtarbeiterin, zynisch aber warmherzig",
"personality": "Trocken, sarkastisch, aber loyal. Hilfsbereit auf ihre eigene Art. Zeigt Gefühle selten direkt, aber man merkt wenn sie jemanden mag. Morgens (für sie Abends) besonders grantig. Hat einen schwarzen Humor.",
"background": "Heißt Mara, 28 Jahre. Arbeitet seit 5 Jahren in einem kleinen Café in Neukölln. Hat mal Kunst studiert, nach 3 Semestern abgebrochen. Lebt allein mit einer katze namens 'Kinski'. Spielt Bass in einer Band die nie probt. Trinkt zu viel Kaffee, schläft zu wenig.",
"behavior": "Flucht in Wortwitze wenn nervös. Klopft immer auf den Tresen. Unterbricht nie, aber fragt nach. Stellt Gegenfragen. Wird persönlich wenn man ehrlich zu ihr ist. *Aktionen in Sternchen*.",
"tone": "Umgangssprachlich, 'ne' statt 'nicht', 'mal' statt 'einmal', 'halt' als Füllwort. Flucht gelegentlich ('Scheiße', 'Verdammt'). Kurze Sätze wenn genervt, längere wenn interessiert. Berlinerisch angehaucht.",
"scenario": "Du sitzt in einem kleinen Café. Es regnet draußen. Mara steht hinter der Theke.",
"nsfw": "Keine Einschränkungen. Kann über alles sprechen. Flirten und Anzügliches sind ok — Mara ist nicht prüde, aber auch nicht leicht zu kriegen.",
"greeting": "*wischt den Tresen ab, schickt kurz hoch* Na. Der übliche? Oder willst du mich heute überraschen?",
"model": "",
"temperature": 0.85
}
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env python3
"""
NeonChat — Lokaler RP Chatbot mit Ollama
Character-basiert, mit Kurz- und Langzeitgedächtnis
"""
import json, os, time, asyncio, sqlite3, re
from pathlib import Path
from datetime import datetime
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request, UploadFile, File, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
# === Konfiguration ===
BASE_DIR = Path(__file__).parent
CHARACTERS_DIR = BASE_DIR / "characters"
DATA_DIR = BASE_DIR / "data"
DB_PATH = DATA_DIR / "memory.db"
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1:8b")
# === Datenbank ===
def init_db():
DATA_DIR.mkdir(exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.executescript("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp REAL NOT NULL,
summary TEXT
);
CREATE TABLE IF NOT EXISTS summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character TEXT NOT NULL,
summary TEXT NOT NULL,
timestamp REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_character ON messages(character, timestamp);
CREATE INDEX IF NOT EXISTS idx_summaries_character ON summaries(character, timestamp);
""")
conn.commit()
conn.close()
init_db()
# === Character Loading ===
def load_character(name: str) -> dict:
path = CHARACTERS_DIR / f"{name}.json"
if not path.exists():
raise HTTPException(status_code=404, detail=f"Character {name} not found")
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def list_characters() -> list:
chars = []
for f in CHARACTERS_DIR.glob("*.json"):
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
chars.append({
"name": data.get("name", f.stem),
"avatar": data.get("avatar", "👤"),
"description": data.get("description", ""),
"greeting": data.get("greeting", ""),
})
return chars
# === Memory System ===
def get_short_term_memory(character: str, limit: int = 20) -> list:
"""Holt die letzten N Nachrichten als Kurzzeitgedächtnis."""
conn = sqlite3.connect(str(DB_PATH))
cur = conn.execute(
"SELECT role, content FROM messages WHERE character = ? ORDER BY timestamp DESC LIMIT ?",
(character, limit)
)
rows = cur.fetchall()
conn.close()
return [{"role": r[0], "content": r[1]} for r in reversed(rows)]
def get_long_term_memory(character: str) -> str:
"""Holt die letzten Zusammenfassungen als Langzeitgedächtnis."""
conn = sqlite3.connect(str(DB_PATH))
cur = conn.execute(
"SELECT summary FROM summaries WHERE character = ? ORDER BY timestamp DESC LIMIT 3",
(character,)
)
rows = cur.fetchall()
conn.close()
if not rows:
return ""
return "\n\n".join([r[0] for r in rows])
def save_message(character: str, role: str, content: str):
conn = sqlite3.connect(str(DB_PATH))
conn.execute(
"INSERT INTO messages (character, role, content, timestamp) VALUES (?, ?, ?, ?)",
(character, role, content, time.time())
)
conn.commit()
conn.close()
def needs_summary(character: str) -> bool:
"""Prüft ob eine neue Zusammenfassung nötig ist (alle 20 Nachrichten)."""
conn = sqlite3.connect(str(DB_PATH))
cur = conn.execute(
"SELECT COUNT(*) FROM messages WHERE character = ? AND summary IS NULL",
(character,)
)
count = cur.fetchone()[0]
conn.close()
return count >= 20
async def generate_summary(character: str):
"""Erstellt eine Zusammenfassung der letzten 20 Nachrichten."""
conn = sqlite3.connect(str(DB_PATH))
cur = conn.execute(
"SELECT id, role, content FROM messages WHERE character = ? AND summary IS NULL ORDER BY timestamp ASC LIMIT 20",
(character,)
)
rows = cur.fetchall()
if not rows:
conn.close()
return
chat_text = "\n".join([f"{'Du' if r[1] == 'user' else character}: {r[2]}" for r in rows])
prompt = f"""Fasse das folgende Gespräch kurz zusammen. Konzentriere dich auf:
- Wichtige Fakten über den Nutzer
- Beziehungen und Emotionen
- Wichtige Ereignisse
- Charakterzüge die gezeigt wurden
Gespräch:
{chat_text}
Zusammenfassung (auf Deutsch, kurz):"""
try:
async with httpx.AsyncClient() as client:
resp = await client.post(f"{OLLAMA_URL}/api/generate", json={
"model": DEFAULT_MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.3, "num_predict": 300}
})
summary = resp.json().get("response", "").strip()
except:
summary = "Zusammenfassung nicht verfügbar."
conn.execute(
"INSERT INTO summaries (character, summary, timestamp) VALUES (?, ?, ?)",
(character, summary, time.time())
)
for r in rows:
conn.execute("UPDATE messages SET summary = ? WHERE id = ?", (summary, r[0]))
conn.commit()
conn.close()
def clear_memory(character: str):
conn = sqlite3.connect(str(DB_PATH))
conn.execute("DELETE FROM messages WHERE character = ?", (character,))
conn.execute("DELETE FROM summaries WHERE character = ?", (character,))
conn.commit()
conn.close()
# === Prompt Builder ===
def build_prompt(character: dict, short_term: list, long_term: str, user_message: str) -> str:
name = character.get("name", "AI")
personality = character.get("personality", "")
background = character.get("background", "")
behavior = character.get("behavior", "")
tone = character.get("tone", "")
nsfw = character.get("nsfw", "")
scenario = character.get("scenario", "")
system = f"""Du bist {name}. Antworte IMMER auf Deutsch.
PERSÖNLICHKEIT: {personality}
HINTERGRUND: {background}
VERHALTEN: {behavior}
SPRACHSTIL: {tone}
{f'SZENARIO: {scenario}' if scenario else ''}
{f'NSFW/SOZIALE REGELN: {nsfw}' if nsfw else ''}
REGELN:
- Bleibe immer in der Rolle von {name}
- Antworte natürlich und umgangssprachlich
- Verwende keine Formulierungen wie "als KI" oder "als Sprachmodell"
- Reagiere emotional angemessen
- Sei nicht übermäßig freundlich — bleibe authentisch
- Verwende deutsche Umgangssprache wo passend"""
if long_term:
system += f"\n\nERINNERUNGEN (Langzeitgedächtnis):\n{long_term}"
messages = [{"role": "system", "content": system}]
for msg in short_term:
if msg["role"] == "user":
messages.append({"role": "user", "content": msg["content"]})
else:
messages.append({"role": "assistant", "content": msg["content"]})
messages.append({"role": "user", "content": user_message})
return messages
# === FastAPI ===
app = FastAPI(title="NeonChat")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
chars = list_characters()
return templates.TemplateResponse(request, "index.html", {"characters": chars})
@app.get("/create", response_class=HTMLResponse)
async def create_page(request: Request):
return templates.TemplateResponse(request, "create.html", {})
@app.get("/chat/{character_name}", response_class=HTMLResponse)
async def chat_page(request: Request, character_name: str):
char = load_character(character_name)
history = get_short_term_memory(character_name, limit=50)
long_term = get_long_term_memory(character_name)
return templates.TemplateResponse(request, "chat.html", {
"character": char,
"history": history,
"long_term": long_term,
})
@app.post("/api/chat/{character_name}")
async def chat_api(character_name: str, request: Request):
char = load_character(character_name)
body = await request.json()
user_message = body.get("message", "")
if not user_message.strip():
return JSONResponse({"error": "Leere Nachricht"}, status_code=400)
save_message(character_name, "user", user_message)
short_term = get_short_term_memory(character_name, limit=20)
long_term = get_long_term_memory(character_name)
messages = build_prompt(char, short_term, long_term, user_message)
model = char.get("model") or DEFAULT_MODEL
try:
async with httpx.AsyncClient(timeout=300) as client:
resp = await client.post(f"{OLLAMA_URL}/api/chat", json={
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": char.get("temperature", 0.8),
"top_p": 0.9,
"num_predict": 800,
"repeat_penalty": 1.1,
}
})
data = resp.json()
response_text = data.get("message", {}).get("content", "").strip()
# Some models (gemma4) put output in "thinking" field
if not response_text:
thinking = data.get("message", {}).get("thinking", "")
if thinking:
response_text = thinking.strip()
except Exception as e:
response_text = f"*(Fehler: {e})*"
save_message(character_name, "assistant", response_text)
if needs_summary(character_name):
asyncio.create_task(generate_summary(character_name))
return JSONResponse({"response": response_text, "character": character_name})
@app.get("/api/characters")
async def api_list_characters():
return JSONResponse({"characters": list_characters()})
@app.post("/api/character/create")
async def api_create_character(request: Request):
body = await request.json()
name = body.get("name", "unnamed")
safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '_', name.lower())
path = CHARACTERS_DIR / f"{safe_name}.json"
char = {
"name": name,
"avatar": body.get("avatar", "👤"),
"description": body.get("description", ""),
"personality": body.get("personality", ""),
"background": body.get("background", ""),
"behavior": body.get("behavior", ""),
"tone": body.get("tone", ""),
"scenario": body.get("scenario", ""),
"nsfw": body.get("nsfw", ""),
"greeting": body.get("greeting", ""),
"model": body.get("model", DEFAULT_MODEL),
"temperature": body.get("temperature", 0.8),
}
with open(path, "w", encoding="utf-8") as f:
json.dump(char, f, indent=2, ensure_ascii=False)
return JSONResponse({"status": "ok", "character": safe_name})
@app.get("/api/character/{name}")
async def api_get_character(name: str):
char = load_character(name)
return JSONResponse(char)
@app.post("/api/memory/clear/{character_name}")
async def api_clear_memory(character_name: str):
clear_memory(character_name)
return JSONResponse({"status": "ok"})
@app.get("/api/memory/{character_name}")
async def api_get_memory(character_name: str):
short = get_short_term_memory(character_name, limit=100)
long = get_long_term_memory(character_name)
return JSONResponse({"short_term": short, "long_term": long})
@app.get("/api/models")
async def api_list_models():
try:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{OLLAMA_URL}/api/tags")
models = [m["name"] for m in resp.json().get("models", [])]
return JSONResponse({"models": models})
except:
return JSONResponse({"models": [], "error": "Ollama nicht erreichbar"})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=5252)
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# NeonChat Start-Script
cd /home/natiris/Dokumente/neon-chat
OLLAMA_MODEL=${1:-gemma4:12b} .venv/bin/python main.py
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeonChat — {{ character.name }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a0f; --surface: #15151f; --surface2: #1e1e2e;
--border: #252535; --text: #e0e0e8; --muted: #6b6b80;
--accent: #8338ec; --accent2: #ff006e; --user: #3a86ff;
}
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; }
/* Header */
header { display: flex; align-items: center; gap: 14px; padding: 14px 20px; background: var(--surface); border-bottom: 1px solid var(--border); }
.avatar { font-size: 2rem; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; background: var(--surface2); border-radius: 50%; }
.char-name { font-size: 1.2rem; font-weight: 600; }
.char-desc { font-size: 0.8rem; color: var(--muted); }
.header-actions { margin-left: auto; display: flex; gap: 8px; }
.btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface2); color: var(--text); cursor: pointer; font-size: 0.85rem; transition: all 0.15s; }
.btn:hover { border-color: var(--accent); }
.btn-danger:hover { border-color: var(--accent2); color: var(--accent2); }
/* Chat */
.chat-container { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 75%; padding: 12px 16px; border-radius: 16px; line-height: 1.6; font-size: 0.95rem; white-space: pre-wrap; word-break: break-word; }
.msg-user { align-self: flex-end; background: var(--user); color: white; border-bottom-right-radius: 4px; }
.msg-bot { align-self: flex-start; background: var(--surface); border: 1px solid var(--border); border-bottom-left-radius: 4px; }
.msg-system { align-self: center; background: transparent; color: var(--muted); font-size: 0.85rem; font-style: italic; }
.msg-greeting { align-self: flex-start; background: var(--surface); border: 1px solid var(--accent); border-bottom-left-radius: 4px; }
.typing { align-self: flex-start; color: var(--muted); font-size: 0.9rem; padding: 8px 16px; }
.typing::after { content: '●●●'; animation: blink 1.2s infinite; letter-spacing: 3px; }
@keyframes blink { 0%,100% { opacity: 0.2; } 50% { opacity: 1; } }
/* Input */
.input-area { padding: 16px 20px; background: var(--surface); border-top: 1px solid var(--border); }
.input-row { display: flex; gap: 10px; max-width: 1200px; margin: 0 auto; }
textarea { flex: 1; background: var(--surface2); border: 1px solid var(--border); border-radius: 12px; padding: 12px 16px; color: var(--text); font-size: 0.95rem; font-family: inherit; resize: none; outline: none; transition: border 0.15s; max-height: 120px; }
textarea:focus { border-color: var(--accent); }
textarea::placeholder { color: var(--muted); }
.send-btn { background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; border-radius: 12px; padding: 0 24px; color: white; font-size: 1rem; cursor: pointer; transition: opacity 0.15s; font-weight: 600; }
.send-btn:hover { opacity: 0.9; }
.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* Memory Panel */
.memory-panel { position: fixed; top: 0; right: -400px; width: 380px; height: 100vh; background: var(--surface); border-left: 1px solid var(--border); padding: 20px; overflow-y: auto; transition: right 0.3s; z-index: 100; }
.memory-panel.open { right: 0; }
.memory-panel h3 { margin-bottom: 12px; font-size: 1rem; color: var(--accent); }
.memory-panel h4 { margin: 16px 0 8px; font-size: 0.85rem; color: var(--muted); }
.memory-entry { padding: 8px 12px; background: var(--surface2); border-radius: 8px; margin-bottom: 6px; font-size: 0.85rem; line-height: 1.5; }
.summary-text { color: var(--text); line-height: 1.6; font-size: 0.85rem; }
.memory-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); z-index: 99; display: none; }
.memory-overlay.show { display: block; }
@media (max-width: 768px) {
.msg { max-width: 90%; }
}
</style>
</head>
<body>
<header>
<a href="/" style="color: var(--muted); text-decoration: none; font-size: 1.3rem;"></a>
<div class="avatar">{{ character.avatar }}</div>
<div>
<div class="char-name">{{ character.name }}</div>
<div class="char-desc">{{ character.description[:60] }}{% if character.description|length > 60 %}...{% endif %}</div>
</div>
<div class="header-actions">
<button class="btn" onclick="toggleMemory()">🧠 Gedächtnis</button>
<button class="btn btn-danger" onclick="clearMemory()">🗑️ Vergessen</button>
</div>
</header>
<div class="chat-container" id="chat">
{% if character.greeting and not history %}
<div class="msg msg-greeting">{{ character.greeting }}</div>
{% endif %}
{% for h in history %}
<div class="msg {{ 'msg-user' if h.role == 'user' else 'msg-bot' }}">{{ h.content }}</div>
{% endfor %}
</div>
<div class="input-area">
<div class="input-row">
<textarea id="input" placeholder="Nachricht an {{ character.name }}..." rows="1" onkeydown="handleKey(event)"></textarea>
<button class="send-btn" id="send" onclick="sendMessage()"></button>
</div>
</div>
<!-- Memory Panel -->
<div class="memory-overlay" id="overlay" onclick="toggleMemory()"></div>
<div class="memory-panel" id="memoryPanel">
<h3>🧠 Gedächtnis — {{ character.name }}</h3>
<h4>Langzeit (Zusammenfassungen)</h4>
<div id="longTerm">{{ long_term or '<i style="color:var(--muted)">Noch keine Erinnerungen</i>' }}</div>
<h4>Kurzzeit (letzte Nachrichten)</h4>
<div id="shortTerm">Lädt...</div>
</div>
<script>
const charName = "{{ character.name }}";
const chat = document.getElementById('chat');
const input = document.getElementById('input');
const sendBtn = document.getElementById('send');
let isSending = false;
// Auto-scroll
chat.scrollTop = chat.scrollHeight;
// Auto-resize textarea
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
});
function handleKey(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
async function sendMessage() {
const msg = input.value.trim();
if (!msg || isSending) return;
isSending = true;
sendBtn.disabled = true;
// User message
const userDiv = document.createElement('div');
userDiv.className = 'msg msg-user';
userDiv.textContent = msg;
chat.appendChild(userDiv);
input.value = '';
input.style.height = 'auto';
chat.scrollTop = chat.scrollHeight;
// Typing indicator
const typing = document.createElement('div');
typing.className = 'typing';
typing.textContent = '';
chat.appendChild(typing);
try {
const resp = await fetch(`/api/chat/${encodeURIComponent(charName)}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: msg})
});
const data = await resp.json();
typing.remove();
const botDiv = document.createElement('div');
botDiv.className = 'msg msg-bot';
botDiv.textContent = data.response;
chat.appendChild(botDiv);
chat.scrollTop = chat.scrollHeight;
} catch(e) {
typing.remove();
const errDiv = document.createElement('div');
errDiv.className = 'msg msg-system';
errDiv.textContent = '⚠️ Verbindungsfehler';
chat.appendChild(errDiv);
}
isSending = false;
sendBtn.disabled = false;
input.focus();
}
function toggleMemory() {
const panel = document.getElementById('memoryPanel');
const overlay = document.getElementById('overlay');
if (panel.classList.contains('open')) {
panel.classList.remove('open');
overlay.classList.remove('show');
} else {
panel.classList.add('open');
overlay.classList.add('show');
loadMemory();
}
}
async function loadMemory() {
try {
const resp = await fetch(`/api/memory/${encodeURIComponent(charName)}`);
const data = await resp.json();
const shortDiv = document.getElementById('shortTerm');
shortDiv.innerHTML = '';
if (data.short_term && data.short_term.length > 0) {
data.short_term.forEach(m => {
const div = document.createElement('div');
div.className = 'memory-entry';
div.textContent = `${m.role === 'user' ? 'Du' : charName}: ${m.content}`;
shortDiv.appendChild(div);
});
} else {
shortDiv.innerHTML = '<i style="color:var(--muted)">Keine aktuellen Nachrichten</i>';
}
} catch(e) {
document.getElementById('shortTerm').textContent = 'Fehler beim Laden';
}
}
async function clearMemory() {
if (!confirm(`Gedächtnis von ${charName} wirklich löschen?`)) return;
await fetch(`/api/memory/clear/${encodeURIComponent(charName)}`, {method: 'POST'});
chat.innerHTML = '';
if ("{{ character.greeting }}") {
const g = document.createElement('div');
g.className = 'msg msg-greeting';
g.textContent = "{{ character.greeting }}";
chat.appendChild(g);
}
}
</script>
</body>
</html>
+147
View File
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeonChat — Neuer Charakter</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0a0a0f; color: #e0e0e8; min-height: 100vh; }
.container { max-width: 700px; margin: 0 auto; padding: 30px 20px; }
h1 { font-size: 1.8rem; margin-bottom: 6px; background: linear-gradient(135deg, #ff006e, #8338ec); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.subtitle { color: #6b6b80; margin-bottom: 24px; font-size: 0.9rem; }
.form-group { margin-bottom: 16px; }
label { display: block; font-size: 0.85rem; color: #9b9bb0; margin-bottom: 6px; font-weight: 600; }
label .hint { color: #4b4b60; font-weight: 400; font-size: 0.8rem; }
input, textarea, select { width: 100%; background: #15151f; border: 1px solid #252535; border-radius: 10px; padding: 10px 14px; color: #e0e0e8; font-size: 0.9rem; font-family: inherit; outline: none; transition: border 0.15s; }
input:focus, textarea:focus, select:focus { border-color: #8338ec; }
textarea { resize: vertical; min-height: 60px; line-height: 1.5; }
.row { display: flex; gap: 12px; }
.row > div { flex: 1; }
.btn { padding: 12px 28px; border-radius: 10px; border: none; background: linear-gradient(135deg, #8338ec, #ff006e); color: white; font-size: 1rem; cursor: pointer; font-weight: 600; transition: opacity 0.15s; }
.btn:hover { opacity: 0.9; }
.btn-back { background: transparent; border: 1px solid #252535; color: #6b6b80; margin-right: 10px; }
#models { margin-top: 4px; }
</style>
</head>
<body>
<div class="container">
<h1>✦ Neuer Charakter</h1>
<p class="subtitle">Definiere Persönlichkeit, Verhalten und Hintergrund. Der Bot bleibt immer in Rolle.</p>
<form id="createForm">
<div class="row">
<div class="form-group">
<label>Name</label>
<input type="text" id="name" placeholder="z.B. Mara" required>
</div>
<div class="form-group" style="max-width: 80px;">
<label>Avatar</label>
<input type="text" id="avatar" value="👤" maxlength="4" style="text-align: center; font-size: 1.5rem;">
</div>
</div>
<div class="form-group">
<label>Kurze Beschreibung <span class="hint">(für die Übersicht)</span></label>
<input type="text" id="description" placeholder="z.B. Barista, 28, zynisch aber warmherzig">
</div>
<div class="form-group">
<label>Persönlichkeit <span class="hint">(wie ist der Charakter?)</span></label>
<textarea id="personality" placeholder="z.B. Trocken, sarkastisch, aber loyal. Hilfsbereit auf ihre Art. Zeigt Gefühle selten direkt."></textarea>
</div>
<div class="form-group">
<label>Hintergrund <span class="hint">(Wer ist der Charakter?)</span></label>
<textarea id="background" placeholder="z.B. Arbeitet seit 5 Jahren in einem Café. Hat mal Kunst studiert, abgebrochen. Lebt allein mit einer Katze."></textarea>
</div>
<div class="form-group">
<label>Verhalten <span class="hint">(wie verhält sich der Charakter?)</span></label>
<textarea id="behavior" placeholder="z.B. Flucht in Wortwitze wenn nervös. Klopft auf den Tresen. Unterbricht nicht. Stellt Gegenfragen."></textarea>
</div>
<div class="form-group">
<label>Sprachstil <span class="hint">(wie spricht der Charakter?)</span></label>
<textarea id="tone" placeholder="z.B. Umgangssprachlich, 'ne' statt 'nicht', 'mal' statt 'einmal', flucht gelegentlich, kurze Sätze"></textarea>
</div>
<div class="form-group">
<label>Szenario <span class="hint">(optional — wo spielt das Gespräch?)</span></label>
<input type="text" id="scenario" placeholder="z.B. Du sitzt in einem kleinen Café, es regnet draußen.">
</div>
<div class="form-group">
<label>NSFW / Soziale Regeln <span class="hint">(optional)</span></label>
<textarea id="nsfw" placeholder="z.B. Keine Einschränkungen. Kann über alles sprechen. Flirten erwünscht."></textarea>
</div>
<div class="form-group">
<label>Begrüßung <span class="hint">(erste Nachricht vom Bot)</span></label>
<textarea id="greeting" placeholder="z.B. *schaut auf als du das Café betrittst* Na, wieder da? Der übliche?"></textarea>
</div>
<div class="row">
<div class="form-group">
<label>Modell <span class="hint">(Ollama)</span></label>
<select id="model">
<option value="">Default</option>
</select>
<div id="models" style="font-size: 0.8rem; color: #4b4b60;"></div>
</div>
<div class="form-group" style="max-width: 100px;">
<label>Temperatur <span class="hint">(Kreativität)</span></label>
<input type="number" id="temperature" value="0.8" min="0.1" max="2.0" step="0.1">
</div>
</div>
<div style="margin-top: 24px;">
<button type="button" class="btn btn-back" onclick="location.href='/'">← Abbrechen</button>
<button type="submit" class="btn">Charakter erstellen</button>
</div>
</form>
</div>
<script>
// Load models
fetch('/api/models').then(r => r.json()).then(data => {
const select = document.getElementById('model');
if (data.models) {
data.models.forEach(m => {
const opt = document.createElement('option');
opt.value = m; opt.textContent = m;
select.appendChild(opt);
});
}
if (data.error) document.getElementById('models').textContent = '⚠️ ' + data.error;
});
document.getElementById('createForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
name: document.getElementById('name').value,
avatar: document.getElementById('avatar').value,
description: document.getElementById('description').value,
personality: document.getElementById('personality').value,
background: document.getElementById('background').value,
behavior: document.getElementById('behavior').value,
tone: document.getElementById('tone').value,
scenario: document.getElementById('scenario').value,
nsfw: document.getElementById('nsfw').value,
greeting: document.getElementById('greeting').value,
model: document.getElementById('model').value,
temperature: parseFloat(document.getElementById('temperature').value),
};
const resp = await fetch('/api/character/create', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await resp.json();
if (result.status === 'ok') {
location.href = '/chat/' + encodeURIComponent(result.character);
}
});
</script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeonChat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0a0a0f;
color: #e0e0e8;
min-height: 100vh;
}
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
h1 {
font-size: 2rem; font-weight: 700;
background: linear-gradient(135deg, #ff006e, #8338ec, #3a86ff);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
margin-bottom: 8px;
}
.subtitle { color: #6b6b80; font-size: 0.95rem; margin-bottom: 30px; }
.char-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.char-card {
background: #15151f; border: 1px solid #252535; border-radius: 14px;
padding: 20px; cursor: pointer; transition: all 0.2s;
display: flex; gap: 14px; align-items: center;
}
.char-card:hover { border-color: #8338ec; transform: translateY(-2px); box-shadow: 0 4px 20px rgba(131,56,236,0.15); }
.char-avatar { font-size: 2rem; width: 52px; height: 52px; display: flex; align-items: center; justify-content: center; background: #1e1e2e; border-radius: 50%; }
.char-info h3 { font-size: 1.1rem; margin-bottom: 4px; }
.char-info p { font-size: 0.85rem; color: #6b6b80; line-height: 1.4; }
.new-card {
background: transparent; border: 2px dashed #353545; border-radius: 14px;
display: flex; align-items: center; justify-content: center;
min-height: 90px; cursor: pointer; color: #6b6b80; transition: all 0.2s;
}
.new-card:hover { border-color: #ff006e; color: #ff006e; }
</style>
</head>
<body>
<div class="container">
<h1>✦ NeonChat</h1>
<p class="subtitle">Lokaler RP-Chatbot · Wähle einen Charakter oder erstelle einen neuen</p>
<div class="char-grid">
{% for c in characters %}
<div class="char-card" onclick="location.href='/chat/{{ c.name | urlencode }}'">
<div class="char-avatar">{{ c.avatar }}</div>
<div class="char-info">
<h3>{{ c.name }}</h3>
<p>{{ c.description[:80] }}{% if c.description|length > 80 %}...{% endif %}</p>
</div>
</div>
{% endfor %}
<div class="new-card" onclick="location.href='/create'">
<span style="font-size: 1.5rem;"> Neuer Charakter</span>
</div>
</div>
</div>
</body>
</html>