diff --git a/services/minecraft/src/debug-server.ts b/services/minecraft/src/debug-server.ts new file mode 100644 index 000000000..c5ec7fda8 --- /dev/null +++ b/services/minecraft/src/debug-server.ts @@ -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 = 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) + }) + } +} diff --git a/services/minecraft/src/libs/llm-agent/completion.ts b/services/minecraft/src/libs/llm-agent/completion.ts index 2d141edd5..c0a78ebff 100644 --- a/services/minecraft/src/libs/llm-agent/completion.ts +++ b/services/minecraft/src/libs/llm-agent/completion.ts @@ -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 { 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 } diff --git a/services/minecraft/src/libs/llm-agent/handler.ts b/services/minecraft/src/libs/llm-agent/handler.ts index f2871963b..2c5c5247e 100644 --- a/services/minecraft/src/libs/llm-agent/handler.ts +++ b/services/minecraft/src/libs/llm-agent/handler.ts @@ -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, diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts index 0628d235b..306ec4ea1 100644 --- a/services/minecraft/src/main.ts +++ b/services/minecraft/src/main.ts @@ -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: [ diff --git a/services/minecraft/src/utils/logger.ts b/services/minecraft/src/utils/logger.ts index 038b1fc10..cb8ba207c 100644 --- a/services/minecraft/src/utils/logger.ts +++ b/services/minecraft/src/utils/logger.ts @@ -1,5 +1,9 @@ import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' +import { DebugServer } from '../debug-server' + +export type Logger = ReturnType + 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) => { + 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) => 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 // Force type for now as complete Logg interface is complex to mock fully perfectly without more boilerplate } diff --git a/services/minecraft/src/web/dashboard.html b/services/minecraft/src/web/dashboard.html new file mode 100644 index 000000000..509e245d0 --- /dev/null +++ b/services/minecraft/src/web/dashboard.html @@ -0,0 +1,418 @@ + + + + + + Minecraft Service Debugger + + + +
+
Minecraft Service Debugger
+
+ + Disconnected + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+
+
+ + + +