feat(minecraft): add debug web dashboard

This commit is contained in:
Rin
2026-02-18 11:09:57 +08:00
committed by Neko Ayaka
parent 3b22a45bd3
commit d20bcab692
6 changed files with 621 additions and 1 deletions
+86
View File
@@ -0,0 +1,86 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { useLogger } from './utils/logger'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export class DebugServer {
private static instance: DebugServer
private clients: Set<ServerResponse> = new Set()
private server: http.Server | null = null
private constructor() {}
public static getInstance(): DebugServer {
if (!DebugServer.instance) {
DebugServer.instance = new DebugServer()
}
return DebugServer.instance
}
public start(port = 3000): void {
if (this.server)
return
this.server = http.createServer((req: IncomingMessage, res: ServerResponse) => {
// Enable CORS
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
if (req.url === '/') {
// Serve dashboard
const htmlPath = path.join(__dirname, 'web', 'dashboard.html')
const html = fs.readFileSync(htmlPath, 'utf-8')
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(html)
}
else if (req.url === '/events') {
// Serve SSE stream
this.handleSSE(req, res)
}
else {
res.writeHead(404)
res.end('Not Found')
}
})
this.server.listen(port, () => {
useLogger().log(`Debug server running at http://localhost:${port}`)
})
}
public broadcast(type: string, payload: any): void {
const data = JSON.stringify(payload)
const message = `event: ${type}\ndata: ${data}\n\n`
for (const client of this.clients) {
client.write(message)
}
}
private handleSSE(req: IncomingMessage, res: ServerResponse): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
this.clients.add(res)
const keepAlive = setInterval(() => {
res.write(': keep-alive\n\n')
}, 15000)
req.on('close', () => {
clearInterval(keepAlive)
this.clients.delete(res)
})
}
}
@@ -7,6 +7,7 @@ import type { MineflayerWithAgents } from './types'
import { assistant } from 'neuri/openai'
import { config } from '../../composables/config'
import { DebugServer } from '../../debug-server'
export async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: Logger): Promise<string> {
logger.log('rerouting...')
@@ -24,6 +25,15 @@ export async function handleLLMCompletion(context: NeuriContext, bot: Mineflayer
const content = await completion.firstContent()
logger.withFields({ usage: completion.usage, content }).log('output')
// Broadcast LLM trace
DebugServer.getInstance().broadcast('llm', {
route: 'action',
messages: context.messages,
content,
usage: completion.usage,
timestamp: Date.now(),
})
bot.memory.chatHistory.push(assistant(content))
return content
}
@@ -7,6 +7,7 @@ import type { LLMConfig, LLMResponse } from './types'
import { withRetry } from '@moeru/std'
import { config } from '../../composables/config'
import { DebugServer } from '../../debug-server'
import { useLogger } from '../../utils/logger'
export abstract class BaseLLMHandler {
@@ -33,6 +34,15 @@ export abstract class BaseLLMHandler {
const content = await completion.firstContent()
this.logger.withFields({ usage: completion.usage, content }).log('Generated content')
// Broadcast LLM trace
DebugServer.getInstance().broadcast('llm', {
route,
messages,
content,
usage: completion.usage,
timestamp: Date.now(),
})
return {
content,
usage: completion.usage,
+6
View File
@@ -12,14 +12,20 @@ import { plugin as MineflayerTool } from 'mineflayer-tool'
import { initBot } from './composables/bot'
import { config, initEnv } from './composables/config'
import { createNeuriAgent } from './composables/neuri'
import { DebugServer } from './debug-server'
import { LLMAgent } from './libs/llm-agent'
import { wrapPlugin } from './libs/mineflayer'
import { initLogger, useLogger } from './utils/logger'
// ...
async function main() {
initLogger() // todo: save logs to file
initEnv()
// Start debug server
DebugServer.getInstance().start()
const { bot } = await initBot({
botConfig: config.bot,
plugins: [
+91 -1
View File
@@ -1,5 +1,9 @@
import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg'
import { DebugServer } from '../debug-server'
export type Logger = ReturnType<typeof useLogg>
export function initLogger() {
setGlobalLogLevel(LogLevel.Debug)
setGlobalFormat(Format.Pretty)
@@ -21,5 +25,91 @@ export function useLogger() {
const dirName = match?.[1] || 'unknown'
const fileName = match?.[2] || 'unknown'
return useLogg(`${dirName}/${fileName}`).useGlobalConfig()
const logger = useLogg(`${dirName}/${fileName}`).useGlobalConfig()
// Proxy logger to broadcast events
// We need to preserve the original formatting/behavior while adding the side-effect
return {
log: (message: string, ...args: any[]) => {
logger.log(message, ...args)
DebugServer.getInstance().broadcast('log', {
level: 'INFO',
message: `[${dirName}/${fileName}] ${message}`,
timestamp: Date.now(),
fields: (logger as any).fields, // Access fields if stored on instance, currently Logg API might differ so we simplify
})
},
error: (message: string, ...args: any[]) => {
logger.error(message, ...args)
DebugServer.getInstance().broadcast('log', {
level: 'ERROR',
message: `[${dirName}/${fileName}] ${message}`,
timestamp: Date.now(),
})
},
warn: (message: string, ...args: any[]) => {
logger.warn(message, ...args)
DebugServer.getInstance().broadcast('log', {
level: 'WARN',
message: `[${dirName}/${fileName}] ${message}`,
timestamp: Date.now(),
})
},
withFields: (fields: Record<string, any>) => {
const subLogger = logger.withFields(fields)
// Return proxied sub-logger
return {
log: (message: string) => {
subLogger.log(message)
DebugServer.getInstance().broadcast('log', {
level: 'INFO',
message: `[${dirName}/${fileName}] ${message}`,
fields,
timestamp: Date.now(),
})
},
error: (message: string) => {
subLogger.error(message)
DebugServer.getInstance().broadcast('log', {
level: 'ERROR',
message: `[${dirName}/${fileName}] ${message}`,
fields,
timestamp: Date.now(),
})
},
warn: (message: string) => {
subLogger.warn(message)
DebugServer.getInstance().broadcast('log', {
level: 'WARN',
message: `[${dirName}/${fileName}] ${message}`,
fields,
timestamp: Date.now(),
})
},
errorWithError: (message: string, error: unknown) => {
subLogger.errorWithError(message, error)
DebugServer.getInstance().broadcast('log', {
level: 'ERROR',
message: `[${dirName}/${fileName}] ${message}`,
fields: { ...fields, error },
timestamp: Date.now(),
})
},
withFields: (newFields: Record<string, any>) => useLogger().withFields({ ...fields, ...newFields }), // Recursion hack for simplicity, ideally properly implement interface
withError: (err: unknown) => useLogger().withFields({ ...fields, error: err }), // Recursion hack
}
},
withError: (error: unknown) => {
return useLogger().withFields({ error })
},
errorWithError: (message: string, error: unknown) => {
logger.errorWithError(message, error)
DebugServer.getInstance().broadcast('log', {
level: 'ERROR',
message: `[${dirName}/${fileName}] ${message}`,
fields: { error },
timestamp: Date.now(),
})
},
} as unknown as ReturnType<typeof useLogg> // Force type for now as complete Logg interface is complex to mock fully perfectly without more boilerplate
}
+418
View File
@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Minecraft Service Debugger</title>
<style>
:root {
--bg-color: #1a1a1a;
--text-color: #e0e0e0;
--sidebar-width: 250px;
--accent-color: #4a9eff;
--border-color: #333;
--log-bg: #111;
--log-info: #4a9eff;
--log-warn: #ffb74d;
--log-error: #ef5350;
--log-debug: #888;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background-color: var(--bg-color);
color: var(--text-color);
height: 100vh;
display: flex;
flex-direction: column;
}
header {
background-color: #252525;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #f44336;
display: inline-block;
margin-right: 8px;
}
.status-dot.connected {
background-color: #4caf50;
}
main {
display: flex;
flex: 1;
overflow: hidden;
}
nav {
width: var(--sidebar-width);
background-color: #252525;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.nav-item {
padding: 1rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
}
.nav-item:hover {
background-color: #333;
}
.nav-item.active {
background-color: #333;
border-left: 3px solid var(--accent-color);
}
.content-panel {
flex: 1;
display: none;
flex-direction: column;
overflow: hidden;
}
.content-panel.active {
display: flex;
}
.toolbar {
padding: 0.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 1rem;
}
.log-container {
flex: 1;
overflow-y: auto;
padding: 1rem;
background-color: var(--log-bg);
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
}
.log-entry {
margin-bottom: 4px;
line-height: 1.4;
white-space: pre-wrap;
border-bottom: 1px solid #222;
}
.log-time {
color: #666;
margin-right: 8px;
}
.level-info { color: var(--log-info); }
.level-warn { color: var(--log-warn); }
.level-error { color: var(--log-error); }
.level-debug { color: var(--log-debug); }
.llm-card {
background-color: #252525;
margin-bottom: 1rem;
border-radius: 4px;
overflow: hidden;
}
.llm-header {
padding: 0.8rem;
background-color: #333;
cursor: pointer;
display: flex;
justify-content: space-between;
}
.llm-body {
padding: 1rem;
display: none;
border-top: 1px solid var(--border-color);
}
.llm-body.open {
display: block;
}
.json-block {
background-color: #111;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
}
.badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8em;
background-color: #555;
}
.section-title {
color: #888;
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 1px;
margin: 1rem 0 0.5rem 0;
font-weight: 600;
}
.content-block {
background-color: #1e1e1e;
padding: 1rem;
border-radius: 4px;
white-space: pre-wrap;
margin: 0;
border-left: 3px solid var(--accent-color);
}
.messages-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.message {
padding: 0.8rem;
border-radius: 4px;
background-color: #1e1e1e;
border-left: 3px solid transparent;
}
.message-role {
font-size: 0.75em;
text-transform: uppercase;
margin-bottom: 0.5rem;
opacity: 0.7;
font-weight: bold;
}
.message-content {
white-space: pre-wrap;
line-height: 1.4;
}
.role-system {
border-left-color: #e91e63;
background-color: #2a1a20;
}
.role-user {
border-left-color: #4caf50;
background-color: #1a2a1a;
}
.role-assistant {
border-left-color: #2196f3;
background-color: #1a202a;
}
</style>
</head>
<body>
<header>
<div>Minecraft Service Debugger</div>
<div>
<span id="connection-status" class="status-dot"></span>
<span id="status-text">Disconnected</span>
<button onclick="reconnect()" style="margin-left: 1rem; padding: 0.25rem 0.75rem; cursor: pointer;">Reconnect</button>
</div>
</header>
<main>
<nav>
<div class="nav-item active" onclick="switchTab('logs')">Logs</div>
<div class="nav-item" onclick="switchTab('llm')">LLM Traces</div>
</nav>
<div id="logs-panel" class="content-panel active">
<div class="toolbar">
<button onclick="clearLogs()">Clear</button>
<label><input type="checkbox" id="auto-scroll" checked> Auto-scroll</label>
</div>
<div id="logs-list" class="log-container"></div>
</div>
<div id="llm-panel" class="content-panel">
<div class="toolbar">
<button onclick="clearLLM()">Clear</button>
</div>
<div id="llm-list" class="log-container"></div>
</div>
</main>
<script>
let autoScroll = true;
const statusDot = document.getElementById('connection-status');
const statusText = document.getElementById('status-text');
document.getElementById('auto-scroll').addEventListener('change', (e) => {
autoScroll = e.target.checked;
});
function switchTab(tabId) {
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.content-panel').forEach(el => el.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabId + '-panel').classList.add('active');
}
function clearLogs() {
document.getElementById('logs-list').innerHTML = '';
}
function clearLLM() {
document.getElementById('llm-list').innerHTML = '';
}
function addLog(entry) {
const container = document.getElementById('logs-list');
const div = document.createElement('div');
div.className = 'log-entry level-' + (entry.level || 'info').toLowerCase();
const time = new Date().toLocaleTimeString();
const fieldsJson = entry.fields ? JSON.stringify(entry.fields) : '';
div.innerHTML = `<span class="log-time">[${time}]</span> <span class="log-level">[${entry.level}]</span> ${entry.message} ${fieldsJson ? `<br><span style="color:#666;font-size:0.9em">${fieldsJson}</span>` : ''}`;
container.appendChild(div);
if (autoScroll) {
container.scrollTop = container.scrollHeight;
}
}
function addLLMTrace(trace) {
if (!trace) {
console.error('Received undefined trace');
return;
}
const container = document.getElementById('llm-list');
const div = document.createElement('div');
div.className = 'llm-card';
const time = new Date(trace.timestamp || Date.now()).toLocaleTimeString();
const usage = trace.usage || {};
const tokens = usage.total_tokens || '?';
// Format messages
let messagesHtml = '';
if (trace.messages && Array.isArray(trace.messages)) {
messagesHtml = trace.messages.map(msg => {
const roleClass = 'role-' + (msg.role || 'unknown');
const content = (msg.content || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `
<div class="message ${roleClass}">
<div class="message-role">${msg.role || 'unknown'}</div>
<div class="message-content">${content}</div>
</div>
`;
}).join('');
}
// Format extra details (excluding messages/content/usage which are shown separately)
const { messages, content, usage: _u, timestamp, ...other } = trace;
const otherJson = Object.keys(other).length > 0 ? JSON.stringify(other, null, 2) : '';
div.innerHTML = `
<div class="llm-header" onclick="this.nextElementSibling.classList.toggle('open')">
<div>
<span class="badge">${time}</span>
<span>LLM Completion (${trace.route || 'unknown'})</span>
</div>
<div>
<span class="badge">${tokens} tokens</span>
</div>
</div>
<div class="llm-body">
<div class="section-title">Result</div>
<pre class="content-block">${(trace.content || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>
<div class="section-title">Messages</div>
<div class="messages-list">
${messagesHtml || '<div style="color:#666">No messages</div>'}
</div>
${otherJson ? `
<div class="section-title">Metadata</div>
<pre class="json-block">${otherJson}</pre>
` : ''}
<div class="section-title">Usage</div>
<pre class="json-block">${JSON.stringify(usage, null, 2)}</pre>
</div>
`;
container.insertBefore(div, container.firstChild);
}
let eventSource = null;
function reconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
statusDot.classList.remove('connected');
statusText.textContent = 'Reconnecting...';
setTimeout(connectSSE, 100);
}
function connectSSE() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/events');
eventSource.onopen = () => {
statusDot.classList.add('connected');
statusText.textContent = 'Connected';
addLog({ level: 'SYSTEM', message: 'Connected to debug server' });
};
eventSource.onerror = (err) => {
console.error('SSE error:', err);
statusDot.classList.remove('connected');
statusText.textContent = 'Disconnected';
};
eventSource.addEventListener('log', (e) => {
try {
const data = JSON.parse(e.data);
addLog(data);
} catch (e) {
console.error('Failed to parse log', e);
}
});
eventSource.addEventListener('llm', (e) => {
try {
const data = JSON.parse(e.data);
addLLMTrace(data);
} catch (e) {
console.error('Failed to parse llm trace', e);
}
});
}
connectSSE();
</script>
</body>
</html>