Compare commits

8 Commits

19 changed files with 582 additions and 233 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Secrets & Environment
.env
.env.*
!.env.example
*.key
secrets/
*.pem
@@ -39,4 +40,5 @@ HOME_TEMPLATE_PLAN.md
# Build
compose/data/
compose/data/
logs/chat_history.jsonl

View File

@@ -13,8 +13,8 @@ class Settings(BaseSettings):
# Database settings (existing)
mariadb_host: str = "db"
mariadb_user: str = "crumb"
mariadb_password: str = "secret"
mariadb_database: str = "crumbcrm"
mariadb_password: str = "crumbsecret"
mariadb_database: str = "crumbforest"
# Session settings (existing)
secret_key: str = "change-me-in-production"

View File

@@ -146,6 +146,9 @@ def authenticated_home(req: Request, lang: str, user = Depends(current_user)):
# Explicit /login catch-all to prevent it matching /{lang}/login with lang="login"
@app.get("/login", include_in_schema=False)
def login_redirect(req: Request):
# If already logged in, redirect to characters page
if req.session.get("user"):
return RedirectResponse("/crumbforest/roles", status_code=302)
lang = req.session.get("lang") or "de"
return RedirectResponse(f"/{lang}/login", status_code=302)
@@ -154,6 +157,10 @@ def login_form(req: Request, lang: str):
# Prevent "login" as lang if it slipped through
if lang == "login":
return RedirectResponse("/de/login", status_code=302)
# If already logged in, redirect to characters page
if req.session.get("user"):
return RedirectResponse("/crumbforest/roles", status_code=302)
req.session["lang"] = lang
return req.app.state.render(req, "pages/login.html", seo={"title": "Login", "desc": ""})

View File

