feat(minecraft) debug server redesigned
This commit is contained in:
Generated
+16
-3
@@ -69,6 +69,9 @@ catalogs:
|
||||
'@types/unist':
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@vueuse/core':
|
||||
specifier: 14.1.0
|
||||
version: 14.1.0
|
||||
@@ -201,6 +204,9 @@ catalogs:
|
||||
vue-sonner:
|
||||
specifier: 2.0.9
|
||||
version: 2.0.9
|
||||
ws:
|
||||
specifier: ^8.18.3
|
||||
version: 8.19.0
|
||||
xsschema:
|
||||
specifier: 0.4.0-beta.13
|
||||
version: 0.4.0-beta.13
|
||||
@@ -3048,7 +3054,7 @@ importers:
|
||||
version: 14.1.0(vue@3.5.26(typescript@5.9.3))
|
||||
'@wxt-dev/module-vue':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 1.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
nanoid:
|
||||
specifier: ^5.1.6
|
||||
version: 5.1.6
|
||||
@@ -3187,12 +3193,19 @@ importers:
|
||||
vec3:
|
||||
specifier: ^0.1.10
|
||||
version: 0.1.10
|
||||
ws:
|
||||
specifier: 'catalog:'
|
||||
version: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)
|
||||
zod:
|
||||
specifier: ^4.3.5
|
||||
version: 4.3.5
|
||||
zod-to-json-schema:
|
||||
specifier: ^3.25.1
|
||||
version: 3.25.1(zod@4.3.5)
|
||||
devDependencies:
|
||||
'@types/ws':
|
||||
specifier: 'catalog:'
|
||||
version: 8.18.1
|
||||
|
||||
services/satori-bot:
|
||||
dependencies:
|
||||
@@ -23300,9 +23313,9 @@ snapshots:
|
||||
'@types/filesystem': 0.0.36
|
||||
'@types/har-format': 1.2.16
|
||||
|
||||
'@wxt-dev/module-vue@1.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
|
||||
wxt: 0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
@@ -46,6 +46,7 @@ catalog:
|
||||
'@types/hast': ^3.0.4
|
||||
'@types/splitpanes': ^2.2.6
|
||||
'@types/unist': ^3.0.3
|
||||
'@types/ws': ^8.18.1
|
||||
'@vueuse/core': 14.1.0
|
||||
'@xsai-ext/providers': ^0.4.2
|
||||
'@xsai/embed': ^0.4.2
|
||||
@@ -90,6 +91,7 @@ catalog:
|
||||
vite-plugin-mkcert: ^1.17.9
|
||||
vue: ^3.5.25
|
||||
vue-sonner: 2.0.9
|
||||
ws: ^8.18.3
|
||||
xsschema: 0.4.0-beta.13
|
||||
zod: ^4.3.5
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
"valibot": "^1.2.0",
|
||||
"vec3": "^0.1.10",
|
||||
"zod": "^4.3.5",
|
||||
"ws": "catalog:",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { BotEvent, MineflayerWithAgents } from '../types'
|
||||
import { system, user } from 'neuri/openai'
|
||||
|
||||
import { config } from '../../composables/config'
|
||||
import { DebugService } from '../../debug-server'
|
||||
import { DebugService } from '../../debug'
|
||||
import { Blackboard } from './blackboard'
|
||||
import { buildConsciousContextView } from './context-view'
|
||||
import { generateBrainSystemPrompt } from './prompts/brain-prompt'
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { MineflayerWithAgents } from '../types'
|
||||
import { assistant } from 'neuri/openai'
|
||||
|
||||
import { config } from '../../composables/config'
|
||||
import { DebugService } from '../../debug-server'
|
||||
import { DebugService } from '../../debug'
|
||||
|
||||
export async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: Logger): Promise<string> {
|
||||
logger.log('rerouting...')
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { LLMConfig, LLMResponse } from '../types'
|
||||
import { withRetry } from '@moeru/std'
|
||||
|
||||
import { config } from '../../composables/config'
|
||||
import { DebugService } from '../../debug-server'
|
||||
import { DebugService } from '../../debug'
|
||||
import { useLogger } from '../../utils/logger'
|
||||
|
||||
export abstract class BaseLLMHandler {
|
||||
|
||||
@@ -6,10 +6,10 @@ import type { PerceptionFrame } from './frame'
|
||||
import type { PerceptionSignal } from './types/signals'
|
||||
import type { PerceptionStage } from './types/stage'
|
||||
|
||||
import { DebugService } from '../../debug-server'
|
||||
import { SaliencyDetector } from './saliency-detector'
|
||||
import { DebugService } from '../../debug'
|
||||
import { createPerceptionFrameFromRawEvent } from './frame'
|
||||
import { MineflayerPerceptionCollector } from './mineflayer-perception-collector'
|
||||
import { SaliencyDetector } from './saliency-detector'
|
||||
|
||||
export class PerceptionPipeline {
|
||||
private readonly detector: SaliencyDetector
|
||||
@@ -119,7 +119,7 @@ export class PerceptionPipeline {
|
||||
this.saliencyEmitTimer = setInterval(() => {
|
||||
if (!this.initialized)
|
||||
return
|
||||
DebugService.getInstance().emit('saliency', this.detector.getDebugSnapshot({ maxKeys: 30 }))
|
||||
DebugService.getInstance().emit('saliency', this.detector.getDebugSnapshot())
|
||||
}, 100)
|
||||
|
||||
this.collector = new MineflayerPerceptionCollector({
|
||||
|
||||
@@ -1,41 +1,76 @@
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
|
||||
import { DEFAULT_THRESHOLD, DEFAULT_WINDOW_TICKS, SALIENCY_RULES, type SaliencyRuleBook } from './saliency-rules'
|
||||
import type { SaliencyRuleBook } from './saliency-rules'
|
||||
import type { RawPerceptionEvent } from './types/raw-events'
|
||||
import type { PerceptionSignal } from './types/signals'
|
||||
|
||||
type WindowCounter = {
|
||||
windowTicks: number
|
||||
import { EVENT_KEYS, SALIENCY_RULES, WINDOW_SIZE } from './saliency-rules'
|
||||
|
||||
/**
|
||||
* Circular buffer counter for a single event type
|
||||
*/
|
||||
interface WindowCounter {
|
||||
/** Current head position in circular buffer */
|
||||
head: number
|
||||
/** Event counts per slot */
|
||||
counts: number[]
|
||||
/** Trigger markers per slot (1 = fired in this slot) */
|
||||
triggers: number[]
|
||||
/** Running total of counts in window */
|
||||
total: number
|
||||
/** Slot when last event was received */
|
||||
lastEventSlot: number
|
||||
/** Slot when threshold was last triggered */
|
||||
lastFireSlot: number | null
|
||||
/** Total count when last fired */
|
||||
lastFireTotal: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug snapshot data for a single counter
|
||||
*/
|
||||
export interface CounterSnapshot {
|
||||
key: string
|
||||
total: number
|
||||
window: number[]
|
||||
triggers: number[]
|
||||
lastFireSlot: number | null
|
||||
lastFireTotal: number
|
||||
}
|
||||
|
||||
export class SaliencyDetector {
|
||||
private readonly counters = new Map<string, WindowCounter>()
|
||||
/**
|
||||
* Debug snapshot for the entire saliency system
|
||||
*/
|
||||
export interface SaliencySnapshot {
|
||||
slot: number
|
||||
counters: CounterSnapshot[]
|
||||
}
|
||||
|
||||
export class SaliencyDetector {
|
||||
/** Fixed set of counters - one per event key, never deleted */
|
||||
private readonly counters: Map<string, WindowCounter> = new Map()
|
||||
|
||||
/** Current slot (advances every slotMs) */
|
||||
private currentSlot = 0
|
||||
|
||||
/** Timer for advancing slots */
|
||||
private timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** Milliseconds per slot */
|
||||
private readonly slotMs = 20
|
||||
|
||||
private lastStatsAt = 0
|
||||
private emittedSinceStats: Record<string, number> = {}
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
logger: Logg
|
||||
onAttention: (signal: PerceptionSignal) => void
|
||||
rules?: SaliencyRuleBook
|
||||
windowTicks?: number
|
||||
threshold?: number
|
||||
},
|
||||
) { }
|
||||
) {
|
||||
// Initialize all counters at construction (fixed set)
|
||||
for (const key of EVENT_KEYS) {
|
||||
this.counters.set(key, this.createCounter())
|
||||
}
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.timer)
|
||||
@@ -44,12 +79,6 @@ export class SaliencyDetector {
|
||||
this.timer = setInterval(() => {
|
||||
this.currentSlot += 1
|
||||
this.advanceWindows()
|
||||
|
||||
const now = Date.now()
|
||||
if (now - this.lastStatsAt >= 2000) {
|
||||
this.lastStatsAt = now
|
||||
this.emittedSinceStats = {}
|
||||
}
|
||||
}, this.slotMs)
|
||||
}
|
||||
|
||||
@@ -60,6 +89,9 @@ export class SaliencyDetector {
|
||||
this.timer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming perception event
|
||||
*/
|
||||
public ingest(event: RawPerceptionEvent): void {
|
||||
const rule = this.lookupRule(event)
|
||||
if (!rule)
|
||||
@@ -68,159 +100,135 @@ export class SaliencyDetector {
|
||||
if (rule.predicate && !rule.predicate(event))
|
||||
return
|
||||
|
||||
const tick = this.currentSlot
|
||||
const windowTicks = rule.windowTicks ?? this.deps.windowTicks ?? DEFAULT_WINDOW_TICKS
|
||||
const threshold = rule.threshold ?? this.deps.threshold ?? DEFAULT_THRESHOLD
|
||||
const key = rule.key(event)
|
||||
|
||||
const counter = this.getOrCreateCounter(key, windowTicks)
|
||||
const counter = this.counters.get(rule.key)
|
||||
if (!counter) {
|
||||
// Unknown key - should not happen with fixed key set
|
||||
return
|
||||
}
|
||||
|
||||
// Increment count in current slot
|
||||
counter.counts[counter.head] = (counter.counts[counter.head] ?? 0) + 1
|
||||
counter.total += 1
|
||||
counter.lastEventSlot = tick
|
||||
counter.lastEventSlot = this.currentSlot
|
||||
|
||||
if (counter.total >= threshold) {
|
||||
counter.lastFireSlot = tick
|
||||
// Check threshold
|
||||
if (counter.total >= rule.threshold) {
|
||||
counter.lastFireSlot = this.currentSlot
|
||||
counter.lastFireTotal = counter.total
|
||||
counter.triggers[counter.head] = 1
|
||||
this.resetCounter(counter)
|
||||
const signal = rule.buildSignal(event)
|
||||
this.emitSignal(signal)
|
||||
this.emitSignal(rule.buildSignal(event))
|
||||
}
|
||||
}
|
||||
|
||||
public getDebugSnapshot(options?: { maxKeys?: number }): {
|
||||
slot: number
|
||||
keys: Array<{
|
||||
key: string
|
||||
total: number
|
||||
windowTicks: number
|
||||
window: number[]
|
||||
triggers: number[]
|
||||
lastFireSlot: number | null
|
||||
lastFireTotal: number
|
||||
}>
|
||||
} {
|
||||
const maxKeys = options?.maxKeys ?? 30
|
||||
/**
|
||||
* Get debug snapshot for visualization
|
||||
* Returns all counters in fixed order (always same keys, same order)
|
||||
*/
|
||||
public getDebugSnapshot(): SaliencySnapshot {
|
||||
const counters: CounterSnapshot[] = []
|
||||
|
||||
const rows = Array.from(this.counters.entries()).map(([key, counter]) => {
|
||||
const window = this.exportWindow(counter)
|
||||
const triggers = this.exportTriggers(counter)
|
||||
const triggerSum = triggers.reduce((acc, v) => acc + (v ? 1 : 0), 0)
|
||||
const firedRecently = counter.lastFireSlot !== null && (this.currentSlot - counter.lastFireSlot) <= counter.windowTicks
|
||||
for (const key of EVENT_KEYS) {
|
||||
const counter = this.counters.get(key)
|
||||
if (!counter)
|
||||
continue
|
||||
|
||||
return {
|
||||
counters.push({
|
||||
key,
|
||||
total: counter.total,
|
||||
windowTicks: counter.windowTicks,
|
||||
window,
|
||||
triggers,
|
||||
window: this.exportWindow(counter),
|
||||
triggers: this.exportTriggers(counter),
|
||||
lastFireSlot: counter.lastFireSlot,
|
||||
lastFireTotal: counter.lastFireTotal,
|
||||
_triggerSum: triggerSum,
|
||||
_firedRecently: firedRecently,
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure keys with triggers/recent fires stay visible even if total was reset.
|
||||
rows.sort((a, b) => {
|
||||
if (a._triggerSum !== b._triggerSum)
|
||||
return b._triggerSum - a._triggerSum
|
||||
|
||||
const af = a._firedRecently ? 1 : 0
|
||||
const bf = b._firedRecently ? 1 : 0
|
||||
if (af !== bf)
|
||||
return bf - af
|
||||
|
||||
const at = a.lastFireSlot ?? -1
|
||||
const bt = b.lastFireSlot ?? -1
|
||||
if (at !== bt)
|
||||
return bt - at
|
||||
|
||||
return b.total - a.total
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
slot: this.currentSlot,
|
||||
keys: rows.slice(0, maxKeys).map(({ _triggerSum: _ts, _firedRecently: _fr, ...row }) => row),
|
||||
counters,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance all windows by one slot
|
||||
* Never deletes counters - they persist with zeroed values
|
||||
*/
|
||||
private advanceWindows(): void {
|
||||
for (const [key, counter] of this.counters.entries()) {
|
||||
counter.head = (counter.head + 1) % counter.windowTicks
|
||||
for (const counter of this.counters.values()) {
|
||||
// Move head forward in circular buffer
|
||||
counter.head = (counter.head + 1) % WINDOW_SIZE
|
||||
|
||||
// Subtract expired slot from total
|
||||
const expired = counter.counts[counter.head] ?? 0
|
||||
if (expired > 0) {
|
||||
counter.total = Math.max(0, counter.total - expired)
|
||||
counter.counts[counter.head] = 0
|
||||
}
|
||||
else {
|
||||
counter.counts[counter.head] = 0
|
||||
}
|
||||
|
||||
// Clear the slot for new data
|
||||
counter.counts[counter.head] = 0
|
||||
counter.triggers[counter.head] = 0
|
||||
|
||||
if (counter.total === 0 && this.currentSlot - counter.lastEventSlot >= counter.windowTicks) {
|
||||
this.counters.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateCounter(key: string, windowTicks: number): WindowCounter {
|
||||
const existing = this.counters.get(key)
|
||||
if (existing && existing.windowTicks === windowTicks)
|
||||
return existing
|
||||
|
||||
const created: WindowCounter = {
|
||||
windowTicks,
|
||||
/**
|
||||
* Create a new counter with zeroed circular buffer
|
||||
*/
|
||||
private createCounter(): WindowCounter {
|
||||
return {
|
||||
head: 0,
|
||||
counts: Array.from({ length: windowTicks }, () => 0),
|
||||
triggers: Array.from({ length: windowTicks }, () => 0),
|
||||
counts: new Array(WINDOW_SIZE).fill(0),
|
||||
triggers: new Array(WINDOW_SIZE).fill(0),
|
||||
total: 0,
|
||||
lastEventSlot: this.currentSlot,
|
||||
lastEventSlot: 0,
|
||||
lastFireSlot: null,
|
||||
lastFireTotal: 0,
|
||||
}
|
||||
this.counters.set(key, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset counter counts (after threshold triggered)
|
||||
*/
|
||||
private resetCounter(counter: WindowCounter): void {
|
||||
counter.total = 0
|
||||
counter.counts.fill(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export window data in chronological order (oldest -> newest)
|
||||
*/
|
||||
private exportWindow(counter: WindowCounter): number[] {
|
||||
const w = counter.windowTicks
|
||||
const out = new Array<number>(w)
|
||||
// Oldest -> newest. The newest bucket is at `head`.
|
||||
for (let i = 0; i < w; i++) {
|
||||
const idx = (counter.head + 1 + i) % w
|
||||
const out = new Array<number>(WINDOW_SIZE)
|
||||
for (let i = 0; i < WINDOW_SIZE; i++) {
|
||||
const idx = (counter.head + 1 + i) % WINDOW_SIZE
|
||||
out[i] = counter.counts[idx] ?? 0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Export trigger markers in chronological order (oldest -> newest)
|
||||
*/
|
||||
private exportTriggers(counter: WindowCounter): number[] {
|
||||
const w = counter.windowTicks
|
||||
const out = new Array<number>(w)
|
||||
// Oldest -> newest. The newest bucket is at `head`.
|
||||
for (let i = 0; i < w; i++) {
|
||||
const idx = (counter.head + 1 + i) % w
|
||||
const out = new Array<number>(WINDOW_SIZE)
|
||||
for (let i = 0; i < WINDOW_SIZE; i++) {
|
||||
const idx = (counter.head + 1 + i) % WINDOW_SIZE
|
||||
out[i] = counter.triggers[idx] ?? 0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Find matching rule for an event
|
||||
*/
|
||||
private lookupRule(event: RawPerceptionEvent) {
|
||||
const rules = this.deps.rules ?? SALIENCY_RULES
|
||||
return rules[event.modality]?.[event.kind]
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a perception signal via callback
|
||||
*/
|
||||
private emitSignal(signal: PerceptionSignal): void {
|
||||
const key = `emit.${signal.type}.${signal.metadata.action || 'unknown'}`
|
||||
this.emittedSinceStats[key] = (this.emittedSinceStats[key] ?? 0) + 1
|
||||
|
||||
this.deps.logger.withFields({
|
||||
type: signal.type,
|
||||
desc: signal.description,
|
||||
|
||||
@@ -2,148 +2,171 @@ import type { RawPerceptionEvent } from './types/raw-events'
|
||||
import type { PerceptionSignal } from './types/signals'
|
||||
|
||||
export interface SaliencyRule<E extends RawPerceptionEvent = RawPerceptionEvent> {
|
||||
/**
|
||||
* How many occurrences within the window are required before emitting.
|
||||
* Defaults to 5.
|
||||
*/
|
||||
threshold?: number
|
||||
/**
|
||||
* Window size in ticks. Defaults to 100 ticks (~5s).
|
||||
*/
|
||||
windowTicks?: number
|
||||
/**
|
||||
* Optional predicate to gate the rule.
|
||||
*/
|
||||
predicate?: (event: E) => boolean
|
||||
/**
|
||||
* Counter key used to bucket occurrences.
|
||||
*/
|
||||
key: (event: E) => string
|
||||
/**
|
||||
* Builds the PerceptionSignal when the rule fires.
|
||||
*/
|
||||
buildSignal: (event: E) => PerceptionSignal
|
||||
/**
|
||||
* How many occurrences within the window are required before emitting.
|
||||
*/
|
||||
threshold: number
|
||||
/**
|
||||
* Optional predicate to gate the rule.
|
||||
*/
|
||||
predicate?: (event: E) => boolean
|
||||
/**
|
||||
* Counter key - should be one of the predefined EVENT_KEYS
|
||||
*/
|
||||
key: string
|
||||
/**
|
||||
* Builds the PerceptionSignal when the rule fires.
|
||||
*/
|
||||
buildSignal: (event: E) => PerceptionSignal
|
||||
}
|
||||
|
||||
export type SaliencyRuleBook = Partial<Record<
|
||||
RawPerceptionEvent['modality'],
|
||||
Record<string, SaliencyRule>
|
||||
RawPerceptionEvent['modality'],
|
||||
Record<string, SaliencyRule>
|
||||
>>
|
||||
|
||||
export const DEFAULT_WINDOW_TICKS = 100
|
||||
/**
|
||||
* Fixed event keys for stable heatmap visualization.
|
||||
* All counters are initialized with these keys at startup.
|
||||
*/
|
||||
export const EVENT_KEYS = [
|
||||
'punch:player',
|
||||
'teabag:player',
|
||||
'move:player',
|
||||
'sound:ambient',
|
||||
'felt:damage',
|
||||
'felt:pickup',
|
||||
] as const
|
||||
|
||||
export type EventKey = typeof EVENT_KEYS[number]
|
||||
|
||||
/**
|
||||
* Unified window size for all event types (100 slots = ~2 seconds at 20ms/slot)
|
||||
*/
|
||||
export const WINDOW_SIZE = 100
|
||||
|
||||
/**
|
||||
* Default threshold for attention triggering
|
||||
*/
|
||||
export const DEFAULT_THRESHOLD = 5
|
||||
|
||||
export const SALIENCY_RULES: SaliencyRuleBook = {
|
||||
sighted: {
|
||||
arm_swing: {
|
||||
key: event => `punch:${(event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'arm_swing' }>).entityId}`,
|
||||
buildSignal: (event) => {
|
||||
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'arm_swing' }>
|
||||
return {
|
||||
type: 'entity_attention',
|
||||
description: `Player ${e.displayName || 'unknown'} is punching nearby`,
|
||||
sourceId: e.entityId,
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'player',
|
||||
action: 'punch',
|
||||
distance: e.distance,
|
||||
hasLineOfSight: e.hasLineOfSight,
|
||||
displayName: e.displayName,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
sneak_toggle: {
|
||||
key: event => `teabag:${(event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'sneak_toggle' }>).entityId}`,
|
||||
buildSignal: (event) => {
|
||||
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'sneak_toggle' }>
|
||||
return {
|
||||
type: 'entity_attention',
|
||||
description: `Player ${e.displayName || 'unknown'} is teabagging (rapid sneaking)`,
|
||||
sourceId: e.entityId,
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'player',
|
||||
action: 'teabag',
|
||||
distance: e.distance,
|
||||
hasLineOfSight: e.hasLineOfSight,
|
||||
displayName: e.displayName,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
entity_moved: {
|
||||
predicate: event => (event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>).entityType === 'player',
|
||||
key: event => `move:${(event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>).entityId}`,
|
||||
buildSignal: (event) => {
|
||||
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>
|
||||
return {
|
||||
type: 'entity_attention',
|
||||
description: `Player ${e.displayName || 'unknown'} is moving nearby`,
|
||||
sourceId: e.entityId,
|
||||
confidence: 0.8,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'player',
|
||||
action: 'move',
|
||||
distance: e.distance,
|
||||
hasLineOfSight: e.hasLineOfSight,
|
||||
displayName: e.displayName,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
sighted: {
|
||||
arm_swing: {
|
||||
threshold: 5,
|
||||
key: 'punch:player',
|
||||
buildSignal: (event) => {
|
||||
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'arm_swing' }>
|
||||
return {
|
||||
type: 'entity_attention',
|
||||
description: `Player ${e.displayName || 'unknown'} is punching nearby`,
|
||||
sourceId: e.entityId,
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'player',
|
||||
action: 'punch',
|
||||
distance: e.distance,
|
||||
hasLineOfSight: e.hasLineOfSight,
|
||||
displayName: e.displayName,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
heard: {
|
||||
sound: {
|
||||
key: event => `sound:${(event as Extract<RawPerceptionEvent, { modality: 'heard', kind: 'sound' }>).soundId}`,
|
||||
buildSignal: (event) => {
|
||||
const e = event as Extract<RawPerceptionEvent, { modality: 'heard', kind: 'sound' }>
|
||||
return {
|
||||
type: 'environmental_anomaly',
|
||||
description: `Heard sound: ${e.soundId}`,
|
||||
sourceId: e.soundId,
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'sound',
|
||||
action: 'sound',
|
||||
soundId: e.soundId,
|
||||
distance: e.distance,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
sneak_toggle: {
|
||||
threshold: 5,
|
||||
key: 'teabag:player',
|
||||
buildSignal: (event) => {
|
||||
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'sneak_toggle' }>
|
||||
return {
|
||||
type: 'entity_attention',
|
||||
description: `Player ${e.displayName || 'unknown'} is teabagging (rapid sneaking)`,
|
||||
sourceId: e.entityId,
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'player',
|
||||
action: 'teabag',
|
||||
distance: e.distance,
|
||||
hasLineOfSight: e.hasLineOfSight,
|
||||
displayName: e.displayName,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
felt: {
|
||||
damage_taken: {
|
||||
key: () => 'felt:damage',
|
||||
buildSignal: (_event) => ({
|
||||
type: 'saliency_high',
|
||||
description: 'Taken damage!',
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'felt',
|
||||
action: 'damage',
|
||||
},
|
||||
}),
|
||||
},
|
||||
item_collected: {
|
||||
key: () => 'felt:pickup',
|
||||
buildSignal: (_event) => ({
|
||||
type: 'entity_attention',
|
||||
description: 'Picked up an item',
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'felt',
|
||||
action: 'pickup',
|
||||
},
|
||||
}),
|
||||
},
|
||||
entity_moved: {
|
||||
threshold: 5,
|
||||
predicate: event => (event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>).entityType === 'player',
|
||||
key: 'move:player',
|
||||
buildSignal: (event) => {
|
||||
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>
|
||||
return {
|
||||
type: 'entity_attention',
|
||||
description: `Player ${e.displayName || 'unknown'} is moving nearby`,
|
||||
sourceId: e.entityId,
|
||||
confidence: 0.8,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'player',
|
||||
action: 'move',
|
||||
distance: e.distance,
|
||||
hasLineOfSight: e.hasLineOfSight,
|
||||
displayName: e.displayName,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
heard: {
|
||||
sound: {
|
||||
threshold: 5,
|
||||
key: 'sound:ambient',
|
||||
buildSignal: (event) => {
|
||||
const e = event as Extract<RawPerceptionEvent, { modality: 'heard', kind: 'sound' }>
|
||||
return {
|
||||
type: 'environmental_anomaly',
|
||||
description: `Heard sound: ${e.soundId}`,
|
||||
sourceId: e.soundId,
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'sound',
|
||||
action: 'sound',
|
||||
soundId: e.soundId,
|
||||
distance: e.distance,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
felt: {
|
||||
damage_taken: {
|
||||
threshold: 1, // Damage is immediately salient
|
||||
key: 'felt:damage',
|
||||
buildSignal: () => ({
|
||||
type: 'saliency_high',
|
||||
description: 'Taken damage!',
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'felt',
|
||||
action: 'damage',
|
||||
},
|
||||
}),
|
||||
},
|
||||
item_collected: {
|
||||
threshold: 3,
|
||||
key: 'felt:pickup',
|
||||
buildSignal: () => ({
|
||||
type: 'entity_attention',
|
||||
description: 'Picked up an item',
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'felt',
|
||||
action: 'pickup',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
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'
|
||||
|
||||
import { useLogger } from './utils/logger'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
interface DebugEvent {
|
||||
type: string
|
||||
payload: any
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface LlmTrace {
|
||||
route: string
|
||||
messages: any
|
||||
content: any
|
||||
usage?: any
|
||||
model?: string
|
||||
duration?: number // ms
|
||||
}
|
||||
|
||||
export class DebugService {
|
||||
private static instance: DebugService
|
||||
private clients: Set<ServerResponse> = new Set()
|
||||
private server: http.Server | null = null
|
||||
|
||||
// History buffer (Ring buffer)
|
||||
private history: DebugEvent[] = []
|
||||
private readonly MAX_HISTORY = 1000
|
||||
|
||||
// File Logging
|
||||
private logStream: fs.WriteStream | null = null
|
||||
|
||||
private constructor() {
|
||||
this.initLogFile()
|
||||
}
|
||||
|
||||
public static getInstance(): DebugService {
|
||||
if (!DebugService.instance) {
|
||||
DebugService.instance = new DebugService()
|
||||
}
|
||||
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 {
|
||||
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.method === 'OPTIONS') {
|
||||
res.writeHead(200)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (req.url === '/') {
|
||||
// Serve dashboard
|
||||
const htmlPath = path.join(__dirname, 'web', 'dashboard.html')
|
||||
try {
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8')
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html',
|
||||
'Cache-Control': 'no-cache',
|
||||
})
|
||||
res.end(html)
|
||||
}
|
||||
catch {
|
||||
res.writeHead(500)
|
||||
res.end('Dashboard not found')
|
||||
}
|
||||
}
|
||||
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 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: LlmTrace) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private handleSSE(req: IncomingMessage, res: ServerResponse): void {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'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(() => {
|
||||
res.write(': keep-alive\n\n')
|
||||
}, 15000)
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(keepAlive)
|
||||
this.clients.delete(res)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { BlackboardEvent, ClientCommand, LLMTraceEvent, LogEvent, QueueEvent, SaliencyEvent, ServerEvent } from './types'
|
||||
|
||||
import { DebugServer } from './server'
|
||||
|
||||
type CommandHandler = (command: ClientCommand) => void
|
||||
|
||||
/**
|
||||
* DebugService - Public API for application code to emit debug events
|
||||
*
|
||||
* This is a singleton that wraps the DebugServer and provides
|
||||
* a convenient API for emitting events and handling commands.
|
||||
*/
|
||||
export class DebugService {
|
||||
private static instance: DebugService
|
||||
private server: DebugServer
|
||||
|
||||
private constructor() {
|
||||
this.server = new DebugServer()
|
||||
}
|
||||
|
||||
public static getInstance(): DebugService {
|
||||
if (!DebugService.instance) {
|
||||
DebugService.instance = new DebugService()
|
||||
}
|
||||
return DebugService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the debug server
|
||||
*/
|
||||
public start(port = 3000): void {
|
||||
this.server.start(port)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the debug server
|
||||
*/
|
||||
public stop(): void {
|
||||
this.server.stop()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Convenience methods for common event types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Emit a log event
|
||||
*/
|
||||
public log(level: LogEvent['level'], message: string, fields?: Record<string, unknown>): void {
|
||||
const event: ServerEvent = {
|
||||
type: 'log',
|
||||
payload: {
|
||||
level,
|
||||
message,
|
||||
fields,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
this.server.broadcast(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an LLM trace event
|
||||
*/
|
||||
public traceLLM(trace: Omit<LLMTraceEvent, 'timestamp'>): void {
|
||||
const event: ServerEvent = {
|
||||
type: 'llm',
|
||||
payload: {
|
||||
...trace,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
this.server.broadcast(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a blackboard state update
|
||||
*/
|
||||
public updateBlackboard(state: BlackboardEvent['state'] | { goal?: string, thought?: string, strategy?: string }): void {
|
||||
const event: ServerEvent = {
|
||||
type: 'blackboard',
|
||||
payload: {
|
||||
state,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
this.server.broadcast(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a queue state update
|
||||
*/
|
||||
public updateQueue(queue: QueueEvent['queue'], processing?: QueueEvent['processing']): void {
|
||||
const event: ServerEvent = {
|
||||
type: 'queue',
|
||||
payload: {
|
||||
queue,
|
||||
processing,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
this.server.broadcast(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a saliency snapshot
|
||||
*/
|
||||
public emitSaliency(
|
||||
slot: number,
|
||||
counters: SaliencyEvent['counters'],
|
||||
): void {
|
||||
const event: ServerEvent = {
|
||||
type: 'saliency',
|
||||
payload: {
|
||||
slot,
|
||||
counters,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
this.server.broadcast(event)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Generic emit for custom events
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Emit a raw event (for backwards compatibility and custom events)
|
||||
*/
|
||||
public emit(type: string, payload: unknown): void {
|
||||
// Map to strongly-typed events where possible
|
||||
switch (type) {
|
||||
case 'log':
|
||||
this.server.broadcast({ type: 'log', payload: payload as LogEvent })
|
||||
break
|
||||
case 'llm':
|
||||
this.server.broadcast({ type: 'llm', payload: payload as LLMTraceEvent })
|
||||
break
|
||||
case 'blackboard':
|
||||
this.server.broadcast({ type: 'blackboard', payload: payload as BlackboardEvent })
|
||||
break
|
||||
case 'queue':
|
||||
this.server.broadcast({ type: 'queue', payload: payload as QueueEvent })
|
||||
break
|
||||
case 'saliency': {
|
||||
// Transform the saliency data to match expected format
|
||||
const data = payload as { slot?: number, counters?: SaliencyEvent['counters'] }
|
||||
this.server.broadcast({
|
||||
type: 'saliency',
|
||||
payload: {
|
||||
slot: data.slot || 0,
|
||||
counters: data.counters || [],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
default:
|
||||
// For unknown types, emit as log
|
||||
this.log('DEBUG', `Unknown event type: ${type}`, { payload })
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Command handling
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Register a handler for client commands
|
||||
* Returns an unsubscribe function
|
||||
*/
|
||||
public onCommand(type: string, handler: CommandHandler): () => void {
|
||||
return this.server.onCommand(type, handler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Re-export public API
|
||||
export { DebugService } from './debug-service'
|
||||
export { DebugServer } from './server'
|
||||
export type {
|
||||
BlackboardEvent,
|
||||
ClientCommand,
|
||||
DebugMessage,
|
||||
LLMTraceEvent,
|
||||
LogEvent,
|
||||
QueueEvent,
|
||||
SaliencyEvent,
|
||||
ServerEvent,
|
||||
} from './types'
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
|
||||
import type { WebSocket } from 'ws'
|
||||
|
||||
import type { ClientCommand, DebugMessage, ServerEvent } from './types'
|
||||
|
||||
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'
|
||||
|
||||
import { WebSocketServer } from 'ws'
|
||||
|
||||
import { useLogger } from '../utils/logger'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
interface ClientInfo {
|
||||
ws: WebSocket
|
||||
id: string
|
||||
connectedAt: number
|
||||
lastPing: number
|
||||
}
|
||||
|
||||
type CommandHandler = (command: ClientCommand, client: ClientInfo) => void
|
||||
|
||||
export class DebugServer {
|
||||
private httpServer: http.Server | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
private clients: Map<string, ClientInfo> = new Map()
|
||||
private commandHandlers: Map<string, CommandHandler[]> = new Map()
|
||||
|
||||
// History buffer (Ring buffer)
|
||||
private history: ServerEvent[] = []
|
||||
private readonly MAX_HISTORY = 1000
|
||||
|
||||
// File Logging
|
||||
private logStream: fs.WriteStream | null = null
|
||||
|
||||
// Heartbeat
|
||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null
|
||||
private readonly HEARTBEAT_INTERVAL = 30000
|
||||
|
||||
private messageIdCounter = 0
|
||||
|
||||
constructor() {
|
||||
this.initLogFile()
|
||||
}
|
||||
|
||||
private initLogFile(): void {
|
||||
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 {
|
||||
if (this.httpServer) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create HTTP server for static files
|
||||
this.httpServer = http.createServer((req, res) => {
|
||||
this.handleHttpRequest(req, res)
|
||||
})
|
||||
|
||||
// Create WebSocket server
|
||||
this.wss = new WebSocketServer({ server: this.httpServer })
|
||||
|
||||
this.wss.on('connection', (ws, req) => {
|
||||
this.handleConnection(ws, req)
|
||||
})
|
||||
|
||||
// Start heartbeat
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
this.sendHeartbeat()
|
||||
}, this.HEARTBEAT_INTERVAL)
|
||||
|
||||
this.httpServer.listen(port, () => {
|
||||
useLogger().log(`Debug server running at http://localhost:${port}`)
|
||||
})
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
this.heartbeatInterval = null
|
||||
}
|
||||
|
||||
for (const client of this.clients.values()) {
|
||||
client.ws.close()
|
||||
}
|
||||
this.clients.clear()
|
||||
|
||||
this.wss?.close()
|
||||
this.wss = null
|
||||
|
||||
this.httpServer?.close()
|
||||
this.httpServer = null
|
||||
|
||||
this.logStream?.end()
|
||||
this.logStream = null
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API for emitting events
|
||||
// ============================================================
|
||||
|
||||
public broadcast(event: ServerEvent): void {
|
||||
// Add to history
|
||||
this.addToHistory(event)
|
||||
|
||||
// Persist to disk
|
||||
this.persistEvent(event)
|
||||
|
||||
// Send to all connected clients
|
||||
const message = this.createMessage(event)
|
||||
const data = JSON.stringify(message)
|
||||
|
||||
for (const client of this.clients.values()) {
|
||||
if (client.ws.readyState === 1) { // WebSocket.OPEN
|
||||
client.ws.send(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Command handling
|
||||
// ============================================================
|
||||
|
||||
public onCommand(type: string, handler: CommandHandler): () => void {
|
||||
const handlers = this.commandHandlers.get(type) || []
|
||||
handlers.push(handler)
|
||||
this.commandHandlers.set(type, handlers)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const h = this.commandHandlers.get(type)
|
||||
if (h) {
|
||||
const idx = h.indexOf(handler)
|
||||
if (idx !== -1) {
|
||||
h.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private methods
|
||||
// ============================================================
|
||||
|
||||
private handleHttpRequest(req: IncomingMessage, res: http.ServerResponse): void {
|
||||
// Enable CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// Serve static files from web directory
|
||||
let filePath = req.url === '/' ? '/index.html' : req.url || '/index.html'
|
||||
|
||||
// Security: prevent directory traversal
|
||||
filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, '')
|
||||
|
||||
const fullPath = path.join(__dirname, 'web', filePath)
|
||||
const extname = path.extname(fullPath).toLowerCase()
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
}
|
||||
|
||||
const contentType = mimeTypes[extname] || 'application/octet-stream'
|
||||
|
||||
fs.readFile(fullPath, (err, content) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
res.writeHead(404)
|
||||
res.end('Not Found')
|
||||
}
|
||||
else {
|
||||
res.writeHead(500)
|
||||
res.end('Server Error')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'no-cache',
|
||||
})
|
||||
res.end(content)
|
||||
})
|
||||
}
|
||||
|
||||
private handleConnection(ws: WebSocket, _req: IncomingMessage): void {
|
||||
const clientId = this.generateClientId()
|
||||
const client: ClientInfo = {
|
||||
ws,
|
||||
id: clientId,
|
||||
connectedAt: Date.now(),
|
||||
lastPing: Date.now(),
|
||||
}
|
||||
|
||||
this.clients.set(clientId, client)
|
||||
|
||||
// Send history on connect
|
||||
this.sendHistory(client)
|
||||
|
||||
ws.on('message', (data) => {
|
||||
this.handleMessage(client, data.toString())
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
this.clients.delete(clientId)
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error(`WebSocket error for client ${clientId}:`, err)
|
||||
this.clients.delete(clientId)
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(client: ClientInfo, data: string): void {
|
||||
try {
|
||||
const message = JSON.parse(data) as DebugMessage<ClientCommand>
|
||||
const command = message.data
|
||||
|
||||
// Handle ping internally
|
||||
if (command.type === 'ping') {
|
||||
client.lastPing = Date.now()
|
||||
const pong: ServerEvent = { type: 'pong', payload: { timestamp: Date.now() } }
|
||||
client.ws.send(JSON.stringify(this.createMessage(pong)))
|
||||
return
|
||||
}
|
||||
|
||||
// Handle history request
|
||||
if (command.type === 'request_history') {
|
||||
this.sendHistory(client)
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch to registered handlers
|
||||
const handlers = this.commandHandlers.get(command.type)
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(command, client)
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`Command handler error for ${command.type}:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to parse WebSocket message:', err)
|
||||
}
|
||||
}
|
||||
|
||||
private sendHistory(client: ClientInfo): void {
|
||||
if (this.history.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const historyEvent: ServerEvent = {
|
||||
type: 'history',
|
||||
payload: this.history,
|
||||
}
|
||||
|
||||
client.ws.send(JSON.stringify(this.createMessage(historyEvent)))
|
||||
}
|
||||
|
||||
private sendHeartbeat(): void {
|
||||
const now = Date.now()
|
||||
for (const [clientId, client] of this.clients) {
|
||||
// Check if client is still alive (responded to ping within 2x heartbeat interval)
|
||||
if (now - client.lastPing > this.HEARTBEAT_INTERVAL * 2) {
|
||||
client.ws.close()
|
||||
this.clients.delete(clientId)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send ping request
|
||||
if (client.ws.readyState === 1) {
|
||||
const ping: ServerEvent = { type: 'pong', payload: { timestamp: now } }
|
||||
client.ws.send(JSON.stringify(this.createMessage(ping)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addToHistory(event: ServerEvent): void {
|
||||
this.history.push(event)
|
||||
if (this.history.length > this.MAX_HISTORY) {
|
||||
this.history.shift()
|
||||
}
|
||||
}
|
||||
|
||||
private persistEvent(event: ServerEvent): void {
|
||||
if (this.logStream) {
|
||||
try {
|
||||
this.logStream.write(`${JSON.stringify(event)}\n`)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to write to log file', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createMessage(event: ServerEvent): DebugMessage<ServerEvent> {
|
||||
return {
|
||||
id: `${++this.messageIdCounter}`,
|
||||
data: event,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
private generateClientId(): string {
|
||||
return `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Debug server message types for bidirectional WebSocket communication
|
||||
|
||||
// ============================================================
|
||||
// Server -> Client events
|
||||
// ============================================================
|
||||
|
||||
export interface LogEvent {
|
||||
level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG'
|
||||
message: string
|
||||
fields?: Record<string, unknown>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface LLMTraceEvent {
|
||||
route: string
|
||||
messages: unknown[]
|
||||
content: string
|
||||
usage?: {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
}
|
||||
model?: string
|
||||
duration?: number // ms
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface BlackboardEvent {
|
||||
state: Record<string, unknown>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface QueueEvent {
|
||||
queue: Array<{
|
||||
type: string
|
||||
payload: unknown
|
||||
source?: { type: string, id: string }
|
||||
}>
|
||||
processing?: {
|
||||
type: string
|
||||
payload: unknown
|
||||
source?: { type: string, id: string }
|
||||
}
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface SaliencyEvent {
|
||||
slot: number
|
||||
counters: Array<{
|
||||
key: string
|
||||
total: number
|
||||
window: number[]
|
||||
triggers: number[]
|
||||
lastFireSlot: number | null
|
||||
lastFireTotal: number
|
||||
}>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// Union type for all server events
|
||||
export type ServerEvent
|
||||
= | { type: 'log', payload: LogEvent }
|
||||
| { type: 'llm', payload: LLMTraceEvent }
|
||||
| { type: 'blackboard', payload: BlackboardEvent }
|
||||
| { type: 'queue', payload: QueueEvent }
|
||||
| { type: 'saliency', payload: SaliencyEvent }
|
||||
| { type: 'history', payload: ServerEvent[] }
|
||||
| { type: 'pong', payload: { timestamp: number } }
|
||||
|
||||
// ============================================================
|
||||
// Client -> Server commands
|
||||
// ============================================================
|
||||
|
||||
export interface ClearLogsCommand {
|
||||
type: 'clear_logs'
|
||||
}
|
||||
|
||||
export interface SetFilterCommand {
|
||||
type: 'set_filter'
|
||||
payload: {
|
||||
panel: string
|
||||
filter: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface InjectEventCommand {
|
||||
type: 'inject_event'
|
||||
payload: {
|
||||
eventType: string
|
||||
data: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface PingCommand {
|
||||
type: 'ping'
|
||||
payload: { timestamp: number }
|
||||
}
|
||||
|
||||
export interface RequestHistoryCommand {
|
||||
type: 'request_history'
|
||||
}
|
||||
|
||||
// Union type for all client commands
|
||||
export type ClientCommand
|
||||
= | ClearLogsCommand
|
||||
| SetFilterCommand
|
||||
| InjectEventCommand
|
||||
| PingCommand
|
||||
| RequestHistoryCommand
|
||||
|
||||
// ============================================================
|
||||
// Wire format
|
||||
// ============================================================
|
||||
|
||||
export interface DebugMessage<T = ServerEvent | ClientCommand> {
|
||||
id: string
|
||||
data: T
|
||||
timestamp: number
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* Debug Dashboard - Multi-View Application
|
||||
* All panels visible simultaneously, focus on robustness and utility
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Configuration & Constants
|
||||
// =============================================================================
|
||||
|
||||
const CONFIG = {
|
||||
MAX_LOGS: 500,
|
||||
MAX_LLM_TRACES: 50,
|
||||
RECONNECT_MAX_ATTEMPTS: 10,
|
||||
RECONNECT_DELAY: 1000,
|
||||
PING_INTERVAL: 25000,
|
||||
UPDATE_THROTTLE: 100, // ms
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
function throttle(func, wait) {
|
||||
let timeout
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout)
|
||||
func(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WebSocket Client
|
||||
// =============================================================================
|
||||
|
||||
class DebugClient {
|
||||
constructor() {
|
||||
this.ws = null
|
||||
this.reconnectAttempts = 0
|
||||
this.pingInterval = null
|
||||
this.messageIdCounter = 0
|
||||
this.eventHandlers = new Map()
|
||||
}
|
||||
|
||||
on(eventType, handler) {
|
||||
if (!this.eventHandlers.has(eventType)) {
|
||||
this.eventHandlers.set(eventType, [])
|
||||
}
|
||||
this.eventHandlers.get(eventType).push(handler)
|
||||
}
|
||||
|
||||
emit(eventType, data) {
|
||||
const handlers = this.eventHandlers.get(eventType)
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data)
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`Error in ${eventType} handler:`, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}`
|
||||
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0
|
||||
this.updateConnectionStatus(true)
|
||||
this.startPing()
|
||||
this.emit('connected')
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.updateConnectionStatus(false)
|
||||
this.stopPing()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
console.error('WebSocket error:', err)
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleMessage(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.stopPing()
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
this.disconnect()
|
||||
this.reconnectAttempts = 0
|
||||
this.connect()
|
||||
}
|
||||
|
||||
send(command) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
id: `${++this.messageIdCounter}`,
|
||||
data: command,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
this.ws.send(JSON.stringify(message))
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(data) {
|
||||
try {
|
||||
const message = JSON.parse(data)
|
||||
const event = message.data
|
||||
|
||||
if (event.type === 'history') {
|
||||
for (const historyEvent of event.payload) {
|
||||
this.routeEvent(historyEvent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'pong') {
|
||||
return
|
||||
}
|
||||
|
||||
this.routeEvent(event)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to parse message:', err)
|
||||
}
|
||||
}
|
||||
|
||||
routeEvent(event) {
|
||||
this.emit(event.type, event.payload)
|
||||
}
|
||||
|
||||
updateConnectionStatus(connected) {
|
||||
const dot = document.getElementById('connection-status')
|
||||
const text = document.getElementById('status-text')
|
||||
|
||||
if (connected) {
|
||||
dot.classList.add('connected')
|
||||
text.textContent = 'Connected'
|
||||
}
|
||||
else {
|
||||
dot.classList.remove('connected')
|
||||
text.textContent = 'Disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= CONFIG.RECONNECT_MAX_ATTEMPTS) {
|
||||
console.log('Max reconnect attempts reached')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
const delay = CONFIG.RECONNECT_DELAY * Math.min(this.reconnectAttempts, 5)
|
||||
|
||||
const text = document.getElementById('status-text')
|
||||
text.textContent = `Reconnecting (${this.reconnectAttempts})...`
|
||||
|
||||
setTimeout(() => this.connect(), delay)
|
||||
}
|
||||
|
||||
startPing() {
|
||||
this.pingInterval = setInterval(() => {
|
||||
this.send({ type: 'ping', payload: { timestamp: Date.now() } })
|
||||
}, CONFIG.PING_INTERVAL)
|
||||
}
|
||||
|
||||
stopPing() {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval)
|
||||
this.pingInterval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Queue Panel
|
||||
// =============================================================================
|
||||
|
||||
class QueuePanel {
|
||||
constructor(client) {
|
||||
this.client = client
|
||||
this.data = { queue: [], processing: null }
|
||||
this.elements = {
|
||||
queueList: document.getElementById('queue-list'),
|
||||
queueCount: document.getElementById('queue-count'),
|
||||
processingContent: document.getElementById('processing-content'),
|
||||
statQueue: document.getElementById('stat-queue'),
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.client.on('queue', data => this.update(data))
|
||||
this.client.on('connected', () => this.reset())
|
||||
this.render()
|
||||
}
|
||||
|
||||
update(data) {
|
||||
this.data = data
|
||||
this.render()
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.data = { queue: [], processing: null }
|
||||
this.render()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { queue, processing } = this.data
|
||||
|
||||
// Queue count
|
||||
const queueSize = queue?.length || 0
|
||||
this.elements.queueCount.textContent = queueSize
|
||||
this.elements.statQueue.textContent = queueSize
|
||||
|
||||
// Queue list
|
||||
if (queue && queue.length > 0) {
|
||||
this.elements.queueList.innerHTML = queue.map((item, idx) => `
|
||||
<div class="queue-item">
|
||||
<span class="queue-index">#${idx + 1}</span>
|
||||
<span class="queue-type">${escapeHtml(item.type)}</span>
|
||||
<span class="queue-source">${escapeHtml(item.source?.id || 'unknown')}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
else {
|
||||
this.elements.queueList.innerHTML = '<div class="empty-state">Queue empty</div>'
|
||||
}
|
||||
|
||||
// Processing
|
||||
if (processing) {
|
||||
this.elements.processingContent.innerHTML = `
|
||||
<div class="processing-active">
|
||||
<strong>${escapeHtml(processing.type)}</strong>
|
||||
<div style="color: var(--text-secondary); font-size: 11px; margin-top: 4px;">
|
||||
Source: ${escapeHtml(processing.source?.type)}/${escapeHtml(processing.source?.id)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
else {
|
||||
this.elements.processingContent.innerHTML = '<span style="color: var(--text-muted);">Idle</span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Blackboard Panel
|
||||
// =============================================================================
|
||||
|
||||
class BlackboardPanel {
|
||||
constructor(client) {
|
||||
this.client = client
|
||||
this.state = {}
|
||||
this.elements = {
|
||||
json: document.getElementById('blackboard-json'),
|
||||
copyBtn: document.getElementById('blackboard-copy-btn'),
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.client.on('blackboard', data => this.update(data))
|
||||
this.client.on('connected', () => this.reset())
|
||||
this.elements.copyBtn.addEventListener('click', () => this.copy())
|
||||
this.render()
|
||||
}
|
||||
|
||||
update(data) {
|
||||
this.state = data.state || {}
|
||||
this.render()
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state = {}
|
||||
this.render()
|
||||
}
|
||||
|
||||
render() {
|
||||
this.elements.json.textContent = JSON.stringify(this.state, null, 2)
|
||||
}
|
||||
|
||||
copy() {
|
||||
navigator.clipboard.writeText(JSON.stringify(this.state, null, 2))
|
||||
.then(() => {
|
||||
this.elements.copyBtn.textContent = '✓'
|
||||
setTimeout(() => { this.elements.copyBtn.textContent = '📋' }, 1000)
|
||||
})
|
||||
.catch(err => console.error('Copy failed:', err))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Logs Panel
|
||||
// =============================================================================
|
||||
|
||||
class LogsPanel {
|
||||
constructor(client) {
|
||||
this.client = client
|
||||
this.logs = []
|
||||
this.autoScroll = true
|
||||
this.paused = false
|
||||
this.filter = { level: 'all', search: '' }
|
||||
this.elements = {
|
||||
container: document.getElementById('logs-container'),
|
||||
search: document.getElementById('log-search'),
|
||||
levelFilter: document.getElementById('log-level-filter'),
|
||||
autoScroll: document.getElementById('auto-scroll'),
|
||||
statEvents: document.getElementById('stat-events'),
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.client.on('log', data => this.addLog(data))
|
||||
this.client.on('connected', () => this.reset())
|
||||
|
||||
this.elements.search.addEventListener('input', (e) => {
|
||||
this.filter.search = e.target.value.toLowerCase()
|
||||
this.renderThrottled()
|
||||
})
|
||||
|
||||
this.elements.levelFilter.addEventListener('change', (e) => {
|
||||
this.filter.level = e.target.value
|
||||
this.renderThrottled()
|
||||
})
|
||||
|
||||
this.elements.autoScroll.addEventListener('change', (e) => {
|
||||
this.autoScroll = e.target.checked
|
||||
})
|
||||
|
||||
this.renderThrottled = throttle(() => this.render(), CONFIG.UPDATE_THROTTLE)
|
||||
this.render()
|
||||
}
|
||||
|
||||
addLog(entry) {
|
||||
if (this.paused)
|
||||
return
|
||||
|
||||
this.logs.push(entry)
|
||||
if (this.logs.length > CONFIG.MAX_LOGS) {
|
||||
this.logs.shift()
|
||||
}
|
||||
|
||||
this.elements.statEvents.textContent = this.logs.length
|
||||
this.renderThrottled()
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.logs = []
|
||||
this.elements.statEvents.textContent = '0'
|
||||
this.render()
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.logs = []
|
||||
this.elements.statEvents.textContent = '0'
|
||||
this.render()
|
||||
}
|
||||
|
||||
setPaused(paused) {
|
||||
this.paused = paused
|
||||
}
|
||||
|
||||
render() {
|
||||
const filtered = this.logs.filter((log) => {
|
||||
if (this.filter.level !== 'all' && log.level !== this.filter.level) {
|
||||
return false
|
||||
}
|
||||
if (this.filter.search && !log.message.toLowerCase().includes(this.filter.search)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
this.elements.container.innerHTML = filtered.map((log) => {
|
||||
const time = new Date(log.timestamp).toLocaleTimeString()
|
||||
const fieldsStr = log.fields ? JSON.stringify(log.fields) : ''
|
||||
|
||||
return `
|
||||
<div class="log-entry">
|
||||
<span class="log-time">${time}</span>
|
||||
<span class="log-level level-${log.level}">[${log.level}]</span>
|
||||
<span class="log-message">${escapeHtml(log.message)}</span>
|
||||
${fieldsStr ? `<div class="log-fields">${escapeHtml(fieldsStr)}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
if (this.autoScroll) {
|
||||
this.elements.container.scrollTop = this.elements.container.scrollHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LLM Panel
|
||||
// =============================================================================
|
||||
|
||||
class LLMPanel {
|
||||
constructor(client) {
|
||||
this.client = client
|
||||
this.traces = []
|
||||
this.elements = {
|
||||
list: document.getElementById('llm-list'),
|
||||
count: document.getElementById('llm-count'),
|
||||
statLlm: document.getElementById('stat-llm'),
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.client.on('llm', data => this.addTrace(data))
|
||||
this.client.on('connected', () => this.reset())
|
||||
}
|
||||
|
||||
addTrace(trace) {
|
||||
this.traces.unshift(trace)
|
||||
if (this.traces.length > CONFIG.MAX_LLM_TRACES) {
|
||||
this.traces.pop()
|
||||
}
|
||||
|
||||
this.elements.count.textContent = this.traces.length
|
||||
this.elements.statLlm.textContent = this.traces.length
|
||||
this.renderTrace(trace)
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.traces = []
|
||||
this.elements.list.innerHTML = ''
|
||||
this.elements.count.textContent = '0'
|
||||
this.elements.statLlm.textContent = '0'
|
||||
}
|
||||
|
||||
renderTrace(trace) {
|
||||
const time = new Date(trace.timestamp).toLocaleTimeString()
|
||||
const tokens = trace.usage?.total_tokens || '?'
|
||||
const duration = Number.isFinite(trace.duration) ? `${trace.duration}ms` : 'n/a'
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.className = 'llm-card'
|
||||
|
||||
// Messages HTML
|
||||
let messagesHtml = ''
|
||||
if (trace.messages && Array.isArray(trace.messages)) {
|
||||
messagesHtml = trace.messages.map(msg => `
|
||||
<div class="llm-message role-${msg.role || 'unknown'}">
|
||||
<div class="llm-message-role">${msg.role || 'unknown'}</div>
|
||||
<div class="llm-message-content">${escapeHtml(msg.content || '')}</div>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="llm-header">
|
||||
<div class="llm-title">
|
||||
<span class="llm-route">${escapeHtml(trace.route || 'unknown')}</span>
|
||||
<span style="color: var(--text-muted);">${time}</span>
|
||||
</div>
|
||||
<div class="llm-meta">
|
||||
<span>${tokens} tokens</span>
|
||||
<span>${duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="llm-body">
|
||||
<div class="llm-section-title">Result</div>
|
||||
<div class="llm-content">${escapeHtml(trace.content || '')}</div>
|
||||
|
||||
<div class="llm-section-title">Messages</div>
|
||||
<div class="llm-messages">
|
||||
${messagesHtml || '<div class="empty-state">No messages</div>'}
|
||||
</div>
|
||||
|
||||
<div class="llm-section-title">Usage</div>
|
||||
<div class="llm-content">${JSON.stringify(trace.usage || {}, null, 2)}</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const header = card.querySelector('.llm-header')
|
||||
const body = card.querySelector('.llm-body')
|
||||
header.addEventListener('click', () => {
|
||||
body.classList.toggle('open')
|
||||
})
|
||||
|
||||
this.elements.list.insertBefore(card, this.elements.list.firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Saliency Panel
|
||||
// =============================================================================
|
||||
|
||||
class SaliencyPanel {
|
||||
constructor(client) {
|
||||
this.client = client
|
||||
this.data = null
|
||||
this.elements = {
|
||||
canvas: document.getElementById('saliency-canvas'),
|
||||
labels: document.getElementById('saliency-labels'),
|
||||
slot: document.getElementById('saliency-slot'),
|
||||
}
|
||||
this.ctx = this.elements.canvas.getContext('2d')
|
||||
}
|
||||
|
||||
init() {
|
||||
this.client.on('saliency', data => this.update(data))
|
||||
this.client.on('connected', () => this.reset())
|
||||
}
|
||||
|
||||
update(data) {
|
||||
this.data = data
|
||||
this.render()
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.data = null
|
||||
this.render()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.data || !this.data.counters) {
|
||||
this.elements.labels.innerHTML = '<div class="empty-state">No data</div>'
|
||||
return
|
||||
}
|
||||
|
||||
this.elements.slot.textContent = this.data.slot || 0
|
||||
|
||||
// Use all counters directly from server (fixed order)
|
||||
const counters = this.data.counters
|
||||
|
||||
// Render labels
|
||||
this.elements.labels.innerHTML = counters.map(c => `
|
||||
<div class="saliency-label">
|
||||
<span class="saliency-key">${escapeHtml(c.key || '')}</span>
|
||||
<span class="saliency-total">(${c.total || 0})</span>
|
||||
</div>
|
||||
`).join('')
|
||||
|
||||
// Render canvas
|
||||
const cols = 100
|
||||
const rows = counters.length
|
||||
const cellW = 6
|
||||
const cellH = 16
|
||||
|
||||
this.elements.canvas.width = cols * cellW
|
||||
this.elements.canvas.height = Math.max(1, rows) * cellH
|
||||
|
||||
this.ctx.fillStyle = '#0d1117'
|
||||
this.ctx.fillRect(0, 0, this.elements.canvas.width, this.elements.canvas.height)
|
||||
|
||||
let maxCell = 0
|
||||
for (const counter of counters) {
|
||||
for (let i = 0; i < Math.min(cols, counter.window?.length || 0); i++) {
|
||||
maxCell = Math.max(maxCell, counter.window[i] || 0)
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
const counter = counters[y]
|
||||
const w = counter.window || []
|
||||
const t = counter.triggers || []
|
||||
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const v = x < w.length ? (w[x] || 0) : 0
|
||||
const fired = x < t.length ? (t[x] || 0) : 0
|
||||
|
||||
this.ctx.fillStyle = fired
|
||||
? 'rgba(248, 81, 73, 0.9)'
|
||||
: this.colorFor(v, maxCell)
|
||||
this.ctx.fillRect(x * cellW, y * cellH, cellW - 1, cellH - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
colorFor(value, maxValue) {
|
||||
if (!maxValue)
|
||||
return 'rgba(0,0,0,0)'
|
||||
const t = Math.min(1, value / maxValue)
|
||||
const r = Math.round(88 + 80 * t)
|
||||
const g = Math.round(166 + 80 * t)
|
||||
const b = Math.round(255 * t)
|
||||
return `rgba(${r},${g},${b},${0.2 + 0.8 * t})`
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Application
|
||||
// =============================================================================
|
||||
|
||||
class DebugApp {
|
||||
constructor() {
|
||||
this.client = new DebugClient()
|
||||
this.panels = {
|
||||
queue: new QueuePanel(this.client),
|
||||
blackboard: new BlackboardPanel(this.client),
|
||||
logs: new LogsPanel(this.client),
|
||||
llm: new LLMPanel(this.client),
|
||||
saliency: new SaliencyPanel(this.client),
|
||||
}
|
||||
this.paused = false
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize all panels
|
||||
Object.values(this.panels).forEach(panel => panel.init())
|
||||
|
||||
// Setup controls
|
||||
document.getElementById('clear-logs-btn').addEventListener('click', () => {
|
||||
this.panels.logs.clear()
|
||||
})
|
||||
|
||||
document.getElementById('pause-btn').addEventListener('click', (e) => {
|
||||
this.paused = !this.paused
|
||||
this.panels.logs.setPaused(this.paused)
|
||||
e.target.textContent = this.paused ? 'Resume' : 'Pause'
|
||||
})
|
||||
|
||||
document.getElementById('reconnect-btn').addEventListener('click', () => {
|
||||
this.client.reconnect()
|
||||
})
|
||||
|
||||
// Connect
|
||||
this.client.connect()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bootstrap
|
||||
// =============================================================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const app = new DebugApp()
|
||||
app.init()
|
||||
window.debugApp = app // For debugging
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Dashboard</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Header Bar -->
|
||||
<header id="header">
|
||||
<div class="header-left">
|
||||
<h1>Debug Dashboard</h1>
|
||||
<div class="status-indicator">
|
||||
<span id="connection-status" class="status-dot"></span>
|
||||
<span id="status-text">Disconnected</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Events</span>
|
||||
<span id="stat-events" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Queue</span>
|
||||
<span id="stat-queue" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">LLM Calls</span>
|
||||
<span id="stat-llm" class="stat-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<button id="clear-logs-btn" title="Clear logs">Clear Logs</button>
|
||||
<button id="pause-btn" title="Pause updates">Pause</button>
|
||||
<button id="reconnect-btn" title="Reconnect WebSocket">Reconnect</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Grid Layout -->
|
||||
<main id="main-grid">
|
||||
<!-- Left Column -->
|
||||
<div id="left-column">
|
||||
<!-- Queue & Processing -->
|
||||
<section id="queue-section" class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Queue & Processing</h2>
|
||||
<span class="panel-badge" id="queue-count">0</span>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div id="queue-list" class="queue-list"></div>
|
||||
<div class="divider"></div>
|
||||
<div id="processing-info" class="processing-info">
|
||||
<div class="processing-label">Processing:</div>
|
||||
<div id="processing-content">Idle</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Blackboard -->
|
||||
<section id="blackboard-section" class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Blackboard</h2>
|
||||
<button id="blackboard-copy-btn" class="icon-btn" title="Copy JSON">📋</button>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<pre id="blackboard-json" class="json-display">{}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Logs -->
|
||||
<section id="logs-section" class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Logs</h2>
|
||||
<div class="panel-controls">
|
||||
<input type="search" id="log-search" placeholder="Search logs..." />
|
||||
<select id="log-level-filter">
|
||||
<option value="all">All Levels</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="WARN">WARN</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
</select>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="auto-scroll" checked />
|
||||
Auto-scroll
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div id="logs-container" class="logs-container"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bottom Row: LLM Traces -->
|
||||
<section id="llm-section" class="panel panel-full-width">
|
||||
<div class="panel-header">
|
||||
<h2>LLM Traces</h2>
|
||||
<span class="panel-badge" id="llm-count">0</span>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div id="llm-list" class="llm-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bottom Row: Saliency -->
|
||||
<section id="saliency-section" class="panel panel-full-width">
|
||||
<div class="panel-header">
|
||||
<h2>Saliency Heatmap</h2>
|
||||
<div class="panel-controls">
|
||||
<span>Slot: <span id="saliency-slot" class="panel-badge">0</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="saliency-container">
|
||||
<canvas id="saliency-canvas"></canvas>
|
||||
<div id="saliency-labels" class="saliency-labels"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,554 @@
|
||||
/* ==========================================================================
|
||||
Debug Dashboard - Utility-Focused Styles
|
||||
========================================================================== */
|
||||
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--border-color: #30363d;
|
||||
--text-primary: #c9d1d9;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--accent-info: #58a6ff;
|
||||
--accent-success: #3fb950;
|
||||
--accent-warning: #d29922;
|
||||
--accent-error: #f85149;
|
||||
--font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
#header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-error);
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background-color: var(--accent-success);
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-info);
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button,
|
||||
.icon-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.icon-btn:hover {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Main Grid Layout */
|
||||
#main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
grid-template-rows: minmax(300px, 1fr) minmax(200px, 0.6fr) minmax(200px, 0.4fr);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#left-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
#queue-section {
|
||||
flex: 0 0 auto;
|
||||
max-height: 40%;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
#blackboard-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#logs-section {
|
||||
grid-row: 1 / 2;
|
||||
grid-column: 2 / 3;
|
||||
}
|
||||
|
||||
#llm-section {
|
||||
grid-row: 2 / 3;
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
#saliency-section {
|
||||
grid-row: 3 / 4;
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
/* Panel Styles */
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-badge {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--accent-info);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.panel-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-controls input[type="search"],
|
||||
.panel-controls input[type="number"],
|
||||
.panel-controls select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.panel-controls input[type="search"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Queue & Processing */
|
||||
.queue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.queue-item .queue-index {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.queue-item .queue-type {
|
||||
color: var(--accent-info);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-color);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.processing-info {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.processing-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
#processing-content {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.processing-active {
|
||||
color: var(--accent-success);
|
||||
}
|
||||
|
||||
/* Blackboard */
|
||||
.json-display {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.logs-container {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
margin-right: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.level-ERROR {
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
.level-WARN {
|
||||
color: var(--accent-warning);
|
||||
}
|
||||
|
||||
.level-INFO {
|
||||
color: var(--accent-info);
|
||||
}
|
||||
|
||||
.level-DEBUG {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.level-SYSTEM {
|
||||
color: var(--accent-success);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.log-fields {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* LLM Traces */
|
||||
.llm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.llm-card {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.llm-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.llm-header:hover {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.llm-title {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.llm-route {
|
||||
color: var(--accent-info);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.llm-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.llm-body {
|
||||
display: none;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.llm-body.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.llm-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.llm-section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.llm-content {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.llm-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.llm-message {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.llm-message.role-system {
|
||||
border-left-color: #a371f7;
|
||||
}
|
||||
|
||||
.llm-message.role-user {
|
||||
border-left-color: var(--accent-success);
|
||||
}
|
||||
|
||||
.llm-message.role-assistant {
|
||||
border-left-color: var(--accent-info);
|
||||
}
|
||||
|
||||
.llm-message-role {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.llm-message-content {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Saliency */
|
||||
.saliency-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#saliency-canvas {
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.saliency-labels {
|
||||
flex: 0 0 300px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.saliency-label {
|
||||
height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.saliency-key {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.saliency-total {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
@@ -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 { DebugService } from './debug-server'
|
||||
import { DebugService } from './debug'
|
||||
import { wrapPlugin } from './libs/mineflayer'
|
||||
import { initLogger, useLogger } from './utils/logger'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg'
|
||||
|
||||
import { DebugService } from '../debug-server'
|
||||
import { DebugService } from '../debug'
|
||||
|
||||
export type Logger = ReturnType<typeof useLogg>
|
||||
|
||||
|
||||
@@ -1,711 +0,0 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
.heatmap-wrap {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
color: #888;
|
||||
font-size: 0.85em;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.heatmap-labels {
|
||||
width: 260px;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.heatmap-label {
|
||||
height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.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('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>
|
||||
<div class="nav-item" onclick="switchTab('saliency')">Saliency</div>
|
||||
</nav>
|
||||
|
||||
<div id="overview-panel" class="content-panel active">
|
||||
<div class="toolbar">
|
||||
<h3 style="margin: 0;">System Overview</h3>
|
||||
</div>
|
||||
<div class="log-container" style="padding: 2rem;">
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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="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>
|
||||
</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>
|
||||
|
||||
<div id="saliency-panel" class="content-panel">
|
||||
<div class="toolbar" style="align-items: center;">
|
||||
<h3 style="margin: 0;">Saliency Window</h3>
|
||||
<div style="margin-left: auto; display: flex; gap: 0.75rem; align-items: center; color:#aaa; font-size: 0.9em;">
|
||||
<span>Top</span>
|
||||
<input id="saliency-topn" type="number" min="1" max="100" value="30" style="width: 64px;" />
|
||||
<span>Slot</span>
|
||||
<span id="saliency-slot" class="badge">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-container" style="padding: 1rem;">
|
||||
<div class="heatmap-wrap">
|
||||
<div>
|
||||
<canvas id="saliency-canvas" width="800" height="480"
|
||||
style="border: 1px solid #222; background:#0f0f0f;"></canvas>
|
||||
<div class="heatmap-legend">Columns: oldest → newest (100 slots). Rows: counter keys (top-N by total).</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-title" style="margin-top:0;">Keys</div>
|
||||
<div id="saliency-labels" class="heatmap-labels"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
var autoScroll = true;
|
||||
var sseClient = null;
|
||||
var latestSaliency = null;
|
||||
var saliencyKeyOrder = [];
|
||||
var saliencyKeyMap = new Map();
|
||||
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(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>` : ''}`;
|
||||
|
||||
container.appendChild(div);
|
||||
|
||||
if (autoScroll) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function addLLMTrace(trace) {
|
||||
if (!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 || '?';
|
||||
const durationMs = trace.duration;
|
||||
const durationText = Number.isFinite(durationMs) ? `${durationMs} ms` : 'n/a';
|
||||
|
||||
// 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, '<').replace(/>/g, '>');
|
||||
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>
|
||||
<span class="badge">${durationText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="llm-body">
|
||||
<div class="section-title">Result</div>
|
||||
<pre class="content-block">${(trace.content || '').replace(/</g, '<').replace(/>/g, '>')}</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);
|
||||
}
|
||||
|
||||
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 (sseClient) {
|
||||
sseClient.close();
|
||||
sseClient = null;
|
||||
}
|
||||
statusDot.classList.remove('connected');
|
||||
statusText.textContent = 'Reconnecting...';
|
||||
setTimeout(connectSSE, 100);
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
if (sseClient) {
|
||||
sseClient.close();
|
||||
}
|
||||
|
||||
sseClient = new EventSource('/events');
|
||||
|
||||
sseClient.onopen = () => {
|
||||
statusDot.classList.add('connected');
|
||||
statusText.textContent = 'Connected';
|
||||
addLog({ level: 'SYSTEM', message: 'Connected to debug server' });
|
||||
};
|
||||
|
||||
sseClient.onerror = (err) => {
|
||||
console.error('SSE error:', err);
|
||||
statusDot.classList.remove('connected');
|
||||
statusText.textContent = 'Disconnected';
|
||||
};
|
||||
|
||||
sseClient.addEventListener('log', (e) => {
|
||||
try {
|
||||
addLog(JSON.parse(e.data));
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
|
||||
sseClient.addEventListener('llm', (e) => {
|
||||
try {
|
||||
addLLMTrace(JSON.parse(e.data));
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
|
||||
sseClient.addEventListener('queue', (e) => {
|
||||
try {
|
||||
updateQueue(JSON.parse(e.data));
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
|
||||
sseClient.addEventListener('blackboard', (e) => {
|
||||
try {
|
||||
updateBlackboard(JSON.parse(e.data));
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
|
||||
sseClient.addEventListener('saliency', (e) => {
|
||||
try {
|
||||
latestSaliency = JSON.parse(e.data);
|
||||
ingestSaliency(latestSaliency);
|
||||
renderSaliency();
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
|
||||
function ingestSaliency(snapshot) {
|
||||
if (!snapshot || !snapshot.keys) return;
|
||||
|
||||
const slot = snapshot.slot || 0;
|
||||
const TTL_SLOTS = 300;
|
||||
|
||||
for (const row of snapshot.keys) {
|
||||
const key = row.key;
|
||||
if (!key) continue;
|
||||
|
||||
if (!saliencyKeyMap.has(key)) {
|
||||
saliencyKeyOrder.push(key);
|
||||
}
|
||||
|
||||
saliencyKeyMap.set(key, {
|
||||
key,
|
||||
total: row.total || 0,
|
||||
windowTicks: row.windowTicks || 100,
|
||||
window: Array.isArray(row.window) ? row.window : [],
|
||||
triggers: Array.isArray(row.triggers) ? row.triggers : [],
|
||||
lastFireSlot: (row.lastFireSlot === null || row.lastFireSlot === undefined) ? null : row.lastFireSlot,
|
||||
lastFireTotal: row.lastFireTotal || 0,
|
||||
lastSeenSlot: slot,
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup stale keys to avoid unbounded growth
|
||||
saliencyKeyOrder = saliencyKeyOrder.filter((key) => {
|
||||
const item = saliencyKeyMap.get(key);
|
||||
if (!item) return false;
|
||||
if (slot - item.lastSeenSlot > TTL_SLOTS) {
|
||||
saliencyKeyMap.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function colorFor(value, maxValue) {
|
||||
if (!maxValue || maxValue <= 0) return 'rgba(0,0,0,0)';
|
||||
const t = Math.max(0, Math.min(1, value / maxValue));
|
||||
// dark -> bright blue
|
||||
const r = Math.round(20 + 30 * t);
|
||||
const g = Math.round(40 + 80 * t);
|
||||
const b = Math.round(80 + 170 * t);
|
||||
const a = 0.15 + 0.85 * t;
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
|
||||
function renderSaliency() {
|
||||
if (!latestSaliency) return;
|
||||
|
||||
const topNInput = document.getElementById('saliency-topn');
|
||||
const topN = Math.max(1, Math.min(100, parseInt(topNInput.value || '30', 10)));
|
||||
const tickBadge = document.getElementById('saliency-slot');
|
||||
tickBadge.textContent = String(latestSaliency.slot || 0);
|
||||
|
||||
const order = saliencyKeyOrder.slice(Math.max(0, saliencyKeyOrder.length - topN));
|
||||
const keys = order.map(k => saliencyKeyMap.get(k)).filter(Boolean);
|
||||
const labels = document.getElementById('saliency-labels');
|
||||
labels.innerHTML = keys.map(k => {
|
||||
const fire = (k.lastFireSlot !== null && k.lastFireSlot !== undefined)
|
||||
? ` <span style="color:#666">fire:${k.lastFireTotal || 0}@${k.lastFireSlot}</span>`
|
||||
: '';
|
||||
return `<div class="heatmap-label">${(k.key || '')} <span style="color:#666">(${k.total || 0})</span>${fire}</div>`;
|
||||
}).join('') || '<div style="color:#666">No data</div>';
|
||||
|
||||
const canvas = document.getElementById('saliency-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const cols = 100;
|
||||
const rows = keys.length;
|
||||
|
||||
const cellW = 6;
|
||||
const cellH = 16;
|
||||
|
||||
canvas.width = cols * cellW;
|
||||
canvas.height = Math.max(1, rows) * cellH;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#0f0f0f';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
let maxCell = 0;
|
||||
for (const row of keys) {
|
||||
const w = row.window || [];
|
||||
for (let i = 0; i < Math.min(cols, w.length); i++) {
|
||||
const v = w[i] || 0;
|
||||
if (v > maxCell) maxCell = v;
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
const row = keys[y];
|
||||
const w = row.window || [];
|
||||
const t = row.triggers || [];
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const v = (x < w.length) ? (w[x] || 0) : 0;
|
||||
const fired = (x < t.length) ? (t[x] || 0) : 0;
|
||||
ctx.fillStyle = fired ? 'rgba(255, 80, 80, 0.95)' : colorFor(v, maxCell);
|
||||
ctx.fillRect(x * cellW, y * cellH, cellW - 1, cellH - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('saliency-topn').addEventListener('change', () => {
|
||||
renderSaliency();
|
||||
});
|
||||
|
||||
connectSSE();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user