9c2c9a2310
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>
1307 lines
40 KiB
Python
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">▶</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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
// 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()
|