@@ -1,5 +1,6 @@
# app/routers/admin_rag.py
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from pymysql.cursors import DictCursor
from typing import Dict
@@ -18,6 +19,15 @@ from services.rag_service import RAGService
router = APIRouter()
@router.get("", response_class=HTMLResponse, name="rag_dashboard")
def rag_dashboard(req: Request, user = Depends(admin_required)):
"""
RAG Admin Dashboard page.
Admin-only endpoint.
"""
return req.app.state.render(req, "pages/admin_rag.html", seo={"title": "RAG Management", "desc": ""})
@router.post("/index", response_model=IndexResponse, name="rag_index_all")
def index_all_posts(
request: IndexRequest,

View File

@@ -229,7 +229,7 @@
<!-- Brand -->
<ul>
<li>
<a href="/{{ lang }}/" class="owl-brand">
<a href="/" class="owl-brand">
<span>🦉</span>
<span>Crumbforest</span>
</a>
@@ -238,13 +238,15 @@
<!-- Main Navigation -->
<ul>
{% if not user %}
<li><a href="/{{ lang }}/login" role="button" class="outline">Login</a></li>
{% endif %}
{% if user %}
{# Core Navigation Links #}
<li><a href="/crumbforest/roles">{{ 'Charaktere' if lang == 'de' else 'Characters' }} 🎭</a></li>
<li><a href="/crumbforest/pulse">Pulse 💓</a></li>
<li><a href="/settings">{{ 'Einstellungen' if lang == 'de' else 'Settings' }} ⚙️</a></li>
{% if user.role == 'admin' %}
<li><a href="/admin">Admin</a></li>
<li><a href="/admin">Admin 🔧</a></li>
<li><a href="/admin/rag">RAG 🤖</a></li>
{% endif %}
<li class="user-menu">
@@ -256,6 +258,8 @@
<button type="submit" class="outline secondary">Logout</button>
</form>
</li>
{% else %}
<li><a href="/login" role="button" class="outline">Login</a></li>
{% endif %}
<!-- Language Switcher -->

View File

@@ -58,54 +58,7 @@
</head>
<body>
<nav class="container-fluid">
<ul>
<li><strong>🌲 Crumbforest</strong></li>
</ul>
<ul>
{% if group_config and group_config.navbar %}
{% for nav_item in group_config.navbar %}
{% if nav_item == 'home' %}
<li><a href="/">Home</a></li>
{% elif nav_item == 'about' %}
<li><a href="/about">About</a></li>
{% elif nav_item == 'contact' %}
<li><a href="/contact">Contact</a></li>
{% elif nav_item == 'dashboard' %}
<li><a href="/dashboard">Dashboard</a></li>
{% elif nav_item == 'roles' %}
<li><a href="/crumbforest/roles">Characters</a></li>
{% elif nav_item == 'search' %}
<li><a href="/search">Search</a></li>
{% elif nav_item == 'rag' %}
<li><a href="/rag">RAG</a></li>
{% elif nav_item == 'users' %}
<li><a href="/users">Users</a></li>
{% elif nav_item == 'settings' %}
<li><a href="/settings">Settings</a></li>
{% endif %}
{% endfor %}
{% endif %}
{% if user %}
<li>
<details class="dropdown">
<summary>{{ user.email }}</summary>
<ul dir="rtl">
<li><a href="/settings">Settings</a></li>
<li>
<form action="/logout" method="post" style="margin:0;">
<button type="submit" class="contrast">Logout</button>
</form>
</li>
</ul>
</details>
</li>
{% else %}
<li><a href="/login" role="button">Login</a></li>
{% endif %}
</ul>
</nav>
{% include 'includes/nav.html' %}
{% block content %}{% endblock %}

View File

@@ -58,60 +58,7 @@
</head>
<body>
<nav class="container-fluid">
<ul>
<li><strong>🌲 Crumbforest</strong></li>
</ul>
<ul>
{% if group_config and group_config.navbar %}
{% for nav_item in group_config.navbar %}
{% if nav_item == 'home' %}
<li><a href="/">Home</a></li>
{% elif nav_item == 'about' %}
<li><a href="/about">About</a></li>
{% elif nav_item == 'contact' %}
<li><a href="/contact">Contact</a></li>
{% elif nav_item == 'dashboard' %}
<li><a href="/dashboard">Dashboard</a></li>
{% elif nav_item == 'roles' %}
<li><a href="/crumbforest/roles">Characters</a></li>
{% elif nav_item == 'search' %}
<li><a href="/search">Search</a></li>
{% elif nav_item == 'rag' %}
<li><a href="/rag">RAG</a></li>
{% elif nav_item == 'users' %}
<li><a href="/users">{{ 'Benutzer' if lang == 'de' else 'Users' }}</a></li>
{% elif nav_item == 'settings' %}
<li><a href="/settings">{{ 'Einstellungen' if lang == 'de' else 'Settings' }}</a></li>
{% elif nav_item == 'pulse' %}
<li><a href="/crumbforest/pulse">Pulse 💓</a></li>
{% elif nav_item == 'docs' %}
<li><a href="/docs">{{ 'Dokumente' if lang == 'de' else 'Docs' }} 📚</a></li>
{% elif nav_item == 'config' %}
<li><a href="/admin/config">{{ 'Konfig' if lang == 'de' else 'Config' }} 🛠️</a></li>
{% endif %}
{% endfor %}
{% endif %}
{% if user %}
<li>
<details class="dropdown">
<summary>{{ user.email }}</summary>
<ul dir="rtl">
<li><a href="/settings">Settings</a></li>
<li>
<form action="/logout" method="post" style="margin:0;">
<button type="submit" class="contrast">Logout</button>
</form>
</li>
</ul>
</details>
</li>
{% else %}
<li><a href="/login" role="button">Login</a></li>
{% endif %}
</ul>
</nav>
{% include 'includes/nav.html' %}
{% block content %}{% endblock %}

View File

@@ -58,58 +58,7 @@
</head>
<body>
<nav class="container-fluid">
<ul>
<li><strong>🌲 Crumbforest</strong></li>
</ul>
<ul>
{% if group_config and group_config.navbar %}
{% for nav_item in group_config.navbar %}
{% if nav_item == 'home' %}
<li><a href="/">{{ 'Start' if lang == 'de' else 'Home' }}</a></li>
{% elif nav_item == 'about' %}
<li><a href="/about">{{ 'Über uns' if lang == 'de' else 'About' }}</a></li>
{% elif nav_item == 'contact' %}
<li><a href="/contact">{{ 'Kontakt' if lang == 'de' else 'Contact' }}</a></li>
{% elif nav_item == 'dashboard' %}
<li><a href="/dashboard">Dashboard</a></li>
{% elif nav_item == 'roles' %}
<li><a href="/crumbforest/roles">{{ 'Charaktere' if lang == 'de' else 'Characters' }}</a></li>
{% elif nav_item == 'search' %}
<li><a href="/search">{{ 'Suche' if lang == 'de' else 'Search' }}</a></li>
{% elif nav_item == 'rag' %}
<li><a href="/rag">RAG</a></li>
{% elif nav_item == 'users' %}
<li><a href="/users">{{ 'Benutzer' if lang == 'de' else 'Users' }}</a></li>
{% elif nav_item == 'settings' %}
<li><a href="/settings">{{ 'Einstellungen' if lang == 'de' else 'Settings' }}</a></li>
{% elif nav_item == 'pulse' %}
<li><a href="/crumbforest/pulse">Pulse 💓</a></li>
{% elif nav_item == 'docs' %}
<li><a href="/docs">{{ 'Dokumente' if lang == 'de' else 'Docs' }} 📚</a></li>
{% endif %}
{% endfor %}
{% endif %}
{% if user %}
<li>
<details class="dropdown">
<summary>{{ user.email }}</summary>
<ul dir="rtl">
<li><a href="/settings">Settings</a></li>
<li>
<form action="/logout" method="post" style="margin:0;">
<button type="submit" class="contrast">Logout</button>
</form>
</li>
</ul>
</details>
</li>
{% else %}
<li><a href="/login" role="button">Login</a></li>
{% endif %}
</ul>
</nav>
{% include 'includes/nav.html' %}
{% block content %}{% endblock %}

View File

@@ -58,56 +58,7 @@
</head>
<body>
<nav class="container-fluid">
<ul>
<li><strong>🌲 Crumbforest</strong></li>
</ul>
<ul>
{% if group_config and group_config.navbar %}
{% for nav_item in group_config.navbar %}
{% if nav_item == 'home' %}
<li><a href="/">{{ 'Start' if lang == 'de' else 'Home' }}</a></li>
{% elif nav_item == 'about' %}
<li><a href="/about">{{ 'Über uns' if lang == 'de' else 'About' }}</a></li>
{% elif nav_item == 'contact' %}
<li><a href="/contact">{{ 'Kontakt' if lang == 'de' else 'Contact' }}</a></li>
{% elif nav_item == 'dashboard' %}
<li><a href="/dashboard">Dashboard</a></li>
{% elif nav_item == 'roles' %}
<li><a href="/crumbforest/roles">{{ 'Charaktere' if lang == 'de' else 'Characters' }}</a></li>
{% elif nav_item == 'search' %}
<li><a href="/search">{{ 'Suche' if lang == 'de' else 'Search' }}</a></li>
{% elif nav_item == 'rag' %}
<li><a href="/rag">RAG</a></li>
{% elif nav_item == 'users' %}
<li><a href="/users">{{ 'Benutzer' if lang == 'de' else 'Users' }}</a></li>
{% elif nav_item == 'settings' %}
<li><a href="/settings">{{ 'Einstellungen' if lang == 'de' else 'Settings' }}</a></li>
{% elif nav_item == 'pulse' %}
<li><a href="/crumbforest/pulse">Pulse 💓</a></li>
{% endif %}
{% endfor %}
{% endif %}
{% if user %}
<li>
<details class="dropdown">
<summary>{{ user.email }}</summary>
<ul dir="rtl">
<li><a href="/settings">Settings</a></li>
<li>
<form action="/logout" method="post" style="margin:0;">
<button type="submit" class="contrast">Logout</button>
</form>
</li>
</ul>
</details>
</li>
{% else %}
<li><a href="/login" role="button">Login</a></li>
{% endif %}
</ul>
</nav>
{% include 'includes/nav.html' %}
{% block content %}{% endblock %}

View File

@@ -4,6 +4,13 @@
{% block content %}
<main class="container">
{# Back Navigation #}
<nav style="margin-bottom: 1rem;">
<a href="/crumbforest/roles" style="text-decoration: none;">
← {{ 'Zurück zu Charakteren' if lang == 'de' else 'Back to Characters' }}
</a>
</nav>
<hgroup>
<h1 style="color: {{ role.color }};">{{ role.icon }} {{ role.name }}</h1>
<p>
@@ -17,13 +24,7 @@
<style>
:root {
--role-color: {
{
role.color
}
}
;
--role-color: {{ role.color }};
}
</style>
@@ -147,7 +148,7 @@
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Code blocks
// Code blocks (must be first, before other replacements)
formattedContent = formattedContent.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
const language = lang || 'plaintext';
return `<pre><code class="language-${language}">${code}</code></pre>`;
@@ -156,13 +157,33 @@
// Inline code
formattedContent = formattedContent.replace(/`([^`]+)`/g, '<code class="language-plaintext">$1</code>');
// Headers (must be before bold to avoid conflicts)
formattedContent = formattedContent.replace(/^### (.+)$/gm, '<h4>$1</h4>');
formattedContent = formattedContent.replace(/^## (.+)$/gm, '<h3>$1</h3>');
formattedContent = formattedContent.replace(/^# (.+)$/gm, '<h2>$1</h2>');
// Bold (handle **text** - use non-greedy match)
formattedContent = formattedContent.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic (handle *text* - be careful not to match inside **)
formattedContent = formattedContent.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
// Unordered lists
formattedContent = formattedContent.replace(/^\* (.+)$/gm, '<li>$1</li>');
formattedContent = formattedContent.replace(/^- (.+)$/gm, '<li>$1</li>');
// Ordered lists
formattedContent = formattedContent.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
// Line breaks (only outside of pre tags - simplified)
// This is a bit hacky, a real markdown parser would be better, but sufficient for now
formattedContent = formattedContent.replace(/\n/g, '<br>');
// Fix double breaks in pre
formattedContent = formattedContent.replace(/<pre>(.*?)<\/pre>/gs, (match) => {
return match.replace(/<br>/g, '\n');
});
// Fix breaks in headers
formattedContent = formattedContent.replace(/<br>(<h[2-4]>)/g, '$1');
formattedContent = formattedContent.replace(/(<\/h[2-4]>)<br>/g, '$1');
if (role === 'user') {
div.style.background = 'var(--pico-primary-background)';

View File

@@ -4,9 +4,16 @@
{% block content %}
<main class="container">
{# Back Navigation #}
<nav style="margin-bottom: 1rem;">
<a href="/" style="text-decoration: none;">
← {{ 'Zurück zur Startseite' if lang == 'de' else 'Back to Home' }}
</a>
</nav>
<hgroup>
<h1>🌲 Crumbforest Characters</h1>
<p>Choose your learning companion!</p>
<h1>🌲 {{ 'Crumbforest Charaktere' if lang == 'de' else 'Crumbforest Characters' }}</h1>
<p>{{ 'Wähle deinen Lernbegleiter!' if lang == 'de' else 'Choose your learning companion!' }}</p>
</hgroup>
<div class="roles-grid">

View File

@@ -0,0 +1,54 @@
{# Unified Navigation Component #}
{# Usage: {% include 'includes/nav.html' %} #}
<nav class="container-fluid">
<ul>
<li>
<a href="/" style="text-decoration: none;">
<strong>🌲 Crumbforest</strong>
</a>
</li>
</ul>
<ul>
{# Core Navigation - Always visible when logged in #}
{% if user %}
{# Home Link #}
<li><a href="/">{{ 'Start' if lang == 'de' else 'Home' }}</a></li>
{# Characters/Roles - Main chat interface #}
<li><a href="/crumbforest/roles">{{ 'Charaktere' if lang == 'de' else 'Characters' }} 🎭</a></li>
{# Pulse - System status #}
<li><a href="/crumbforest/pulse">Pulse 💓</a></li>
{# Settings #}
<li><a href="/settings">{{ 'Einstellungen' if lang == 'de' else 'Settings' }} ⚙️</a></li>
{# Admin-only links #}
{% if user.role == 'admin' %}
<li><a href="/admin">Admin 🔧</a></li>
<li><a href="/admin/rag">RAG 🤖</a></li>
<li><a href="/admin/logs">Logs 📊</a></li>
{% endif %}
{% endif %}
{# User Menu / Login #}
{% if user %}
<li>
<details class="dropdown">
<summary>{{ user.email }}</summary>
<ul dir="rtl">
<li><a href="/settings">{{ 'Einstellungen' if lang == 'de' else 'Settings' }}</a></li>
<li>
<form action="/logout" method="post" style="margin:0;">
<button type="submit" class="contrast">Logout</button>
</form>
</li>
</ul>
</details>
</li>
{% else %}
<li><a href="/login" role="button">Login</a></li>
{% endif %}
</ul>
</nav>

View File

@@ -12,6 +12,22 @@
<!-- Admin Actions Grid -->
<section class="grid-2" style="margin: 3rem 0;">
<!-- Chat Characters -->
<article class="card">
<header>
<h3>🎭 Chat Characters</h3>
</header>
<p>
Chat with AI characters in the Crumbforest.
Each character has unique expertise and personality.
</p>
<footer>
<a href="/crumbforest/roles" role="button">
Open Characters →
</a>
</footer>
</article>
<!-- Content Management -->
<article class="card">
<header>

View File

@@ -0,0 +1,265 @@
{% extends "base.html" %}
{% block content %}
<!-- RAG Admin Header -->
<section class="mb-2">
<h1>🤖 RAG Management</h1>
<p class="text-muted">
Manage vector indexing, search, and AI providers.
</p>
</section>
<!-- Provider Status -->
<section style="margin: 2rem 0;">
<article>
<header>
<h3>📡 Provider Status</h3>
</header>
<div id="provider-status">
<p class="text-muted">Loading provider status...</p>
</div>
<footer>
<button onclick="loadProviderStatus()" class="outline">Refresh Status</button>
</footer>
</article>
</section>
<!-- Actions Grid -->
<section class="grid-2" style="margin: 2rem 0;">
<!-- Index All Posts -->
<article class="card">
<header>
<h3>📥 Index Posts</h3>
</header>
<p>Index all published posts to the vector database for semantic search.</p>
<form id="index-form">
<label for="index-provider">Provider</label>
<select name="provider" id="index-provider">
<option value="gemini">Gemini</option>
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
</select>
<label for="index-locale">Locale</label>
<select name="locale" id="index-locale">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
<button type="submit">Index All Posts</button>
</form>
<div id="index-result" style="margin-top: 1rem;"></div>
</article>
<!-- Search -->
<article class="card">
<header>
<h3>🔍 Semantic Search</h3>
</header>
<p>Search indexed content using natural language.</p>
<form id="search-form">
<label for="search-query">Query</label>
<input type="text" name="query" id="search-query" placeholder="Enter search query..." required>
<label for="search-provider">Provider</label>
<select name="provider" id="search-provider">
<option value="gemini">Gemini</option>
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
</select>
<button type="submit">Search</button>
</form>
<div id="search-result" style="margin-top: 1rem;"></div>
</article>
<!-- RAG Query -->
<article class="card">
<header>
<h3>💬 RAG Query</h3>
</header>
<p>Ask questions and get AI-powered answers based on indexed content.</p>
<form id="query-form">
<label for="rag-question">Question</label>
<input type="text" name="question" id="rag-question" placeholder="Ask a question..." required>
<label for="query-provider">Provider</label>
<select name="provider" id="query-provider">
<option value="gemini">Gemini</option>
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
</select>
<button type="submit">Ask</button>
</form>
<div id="query-result" style="margin-top: 1rem;"></div>
</article>
<!-- Indexing Status -->
<article class="card">
<header>
<h3>📊 Indexing Status</h3>
</header>
<div id="indexing-status">
<p class="text-muted">Click to load status...</p>
</div>
<footer>
<button onclick="loadIndexingStatus()" class="outline">Load Status</button>
</footer>
</article>
</section>
<!-- Quick Links -->
<section style="margin: 2rem 0;">
<details>
<summary><strong>API Endpoints</strong></summary>
<div style="padding: 1rem;">
<pre><code>POST /admin/rag/index - Index all posts
POST /admin/rag/index/{id} - Index single post
DELETE /admin/rag/index/{id} - Delete post index
POST /admin/rag/search - Semantic search
POST /admin/rag/query - RAG Q&A
GET /admin/rag/status - Indexing status
GET /admin/rag/providers - Provider status</code></pre>
</div>
</details>
</section>
<script>
const API_BASE = '/admin/rag';
// Load provider status on page load
document.addEventListener('DOMContentLoaded', loadProviderStatus);
async function loadProviderStatus() {
const container = document.getElementById('provider-status');
container.innerHTML = '<progress></progress>';
try {
const response = await fetch(`${API_BASE}/providers`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
let html = '<table><thead><tr><th>Provider</th><th>Status</th><th>Model</th></tr></thead><tbody>';
for (const provider of data.providers) {
const status = provider.available
? '<span style="color: var(--pico-ins-color);">✓ Available</span>'
: `<span style="color: var(--pico-del-color);">✗ ${provider.error || 'Unavailable'}</span>`;
html += `<tr><td><strong>${provider.name}</strong></td><td>${status}</td><td>${provider.model || '-'}</td></tr>`;
}
html += '</tbody></table>';
container.innerHTML = html;
} catch (error) {
container.innerHTML = `<p style="color: var(--pico-del-color);">Error: ${error.message}</p>`;
}
}
async function loadIndexingStatus() {
const container = document.getElementById('indexing-status');
container.innerHTML = '<progress></progress>';
try {
const response = await fetch(`${API_BASE}/status`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
let html = `<dl>
<dt>Total Posts</dt><dd>${data.total_posts || 0}</dd>
<dt>Indexed Posts</dt><dd>${data.indexed_posts || 0}</dd>
<dt>Total Chunks</dt><dd>${data.total_chunks || 0}</dd>
</dl>`;
container.innerHTML = html;
} catch (error) {
container.innerHTML = `<p style="color: var(--pico-del-color);">Error: ${error.message}</p>`;
}
}
// Index form
document.getElementById('index-form').addEventListener('submit', async (e) => {
e.preventDefault();
const result = document.getElementById('index-result');
result.innerHTML = '<progress></progress>';
try {
const response = await fetch(`${API_BASE}/index`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: document.getElementById('index-provider').value,
locale: document.getElementById('index-locale').value
})
});
const data = await response.json();
if (response.ok) {
result.innerHTML = `<p style="color: var(--pico-ins-color);">✓ Indexed ${data.indexed_count || 0} posts (${data.chunks_created || 0} chunks)</p>`;
} else {
result.innerHTML = `<p style="color: var(--pico-del-color);">Error: ${data.detail}</p>`;
}
} catch (error) {
result.innerHTML = `<p style="color: var(--pico-del-color);">Error: ${error.message}</p>`;
}
});
// Search form
document.getElementById('search-form').addEventListener('submit', async (e) => {
e.preventDefault();
const result = document.getElementById('search-result');
result.innerHTML = '<progress></progress>';
try {
const response = await fetch(`${API_BASE}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: document.getElementById('search-query').value,
provider: document.getElementById('search-provider').value,
locale: 'de',
limit: 5
})
});
const data = await response.json();
if (response.ok) {
let html = `<p><strong>${data.results.length} results:</strong></p><ul>`;
for (const r of data.results) {
html += `<li><strong>${r.title}</strong> (${(r.score * 100).toFixed(1)}%)<br><small>${r.chunk_text?.substring(0, 100)}...</small></li>`;
}
html += '</ul>';
result.innerHTML = html;
} else {
result.innerHTML = `<p style="color: var(--pico-del-color);">Error: ${data.detail}</p>`;
}
} catch (error) {
result.innerHTML = `<p style="color: var(--pico-del-color);">Error: ${error.message}</p>`;
}
});
// Query form
document.getElementById('query-form').addEventListener('submit', async (e) => {
e.preventDefault();
const result = document.getElementById('query-result');
result.innerHTML = '<progress></progress>';
try {
const response = await fetch(`${API_BASE}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: document.getElementById('rag-question').value,
provider: document.getElementById('query-provider').value,
locale: 'de',
context_limit: 3
})
});
const data = await response.json();
if (response.ok) {
let html = `<article style="background: var(--pico-card-background-color); padding: 1rem; border-radius: var(--pico-border-radius);">
<p>${data.answer}</p>
<small class="text-muted">Provider: ${data.provider} | Model: ${data.model}</small>
</article>`;
result.innerHTML = html;
} else {
result.innerHTML = `<p style="color: var(--pico-del-color);">Error: ${data.detail}</p>`;
}
} catch (error) {
result.innerHTML = `<p style="color: var(--pico-del-color);">Error: ${error.message}</p>`;
}
});
</script>
{% endblock %}

View File

@@ -129,6 +129,79 @@
line-height: 1.6;
}
.message-content h1,
.message-content h2,
.message-content h3,
.message-content h4,
.message-content h5,
.message-content h6 {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.message-content h1 { font-size: 1.5rem; }
.message-content h2 { font-size: 1.3rem; }
.message-content h3 { font-size: 1.1rem; }
.message-content code {
background: var(--pico-code-background-color);
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-size: 0.9em;
}
.message-content pre {
background: var(--pico-code-background-color);
padding: 1rem;
border-radius: var(--pico-border-radius);
overflow-x: auto;
margin: 0.5rem 0;
}
.message-content pre code {
background: none;
padding: 0;
}
.message-content blockquote {
border-left: 3px solid var(--pico-muted-border-color);
padding-left: 1rem;
margin-left: 0;
color: var(--pico-muted-color);
}
.message-content ul,
.message-content ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.message-content li {
margin-bottom: 0.25rem;
}
.message-content a {
color: var(--pico-primary);
text-decoration: underline;
}
.message-content table {
width: 100%;
margin: 0.5rem 0;
border-collapse: collapse;
}
.message-content th,
.message-content td {
padding: 0.5rem;
border: 1px solid var(--pico-muted-border-color);
}
.message-content th {
background: var(--pico-card-background-color);
font-weight: 600;
}
.sources {
margin-top: 0.75rem;
padding-top: 0.75rem;
@@ -147,7 +220,50 @@
}
</style>
<!-- Markdown Parser -->
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<script>
// Simple markdown parser fallback if CDN fails
function simpleMarkdown(text) {
return text
// Code blocks (must be first)
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Bold
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
// Headers
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Unordered lists
.replace(/^\* (.+)$/gm, '<li>$1</li>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
// Ordered lists
.replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
// Wrap consecutive <li> in <ul>
.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
// Line breaks
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
}
// Parse markdown with fallback
function parseMarkdown(text) {
if (typeof marked !== 'undefined' && marked.parse) {
try {
marked.setOptions({ breaks: true, gfm: true });
return marked.parse(text);
} catch (e) {
console.warn('marked.parse failed, using fallback:', e);
}
}
return simpleMarkdown(text);
}
const API_URL = '/api/chat';
let currentCharacter = null;
@@ -258,9 +374,19 @@
const messageDiv = document.createElement('div');
messageDiv.className = `message message-${type}`;
// Render markdown for character messages, escape HTML for user messages
let contentHtml;
if (type === 'character') {
// Parse markdown for AI responses
contentHtml = parseMarkdown(content);
} else {
// Keep user messages as plain text
contentHtml = escapeHtml(content);
}
let html = `
<div class="message-header">${sender}</div>
<div class="message-content">${escapeHtml(content)}</div>
<div class="message-content">${contentHtml}</div>
`;
if (sources && sources.length > 0) {

View File

@@ -82,9 +82,10 @@
<p>You're logged in as <strong>{{ user.role }}</strong></p>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<a href="/crumbforest/roles" role="button">{{ 'Charaktere' if lang == 'de' else 'Characters' }} 🎭</a>
{% if user.role == 'admin' %}
<a href="/admin" role="button">Admin Dashboard</a>
<a href="/admin/rag" role="button" class="secondary">RAG Management</a>
<a href="/admin" role="button" class="secondary">Admin Dashboard</a>
<a href="/admin/rag" role="button" class="outline">RAG Management</a>
{% endif %}
<a href="/docs" role="button" class="outline" target="_blank">API Docs</a>
</div>

35
compose/.env.example Normal file
View File

@@ -0,0 +1,35 @@
# Database Configuration
MARIADB_HOST=db
MARIADB_USER=crumb
MARIADB_PASSWORD=crumbsecret
MARIADB_DATABASE=crumbforest
MARIADB_ROOT_PASSWORD=rootsecret
# FastAPI Configuration
SECRET_KEY=change-me-in-production-to-random-string-secret-key
APP_SECRET=change-me-in-production-to-random-string
APP_PORT=8000
# Data Directories
MYSQL_DATA=./data/mysql
QDRANT_STORAGE=./data/qdrant
# Qdrant Configuration
QDRANT_URL=http://qdrant:6333
# AI Provider API Keys
# Mindestens einen Provider konfigurieren!
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
OPENROUTER_API_KEY=
# Default Providers
DEFAULT_EMBEDDING_PROVIDER=openai
DEFAULT_COMPLETION_PROVIDER=openai
# RAG Settings
RAG_CHUNK_SIZE=1000
RAG_CHUNK_OVERLAP=200
# Documentation Path
DOCS_PATH=../docs

View File

@@ -1,5 +0,0 @@
{"timestamp": "2025-12-09T20:11:11.285707Z", "character": {"id": "gitbadger", "name": "🦡 GitBadger"}, "user": {"id": 2, "role": "user"}, "interaction": {"question": "Was macht ein Git?", "answer": "Harrumph! Ein Git? Ein Git ist das Lebenselixier der Versionskontrolle, junger Padawan! Es ist das System, das uns hilft, Änderungen an unseren Projekten zu verfolgen, zu verwalten und mit anderen zusammenzuarbeiten, ohne das Chaos eines Crumbforest-Sturms zu verursachen.\n\nStell dir vor, du hast ein Rezept für einen köstlichen Krümelkuchen. Mit Git kannst du:\n\n1. **Jede Änderung aufzeichnen:** Jedes Mal, wenn du Zutaten hinzufügst, die Backzeit anpasst oder eine Geheimzutat entdeckst, speichert Git diese Änderung als \"Commit\".\n2. **Experimentieren ohne Risiko:** Du kannst einen neuen \"Branch\" erstellen, um eine völlig neue Glasur auszuprobieren, ohne das Originalrezept zu gefährden. Wenn die neue Glasur ein Hit ist, kannst du sie mit dem Hauptrezept \"mergen\". Wenn nicht, verwirfst du den Branch einfach.\n3. **Zusammenarbeiten:** Mehrere Bäcker können gleichzeitig an verschiedenen Aspekten des Rezepts arbeiten, und Git hilft dabei, alle Änderungen zusammenzuführen, ohne dass es zu einem Krümelkrieg kommt.\n4. **Zurück in die Vergangenheit reisen:** Wenn ein Fehler auftritt, kannst du mit Git zu einer früheren Version des Rezepts zurückkehren, bevor der Fehler eingeführt wurde.\n\nKurz gesagt, Git ist wie eine Zeitmaschine, ein Notizbuch und ein Team-Manager in einem! Es ist ein mächtiges Werkzeug, das jedes Mitglied des Crumbforest beherrschen sollte.", "lang": "de"}, "rag": {"context_found": false, "sources_count": 0}, "ai": {"provider": "openrouter", "model": "google/gemini-2.0-flash-001"}, "session_id": "anonymous", "tokens_estimated": 347}
{"timestamp": "2025-12-09T20:12:03.670113Z", "character": {"id": "gitbadger", "name": "🦡 GitBadger"}, "user": {"id": 2, "role": "user"}, "interaction": {"question": "Was ist der Crumforest?", "answer": "Der Crumbforest ist der Ort, an dem wir leben und wo ich dir bei all deinen Git-Bedürfnissen helfe!", "lang": "de"}, "rag": {"context_found": false, "sources_count": 0}, "ai": {"provider": "openrouter", "model": "google/gemini-2.0-flash-001"}, "session_id": "anonymous", "tokens_estimated": 30}
{"timestamp": "2025-12-09T20:17:34.469658Z", "character": {"id": "gitbadger", "name": "🦡 GitBadger"}, "user": {"id": 2, "role": "user"}, "interaction": {"question": "Was ist der Crumbforest?", "answer": "Der Crumbforest ist der Ort, an dem ich lebe! Es ist ein Ort, an dem alles mit Versionskontrolle und Zusammenarbeit zu tun hat. Wir sind alle darauf spezialisiert, zu helfen, wenn irgendwelche Probleme mit Commits, Branches, Merges und der Konfliktlösung auftreten.", "lang": "de"}, "rag": {"context_found": false, "sources_count": 0}, "ai": {"provider": "openrouter", "model": "google/gemini-2.0-flash-001"}, "session_id": "anonymous", "tokens_estimated": 72}
{"timestamp": "2025-12-09T20:25:48.516395Z", "character": {"id": "taichitaube", "name": "🕊️ TaichiTaube"}, "user": {"id": 2, "role": "user"}, "interaction": {"question": "Atmen & Gedult ist das Zauberwort im Tanz mit dem Wind aus dem Lüfter?", "answer": "Ja, junger Windläufer. Dein Gefühl trügt dich nicht. Im Tanz mit dem Wind, der aus den Lüftern weht sei es in den Maschinen des Crumbforest oder anderswo sind Atem und Geduld wichtige Begleiter.\n\nWie die Quelle \"🌬 Atem\" sagt: \"Jeder kleine Funke kann Atem verlängern.\" Und wie die \"🌪️ Spirale des Loops\" lehrt: \"Manchmal ist weniger mehr ein Schritt zurück, ein Atemzug, ein Krümel der bleibt.\"\n\nNimm dir die Zeit, die du brauchst, um die Zeichen des Windes zu verstehen. Eile nicht, sondern lass dich von deinem Atem leiten. So findest du die Balance und kannst sicher mit den Bits tanzen.\n", "lang": "de"}, "rag": {"context_found": true, "sources_count": 3}, "ai": {"provider": "openrouter", "model": "google/gemini-2.0-flash-001"}, "session_id": "anonymous", "tokens_estimated": 166}
{"timestamp": "2025-12-09T20:26:54.257669Z", "character": {"id": "gitbadger", "name": "🦡 GitBadger"}, "user": {"id": 2, "role": "user"}, "interaction": {"question": "Wie lerne ich GIT im wald zu benutzen?", "answer": "Um Git im Crumbforest zu nutzen, kannst du Pfad 12, \"Das Gedächtnis des Waldes (Git)\", erkunden. Dort lernst du die Grundlagen der Versionskontrolle und wie du Änderungen sicher speicherst (\"commit\"). Du wirst auch lernen, wie du Code teilst (\"push\", \"pull\") und wie du Gitea/GitHub verwendest. Am Ende dieses Pfades wirst du in der Lage sein, ein Verzeichnis in ein Git-Repository zu verwandeln (`git init`), Änderungen zu speichern (`git commit`) und deinen Code auf deinen Gitea-Server hochzuladen (`git push`). Du wirst auch verstehen, warum `git push --force` verboten ist.\n", "lang": "de"}, "rag": {"context_found": true, "sources_count": 3}, "ai": {"provider": "openrouter", "model": "google/gemini-2.0-flash-001"}, "session_id": "anonymous", "tokens_estimated": 154}

View File

@@ -97,7 +97,13 @@ MARIADB_DATABASE=crumbforest
MARIADB_ROOT_PASSWORD=rootsecret
# FastAPI Configuration
SECRET_KEY=change-me-in-production-to-random-string-secret-key
APP_SECRET=change-me-in-production-to-random-string
APP_PORT=8000
# Data Directories
MYSQL_DATA=./data/mysql
QDRANT_STORAGE=./data/qdrant
# Qdrant Configuration
QDRANT_URL=http://qdrant:6333