feat(minecraft): better debug server, event queue for the brain

This commit is contained in:
Rin
2026-02-18 11:09:59 +08:00
committed by Neko Ayaka
parent 9d3e808a06
commit 3f113cf1ce
8 changed files with 348 additions and 222 deletions
+1
View File
@@ -0,0 +1 @@
logs/
@@ -4,11 +4,12 @@ import type { Neuri } from 'neuri'
import type { TaskExecutor } from '../action/task-executor'
import type { ActionInstruction } from '../action/types'
import type { EventManager } from '../perception/event-manager'
import type { MineflayerWithAgents, StimulusPayload } from '../types'
import type { BotEvent, MineflayerWithAgents, StimulusPayload } from '../types'
import { system, user } from 'neuri/openai'
import { config } from '../../composables/config'
import { DebugService } from '../../debug-server'
import { Blackboard } from './blackboard'
import { generateBrainSystemPrompt } from './prompts/brain-prompt'
@@ -29,31 +30,43 @@ interface BrainResponse {
actions: ActionInstruction[]
}
interface QueuedEvent {
event: BotEvent
resolve: () => void
reject: (err: Error) => void
}
export class Brain {
private blackboard: Blackboard
private debugService: DebugService
// Event Queue
private queue: QueuedEvent[] = []
private isProcessing = false
constructor(private readonly deps: BrainDeps) {
this.blackboard = new Blackboard()
this.debugService = DebugService.getInstance()
}
public init(bot: MineflayerWithAgents): void {
this.deps.logger.log('Brain: Initializing...')
this.log('INFO', 'Brain: Initializing...')
// Listen to Stimuli (Chat/Voice)
// We treat these as "Sensory Inputs" that trigger the Cognitive Cycle
this.deps.eventManager.on<StimulusPayload>('stimulus', async (event) => {
if (event.handled) {
this.deps.logger.log(`Brain: Stimulus from ${event.source.id} already handled by reflex, ignoring.`)
this.log('INFO', `Brain: Stimulus from ${event.source.id} already handled by reflex, ignoring.`)
return
}
this.deps.logger.log(`Brain: Received stimulus from ${event.source.id}: ${event.payload.content}`)
await this.processEvent(bot, event)
this.log('INFO', `Brain: Received stimulus from ${event.source.id}: ${event.payload.content}`)
await this.enqueueEvent(bot, event)
})
// Listen to Task Execution Events (Action Feedback)
this.deps.taskExecutor.on('action:completed', async ({ action, result }) => {
this.deps.logger.log(`Brain: Action completed: ${action.type}`)
await this.processEvent(bot, {
this.log('INFO', `Brain: Action completed: ${action.type}`)
await this.enqueueEvent(bot, {
type: 'feedback',
payload: {
status: 'success',
@@ -66,8 +79,8 @@ export class Brain {
})
this.deps.taskExecutor.on('action:failed', async ({ action, error }) => {
this.deps.logger.withError(error).warn(`Brain: Action failed: ${action.type}`)
await this.processEvent(bot, {
this.log('WARN', `Brain: Action failed: ${action.type}`, { error })
await this.enqueueEvent(bot, {
type: 'feedback',
payload: {
status: 'failure',
@@ -79,10 +92,51 @@ export class Brain {
})
})
this.deps.logger.log('Brain: Online.')
this.log('INFO', 'Brain: Online.')
this.updateDebugState()
}
private async processEvent(bot: MineflayerWithAgents, event: any): Promise<void> {
// --- Event Queue Logic ---
private async enqueueEvent(bot: MineflayerWithAgents, event: BotEvent): Promise<void> {
return new Promise((resolve, reject) => {
this.queue.push({ event, resolve, reject })
this.updateDebugState()
this.processQueue(bot)
})
}
private async processQueue(bot: MineflayerWithAgents): Promise<void> {
if (this.isProcessing)
return
if (this.queue.length === 0)
return
this.isProcessing = true
const item = this.queue.shift()!
this.updateDebugState(item.event)
try {
await this.processEvent(bot, item.event)
item.resolve()
}
catch (err) {
this.log('ERROR', 'Brain: Error processing event', { error: err })
item.reject(err as Error)
}
finally {
this.isProcessing = false
this.updateDebugState()
// Context switch: Check queue again
if (this.queue.length > 0) {
setImmediate(() => this.processQueue(bot))
}
}
}
// --- Cognitive Cycle ---
private async processEvent(bot: MineflayerWithAgents, event: BotEvent): Promise<void> {
// OODA Loop: Observe -> Orient -> Decide -> Act
// 1. Observe (Update Blackboard with Environment Sense)
@@ -104,12 +158,12 @@ export class Brain {
const decision = await this.decide(systemPrompt, contextMsg)
if (!decision) {
this.deps.logger.warn('Brain: No decision made.')
this.log('WARN', 'Brain: No decision made.')
return
}
// 4. Act (Execute Decision)
this.deps.logger.log(`Brain: Thought: ${decision.thought}`)
this.log('INFO', `Brain: Thought: ${decision.thought}`)
// Update Blackboard
this.blackboard.update({
@@ -118,6 +172,9 @@ export class Brain {
executionStrategy: decision.blackboard.executionStrategy || this.blackboard.strategy,
})
// Sync Blackboard to Debug
this.debugService.updateBlackboard(this.blackboard)
// Issue Actions
if (decision.actions && decision.actions.length > 0) {
this.deps.taskExecutor.executeActions(decision.actions)
@@ -137,6 +194,9 @@ export class Brain {
weather: bot.bot.isRaining ? 'rain' : 'clear',
nearbyAgents: Object.keys(bot.bot.players).filter(p => p !== bot.bot.username),
})
// Sync Blackboard to Debug
this.debugService.updateBlackboard(this.blackboard)
}
private async decide(sysPrompt: string, userMsg: string): Promise<BrainResponse | null> {
@@ -153,6 +213,15 @@ export class Brain {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any) as any
// Trace LLM
this.debugService.traceLLM({
route: 'action',
messages: ctx.messages,
content: completion?.choices?.[0]?.message?.content,
usage: completion?.usage,
model: config.openai.model,
})
if (!completion || !completion.choices?.[0]?.message?.content) {
throw new Error('LLM failed to return content')
}
@@ -167,7 +236,7 @@ export class Brain {
return parsed
}
catch (err) {
this.deps.logger.withError(err).error('Brain: Decision failed')
this.log('ERROR', 'Brain: Decision failed', { error: err })
return null
}
}
@@ -176,4 +245,24 @@ export class Brain {
const actions = this.deps.taskExecutor.getAvailableActions()
return generateBrainSystemPrompt(blackboard, actions)
}
// --- Debug Helpers ---
private log(level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG', message: string, fields?: any) {
// Dual logging: Console/File via Logger AND DebugServer
if (level === 'ERROR')
this.deps.logger.withError(fields?.error).error(message)
else if (level === 'WARN')
this.deps.logger.warn(message, fields)
else this.deps.logger.log(message, fields)
this.debugService.log(level, message, fields)
}
private updateDebugState(processingEvent?: BotEvent) {
this.debugService.updateQueue(
this.queue.map(q => q.event),
processingEvent,
)
}
}
@@ -7,7 +7,7 @@ import type { MineflayerWithAgents } from '../types'
import { assistant } from 'neuri/openai'
import { config } from '../../composables/config'
import { DebugServer } from '../../debug-server'
import { DebugService } from '../../debug-server'
export async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: Logger): Promise<string> {
logger.log('rerouting...')
@@ -26,12 +26,12 @@ export async function handleLLMCompletion(context: NeuriContext, bot: Mineflayer
logger.withFields({ usage: completion.usage, content }).log('output')
// Broadcast LLM trace
DebugServer.getInstance().broadcast('llm', {
DebugService.getInstance().traceLLM({
route: 'action',
messages: context.messages,
content,
usage: completion.usage,
timestamp: Date.now(),
model: config.openai.model,
})
bot.memory.chatHistory.push(assistant(content))
@@ -7,7 +7,7 @@ import type { LLMConfig, LLMResponse } from '../types'
import { withRetry } from '@moeru/std'
import { config } from '../../composables/config'
import { DebugServer } from '../../debug-server'
import { DebugService } from '../../debug-server'
import { useLogger } from '../../utils/logger'
export abstract class BaseLLMHandler {
@@ -35,12 +35,12 @@ export abstract class BaseLLMHandler {
this.logger.withFields({ usage: completion.usage, content }).log('Generated content')
// Broadcast LLM trace
DebugServer.getInstance().broadcast('llm', {
DebugService.getInstance().traceLLM({
route,
messages,
content,
usage: completion.usage,
timestamp: Date.now(),
model: this.config.model ?? config.openai.model,
})
return {
+126 -13
View File
@@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
@@ -11,18 +12,42 @@ import { useLogger } from './utils/logger'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export class DebugServer {
private static instance: DebugServer
interface DebugEvent {
type: string
payload: any
timestamp: number
}
export class DebugService {
private static instance: DebugService
private clients: Set<ServerResponse> = new Set()
private server: http.Server | null = null
private constructor() {}
// History buffer (Ring buffer)
private history: DebugEvent[] = []
private readonly MAX_HISTORY = 1000
public static getInstance(): DebugServer {
if (!DebugServer.instance) {
DebugServer.instance = new DebugServer()
// File Logging
private logStream: fs.WriteStream | null = null
private constructor() {
this.initLogFile()
}
public static getInstance(): DebugService {
if (!DebugService.instance) {
DebugService.instance = new DebugService()
}
return DebugServer.instance
return DebugService.instance
}
private initLogFile() {
const logsDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true })
}
const filename = `session-${new Date().toISOString().replace(/:/g, '-')}.jsonl`
this.logStream = fs.createWriteStream(path.join(logsDir, filename), { flags: 'a' })
}
public start(port = 3000): void {
@@ -34,12 +59,24 @@ export class DebugServer {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(200)
res.end()
return
}
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)
try {
const html = fs.readFileSync(htmlPath, 'utf-8')
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(html)
}
catch {
res.writeHead(500)
res.end('Dashboard not found')
}
}
else if (req.url === '/events') {
// Serve SSE stream
@@ -56,9 +93,73 @@ export class DebugServer {
})
}
public broadcast(type: string, payload: any): void {
const data = JSON.stringify(payload)
const message = `event: ${type}\ndata: ${data}\n\n`
// --- Public API for Brain/Agents ---
public log(level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG', message: string, fields?: any) {
this.emit('log', { level, message, fields, timestamp: Date.now() })
}
public traceLLM(trace: any) {
this.emit('llm', { ...trace, timestamp: Date.now() })
}
public updateBlackboard(state: any) {
this.emit('blackboard', { state, timestamp: Date.now() })
}
public updateQueue(queue: any[], processing?: any) {
this.emit('queue', { queue, processing, timestamp: Date.now() })
}
// Generic emit
public emit(type: string, payload: any): void {
const event: DebugEvent = {
type,
payload,
timestamp: Date.now(),
}
// 1. Add to Histroy
this.addToHistory(event)
// 2. Persist to Disk
this.persistEvent(event)
// 3. Broadcast to Clients
this.broadcast(event)
}
// --- Internal Logic ---
private addToHistory(event: DebugEvent) {
this.history.push(event)
if (this.history.length > this.MAX_HISTORY) {
this.history.shift()
}
}
private persistEvent(event: DebugEvent) {
if (this.logStream) {
try {
this.logStream.write(`${JSON.stringify(event)}\n`)
}
catch (err) {
console.error('Failed to write to log file', err)
}
}
}
private broadcast(event: DebugEvent): void {
// Safe stringify to handle circular refs if any (basic protection)
let data = ''
try {
data = JSON.stringify(event.payload)
}
catch {
data = JSON.stringify({ error: 'Circular Reference or Serialization Error' })
}
const message = `event: ${event.type}\ndata: ${data}\n\n`
for (const client of this.clients) {
client.write(message)
@@ -72,6 +173,18 @@ export class DebugServer {
'Connection': 'keep-alive',
})
// Send history immediately on connection
for (const event of this.history) {
let data = ''
try {
data = JSON.stringify(event.payload)
}
catch {
continue
}
res.write(`event: ${event.type}\ndata: ${data}\n\n`)
}
this.clients.add(res)
const keepAlive = setInterval(() => {
+2 -2
View File
@@ -13,7 +13,7 @@ import { CognitiveEngine } from './cognitive'
import { initBot } from './composables/bot'
import { config, initEnv } from './composables/config'
import { createNeuriAgent } from './composables/neuri'
import { DebugServer } from './debug-server'
import { DebugService } from './debug-server'
import { wrapPlugin } from './libs/mineflayer'
import { initLogger, useLogger } from './utils/logger'
@@ -24,7 +24,7 @@ async function main() {
initEnv()
// Start debug server
DebugServer.getInstance().start()
DebugService.getInstance().start()
const { bot } = await initBot({
botConfig: config.bot,
+9 -47
View File
@@ -1,6 +1,6 @@
import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg'
import { DebugServer } from '../debug-server'
import { DebugService } from '../debug-server'
export type Logger = ReturnType<typeof useLogg>
@@ -32,28 +32,15 @@ export function useLogger() {
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
})
DebugService.getInstance().log('INFO', `[${dirName}/${fileName}] ${message}`, { args })
},
error: (message: string, ...args: any[]) => {
logger.error(message, ...args)
DebugServer.getInstance().broadcast('log', {
level: 'ERROR',
message: `[${dirName}/${fileName}] ${message}`,
timestamp: Date.now(),
})
DebugService.getInstance().log('ERROR', `[${dirName}/${fileName}] ${message}`, { args })
},
warn: (message: string, ...args: any[]) => {
logger.warn(message, ...args)
DebugServer.getInstance().broadcast('log', {
level: 'WARN',
message: `[${dirName}/${fileName}] ${message}`,
timestamp: Date.now(),
})
DebugService.getInstance().log('WARN', `[${dirName}/${fileName}] ${message}`, { args })
},
withFields: (fields: Record<string, any>) => {
const subLogger = logger.withFields(fields)
@@ -61,39 +48,19 @@ export function useLogger() {
return {
log: (message: string) => {
subLogger.log(message)
DebugServer.getInstance().broadcast('log', {
level: 'INFO',
message: `[${dirName}/${fileName}] ${message}`,
fields,
timestamp: Date.now(),
})
DebugService.getInstance().log('INFO', `[${dirName}/${fileName}] ${message}`, fields)
},
error: (message: string) => {
subLogger.error(message)
DebugServer.getInstance().broadcast('log', {
level: 'ERROR',
message: `[${dirName}/${fileName}] ${message}`,
fields,
timestamp: Date.now(),
})
DebugService.getInstance().log('ERROR', `[${dirName}/${fileName}] ${message}`, fields)
},
warn: (message: string) => {
subLogger.warn(message)
DebugServer.getInstance().broadcast('log', {
level: 'WARN',
message: `[${dirName}/${fileName}] ${message}`,
fields,
timestamp: Date.now(),
})
DebugService.getInstance().log('WARN', `[${dirName}/${fileName}] ${message}`, fields)
},
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(),
})
DebugService.getInstance().log('ERROR', `[${dirName}/${fileName}] ${message}`, { ...fields, error })
},
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
@@ -104,12 +71,7 @@ export function useLogger() {
},
errorWithError: (message: string, error: unknown) => {
logger.errorWithError(message, error)
DebugServer.getInstance().broadcast('log', {
level: 'ERROR',
message: `[${dirName}/${fileName}] ${message}`,
fields: { error },
timestamp: Date.now(),
})
DebugService.getInstance().log('ERROR', `[${dirName}/${fileName}] ${message}`, { error })
},
} as unknown as ReturnType<typeof useLogg> // Force type for now as complete Logg interface is complex to mock fully perfectly without more boilerplate
}
+101 -140
View File
@@ -235,35 +235,45 @@
</header>
<main>
<nav>
<div class="nav-item active" onclick="switchTab('task-status')">Task Status</div>
<div class="nav-item active" onclick="switchTab('overview')">Overview</div>
<div class="nav-item" onclick="switchTab('logs')">Logs</div>
<div class="nav-item" onclick="switchTab('llm')">LLM Traces</div>
<div class="nav-item" onclick="switchTab('blackboard')">Blackboard</div>
</nav>
<div id="task-status-panel" class="content-panel active">
<div id="overview-panel" class="content-panel active">
<div class="toolbar">
<h3 style="margin: 0;">Current Task State</h3>
<h3 style="margin: 0;">System Overview</h3>
</div>
<div class="log-container" style="padding: 2rem;">
<div id="current-task" style="margin-bottom: 2rem;">
<div class="section-title">Active Task</div>
<div id="task-info" class="llm-card" style="background-color: #1e1e1e;">
<div style="padding: 1rem; color: #666;">No active task</div>
<!-- Event Queue -->
<div id="queue-section" style="margin-bottom: 2rem;">
<div class="section-title">Event Queue (<span id="queue-size">0</span>)</div>
<div id="queue-list" style="display: flex; flex-direction: column; gap: 0.5rem; min-height: 50px; background: #111; padding: 1rem; border-radius: 4px;">
<div style="color: #666;">Queue is empty</div>
</div>
</div>
<div id="task-queue" style="margin-bottom: 2rem;">
<div class="section-title">Event Queue (<span id="queue-count">0</span>)</div>
<div id="queue-list" style="display: flex; flex-direction: column; gap: 0.5rem;"></div>
</div>
<div id="task-history">
<div class="section-title">Recent Tasks (Last 5)</div>
<div id="history-list" style="display: flex; flex-direction: column; gap: 0.5rem;"></div>
<!-- Current Processing -->
<div id="processing-section">
<div class="section-title">Currently Processing</div>
<div id="current-event" class="llm-card" style="background-color: #1e1e1e;">
<div style="padding: 1rem; color: #666;">Idle</div>
</div>
</div>
</div>
</div>
<div id="logs-panel" class="content-panel active">
<div id="blackboard-panel" class="content-panel">
<div class="toolbar">
<h3 style="margin: 0;">Blackboard State</h3>
</div>
<div class="log-container">
<pre id="blackboard-content" class="json-block" style="height: 100%; border: none;">{}</pre>
</div>
</div>
<div id="logs-panel" class="content-panel">
<div class="toolbar">
<button onclick="clearLogs()">Clear</button>
<label><input type="checkbox" id="auto-scroll" checked> Auto-scroll</label>
@@ -288,6 +298,10 @@
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'));
@@ -309,7 +323,7 @@
const div = document.createElement('div');
div.className = 'log-entry level-' + (entry.level || 'info').toLowerCase();
const time = new Date().toLocaleTimeString();
const time = new Date(entry.timestamp || Date.now()).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>` : ''}`;
@@ -322,10 +336,7 @@
}
function addLLMTrace(trace) {
if (!trace) {
console.error('Received undefined trace');
return;
}
if (!trace) return;
const container = document.getElementById('llm-list');
const div = document.createElement('div');
@@ -386,7 +397,63 @@
container.insertBefore(div, container.firstChild);
}
let eventSource = null;
function updateQueue(data) {
const queueList = document.getElementById('queue-list');
const queueSize = document.getElementById('queue-size');
const currentEventEl = document.getElementById('current-event');
if (!data) return;
// Update Header
queueSize.textContent = data.queue ? data.queue.length : 0;
// Render Queue
if (data.queue && data.queue.length > 0) {
queueList.innerHTML = data.queue.map((item, idx) => {
return `
<div class="llm-card" style="margin-bottom: 0.5rem; background-color: #1e1e1e;">
<div style="padding: 0.5rem; display: flex; justify-content: space-between;">
<span><span style="color: #666; margin-right: 0.5rem;">#${idx+1}</span> ${item.type}</span>
<span class="badge">${item.source?.id || 'unknown'}</span>
</div>
<div style="padding: 0 0.5rem 0.5rem 0.5rem; color: #aaa; font-size: 0.9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
${item.payload?.content || JSON.stringify(item.payload)}
</div>
</div>
`
}).join('');
} else {
queueList.innerHTML = '<div style="color: #666;">Queue is empty</div>';
}
// Render Current Processing
if (data.processing) {
const item = data.processing;
currentEventEl.innerHTML = `
<div style="padding: 1rem;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="color: var(--accent-color); font-weight: bold;">${item.type}</span>
<span class="badge" style="background-color: var(--accent-color);">PROCESSING</span>
</div>
<div style="background-color: #111; padding: 0.5rem; border-radius: 4px; margin-top: 0.5rem;">
<pre style="margin: 0; white-space: pre-wrap;">${JSON.stringify(item.payload, null, 2)}</pre>
</div>
<div style="margin-top: 0.5rem; font-size: 0.9em; color: #888;">
Source: ${item.source?.type} / ${item.source?.id}
</div>
</div>
`;
} else {
currentEventEl.innerHTML = '<div style="padding: 1rem; color: #666;">Idle</div>';
}
}
function updateBlackboard(data) {
const el = document.getElementById('blackboard-content');
if (data && data.state) {
el.textContent = JSON.stringify(data.state, null, 2);
}
}
function reconnect() {
if (eventSource) {
@@ -419,133 +486,27 @@
eventSource.addEventListener('log', (e) => {
try {
const data = JSON.parse(e.data);
addLog(data);
} catch (e) {
console.error('Failed to parse log', e);
}
addLog(JSON.parse(e.data));
} catch (e) { console.error(e); }
});
eventSource.addEventListener('llm', (e) => {
try {
const data = JSON.parse(e.data);
addLLMTrace(data);
} catch (e) {
console.error('Failed to parse llm trace', e);
}
addLLMTrace(JSON.parse(e.data));
} catch (e) { console.error(e); }
});
eventSource.addEventListener('task-status', (e) => {
eventSource.addEventListener('queue', (e) => {
try {
const data = JSON.parse(e.data);
updateTaskStatus(data);
} catch (e) {
console.error('Failed to parse task status', e);
}
updateQueue(JSON.parse(e.data));
} catch (e) { console.error(e); }
});
}
function updateTaskStatus(status) {
// Update active tasks
const taskInfo = document.getElementById('task-info');
// Handle new multi-task structure or fallback to single task
let tasks = [];
if (status.activeTasks && Array.isArray(status.activeTasks)) {
tasks = status.activeTasks;
} else if (status.currentTask) {
tasks = [status.currentTask];
}
if (tasks.length > 0) {
taskInfo.innerHTML = tasks.map(task => {
// Determine if primary based on data or assumption
// Note: TaskManager now logs 'type' but task object structure might not have it explicitly unless we added it to TaskContext
// But we can infer order or just display all.
// Let's assume the first one might be primary if we used getAllActiveTasks() order (primary first)
const elapsed = Math.floor((Date.now() - task.startTime) / 1000);
const statusColor = {
'idle': '#888',
'planning': '#ffb74d',
'executing': '#4a9eff',
'responding': '#9c27b0',
'cancelling': '#ef5350'
}[task.status] || '#888';
// Visual distinction for secondary vs primary could be nice, but simple list is fine for now
return `
<div style="padding: 1rem; border-bottom: 1px solid #333; last-child: border-bottom: none;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="font-weight: bold; font-size: 1.1em;">${task.goal}</span>
<span class="badge" style="background-color: ${statusColor}">${task.status.toUpperCase()}</span>
</div>
<div style="color: #888; font-size: 0.9em;">
<div style="display: flex; gap: 1rem;">
<span>ID: <span title="${task.id}">${task.id.substr(0,12)}...</span></span>
<span>Time: ${elapsed}s</span>
</div>
${task.currentStep ? `<div style="margin-top:0.25rem;">Step: ${task.currentStep}</div>` : ''}
${task.plan ? `<div style="margin-top:0.25rem;">Plan: ${task.plan.steps.length} steps (${task.plan.status})</div>` : ''}
</div>
</div>
`;
}).join('');
} else {
taskInfo.innerHTML = '<div style="padding: 1rem; color: #666;">No active tasks</div>';
}
// Update queue
// ... (rest same, removing queue updates here as they are separate chunks?)
// Actually I need to include the rest of the function or it will be cut off.
// Let's just replace the task rendering part if I target specific lines.
const queueCount = document.getElementById('queue-count');
const queueList = document.getElementById('queue-list');
queueCount.textContent = status.queueSize || 0;
if (status.queue && status.queue.length > 0) {
queueList.innerHTML = status.queue.map((event, idx) => `
<div class="message" style="background-color: #1e1e1e; border-left-color: #ffb74d;">
<div style="display: flex; justify-content: space-between;">
<span>#${idx + 1}: ${event.payload?.content || 'Unknown event'}</span>
<span class="badge">Priority: ${event.priority || 5}</span>
</div>
<div style="color: #666; font-size: 0.85em; margin-top: 0.5rem;">
From: ${event.source?.id || 'unknown'}
</div>
</div>
`).join('');
} else {
queueList.innerHTML = '<div style="color: #666; padding: 1rem;">Queue is empty</div>';
}
// Update history
const historyList = document.getElementById('history-list');
if (status.history && status.history.length > 0) {
historyList.innerHTML = status.history.slice(0, 5).map(task => {
const elapsed = Math.floor((Date.now() - task.startTime) / 1000);
const statusColor = {
'completed': '#4caf50',
'failed': '#ef5350',
'cancelled': '#ff9800'
}[task.plan?.status] || '#888';
return `
<div class="message" style="background-color: #1e1e1e; border-left-color: ${statusColor};">
<div style="display: flex; justify-content: space-between;">
<span>${task.goal}</span>
<span class="badge" style="background-color: ${statusColor}">${task.plan?.status || task.status}</span>
</div>
<div style="color: #666; font-size: 0.85em; margin-top: 0.5rem;">
Duration: ${elapsed}s
</div>
</div>
`;
}).join('');
} else {
historyList.innerHTML = '<div style="color: #666; padding: 1rem;">No task history</div>';
}
eventSource.addEventListener('blackboard', (e) => {
try {
updateBlackboard(JSON.parse(e.data));
} catch (e) { console.error(e); }
});
}
connectSSE();