Files
Mortdecai/web/chat_app.py
T
Mortdecai 9c2c9a2310 1200+ distilled gold examples, journal system, redstone mastery, safety awareness
Distilled Training Data (1,203 examples):
- 341 initial gold (plugins, enchantments, builds, effects, god, errors)
- 165 buildings + pipeline (100 structures built on dev, 65 request→query→act)
- 24 safety-aware (worldborder, safe tp, intentional harm, gamemode checks)
- 17 advanced logic (decanonized items, redstone gates, iterative builds)
- 12 redstone mastery (NOT/OR/AND/XOR/RS-latch/T-flip-flop/comparator/clock)
- 7 circuit verification and diagnosis
- 1 compact comparator gates
- 10 redstone methodology (build→test→save→recall→learn from mistakes)
- 8 player journal usage
- 29 creative+uncommon+pipeline+god with full tool chains

Player Journal System:
- agent/tools/player_journal.py — per-player text files (1-10 lines)
- journal.read + journal.write tool schemas added
- Cross-contaminated: God and Sudo share same journal per player
- Includes sentiment, relationship, builds, preferences, skill level

Redstone Engineering:
- agent/prompts/redstone_rules.md — baked-in wall torch, dedicated lead, repeater rules
- Learned from 4 iterations of 8-switch circuit: wall_torch on back face, not top
- T-junction bypass prevention: dedicated lead wire between merge and NOT block
- RCON limitation: can build circuits but cannot test them (lever toggle doesn't propagate)

Training Data Cleaning:
- 466 @s→@p fixes, 10 template commands removed
- 12 outdated refusals replaced with correct plugin commands
- Data de-duped across all sources

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:50:52 -04:00

1307 lines
40 KiB
Python

#!/usr/bin/env python3
"""
Mortdecai Chat App — interactive dev console for testing gateway and model calls.
Features:
- Mode switch: Gateway call (full pipeline) vs Model call (direct Ollama)
- Template selector powered by training/prompts/ JSONL files
- Editable JSON payload with live trace log (SSE streaming)
- Training data export — save interactions for audit/training
- Branding: Rajdhani Bold, Sethian orange (#D35400), dark theme
Usage:
python3 web/chat_app.py --port 8097
Deploy on CT 650 @ pve112, serve as chat.api.mortdec.ai behind Caddy + Google OAuth.
"""
import argparse
import json
import os
import sys
import time
import threading
import traceback
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from datetime import datetime
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# Lazy imports — agent modules may not be on every machine
def _get_assistant(config):
from agent.serve import MinecraftAssistant
return MinecraftAssistant(config)
def _get_prompts_module():
from training.prompts.loader import load_manifest, load_prompts, load_prompt_entries, expand_template, TEMPLATE_VARS
return load_manifest, load_prompts, load_prompt_entries, expand_template, TEMPLATE_VARS
PORT = 8097
LOG_DIR = PROJECT_ROOT / "data" / "chat_logs"
TRAINING_LOG = LOG_DIR / "training_export.jsonl"
# Default call configs
DEFAULT_GATEWAY_CONFIG = {
"mode": "sudo",
"player": "slingshooter08",
"query": "",
"ollama_url": "http://192.168.0.141:11434",
"model": "mortdecai:0.5.0-f16",
"rcon_host": "192.168.0.244",
"rcon_port": 25578,
"rcon_password": "REDACTED_RCON",
"temperature": 0.7,
"max_tokens": 800,
}
DEFAULT_MODEL_CONFIG = {
"model": "mortdecai:0.5.0-f16",
"ollama_url": "http://192.168.0.141:11434",
"messages": [
{"role": "system", "content": "You are a Minecraft 1.21 command translator for a Paper server.\nPlugins: FastAsyncWorldEdit, WorldGuard, CoreProtect, EssentialsX, Vault, LuckPerms.\nReturn JSON: {\"commands\": [...], \"reasoning\": \"...\", \"message\": \"...\"}\nUse /no_think mode."},
{"role": "user", "content": "Player slingshooter08: sudo give me diamond armor"}
],
"temperature": 0.7,
"max_tokens": 800,
"format": "json",
}
# ── HTML / CSS / JS ──────────────────────────────────────────────────────────
HTML_PAGE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mortdecai Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--orange: #D35400;
--orange-dim: #a34200;
--orange-glow: rgba(211,84,0,0.25);
--bg-0: #0d0d0d;
--bg-1: #141414;
--bg-2: #1c1c1c;
--bg-3: #252525;
--border: #2a2a2a;
--border-hi: #3a3a3a;
--text: #d4d4d4;
--text-dim: #777;
--text-bright: #f0f0f0;
--green: #27ae60;
--green-dim: rgba(39,174,96,0.15);
--red: #c0392b;
--red-dim: rgba(192,57,43,0.15);
--blue: #2980b9;
--blue-dim: rgba(41,128,185,0.15);
--yellow: #f39c12;
--yellow-dim: rgba(243,156,18,0.15);
--purple: #8e44ad;
--purple-dim: rgba(142,68,173,0.15);
--cyan: #00bcd4;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Rajdhani', sans-serif;
background: var(--bg-0);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Noise overlay ── */
body::before {
content: '';
position: fixed;
inset: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' fill='%23fff' opacity='0.02'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
}
/* ── Top Bar ── */
.topbar {
background: var(--bg-1);
border-bottom: 2px solid var(--orange);
padding: 0 20px;
height: 56px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
position: relative;
}
.topbar::after {
content: '';
position: absolute;
bottom: -6px;
left: 0; right: 0;
height: 4px;
background: linear-gradient(180deg, var(--orange-glow), transparent);
}
.logo {
font-weight: 700;
font-size: 22px;
color: var(--orange);
letter-spacing: 2px;
text-transform: uppercase;
margin-right: 8px;
white-space: nowrap;
}
.logo span {
color: var(--text-dim);
font-weight: 400;
font-size: 14px;
letter-spacing: 1px;
margin-left: 6px;
}
.sep {
width: 1px;
height: 28px;
background: var(--border-hi);
flex-shrink: 0;
}
.ctrl-group {
display: flex;
align-items: center;
gap: 8px;
}
.ctrl-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
}
select, .btn {
font-family: 'Rajdhani', sans-serif;
font-weight: 600;
font-size: 14px;
background: var(--bg-3);
color: var(--text-bright);
border: 1px solid var(--border-hi);
padding: 6px 12px;
cursor: pointer;
transition: all 0.15s;
}
select:hover, .btn:hover {
border-color: var(--orange);
background: var(--bg-2);
}
select:focus, .btn:focus {
outline: none;
border-color: var(--orange);
box-shadow: 0 0 0 2px var(--orange-glow);
}
select { padding-right: 28px; }
.btn-send {
background: var(--orange);
color: #fff;
border-color: var(--orange);
padding: 6px 20px;
letter-spacing: 1px;
text-transform: uppercase;
font-size: 13px;
}
.btn-send:hover {
background: var(--orange-dim);
}
.btn-send:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-save {
background: var(--bg-3);
border-color: var(--green);
color: var(--green);
font-size: 12px;
padding: 5px 12px;
}
.btn-save:hover { background: var(--green-dim); }
.btn-clear {
background: var(--bg-3);
border-color: var(--border-hi);
color: var(--text-dim);
font-size: 12px;
padding: 5px 12px;
}
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
display: inline-block;
}
.status-dot.busy {
background: var(--orange);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Main Split ── */
.main {
flex: 1;
display: flex;
overflow: hidden;
}
/* ── Left Pane: Payload Editor ── */
.pane-left {
width: 45%;
min-width: 380px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
}
.pane-header {
background: var(--bg-2);
border-bottom: 1px solid var(--border);
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.pane-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
}
.pane-body {
flex: 1;
overflow: hidden;
position: relative;
}
#payload-editor {
width: 100%;
height: 100%;
background: var(--bg-1);
color: var(--cyan);
border: none;
padding: 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.6;
resize: none;
outline: none;
tab-size: 2;
}
#payload-editor::selection {
background: var(--orange-glow);
}
/* ── Right Pane: Trace Log ── */
.pane-right {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
#trace-log {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
background: var(--bg-0);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.7;
}
#trace-log::-webkit-scrollbar {
width: 6px;
}
#trace-log::-webkit-scrollbar-track {
background: var(--bg-1);
}
#trace-log::-webkit-scrollbar-thumb {
background: var(--border-hi);
border-radius: 3px;
}
.trace-entry {
margin-bottom: 2px;
padding: 3px 8px;
border-left: 3px solid transparent;
animation: fadeIn 0.2s ease-out;
cursor: pointer;
}
.trace-entry:hover {
filter: brightness(1.15);
}
.trace-expand {
display: none;
margin-top: 4px;
padding: 8px 10px;
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: 2px;
font-size: 11px;
line-height: 1.5;
color: var(--cyan);
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
}
.trace-entry.expanded .trace-expand {
display: block;
animation: fadeIn 0.15s ease-out;
}
.trace-chevron {
display: inline-block;
width: 12px;
font-size: 9px;
color: var(--text-dim);
transition: transform 0.15s;
}
.trace-entry.expanded .trace-chevron {
transform: rotate(90deg);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-4px); }
to { opacity: 1; transform: translateX(0); }
}
.trace-entry.type-system { border-color: var(--text-dim); color: var(--text-dim); }
.trace-entry.type-context { border-color: var(--blue); background: var(--blue-dim); }
.trace-entry.type-llm { border-color: var(--orange); background: var(--orange-glow); }
.trace-entry.type-guard { border-color: var(--yellow); background: var(--yellow-dim); }
.trace-entry.type-rcon { border-color: var(--green); background: var(--green-dim); }
.trace-entry.type-rcon-fail { border-color: var(--red); background: var(--red-dim); }
.trace-entry.type-error { border-color: var(--red); background: var(--red-dim); color: var(--red); }
.trace-entry.type-result { border-color: var(--purple); background: var(--purple-dim); }
.trace-entry.type-saved { border-color: var(--green); background: var(--green-dim); color: var(--green); }
.trace-ts {
color: var(--text-dim);
font-size: 11px;
margin-right: 8px;
}
.trace-tag {
display: inline-block;
padding: 0 5px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-right: 6px;
border-radius: 2px;
}
.trace-tag.ctx { background: var(--blue); color: #fff; }
.trace-tag.llm { background: var(--orange); color: #fff; }
.trace-tag.guard { background: var(--yellow); color: #000; }
.trace-tag.rcon { background: var(--green); color: #fff; }
.trace-tag.err { background: var(--red); color: #fff; }
.trace-tag.res { background: var(--purple); color: #fff; }
/* ── Bottom Bar ── */
.bottombar {
background: var(--bg-1);
border-top: 1px solid var(--border);
padding: 6px 20px;
font-size: 11px;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
.bottombar .stat { font-family: 'JetBrains Mono', monospace; }
.bottombar .stat b { color: var(--text); }
/* ── Template Picker Overlay ── */
.template-list {
max-height: 300px;
overflow-y: auto;
background: var(--bg-2);
border: 1px solid var(--border-hi);
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
display: none;
}
.template-item {
padding: 6px 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
cursor: pointer;
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.template-item:hover {
background: var(--orange-glow);
color: var(--text-bright);
}
</style>
</head>
<body>
<!-- Top Bar -->
<div class="topbar">
<div class="logo">MORTDECAI<span>CHAT</span></div>
<div class="sep"></div>
<div class="ctrl-group">
<span class="ctrl-label">Call</span>
<select id="call-type">
<option value="gateway">Gateway</option>
<option value="model">Model</option>
</select>
</div>
<div class="ctrl-group">
<span class="ctrl-label">Mode</span>
<select id="mode-select">
<option value="sudo">sudo</option>
<option value="god">god</option>
<option value="god_system">god_system</option>
</select>
</div>
<div class="ctrl-group" style="position:relative">
<span class="ctrl-label">Template</span>
<select id="category-select">
<option value="">— select category —</option>
</select>
<select id="prompt-select" style="max-width:280px">
<option value="">— select prompt —</option>
</select>
</div>
<div class="topbar-right">
<button class="btn btn-clear" onclick="clearLog()">Clear Log</button>
<button class="btn btn-save" id="btn-save" onclick="saveTraining()" disabled>Save to Training</button>
<button class="btn btn-send" id="btn-send" onclick="sendCall()">Send</button>
<span class="status-dot" id="status-dot"></span>
</div>
</div>
<!-- Main Split -->
<div class="main">
<div class="pane-left">
<div class="pane-header">
<span class="pane-title" id="pane-left-title">Gateway Call Payload</span>
<button class="btn" style="font-size:11px;padding:3px 8px" onclick="resetPayload()">Reset</button>
</div>
<div class="pane-body">
<textarea id="payload-editor" spellcheck="false"></textarea>
</div>
</div>
<div class="pane-right">
<div class="pane-header">
<span class="pane-title">Trace Log</span>
<span class="pane-title" id="trace-count" style="color:var(--text-dim)">0 entries</span>
</div>
<div id="trace-log"></div>
</div>
</div>
<!-- Bottom Bar -->
<div class="bottombar">
<span class="stat">Model: <b id="stat-model">—</b></span>
<span class="stat">Last: <b id="stat-duration">—</b></span>
<span class="stat">Cmds: <b id="stat-cmds">—</b></span>
<span class="stat">Session: <b id="stat-session">0</b> calls</span>
<span style="margin-left:auto;font-family:'Rajdhani',sans-serif;font-size:12px">
chat.api.mortdec.ai
</span>
</div>
<script>
const $ = id => document.getElementById(id);
let currentResult = null;
let sessionCalls = 0;
let traceCount = 0;
let manifest = {};
// ── Init ──
async function init() {
try {
const r = await fetch('/api/manifest');
manifest = await r.json();
populateCategories();
} catch(e) {
log('system', 'Failed to load manifest: ' + e.message);
}
resetPayload();
log('system', 'Mortdecai Chat ready. Select a mode and template, or edit the payload directly.');
}
function populateCategories() {
const sel = $('category-select');
const mode = $('mode-select').value;
const callType = $('call-type').value;
// Clear existing options
sel.innerHTML = '<option value="">-- select category --</option>';
for (const [cat, meta] of Object.entries(manifest)) {
// Filter by mode
const catMode = meta.mode || 'sudo';
if (catMode !== 'mixed' && catMode !== mode) continue;
// Filter by call type
const catCall = meta.call_type || 'model';
if (callType === 'model' && catCall === 'gateway') continue;
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat.replace(/_/g, ' ') + ' (' + meta.count + ')';
sel.appendChild(opt);
}
}
async function loadPrompts(category) {
const sel = $('prompt-select');
sel.innerHTML = '<option value="">-- select prompt --</option>';
if (!category) return;
try {
const r = await fetch('/api/prompts/' + category);
const prompts = await r.json();
prompts.forEach((p, i) => {
const opt = document.createElement('option');
opt.value = p.prompt;
opt.textContent = p.prompt.length > 60 ? p.prompt.slice(0, 60) + '...' : p.prompt;
sel.appendChild(opt);
});
} catch(e) {
log('error', 'Failed to load prompts: ' + e.message);
}
}
function applyPromptToPayload(promptText) {
if (!promptText) return;
try {
const payload = JSON.parse($('payload-editor').value);
const callType = $('call-type').value;
if (callType === 'gateway') {
payload.query = promptText;
} else {
// Model mode: update the user message
const userMsg = payload.messages.find(m => m.role === 'user');
if (userMsg) {
userMsg.content = 'Player ' + (payload.player || 'slingshooter08') + ': ' + promptText;
}
}
$('payload-editor').value = JSON.stringify(payload, null, 2);
} catch(e) {
log('error', 'Payload parse error: ' + e.message);
}
}
function resetPayload() {
const callType = $('call-type').value;
const mode = $('mode-select').value;
let payload;
if (callType === 'gateway') {
payload = Object.assign({}, DEFAULTS.gateway, { mode: mode });
} else {
payload = JSON.parse(JSON.stringify(DEFAULTS.model));
}
$('payload-editor').value = JSON.stringify(payload, null, 2);
$('pane-left-title').textContent = callType === 'gateway' ? 'Gateway Call Payload' : 'Model Call Payload';
}
// ── Logging ──
function log(type, text, rawData) {
const el = $('trace-log');
const ts = new Date().toLocaleTimeString('en-US', {hour12:false});
const entry = document.createElement('div');
entry.className = 'trace-entry type-' + type;
const tagMap = {
system:'SYS', context:'CTX', llm:'LLM', guard:'GRD',
rcon:'RCON', 'rcon-fail':'RCON', error:'ERR', result:'RES', saved:'SAVE'
};
const tagClass = {
system:'', context:'ctx', llm:'llm', guard:'guard',
rcon:'rcon', 'rcon-fail':'err', error:'err', result:'res', saved:'rcon'
};
const tag = tagMap[type] || type.toUpperCase();
const cls = tagClass[type] || '';
// Use MC color rendering for RCON results
const renderText = (type === 'rcon' || type === 'rcon-fail') ? mcColorize(text) : escHtml(text);
// Build expand panel content
const expandContent = rawData ? JSON.stringify(rawData, null, 2) : text;
entry.innerHTML =
'<span class="trace-chevron">&#9654;</span>' +
'<span class="trace-ts">' + ts + '</span>' +
(cls ? '<span class="trace-tag ' + cls + '">' + tag + '</span>' : '') +
'<span>' + renderText + '</span>' +
'<div class="trace-expand">' + escHtml(expandContent) + '</div>';
entry.addEventListener('click', () => entry.classList.toggle('expanded'));
el.appendChild(entry);
el.scrollTop = el.scrollHeight;
traceCount++;
$('trace-count').textContent = traceCount + ' entries';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Minecraft § color code rendering
const MC_COLORS = {
'0':'#000','1':'#0000AA','2':'#00AA00','3':'#00AAAA','4':'#AA0000',
'5':'#AA00AA','6':'#FFAA00','7':'#AAAAAA','8':'#555555','9':'#5555FF',
'a':'#55FF55','b':'#55FFFF','c':'#FF5555','d':'#FF55FF','e':'#FFFF55','f':'#FFFFFF',
'r':'', // reset
};
const MC_FORMAT = {'l':'font-weight:bold','o':'font-style:italic','n':'text-decoration:underline','m':'text-decoration:line-through'};
function mcColorize(s) {
if (!s.includes('\u00a7') && !s.includes('§')) return escHtml(s);
let out = '', color = '', fmt = '';
const raw = s.replace(/§/g, '\u00a7');
let i = 0;
while (i < raw.length) {
if (raw[i] === '\u00a7' && i + 1 < raw.length) {
const code = raw[i+1].toLowerCase();
if (code === 'r') { color = ''; fmt = ''; }
else if (MC_COLORS[code] !== undefined) { color = MC_COLORS[code]; }
else if (MC_FORMAT[code]) { fmt += MC_FORMAT[code] + ';'; }
i += 2;
} else {
const style = (color ? 'color:'+color+';' : '') + fmt;
if (style) out += '<span style="'+style+'">' + escHtml(raw[i]) + '</span>';
else out += escHtml(raw[i]);
i++;
}
}
return out;
}
function clearLog() {
$('trace-log').innerHTML = '';
traceCount = 0;
$('trace-count').textContent = '0 entries';
}
// ── Send Call ──
async function sendCall() {
const btn = $('btn-send');
const dot = $('status-dot');
btn.disabled = true;
dot.classList.add('busy');
currentResult = null;
$('btn-save').disabled = true;
let payload;
try {
payload = JSON.parse($('payload-editor').value);
} catch(e) {
log('error', 'Invalid JSON: ' + e.message);
btn.disabled = false;
dot.classList.remove('busy');
return;
}
const callType = $('call-type').value;
log('system', 'Sending ' + callType + ' call...');
const startTime = performance.now();
try {
const r = await fetch('/api/send', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ call_type: callType, payload: payload }),
});
// Stream SSE response
const reader = r.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete SSE events
const events = buffer.split('\n\n');
buffer = events.pop(); // Keep incomplete event in buffer
for (const event of events) {
if (!event.trim()) continue;
for (const line of event.split('\n')) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
handleTraceEvent(data);
} catch(e) { /* skip malformed */ }
}
}
}
}
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
$('stat-duration').textContent = elapsed + 's';
sessionCalls++;
$('stat-session').textContent = sessionCalls;
} catch(e) {
log('error', 'Request failed: ' + e.message);
}
btn.disabled = false;
dot.classList.remove('busy');
}
function handleTraceEvent(data) {
const type = data.type || 'system';
const text = data.text || JSON.stringify(data);
log(type, text, data);
if (data.model) $('stat-model').textContent = data.model;
if (data.cmd_count !== undefined) $('stat-cmds').textContent = data.cmd_count;
if (type === 'result') {
currentResult = data.full_result;
$('btn-save').disabled = false;
}
}
// ── Save to Training ──
async function saveTraining() {
if (!currentResult) return;
try {
const r = await fetch('/api/save-training', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(currentResult),
});
const resp = await r.json();
log('saved', 'Saved to training log: ' + (resp.file || 'training_export.jsonl'));
$('btn-save').disabled = true;
} catch(e) {
log('error', 'Save failed: ' + e.message);
}
}
// ── Event Listeners ──
$('call-type').addEventListener('change', () => { resetPayload(); populateCategories(); });
$('mode-select').addEventListener('change', () => {
const mode = $('mode-select').value;
try {
const payload = JSON.parse($('payload-editor').value);
if (payload.mode !== undefined) {
payload.mode = mode;
$('payload-editor').value = JSON.stringify(payload, null, 2);
}
} catch(e) {}
populateCategories();
});
$('category-select').addEventListener('change', (e) => loadPrompts(e.target.value));
$('prompt-select').addEventListener('change', (e) => applyPromptToPayload(e.target.value));
// Ctrl+Enter to send
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
sendCall();
}
});
// Defaults injected by server
const DEFAULTS = __DEFAULTS_JSON__;
init();
</script>
</body>
</html>"""
# ── HTTP Handler ──────────────────────────────────────────────────────────────
class ChatHandler(BaseHTTPRequestHandler):
"""HTTP handler for the chat app."""
def log_message(self, fmt, *args):
"""Suppress default request logging."""
pass
def _cors_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
def _json_response(self, data, status=200):
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self._cors_headers()
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def _html_response(self, html):
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.end_headers()
self.wfile.write(html.encode())
def _sse_start(self):
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
self.send_header('Cache-Control', 'no-cache')
self.send_header('Connection', 'keep-alive')
self._cors_headers()
self.end_headers()
def _sse_event(self, data: dict):
msg = f"data: {json.dumps(data)}\n\n"
self.wfile.write(msg.encode())
self.wfile.flush()
def do_OPTIONS(self):
self.send_response(204)
self._cors_headers()
self.end_headers()
def do_GET(self):
path = urlparse(self.path).path
if path in ('/', '/index.html'):
defaults_json = json.dumps({
'gateway': DEFAULT_GATEWAY_CONFIG,
'model': DEFAULT_MODEL_CONFIG,
})
html = HTML_PAGE.replace('__DEFAULTS_JSON__', defaults_json)
self._html_response(html)
elif path == '/api/manifest':
try:
load_manifest, *_ = _get_prompts_module()
self._json_response(load_manifest())
except Exception as e:
self._json_response({'error': str(e)}, 500)
elif path.startswith('/api/prompts/'):
category = path.split('/api/prompts/', 1)[1]
try:
*_, load_prompt_entries, _, _ = _get_prompts_module()
entries = load_prompt_entries(category)
self._json_response(entries)
except Exception as e:
self._json_response({'error': str(e)}, 500)
elif path == '/api/template-vars':
try:
*_, TEMPLATE_VARS = _get_prompts_module()
self._json_response(TEMPLATE_VARS)
except Exception as e:
self._json_response({'error': str(e)}, 500)
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
path = urlparse(self.path).path
content_len = int(self.headers.get('Content-Length', 0))
body = json.loads(self.rfile.read(content_len)) if content_len else {}
if path == '/api/send':
self._handle_send(body)
elif path == '/api/save-training':
self._handle_save_training(body)
else:
self.send_response(404)
self.end_headers()
def _handle_send(self, body):
"""Handle a gateway or model call with SSE trace streaming."""
call_type = body.get('call_type', 'gateway')
payload = body.get('payload', {})
self._sse_start()
try:
if call_type == 'gateway':
self._run_gateway_call(payload)
else:
self._run_model_call(payload)
except Exception as e:
self._sse_event({'type': 'error', 'text': f'Exception: {e}'})
traceback.print_exc()
def _run_gateway_call(self, payload):
"""Execute a full gateway call with trace events."""
import requests as req
mode = payload.get('mode', 'sudo')
player = payload.get('player', 'slingshooter08')
query = payload.get('query', '')
ollama_url = payload.get('ollama_url', DEFAULT_GATEWAY_CONFIG['ollama_url'])
model = payload.get('model', DEFAULT_GATEWAY_CONFIG['model'])
rcon_host = payload.get('rcon_host', DEFAULT_GATEWAY_CONFIG['rcon_host'])
rcon_port = payload.get('rcon_port', DEFAULT_GATEWAY_CONFIG['rcon_port'])
rcon_pass = payload.get('rcon_password', DEFAULT_GATEWAY_CONFIG['rcon_password'])
temp = payload.get('temperature', 0.7)
max_tokens = payload.get('max_tokens', 800)
self._sse_event({'type': 'system', 'text': f'Gateway call: mode={mode} player={player} model={model}'})
self._sse_event({'type': 'system', 'text': f'Query: {query}', 'model': model})
start = time.time()
# 1. Context gathering via RCON
self._sse_event({'type': 'context', 'text': f'Gathering context from {rcon_host}:{rcon_port}...'})
context_parts = []
try:
from agent.tools.persistent_rcon import get_rcon
rcon = get_rcon(rcon_host, rcon_port, rcon_pass)
# Player info
player_list = rcon.command('list')
context_parts.append(f'Online: {player_list}')
self._sse_event({'type': 'context', 'text': f'Server: {player_list}'})
if player:
pos_result = rcon.command(f'data get entity {player} Pos')
context_parts.append(f'Position: {pos_result}')
self._sse_event({'type': 'context', 'text': f'Player pos: {pos_result[:100]}'})
except Exception as e:
self._sse_event({'type': 'context', 'text': f'RCON context failed: {e} (continuing without context)'})
ctx_ms = int((time.time() - start) * 1000)
self._sse_event({'type': 'context', 'text': f'Context gathered in {ctx_ms}ms'})
# 2. Build system prompt
try:
from agent.prompts.system_prompts import get_prompt
system_prompt = get_prompt(mode)
except ImportError:
system_prompt = f"You are a Minecraft 1.21 command translator. Mode: {mode}."
context_str = '\n'.join(context_parts) if context_parts else 'No context available'
user_message = f"Request from {player}: {query}\n\nContext:\n{context_str}"
messages = [
{'role': 'system', 'content': '/no_think\n' + system_prompt},
{'role': 'user', 'content': user_message},
]
# 3. LLM call
ollama_payload = {
'model': model,
'messages': messages,
'stream': False,
'format': 'json',
'options': {'temperature': temp, 'num_predict': max_tokens},
}
self._sse_event({
'type': 'llm',
'text': f'Calling {model} on {ollama_url}...',
'api_request': {'url': f'{ollama_url}/api/chat', 'method': 'POST', 'body': ollama_payload},
})
llm_start = time.time()
try:
import re
r = req.post(f"{ollama_url}/api/chat", json=ollama_payload, timeout=120)
r.raise_for_status()
api_response = r.json()
raw_content = api_response['message']['content']
# Strip thinking tags
raw_content = re.sub(r'<think>[\s\S]*?</think>\s*', '', raw_content)
llm_ms = int((time.time() - llm_start) * 1000)
self._sse_event({
'type': 'llm',
'text': f'LLM responded in {llm_ms}ms',
'api_response': {'raw': raw_content, 'model': api_response.get('model'), 'eval_count': api_response.get('eval_count'), 'eval_duration': api_response.get('eval_duration')},
})
try:
parsed = json.loads(raw_content)
except json.JSONDecodeError:
parsed = {'commands': [], 'message': raw_content, 'reasoning': 'parse failed'}
self._sse_event({'type': 'error', 'text': f'JSON parse failed, raw: {raw_content[:200]}'})
commands = parsed.get('commands', [])
message = parsed.get('message', '')
reasoning = parsed.get('reasoning', '')
if reasoning:
self._sse_event({'type': 'llm', 'text': f'Reasoning: {reasoning}'})
if message:
self._sse_event({'type': 'llm', 'text': f'Message: {message}'})
self._sse_event({'type': 'llm', 'text': f'Commands ({len(commands)}): {json.dumps(commands)}', 'parsed': parsed})
except Exception as e:
self._sse_event({'type': 'error', 'text': f'LLM call failed: {e}'})
commands = []
message = ''
reasoning = ''
raw_content = ''
llm_ms = 0
# 4. Guardrails
self._sse_event({'type': 'guard', 'text': f'Running guardrails on {len(commands)} commands...'})
guardrail_results = []
safe_commands = []
try:
from agent.guardrails.command_filter import filter_commands
safe_commands, guardrail_results = filter_commands(commands)
blocked = len(commands) - len(safe_commands)
if blocked:
self._sse_event({'type': 'guard', 'text': f'Blocked {blocked} commands'})
for gr in guardrail_results:
if gr.get('warnings'):
self._sse_event({'type': 'guard', 'text': f" {gr['command']}: {', '.join(gr['warnings'])}"})
except ImportError:
safe_commands = commands
self._sse_event({'type': 'guard', 'text': 'Guardrails not available, passing all commands'})
self._sse_event({'type': 'guard', 'text': f'Passed: {len(safe_commands)} commands'})
# 5. Execute via RCON
rcon_results = []
if safe_commands:
self._sse_event({'type': 'rcon', 'text': f'Executing {len(safe_commands)} commands via RCON...'})
try:
rcon = get_rcon(rcon_host, rcon_port, rcon_pass)
for cmd in safe_commands[:12]:
if not isinstance(cmd, str) or not cmd.strip():
continue
try:
result = rcon.command(cmd)
is_error = any(e in result for e in ('<--[HERE]', 'Unknown', 'Incorrect', 'Expected'))
rcon_entry = {'cmd': cmd, 'result': result[:200], 'ok': not is_error}
rcon_results.append(rcon_entry)
trace_type = 'rcon' if not is_error else 'rcon-fail'
self._sse_event({'type': trace_type, 'text': f'{cmd} -> {result[:120]}',
'rcon_command': cmd, 'rcon_result': result, 'rcon_ok': not is_error})
except Exception as e:
rcon_results.append({'cmd': cmd, 'result': str(e), 'ok': False})
self._sse_event({'type': 'rcon-fail', 'text': f'{cmd} -> ERROR: {e}',
'rcon_command': cmd, 'rcon_error': str(e)})
except Exception as e:
self._sse_event({'type': 'error', 'text': f'RCON execution failed: {e}'})
# 6. Final result
total_ms = int((time.time() - start) * 1000)
ok_count = sum(1 for r in rcon_results if r.get('ok'))
fail_count = sum(1 for r in rcon_results if not r.get('ok'))
full_result = {
'timestamp': datetime.utcnow().isoformat() + 'Z',
'source': 'chat_app',
'mode': mode,
'player': player,
'input': {
'user_message': (('pray ' if mode == 'god' else 'sudo ') + query) if query else '',
'server_context': {'server_type': 'paper', 'version': '1.21.x'},
},
'output': {
'commands_generated': commands,
'commands_executed': safe_commands,
'message': message,
'reasoning': reasoning,
},
'rcon_results': rcon_results,
'metadata': {
'model': model,
'ollama_url': ollama_url,
'temperature': temp,
'duration_ms': total_ms,
'context_ms': ctx_ms,
'llm_ms': llm_ms,
},
}
self._sse_event({
'type': 'result',
'text': f'Complete: {total_ms}ms total, {ok_count} ok / {fail_count} fail',
'cmd_count': f'{ok_count}/{ok_count + fail_count}',
'model': model,
'full_result': full_result,
})
def _run_model_call(self, payload):
"""Execute a direct model call (bypass gateway pipeline)."""
import requests as req
model = payload.get('model', DEFAULT_MODEL_CONFIG['model'])
ollama_url = payload.get('ollama_url', DEFAULT_MODEL_CONFIG['ollama_url'])
messages = payload.get('messages', DEFAULT_MODEL_CONFIG['messages'])
temp = payload.get('temperature', 0.7)
max_tokens = payload.get('max_tokens', 800)
fmt = payload.get('format', 'json')
self._sse_event({'type': 'system', 'text': f'Direct model call: {model} on {ollama_url}', 'model': model})
self._sse_event({'type': 'llm', 'text': f'Sending {len(messages)} messages, temp={temp}'})
start = time.time()
try:
import re
api_payload = {
'model': model,
'messages': messages,
'stream': False,
'options': {'temperature': temp, 'num_predict': max_tokens},
}
if fmt:
api_payload['format'] = fmt
r = req.post(f"{ollama_url}/api/chat", json=api_payload, timeout=120)
r.raise_for_status()
raw_content = r.json()['message']['content']
raw_content = re.sub(r'<think>[\s\S]*?</think>\s*', '', raw_content)
llm_ms = int((time.time() - start) * 1000)
self._sse_event({'type': 'llm', 'text': f'Response in {llm_ms}ms'})
try:
parsed = json.loads(raw_content)
commands = parsed.get('commands', [])
message = parsed.get('message', '')
reasoning = parsed.get('reasoning', '')
if reasoning:
self._sse_event({'type': 'llm', 'text': f'Reasoning: {reasoning}'})
if message:
self._sse_event({'type': 'llm', 'text': f'Message: {message}'})
self._sse_event({'type': 'llm', 'text': f'Commands ({len(commands)}): {json.dumps(commands)}',
'cmd_count': str(len(commands))})
except json.JSONDecodeError:
self._sse_event({'type': 'llm', 'text': f'Raw output: {raw_content[:500]}'})
commands = []
message = raw_content
reasoning = ''
full_result = {
'timestamp': datetime.utcnow().isoformat() + 'Z',
'source': 'chat_app_model',
'input': {'messages': messages},
'output': {
'commands_generated': commands,
'message': message,
'reasoning': reasoning,
'raw': raw_content,
},
'metadata': {
'model': model,
'ollama_url': ollama_url,
'temperature': temp,
'duration_ms': llm_ms,
},
}
self._sse_event({
'type': 'result',
'text': f'Complete: {llm_ms}ms, {len(commands)} commands',
'model': model,
'full_result': full_result,
})
except Exception as e:
self._sse_event({'type': 'error', 'text': f'Model call failed: {e}'})
def _handle_save_training(self, body):
"""Save an interaction to the training export log."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
with open(TRAINING_LOG, 'a') as f:
f.write(json.dumps(body) + '\n')
self._json_response({'ok': True, 'file': str(TRAINING_LOG.name)})
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Mortdecai Chat App")
parser.add_argument("--port", type=int, default=PORT)
parser.add_argument("--host", default="0.0.0.0")
args = parser.parse_args()
LOG_DIR.mkdir(parents=True, exist_ok=True)
server = HTTPServer((args.host, args.port), ChatHandler)
print(f"Mortdecai Chat App")
print(f" http://{args.host}:{args.port}")
print(f" Prompts: {PROJECT_ROOT / 'training' / 'prompts'}")
print(f" Training log: {TRAINING_LOG}")
print()
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutdown.")
server.server_close()
if __name__ == "__main__":
main()