File: /home/panomity.de/public_html/unhinged-ai/index.php
<?php
/**
* ollama_chat_stream_dark_md.php
*
* Single-file PHP app for:
* - Dark-mode chat UI (Verdana)
* - Streaming multi-turn *chat* responses from Ollama (/api/chat)
* - System prompt loaded from ./system.txt
* - Mode switch in UI:
* - "Schnell" → huihui_ai/gemma3-abliterated:12b
* - "Durchdacht" → huihui_ai/qwen3.5-abliterated:35b
* - Assistant-Ausgabe: Markdown → HTML (clientseitig mit marked + DOMPurify, live beim Stream)
*
* Special requirement:
* - Auf die allererste User-Nachricht der Session:
* 1) Diese Nachricht wird vor den Systemprompt gestellt (als Präfix) und
* 2) zusätzlich als normale User-Nachricht gesendet.
*
* UI:
* - Minimal: keine Model-/Server-Badges, kein Admin-Bereich.
*/
declare(strict_types=1);
session_start();
// ---------------- Configuration ----------------
$OLLAMA_BASE = getenv('UNHINGED_OLLAMA_BASE') ?: 'http://127.0.0.1:11434';
$FAST_MODEL = 'huihui_ai/gemma3-abliterated:12b';
$THOUGHTFUL_MODEL = 'huihui_ai/qwen3.5-abliterated:35b';
// Optional generation settings for Ollama
$OPTIONS = [
'temperature' => 0.7,
// 'num_ctx' => 4096,
// 'top_p' => 0.9,
// 'repeat_penalty' => 1.05,
];
$UNHINGED_CONFIG = [
'ollama_base' => $OLLAMA_BASE,
'gpuq_api_base' => 'http://127.0.0.1:8020',
'execute_url' => 'https://panomity.de/unhinged-ai/' . basename(__FILE__) . '?execute=1',
'fast_model' => $FAST_MODEL,
'thoughtful_model' => $THOUGHTFUL_MODEL,
'options' => $OPTIONS,
];
require_once __DIR__ . '/chat-backend.php';
// ---------------- Routing ----------------
if (isset($_GET['execute'])) {
unhinged_handle_execute_request($UNHINGED_CONFIG);
}
if (PHP_SAPI === 'cli') {
// CLI usage: php ollama_chat_stream_dark_md.php "your prompt" [fast|deep]
$prompt = $argv[1] ?? '';
$mode = $argv[2] ?? 'fast';
if ($prompt === '') {
fwrite(STDERR, "Usage: php " . basename(__FILE__) . " \"your prompt\" [fast|deep]\n");
exit(1);
}
unhinged_stream_chat($prompt, $mode === 'deep' ? 'deep' : 'fast', $UNHINGED_CONFIG);
exit;
}
if (isset($_GET['stream'])) {
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$prompt = '';
$mode = 'fast';
if (is_array($data)) {
if (isset($data['prompt'])) $prompt = (string)$data['prompt'];
if (isset($data['mode'])) $mode = (string)$data['mode'];
} else {
$prompt = $_POST['q'] ?? $_GET['q'] ?? '';
$mode = $_POST['mode'] ?? $_GET['mode'] ?? 'fast';
}
$mode = ($mode === 'deep') ? 'deep' : 'fast';
if (trim($prompt) === '') {
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo "Missing prompt.";
exit;
}
unhinged_stream_chat($prompt, $mode, $UNHINGED_CONFIG);
exit;
}
if (isset($_GET['reset'])) {
reset_session_chat();
http_response_code(204);
exit;
}
// ---------------- HTML (Dark Chat UI + Markdown→HTML) ----------------
$history = current_history();
?>
<!doctype html>
<html lang="de" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b1020" />
<title>Chat</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Markdown parser & sanitizer -->
<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>
<style>
body { font-family: Verdana, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; }
#chatScroll::-webkit-scrollbar { width: 8px; }
#chatScroll::-webkit-scrollbar-thumb { background: #4f46e5; border-radius: 6px; }
#chatScroll::-webkit-scrollbar-track { background: #111827; }
.msg { white-space: pre-wrap; }
/* Markdown Styling (dark) */
.md blockquote { border-left: 3px solid #475569; color: #cbd5e1; padding-left: .75rem; margin: .5rem 0; }
.md h1, .md h2, .md h3, .md h4 { color: #e5e7eb; margin-top: .75rem; margin-bottom: .25rem; font-weight: 700; }
.md h1 { font-size: 1.25rem; }
.md h2 { font-size: 1.125rem; }
.md h3 { font-size: 1rem; }
.md p { margin: .25rem 0 .5rem; }
.md a { color: #93c5fd; text-decoration: underline; }
.md ul, .md ol { padding-left: 1.25rem; margin: .25rem 0 .5rem; }
.md code { background: #0b1220; border: 1px solid #1f2937; padding: .125rem .25rem; border-radius: .375rem; }
.md pre { background: #0b1220; border: 1px solid #1f2937; padding: .75rem; border-radius: .75rem; overflow: auto; }
.md pre code { background: transparent; border: none; padding: 0; }
.md table { width: 100%; border-collapse: collapse; margin: .5rem 0; }
.md th, .md td { border: 1px solid #1f2937; padding: .375rem .5rem; }
.md th { background: #0f172a; color: #e5e7eb; }
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-950 via-indigo-950 to-slate-900 text-slate-200">
<div class="max-w-4xl mx-auto p-4 sm:p-6">
<div class="rounded-2xl shadow-xl bg-slate-900 ring-1 ring-slate-800 flex flex-col h-[80vh]">
<!-- Header -->
<div class="px-4 py-3 border-b border-slate-800">
<div class="flex items-center justify-between gap-3">
<h1 class="text-lg md:text-xl font-bold tracking-tight text-white">Chat</h1>
<div class="flex items-center gap-3">
<label for="modeSelect" class="text-xs text-slate-400">Modus</label>
<select id="modeSelect" class="text-xs rounded-lg border border-slate-700 bg-slate-800 text-slate-200 px-3 py-1.5 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-600">
<option value="fast" selected>Schnell</option>
<option value="deep">Durchdacht</option>
</select>
<button id="resetBtn"
class="text-xs rounded-lg border border-slate-700 bg-slate-800 px-3 py-1.5 font-semibold text-slate-200 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-600">
Neue Unterhaltung
</button>
</div>
</div>
</div>
<!-- Chat history -->
<div id="chatScroll" class="flex-1 overflow-auto p-4 space-y-4">
<?php if (empty($history)): ?>
<div class="text-sm text-slate-400">
Starte eine Unterhaltung unten. Du benötigst viel Geduld im durchdachten Modus.
</div>
<?php else: ?>
<?php foreach ($history as $m): ?>
<?php if (($m['role'] ?? '') === 'user'): ?>
<div class="flex justify-end">
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-indigo-600 text-white px-4 py-2 shadow msg">
<?= htmlspecialchars((string)$m['content'], ENT_QUOTES, 'UTF-8') ?>
</div>
</div>
<?php elseif (($m['role'] ?? '') === 'assistant'): ?>
<div class="flex justify-start">
<!-- Wir geben hier den Rohtext aus; JS rendert ihn später als HTML -->
<div class="assistant-md max-w-[85%] rounded-2xl rounded-bl-sm bg-slate-800 text-slate-100 px-4 py-2 shadow msg md">
<?= htmlspecialchars((string)$m['content'], ENT_QUOTES, 'UTF-8') ?>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>
</div>
<!-- Composer -->
<form id="chatForm" class="border-t border-slate-800 p-3">
<div class="flex items-end gap-2">
<textarea id="prompt" name="q" rows="2"
class="flex-1 resize-y min-h-[2.5rem] max-h-48 rounded-xl border border-slate-700 bg-slate-900 text-slate-100 placeholder-slate-500 p-3 shadow-sm outline-none focus:ring-2 focus:ring-indigo-600 focus:border-indigo-600"
placeholder="Schreibe eine Nachricht…"></textarea>
<button id="sendBtn" type="submit"
class="inline-flex items-center gap-2 rounded-xl bg-indigo-600 text-white px-4 py-2 font-semibold shadow hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 disabled:opacity-60">
<svg id="spinner" class="animate-spin h-4 w-4 hidden" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a 8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<span id="btnLabel">Senden</span>
</button>
<button id="stopBtn" type="button"
class="inline-flex items-center gap-2 rounded-xl bg-slate-700 text-slate-200 px-4 py-2 font-semibold shadow hover:bg-slate-600 focus:ring-2 focus:ring-slate-500 disabled:opacity-60"
disabled>Stopp</button>
</div>
<p class="mt-1 text-xs text-slate-500">
Tipp: Mit <kbd class="px-1 py-0.5 bg-slate-800 border border-slate-700 rounded">Shift</kbd> + <kbd class="px-1 py-0.5 bg-slate-800 border border-slate-700 rounded">Enter</kbd> fügst du einen Zeilenumbruch ein.
</p>
</form>
</div>
</div>
<script>
(() => {
// --- marked setup ---
// Schönere Defaults für Chat:
marked.setOptions({
gfm: true,
breaks: true,
headerIds: false,
mangle: false
});
const form = document.getElementById('chatForm');
const promptEl = document.getElementById('prompt');
const chat = document.getElementById('chatScroll');
const sendBtn = document.getElementById('sendBtn');
const stopBtn = document.getElementById('stopBtn');
const spinner = document.getElementById('spinner');
const btnLabel = document.getElementById('btnLabel');
const resetBtn = document.getElementById('resetBtn');
const modeSel = document.getElementById('modeSelect');
const MODE_KEY = 'ollama.chat.mode';
const savedMode = localStorage.getItem(MODE_KEY);
if (savedMode === 'fast' || savedMode === 'deep') {
modeSel.value = savedMode;
}
modeSel.addEventListener('change', () => {
localStorage.setItem(MODE_KEY, modeSel.value);
});
let controller = null;
function setBusy(busy) {
sendBtn.disabled = busy;
stopBtn.disabled = !busy;
spinner.classList.toggle('hidden', !busy);
btnLabel.textContent = busy ? 'Streaming…' : 'Senden';
}
function appendUserBubble(text) {
const wrap = document.createElement('div');
wrap.className = 'flex justify-end';
const bubble = document.createElement('div');
bubble.className = 'max-w-[85%] rounded-2xl rounded-br-sm bg-indigo-600 text-white px-4 py-2 shadow msg';
bubble.textContent = text; // User: niemals HTML
wrap.appendChild(bubble);
chat.appendChild(wrap);
chat.scrollTop = chat.scrollHeight;
}
// Erstellt eine Assistenten-Blase, die Markdown → HTML rendert
function appendAssistantBubble() {
const wrap = document.createElement('div');
wrap.className = 'flex justify-start';
const bubble = document.createElement('div');
bubble.className = 'assistant-md max-w-[85%] rounded-2xl rounded-bl-sm bg-slate-800 text-slate-100 px-4 py-2 shadow md';
bubble.textContent = ''; // Start leer; wir streamen gleich hinein
// Rohpuffer pro Blase
bubble.__raw = '';
wrap.appendChild(bubble);
chat.appendChild(wrap);
chat.scrollTop = chat.scrollHeight;
return bubble;
}
// Render-Funktion: Markdown → (sanitized) HTML
function renderMarkdownTo(el, raw) {
try {
const html = marked.parse(raw);
const clean = DOMPurify.sanitize(html, {USE_PROFILES: {html: true}});
el.innerHTML = clean;
// Links sicher öffnen
for (const a of el.querySelectorAll('a')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'nofollow noopener noreferrer');
}
} catch (e) {
// Fallback: zeige Rohtext
el.textContent = raw;
}
chat.scrollTop = chat.scrollHeight;
}
// Bereits vorhandene Assistenten-Antworten (aus dem Verlauf) nachträglich rendern
function renderExistingAssistantMarkdown() {
document.querySelectorAll('.assistant-md').forEach(el => {
const raw = el.textContent || '';
el.__raw = raw;
renderMarkdownTo(el, raw);
});
}
renderExistingAssistantMarkdown();
stopBtn.addEventListener('click', () => {
if (controller) controller.abort();
setBusy(false);
});
resetBtn.addEventListener('click', async () => {
try {
await fetch(window.location.pathname + '?reset=1', { method: 'POST' });
} catch (e) {}
window.location.reload();
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const prompt = promptEl.value.trim();
if (!prompt) {
promptEl.focus();
return;
}
appendUserBubble(prompt);
promptEl.value = '';
const assistantBubble = appendAssistantBubble();
controller = new AbortController();
setBusy(true);
try {
const resp = await fetch(window.location.pathname + '?stream=1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode: modeSel.value }),
signal: controller.signal
});
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(()=>'');
assistantBubble.textContent = 'Fehler: ' + (text || (resp.status + ' ' + resp.statusText));
setBusy(false);
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// Rohtext anfügen und sofort neu rendern
assistantBubble.__raw += chunk;
renderMarkdownTo(assistantBubble, assistantBubble.__raw);
}
} catch (err) {
if (err.name !== 'AbortError') {
// Auch Fehlermeldungen in Markdown rendern
assistantBubble.__raw += '\n\n**[Netzwerkfehler]** ' + String(err);
renderMarkdownTo(assistantBubble, assistantBubble.__raw);
}
} finally {
setBusy(false);
controller = null;
}
});
// UX: Enter = senden, Shift+Enter = Zeilenumbruch
promptEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendBtn.click();
}
});
})();
</script>
</body>
</html>