feat(minecraft): add REPL dashboard tab with live execution
This commit is contained in:
@@ -2,17 +2,19 @@ import type { Logg } from '@guiiai/logg'
|
||||
import type { Message } from '@xsai/shared-chat'
|
||||
|
||||
import type { TaskExecutor } from '../action/task-executor'
|
||||
import type { ActionInstruction } from '../action/types'
|
||||
import type { EventBus, TracedEvent } from '../os'
|
||||
import type { PerceptionSignal } from '../perception/types/signals'
|
||||
import type { ReflexManager } from '../reflex/reflex-manager'
|
||||
import type { BotEvent, MineflayerWithAgents } from '../types'
|
||||
import type { ActionInstruction } from '../action/types'
|
||||
import type { PlannerGlobalDescriptor } from './js-planner'
|
||||
import type { LLMAgent } from './llm-agent'
|
||||
import type { CancellationToken } from './task-state'
|
||||
|
||||
import { config } from '../../composables/config'
|
||||
import { DebugService } from '../../debug'
|
||||
import { buildConsciousContextView } from './context-view'
|
||||
import { JavaScriptPlanner } from './js-planner'
|
||||
import { LLMAgent } from './llm-agent'
|
||||
import {
|
||||
isLikelyAuthOrBadArgError,
|
||||
isRateLimitError,
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
toErrorMessage,
|
||||
} from './llmlogic'
|
||||
import { generateBrainSystemPrompt } from './prompts/brain-prompt'
|
||||
import { createCancellationToken, type CancellationToken } from './task-state'
|
||||
import { createCancellationToken } from './task-state'
|
||||
|
||||
interface BrainDeps {
|
||||
eventBus: EventBus
|
||||
@@ -45,6 +47,22 @@ interface PlannerOutcomeSummary {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface DebugReplResult {
|
||||
code: string
|
||||
logs: string[]
|
||||
actions: Array<{
|
||||
tool: string
|
||||
params: Record<string, unknown>
|
||||
ok: boolean
|
||||
result?: string
|
||||
error?: string
|
||||
}>
|
||||
returnValue?: string
|
||||
error?: string
|
||||
durationMs: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
function truncateForPrompt(value: string, maxLength = 220): string {
|
||||
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}...`
|
||||
}
|
||||
@@ -56,6 +74,7 @@ export class Brain {
|
||||
// State
|
||||
private queue: QueuedEvent[] = []
|
||||
private isProcessing = false
|
||||
private isReplEvaluating = false
|
||||
private currentCancellationToken: CancellationToken | undefined
|
||||
private giveUpUntil = 0
|
||||
private giveUpReason: string | undefined
|
||||
@@ -123,6 +142,108 @@ export class Brain {
|
||||
this.currentCancellationToken?.cancel()
|
||||
}
|
||||
|
||||
public getReplState(): { variables: PlannerGlobalDescriptor[], updatedAt: number } {
|
||||
const snapshot = this.deps.reflexManager.getContextSnapshot()
|
||||
const variables = this.planner.describeGlobals(
|
||||
this.deps.taskExecutor.getAvailableActions(),
|
||||
{
|
||||
event: {
|
||||
type: 'system_alert',
|
||||
payload: { source: 'debug-repl-state' },
|
||||
source: { type: 'system', id: 'debug-repl' },
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
snapshot: snapshot as unknown as Record<string, unknown>,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
variables,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
public async executeDebugRepl(code: string): Promise<DebugReplResult> {
|
||||
const startedAt = Date.now()
|
||||
if (this.isProcessing || this.isReplEvaluating) {
|
||||
return {
|
||||
code,
|
||||
logs: [],
|
||||
actions: [],
|
||||
error: 'Brain is currently processing an event. Try again in a moment.',
|
||||
durationMs: Date.now() - startedAt,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = this.deps.reflexManager.getContextSnapshot()
|
||||
const actionDefs = new Map(this.deps.taskExecutor.getAvailableActions().map(action => [action.name, action]))
|
||||
const normalizedReplCode = this.normalizeReplCode(code)
|
||||
const codeToEvaluate = this.planner.canEvaluateAsExpression(normalizedReplCode)
|
||||
? `return (\n${normalizedReplCode}\n)`
|
||||
: normalizedReplCode
|
||||
|
||||
this.isReplEvaluating = true
|
||||
try {
|
||||
const runResult = await this.planner.evaluate(
|
||||
codeToEvaluate,
|
||||
this.deps.taskExecutor.getAvailableActions(),
|
||||
{
|
||||
event: {
|
||||
type: 'system_alert',
|
||||
payload: { source: 'debug-repl' },
|
||||
source: { type: 'system', id: 'debug-repl' },
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
snapshot: snapshot as unknown as Record<string, unknown>,
|
||||
},
|
||||
async (action: ActionInstruction) => {
|
||||
const actionDef = actionDefs.get(action.tool)
|
||||
if (actionDef?.followControl === 'detach')
|
||||
this.deps.reflexManager.clearFollowTarget()
|
||||
return this.deps.taskExecutor.executeActionWithResult(action)
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
code,
|
||||
logs: runResult.logs,
|
||||
actions: runResult.actions.map(item => ({
|
||||
tool: item.action.tool,
|
||||
params: item.action.params,
|
||||
ok: item.ok,
|
||||
result: item.result === undefined ? undefined : (typeof item.result === 'string' ? item.result : JSON.stringify(item.result)),
|
||||
error: item.error,
|
||||
})),
|
||||
returnValue: runResult.returnValue,
|
||||
durationMs: Date.now() - startedAt,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
return {
|
||||
code,
|
||||
logs: [],
|
||||
actions: [],
|
||||
error: toErrorMessage(err),
|
||||
durationMs: Date.now() - startedAt,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
finally {
|
||||
this.isReplEvaluating = false
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeReplCode(code: string): string {
|
||||
// Simple top-level var persistence for REPL UX:
|
||||
// const/let/var foo = ... -> globalThis.foo = ...
|
||||
return code.replace(
|
||||
/^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n]+?)\s*(?:;\s*)?$/gm,
|
||||
(_full: string, name: string, valueExpr: string) => `globalThis.${name} = ${valueExpr}`,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Event Queue Logic ---
|
||||
|
||||
private async enqueueEvent(bot: MineflayerWithAgents, event: BotEvent): Promise<void> {
|
||||
@@ -133,7 +254,8 @@ export class Brain {
|
||||
}
|
||||
|
||||
private async processQueue(bot: MineflayerWithAgents): Promise<void> {
|
||||
if (this.isProcessing || this.queue.length === 0) return
|
||||
if (this.isProcessing || this.queue.length === 0)
|
||||
return
|
||||
|
||||
try {
|
||||
this.isProcessing = true
|
||||
@@ -148,11 +270,13 @@ export class Brain {
|
||||
try {
|
||||
await this.processEvent(bot, item.event)
|
||||
item.resolve()
|
||||
} catch (err) {
|
||||
}
|
||||
catch (err) {
|
||||
this.deps.logger.withError(err).error('Brain: Error processing event')
|
||||
item.reject(err as Error)
|
||||
}
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
this.isProcessing = false
|
||||
this.debugService.emitBrainState({
|
||||
status: 'idle',
|
||||
@@ -211,7 +335,8 @@ export class Brain {
|
||||
const content = llmResult.text
|
||||
const reasoning = llmResult.reasoning
|
||||
|
||||
if (!content) throw new Error('No content from LLM')
|
||||
if (!content)
|
||||
throw new Error('No content from LLM')
|
||||
|
||||
// Capture reasoning for later use
|
||||
capturedReasoning = reasoning
|
||||
@@ -234,7 +359,8 @@ export class Brain {
|
||||
})
|
||||
|
||||
break // Success, exit retry loop
|
||||
} catch (err) {
|
||||
}
|
||||
catch (err) {
|
||||
const remaining = maxAttempts - attempt
|
||||
const isRateLimit = isRateLimitError(err)
|
||||
const shouldRetry = remaining > 0 && !isLikelyAuthOrBadArgError(err)
|
||||
@@ -323,8 +449,8 @@ export class Brain {
|
||||
logs: runResult.logs,
|
||||
returnValue: runResult.returnValue,
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
}
|
||||
catch (err) {
|
||||
this.deps.logger.withError(err).error('Brain: Failed to execute decision')
|
||||
void this.enqueueEvent(bot, {
|
||||
type: 'feedback',
|
||||
@@ -343,18 +469,22 @@ export class Brain {
|
||||
const signal = event.payload as PerceptionSignal
|
||||
if (signal.type === 'chat_message') {
|
||||
parts.push(`[EVENT] ${signal.description}`)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
parts.push(`[EVENT] Perception Signal: ${signal.description}`)
|
||||
}
|
||||
} else if (event.type === 'feedback') {
|
||||
}
|
||||
else if (event.type === 'feedback') {
|
||||
const p = event.payload as any
|
||||
const tool = p.action?.tool || 'unknown'
|
||||
if (p.status === 'success') {
|
||||
parts.push(`[FEEDBACK] ${tool}: Success. ${typeof p.result === 'string' ? p.result : JSON.stringify(p.result)}`)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
parts.push(`[FEEDBACK] ${tool}: Failed. ${p.error}`)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
parts.push(`[EVENT] ${event.type}: ${JSON.stringify(event.payload)}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const actions: Action[] = [
|
||||
})),
|
||||
]
|
||||
|
||||
describe('JavaScriptPlanner', () => {
|
||||
describe('javaScriptPlanner', () => {
|
||||
const globals = {
|
||||
event: {
|
||||
type: 'perception',
|
||||
@@ -146,4 +146,23 @@ describe('JavaScriptPlanner', () => {
|
||||
expectMoved(1, "did not move enough")
|
||||
`, actions, globals, executeAction)).rejects.toThrow(/Expectation failed: did not move enough/i)
|
||||
})
|
||||
|
||||
it('describes registered globals for debug REPL', () => {
|
||||
const planner = new JavaScriptPlanner()
|
||||
const descriptors = planner.describeGlobals(actions, globals)
|
||||
const names = descriptors.map(d => d.name)
|
||||
|
||||
expect(names).toContain('mem')
|
||||
expect(names).toContain('chat')
|
||||
expect(names).toContain('goToPlayer')
|
||||
|
||||
const mem = descriptors.find(d => d.name === 'mem')
|
||||
expect(mem?.readonly).toBe(false)
|
||||
})
|
||||
|
||||
it('detects expression-friendly REPL inputs', () => {
|
||||
const planner = new JavaScriptPlanner()
|
||||
expect(planner.canEvaluateAsExpression('2 + 3')).toBe(true)
|
||||
expect(planner.canEvaluateAsExpression('const a = 1; a + 1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { Action } from '../../libs/mineflayer/action'
|
||||
import type { ActionInstruction } from '../action/types'
|
||||
import type { BotEvent } from '../types'
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
import vm from 'node:vm'
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
|
||||
interface JavaScriptPlannerOptions {
|
||||
timeoutMs?: number
|
||||
maxActionsPerTurn?: number
|
||||
@@ -69,6 +70,13 @@ export interface JavaScriptRunResult {
|
||||
returnValue?: string
|
||||
}
|
||||
|
||||
export interface PlannerGlobalDescriptor {
|
||||
name: string
|
||||
kind: 'tool' | 'function' | 'object' | 'number' | 'string' | 'boolean' | 'undefined' | 'null' | 'unknown'
|
||||
readonly: boolean
|
||||
preview: string
|
||||
}
|
||||
|
||||
export function extractJavaScriptCandidate(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
const fenced = trimmed.match(/^```(?:js|javascript|ts|typescript)?\s*([\s\S]*?)\s*```$/i)
|
||||
@@ -136,6 +144,87 @@ export class JavaScriptPlanner {
|
||||
}
|
||||
}
|
||||
|
||||
public canEvaluateAsExpression(content: string): boolean {
|
||||
const script = extractJavaScriptCandidate(content)
|
||||
if (!script.trim())
|
||||
return false
|
||||
|
||||
try {
|
||||
void new vm.Script(`(async () => (\n${script}\n))()`)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public describeGlobals(availableActions: Action[], globals: RuntimeGlobals): PlannerGlobalDescriptor[] {
|
||||
const descriptors: PlannerGlobalDescriptor[] = []
|
||||
|
||||
const staticGlobals: Array<Omit<PlannerGlobalDescriptor, 'preview'>> = [
|
||||
{ name: 'skip', kind: 'tool', readonly: true },
|
||||
{ name: 'use', kind: 'function', readonly: true },
|
||||
{ name: 'log', kind: 'function', readonly: true },
|
||||
{ name: 'expect', kind: 'function', readonly: true },
|
||||
{ name: 'expectMoved', kind: 'function', readonly: true },
|
||||
{ name: 'expectNear', kind: 'function', readonly: true },
|
||||
{ name: 'snapshot', kind: 'object', readonly: true },
|
||||
{ name: 'event', kind: 'object', readonly: true },
|
||||
{ name: 'now', kind: 'number', readonly: true },
|
||||
{ name: 'self', kind: 'object', readonly: true },
|
||||
{ name: 'environment', kind: 'object', readonly: true },
|
||||
{ name: 'social', kind: 'object', readonly: true },
|
||||
{ name: 'threat', kind: 'object', readonly: true },
|
||||
{ name: 'attention', kind: 'object', readonly: true },
|
||||
{ name: 'autonomy', kind: 'object', readonly: true },
|
||||
{ name: 'mem', kind: 'object', readonly: false },
|
||||
{ name: 'lastRun', kind: 'object', readonly: true },
|
||||
{ name: 'prevRun', kind: 'object', readonly: true },
|
||||
{ name: 'lastAction', kind: 'object', readonly: true },
|
||||
]
|
||||
|
||||
const valueByName: Record<string, unknown> = {
|
||||
snapshot: globals.snapshot,
|
||||
event: globals.event,
|
||||
now: Date.now(),
|
||||
self: (globals.snapshot as Record<string, unknown>)?.self,
|
||||
environment: (globals.snapshot as Record<string, unknown>)?.environment,
|
||||
social: (globals.snapshot as Record<string, unknown>)?.social,
|
||||
threat: (globals.snapshot as Record<string, unknown>)?.threat,
|
||||
attention: (globals.snapshot as Record<string, unknown>)?.attention,
|
||||
autonomy: (globals.snapshot as Record<string, unknown>)?.autonomy,
|
||||
mem: this.sandbox.mem,
|
||||
lastRun: this.sandbox.lastRun,
|
||||
prevRun: this.sandbox.prevRun,
|
||||
lastAction: this.sandbox.lastAction,
|
||||
skip: this.sandbox.skip,
|
||||
use: this.sandbox.use,
|
||||
log: this.sandbox.log,
|
||||
expect: this.sandbox.expect,
|
||||
expectMoved: this.sandbox.expectMoved,
|
||||
expectNear: this.sandbox.expectNear,
|
||||
}
|
||||
|
||||
for (const item of staticGlobals) {
|
||||
descriptors.push({
|
||||
...item,
|
||||
preview: this.previewValue(valueByName[item.name]),
|
||||
})
|
||||
}
|
||||
|
||||
for (const action of availableActions) {
|
||||
descriptors.push({
|
||||
name: action.name,
|
||||
kind: 'tool',
|
||||
readonly: true,
|
||||
preview: action.description || '(tool)',
|
||||
})
|
||||
}
|
||||
|
||||
descriptors.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return descriptors
|
||||
}
|
||||
|
||||
private installBuiltins(): void {
|
||||
this.defineGlobalTool('skip', async () => this.runAction('skip', {}))
|
||||
this.defineGlobalTool('use', (toolName: unknown, params?: unknown) => {
|
||||
@@ -373,7 +462,9 @@ export class JavaScriptPlanner {
|
||||
|
||||
const parsed = action.schema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
const details = parsed.error.issues.map(issue => `${issue.path.join('.') || 'root'}: ${issue.message}`).join('; ')
|
||||
const details = parsed.error.issues
|
||||
.map((issue: { path: Array<string | number>, message: string }) => `${issue.path.join('.') || 'root'}: ${issue.message}`)
|
||||
.join('; ')
|
||||
return {
|
||||
error: `Invalid tool parameters for ${tool}: ${details}`,
|
||||
}
|
||||
@@ -397,4 +488,16 @@ export class JavaScriptPlanner {
|
||||
writable: false,
|
||||
})
|
||||
}
|
||||
|
||||
private previewValue(value: unknown): string {
|
||||
if (value === null)
|
||||
return 'null'
|
||||
if (typeof value === 'undefined')
|
||||
return 'undefined'
|
||||
if (typeof value === 'string')
|
||||
return value.length > 120 ? `${value.slice(0, 117)}...` : value
|
||||
|
||||
const rendered = inspect(value, { depth: 1, breakLength: 120 })
|
||||
return rendered.length > 120 ? `${rendered.slice(0, 117)}...` : rendered
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,186 +1,189 @@
|
||||
import type { Message } from '@xsai/shared-chat'
|
||||
|
||||
export interface LLMTraceData {
|
||||
route: string
|
||||
messages: Message[]
|
||||
content: string
|
||||
reasoning?: string
|
||||
usage: any
|
||||
model: string
|
||||
duration: number
|
||||
route: string
|
||||
messages: Message[]
|
||||
content: string
|
||||
reasoning?: string
|
||||
usage: any
|
||||
model: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface RetryDecision {
|
||||
shouldRetry: boolean
|
||||
remainingAttempts: number
|
||||
shouldRetry: boolean
|
||||
remainingAttempts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to build messages for LLM
|
||||
*/
|
||||
export function buildMessages(sysPrompt: string, userMsg: string): Message[] {
|
||||
return [
|
||||
{ role: 'system', content: sysPrompt },
|
||||
{ role: 'user', content: userMsg },
|
||||
]
|
||||
return [
|
||||
{ role: 'system', content: sysPrompt },
|
||||
{ role: 'user', content: userMsg },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to convert error to message string
|
||||
*/
|
||||
export function toErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error)
|
||||
return err.message
|
||||
if (typeof err === 'string')
|
||||
return err
|
||||
try {
|
||||
return JSON.stringify(err)
|
||||
}
|
||||
catch {
|
||||
return String(err)
|
||||
}
|
||||
if (err instanceof Error)
|
||||
return err.message
|
||||
if (typeof err === 'string')
|
||||
return err
|
||||
if (typeof err === 'object' && err !== null && 'message' in err && typeof (err as { message: unknown }).message === 'string')
|
||||
return (err as { message: string }).message
|
||||
try {
|
||||
return JSON.stringify(err)
|
||||
}
|
||||
catch {
|
||||
return String(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to get HTTP status from error
|
||||
*/
|
||||
export function getErrorStatus(err: unknown): number | undefined {
|
||||
const anyErr = err as any
|
||||
const status = anyErr?.status ?? anyErr?.response?.status ?? anyErr?.cause?.status
|
||||
return typeof status === 'number' ? status : undefined
|
||||
const anyErr = err as any
|
||||
const status = anyErr?.status ?? anyErr?.response?.status ?? anyErr?.cause?.status
|
||||
return typeof status === 'number' ? status : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to get error code
|
||||
*/
|
||||
export function getErrorCode(err: unknown): string | undefined {
|
||||
const anyErr = err as any
|
||||
const code = anyErr?.code ?? anyErr?.cause?.code
|
||||
return typeof code === 'string' ? code : undefined
|
||||
const anyErr = err as any
|
||||
const code = anyErr?.code ?? anyErr?.cause?.code
|
||||
return typeof code === 'string' ? code : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to check if error is likely auth or bad argument error
|
||||
*/
|
||||
export function isLikelyAuthOrBadArgError(err: unknown): boolean {
|
||||
const msg = toErrorMessage(err).toLowerCase()
|
||||
const status = getErrorStatus(err)
|
||||
if (status === 401 || status === 403)
|
||||
return true
|
||||
const msg = toErrorMessage(err).toLowerCase()
|
||||
const status = getErrorStatus(err)
|
||||
if (status === 401 || status === 403)
|
||||
return true
|
||||
|
||||
return (
|
||||
msg.includes('unauthorized')
|
||||
|| msg.includes('invalid api key')
|
||||
|| msg.includes('authentication')
|
||||
|| msg.includes('forbidden')
|
||||
|| msg.includes('badarg')
|
||||
|| msg.includes('bad arg')
|
||||
|| msg.includes('invalid argument')
|
||||
|| msg.includes('invalid_request_error')
|
||||
)
|
||||
return (
|
||||
msg.includes('unauthorized')
|
||||
|| msg.includes('invalid api key')
|
||||
|| msg.includes('authentication')
|
||||
|| msg.includes('forbidden')
|
||||
|| msg.includes('badarg')
|
||||
|| msg.includes('bad arg')
|
||||
|| msg.includes('invalid argument')
|
||||
|| msg.includes('invalid_request_error')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to check if error is rate limit
|
||||
*/
|
||||
export function isRateLimitError(err: unknown): boolean {
|
||||
const status = getErrorStatus(err)
|
||||
if (status === 429) return true
|
||||
const msg = toErrorMessage(err).toLowerCase()
|
||||
return msg.includes('rate limit') || msg.includes('too many requests')
|
||||
const status = getErrorStatus(err)
|
||||
if (status === 429)
|
||||
return true
|
||||
const msg = toErrorMessage(err).toLowerCase()
|
||||
return msg.includes('rate limit') || msg.includes('too many requests')
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to check if error is likely recoverable
|
||||
*/
|
||||
export function isLikelyRecoverableError(err: unknown): boolean {
|
||||
if (err instanceof SyntaxError)
|
||||
return true
|
||||
if (err instanceof SyntaxError)
|
||||
return true
|
||||
|
||||
const status = getErrorStatus(err)
|
||||
if (status === 429)
|
||||
return true
|
||||
if (typeof status === 'number' && status >= 500)
|
||||
return true
|
||||
const status = getErrorStatus(err)
|
||||
if (status === 429)
|
||||
return true
|
||||
if (typeof status === 'number' && status >= 500)
|
||||
return true
|
||||
|
||||
const code = getErrorCode(err)
|
||||
if (code && ['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED'].includes(code))
|
||||
return true
|
||||
const code = getErrorCode(err)
|
||||
if (code && ['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED'].includes(code))
|
||||
return true
|
||||
|
||||
const msg = toErrorMessage(err).toLowerCase()
|
||||
return (
|
||||
msg.includes('timeout')
|
||||
|| msg.includes('timed out')
|
||||
|| msg.includes('rate limit')
|
||||
|| msg.includes('overloaded')
|
||||
|| msg.includes('temporarily')
|
||||
|| msg.includes('try again')
|
||||
|| (msg.includes('in json') && msg.includes('position'))
|
||||
)
|
||||
const msg = toErrorMessage(err).toLowerCase()
|
||||
return (
|
||||
msg.includes('timeout')
|
||||
|| msg.includes('timed out')
|
||||
|| msg.includes('rate limit')
|
||||
|| msg.includes('overloaded')
|
||||
|| msg.includes('temporarily')
|
||||
|| msg.includes('try again')
|
||||
|| (msg.includes('in json') && msg.includes('position'))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to decide whether to retry
|
||||
*/
|
||||
export function shouldRetryError(err: unknown, remainingAttempts: number): RetryDecision {
|
||||
const shouldRetry = remainingAttempts > 0 && !isLikelyAuthOrBadArgError(err) && isLikelyRecoverableError(err)
|
||||
return {
|
||||
shouldRetry,
|
||||
remainingAttempts,
|
||||
}
|
||||
const shouldRetry = remainingAttempts > 0 && !isLikelyAuthOrBadArgError(err) && isLikelyRecoverableError(err)
|
||||
return {
|
||||
shouldRetry,
|
||||
remainingAttempts,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to extract JSON from LLM response
|
||||
*/
|
||||
export function extractJsonCandidate(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)
|
||||
if (fenced?.[1])
|
||||
return fenced[1].trim()
|
||||
const trimmed = input.trim()
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)
|
||||
if (fenced?.[1])
|
||||
return fenced[1].trim()
|
||||
|
||||
const start = trimmed.indexOf('{')
|
||||
const end = trimmed.lastIndexOf('}')
|
||||
if (start >= 0 && end > start)
|
||||
return trimmed.slice(start, end + 1)
|
||||
const start = trimmed.indexOf('{')
|
||||
const end = trimmed.lastIndexOf('}')
|
||||
if (start >= 0 && end > start)
|
||||
return trimmed.slice(start, end + 1)
|
||||
|
||||
return trimmed
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JSON error position from error message
|
||||
*/
|
||||
function getJsonErrorPosition(err: unknown): number | null {
|
||||
const msg = toErrorMessage(err)
|
||||
const match = msg.match(/position\s+(\d+)/i)
|
||||
if (!match)
|
||||
return null
|
||||
const msg = toErrorMessage(err)
|
||||
const match = msg.match(/position\s+(\d+)/i)
|
||||
if (!match)
|
||||
return null
|
||||
|
||||
const pos = Number.parseInt(match[1], 10)
|
||||
return Number.isFinite(pos) ? pos : null
|
||||
const pos = Number.parseInt(match[1], 10)
|
||||
return Number.isFinite(pos) ? pos : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to parse LLM JSON response
|
||||
*/
|
||||
export function parseLLMResponseJson<T>(response: string): T {
|
||||
const candidate = extractJsonCandidate(response)
|
||||
try {
|
||||
return JSON.parse(candidate) as T
|
||||
}
|
||||
catch (err) {
|
||||
const pos = getJsonErrorPosition(err)
|
||||
const window = 120
|
||||
const snippet = (typeof pos === 'number')
|
||||
? candidate.slice(Math.max(0, pos - window), Math.min(candidate.length, pos + window))
|
||||
: candidate.slice(0, Math.min(candidate.length, 240))
|
||||
throw new Error(`Failed to parse LLM JSON response: ${toErrorMessage(err)}; snippet=${JSON.stringify(snippet)}`)
|
||||
}
|
||||
const candidate = extractJsonCandidate(response)
|
||||
try {
|
||||
return JSON.parse(candidate) as T
|
||||
}
|
||||
catch (err) {
|
||||
const pos = getJsonErrorPosition(err)
|
||||
const window = 120
|
||||
const snippet = (typeof pos === 'number')
|
||||
? candidate.slice(Math.max(0, pos - window), Math.min(candidate.length, pos + window))
|
||||
: candidate.slice(0, Math.min(candidate.length, 240))
|
||||
throw new Error(`Failed to parse LLM JSON response: ${toErrorMessage(err)}; snippet=${JSON.stringify(snippet)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MineflayerPlugin } from '../libs/mineflayer'
|
||||
import type { CognitiveEngineOptions, MineflayerWithAgents } from './types'
|
||||
|
||||
import { DebugService } from '../debug'
|
||||
import { ChatMessageHandler } from '../libs/mineflayer'
|
||||
import { createAgentContainer } from './container'
|
||||
import { computeNearbyPlayerGaze } from './reflex/gaze'
|
||||
@@ -22,6 +23,32 @@ export function CognitiveEngine(options: CognitiveEngineOptions): MineflayerPlug
|
||||
const brain = container.resolve('brain')
|
||||
const reflexManager = container.resolve('reflexManager')
|
||||
const taskExecutor = container.resolve('taskExecutor')
|
||||
const debugService = DebugService.getInstance()
|
||||
|
||||
debugService.onCommand('request_repl_state', () => {
|
||||
debugService.emit('debug:repl_state', brain.getReplState())
|
||||
})
|
||||
|
||||
debugService.onCommand('execute_repl', async (command) => {
|
||||
if (command.type !== 'execute_repl')
|
||||
return
|
||||
|
||||
const code = command.payload?.code
|
||||
if (typeof code !== 'string') {
|
||||
debugService.emit('debug:repl_result', {
|
||||
code: '',
|
||||
logs: [],
|
||||
actions: [],
|
||||
error: 'Invalid REPL request: code must be a string',
|
||||
durationMs: 0,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await brain.executeDebugRepl(code)
|
||||
debugService.emit('debug:repl_result', result)
|
||||
})
|
||||
|
||||
// Initialize task executor with mineflayer instance
|
||||
taskExecutor.setMineflayer(bot)
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import type { BlackboardEvent, BrainStateEvent, ClientCommand, LLMTraceEvent, LogEvent, QueueEvent, ReflexStateEvent, SaliencyEvent, ServerEvent, TraceEvent } from './types'
|
||||
import type {
|
||||
BlackboardEvent,
|
||||
BrainStateEvent,
|
||||
ClientCommand,
|
||||
LLMTraceEvent,
|
||||
LogEvent,
|
||||
QueueEvent,
|
||||
ReflexStateEvent,
|
||||
ReplExecutionResultEvent,
|
||||
ReplStateEvent,
|
||||
SaliencyEvent,
|
||||
ServerEvent,
|
||||
TraceEvent,
|
||||
} from './types'
|
||||
|
||||
import { DebugServer } from './server'
|
||||
|
||||
@@ -73,8 +86,6 @@ export class DebugService {
|
||||
this.server.broadcast(event)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Emit a brain state update
|
||||
*/
|
||||
@@ -214,6 +225,18 @@ export class DebugService {
|
||||
payload: payload as any,
|
||||
})
|
||||
break
|
||||
case 'debug:repl_state':
|
||||
this.server.broadcast({
|
||||
type: 'debug:repl_state',
|
||||
payload: payload as ReplStateEvent,
|
||||
})
|
||||
break
|
||||
case 'debug:repl_result':
|
||||
this.server.broadcast({
|
||||
type: 'debug:repl_result',
|
||||
payload: payload as ReplExecutionResultEvent,
|
||||
})
|
||||
break
|
||||
default:
|
||||
// For unknown types, emit as log
|
||||
this.log('DEBUG', `Unknown event type: ${type}`, { payload })
|
||||
|
||||
@@ -139,6 +139,34 @@ export interface ToolExecutionResultEvent {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface ReplVariableDescriptor {
|
||||
name: string
|
||||
kind: 'tool' | 'function' | 'object' | 'number' | 'string' | 'boolean' | 'undefined' | 'null' | 'unknown'
|
||||
readonly: boolean
|
||||
preview: string
|
||||
}
|
||||
|
||||
export interface ReplStateEvent {
|
||||
variables: ReplVariableDescriptor[]
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface ReplExecutionResultEvent {
|
||||
code: string
|
||||
logs: string[]
|
||||
actions: Array<{
|
||||
tool: string
|
||||
params: Record<string, unknown>
|
||||
ok: boolean
|
||||
result?: string
|
||||
error?: string
|
||||
}>
|
||||
returnValue?: string
|
||||
error?: string
|
||||
durationMs: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Server Events Extension
|
||||
// ============================================================
|
||||
@@ -147,18 +175,20 @@ export interface ToolExecutionResultEvent {
|
||||
|
||||
export type ServerEvent
|
||||
= | { type: 'log', payload: LogEvent }
|
||||
| { type: 'llm', payload: LLMTraceEvent }
|
||||
| { type: 'blackboard', payload: BlackboardEvent }
|
||||
| { type: 'queue', payload: QueueEvent }
|
||||
| { type: 'saliency', payload: SaliencyEvent }
|
||||
| { type: 'reflex', payload: ReflexStateEvent }
|
||||
| { type: 'trace', payload: TraceEvent }
|
||||
| { type: 'trace_batch', payload: TraceBatchEvent }
|
||||
| { type: 'history', payload: ServerEvent[] }
|
||||
| { type: 'pong', payload: { timestamp: number } }
|
||||
| { type: 'debug:tools_list', payload: { tools: ToolDefinition[] } }
|
||||
| { type: 'debug:tool_result', payload: ToolExecutionResultEvent }
|
||||
| { type: 'brain_state', payload: BrainStateEvent }
|
||||
| { type: 'llm', payload: LLMTraceEvent }
|
||||
| { type: 'blackboard', payload: BlackboardEvent }
|
||||
| { type: 'queue', payload: QueueEvent }
|
||||
| { type: 'saliency', payload: SaliencyEvent }
|
||||
| { type: 'reflex', payload: ReflexStateEvent }
|
||||
| { type: 'trace', payload: TraceEvent }
|
||||
| { type: 'trace_batch', payload: TraceBatchEvent }
|
||||
| { type: 'history', payload: ServerEvent[] }
|
||||
| { type: 'pong', payload: { timestamp: number } }
|
||||
| { type: 'debug:tools_list', payload: { tools: ToolDefinition[] } }
|
||||
| { type: 'debug:tool_result', payload: ToolExecutionResultEvent }
|
||||
| { type: 'debug:repl_state', payload: ReplStateEvent }
|
||||
| { type: 'debug:repl_result', payload: ReplExecutionResultEvent }
|
||||
| { type: 'brain_state', payload: BrainStateEvent }
|
||||
|
||||
// ============================================================
|
||||
// Client -> Server commands
|
||||
@@ -209,14 +239,27 @@ export interface RequestToolsCommand {
|
||||
type: 'request_tools'
|
||||
}
|
||||
|
||||
export interface RequestReplStateCommand {
|
||||
type: 'request_repl_state'
|
||||
}
|
||||
|
||||
export interface ExecuteReplCommand {
|
||||
type: 'execute_repl'
|
||||
payload: {
|
||||
code: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ClientCommand
|
||||
= | ClearLogsCommand
|
||||
| SetFilterCommand
|
||||
| InjectEventCommand
|
||||
| PingCommand
|
||||
| RequestHistoryCommand
|
||||
| ExecuteToolCommand
|
||||
| RequestToolsCommand
|
||||
| SetFilterCommand
|
||||
| InjectEventCommand
|
||||
| PingCommand
|
||||
| RequestHistoryCommand
|
||||
| ExecuteToolCommand
|
||||
| RequestToolsCommand
|
||||
| RequestReplStateCommand
|
||||
| ExecuteReplCommand
|
||||
|
||||
// ============================================================
|
||||
// Wire format
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
const CONFIG = {
|
||||
MAX_LOGS: 500,
|
||||
MAX_LLM_TRACES: 50,
|
||||
MAX_REPL_RESULTS: 20,
|
||||
RECONNECT_MAX_ATTEMPTS: 10,
|
||||
RECONNECT_DELAY: 1000,
|
||||
PING_INTERVAL: 25000,
|
||||
@@ -488,7 +489,7 @@ class LogsPanel {
|
||||
if (!select)
|
||||
return
|
||||
const current = select.value
|
||||
select.innerHTML = '<option value="">Live (current session)</option>' + files.map(f => `<option value="${f}">${f}</option>`).join('')
|
||||
select.innerHTML = `<option value="">Live (current session)</option>${files.map(f => `<option value="${f}">${f}</option>`).join('')}`
|
||||
if (files.includes(current))
|
||||
select.value = current
|
||||
}
|
||||
@@ -614,8 +615,8 @@ class LLMPanel {
|
||||
<div class="llm-message role-${msg.role || 'unknown'}">
|
||||
<div class="llm-message-role">${msg.role || 'unknown'}</div>
|
||||
<div class="llm-message-content">${escapeHtml(msg.role === 'system'
|
||||
? formatSystemMessageContent(msg.content || '')
|
||||
: (msg.content || ''))}</div>
|
||||
? formatSystemMessageContent(msg.content || '')
|
||||
: (msg.content || ''))}</div>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
@@ -632,10 +633,12 @@ class LLMPanel {
|
||||
</div>
|
||||
</div>
|
||||
<div class="llm-body">
|
||||
${trace.reasoning ? `
|
||||
${trace.reasoning
|
||||
? `
|
||||
<div class="llm-section-title">Reasoning</div>
|
||||
<div class="llm-content reasoning">${escapeHtml(trace.reasoning)}</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''}
|
||||
<div class="llm-section-title">Result</div>
|
||||
<div class="llm-content">${escapeHtml(trace.content || '')}</div>
|
||||
|
||||
@@ -995,6 +998,192 @@ class ToolsPanel {
|
||||
}
|
||||
}
|
||||
|
||||
class ReplPanel {
|
||||
constructor(client) {
|
||||
this.client = client
|
||||
this.variables = []
|
||||
this.variableFilter = ''
|
||||
this.results = []
|
||||
this.isRunning = false
|
||||
this.elements = {
|
||||
varsList: document.getElementById('repl-vars-list'),
|
||||
varsSearch: document.getElementById('repl-vars-search'),
|
||||
refreshBtn: document.getElementById('repl-refresh-state'),
|
||||
runBtn: document.getElementById('repl-run-btn'),
|
||||
codeInput: document.getElementById('repl-code-input'),
|
||||
resultList: document.getElementById('repl-result-list'),
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.client.on('debug:repl_state', data => this.updateState(data))
|
||||
this.client.on('debug:repl_result', data => this.handleResult(data))
|
||||
this.client.on('connected', () => this.requestState())
|
||||
|
||||
this.elements.refreshBtn?.addEventListener('click', () => this.requestState())
|
||||
this.elements.varsSearch?.addEventListener('input', (event) => {
|
||||
this.variableFilter = (event.target?.value || '').toLowerCase()
|
||||
this.renderVariables()
|
||||
})
|
||||
this.elements.runBtn?.addEventListener('click', () => this.execute())
|
||||
this.elements.codeInput?.addEventListener('keydown', (event) => {
|
||||
const isEnter = event.key === 'Enter'
|
||||
const hasModifier = event.metaKey || event.ctrlKey
|
||||
if (!isEnter || !hasModifier)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
this.execute()
|
||||
})
|
||||
|
||||
this.renderVariables()
|
||||
this.renderResults()
|
||||
}
|
||||
|
||||
requestState() {
|
||||
this.client.send({ type: 'request_repl_state' })
|
||||
}
|
||||
|
||||
execute() {
|
||||
const code = this.elements.codeInput?.value ?? ''
|
||||
if (!code.trim()) {
|
||||
this.results.unshift({
|
||||
code: '',
|
||||
logs: [],
|
||||
actions: [],
|
||||
error: 'Code is empty',
|
||||
durationMs: 0,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
this.results = this.results.slice(0, CONFIG.MAX_REPL_RESULTS)
|
||||
this.renderResults()
|
||||
return
|
||||
}
|
||||
|
||||
this.isRunning = true
|
||||
this.renderRunState()
|
||||
|
||||
this.client.send({
|
||||
type: 'execute_repl',
|
||||
payload: { code },
|
||||
})
|
||||
}
|
||||
|
||||
updateState(data) {
|
||||
if (!data || !Array.isArray(data.variables))
|
||||
return
|
||||
this.variables = data.variables
|
||||
this.renderVariables()
|
||||
}
|
||||
|
||||
handleResult(data) {
|
||||
this.isRunning = false
|
||||
this.renderRunState()
|
||||
|
||||
this.results.unshift(data)
|
||||
if (this.results.length > CONFIG.MAX_REPL_RESULTS) {
|
||||
this.results = this.results.slice(0, CONFIG.MAX_REPL_RESULTS)
|
||||
}
|
||||
this.renderResults()
|
||||
}
|
||||
|
||||
renderRunState() {
|
||||
if (!this.elements.runBtn)
|
||||
return
|
||||
|
||||
this.elements.runBtn.disabled = this.isRunning
|
||||
this.elements.runBtn.textContent = this.isRunning ? 'Running...' : 'Run (Ctrl/Cmd+Enter)'
|
||||
}
|
||||
|
||||
renderVariables() {
|
||||
if (!this.elements.varsList)
|
||||
return
|
||||
|
||||
if (this.variables.length === 0) {
|
||||
this.elements.varsList.innerHTML = '<div class="empty-state">No variables loaded</div>'
|
||||
return
|
||||
}
|
||||
|
||||
const filtered = this.variables.filter((variable) => {
|
||||
if (!this.variableFilter)
|
||||
return true
|
||||
const searchSpace = `${variable.name} ${variable.kind} ${variable.preview} ${variable.readonly ? 'readonly' : 'writable'}`.toLowerCase()
|
||||
return searchSpace.includes(this.variableFilter)
|
||||
})
|
||||
|
||||
if (filtered.length === 0) {
|
||||
this.elements.varsList.innerHTML = '<div class="empty-state">No variables match filter</div>'
|
||||
return
|
||||
}
|
||||
|
||||
this.elements.varsList.innerHTML = filtered.map(v => `
|
||||
<div class="repl-var-row">
|
||||
<div class="repl-var-name">${escapeHtml(v.name)}</div>
|
||||
<div class="repl-var-meta">${escapeHtml(v.kind)} · ${v.readonly ? 'readonly' : 'writable'}</div>
|
||||
<div class="repl-var-preview">${escapeHtml(v.preview || '')}</div>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
if (!this.elements.resultList)
|
||||
return
|
||||
|
||||
if (this.results.length === 0) {
|
||||
this.elements.resultList.innerHTML = '<div class="empty-state">No REPL executions yet</div>'
|
||||
return
|
||||
}
|
||||
|
||||
this.elements.resultList.innerHTML = this.results.map((result) => {
|
||||
const isError = !!result.error
|
||||
const actionSummary = Array.isArray(result.actions) && result.actions.length > 0
|
||||
? result.actions.map((action) => {
|
||||
const status = action.ok ? 'ok' : 'error'
|
||||
return `${status} ${action.tool}(${JSON.stringify(action.params || {})})${action.error ? ` -> ${action.error}` : ''}${action.result ? ` -> ${action.result}` : ''}`
|
||||
}).join('\n')
|
||||
: '(none)'
|
||||
const logsSummary = Array.isArray(result.logs) && result.logs.length > 0
|
||||
? result.logs.join('\n')
|
||||
: '(none)'
|
||||
const returnValue = typeof result.returnValue === 'string' ? result.returnValue : '(undefined)'
|
||||
const time = new Date(result.timestamp || Date.now()).toLocaleTimeString()
|
||||
|
||||
return `
|
||||
<div class="repl-result-card ${isError ? 'error' : ''}">
|
||||
<div class="repl-result-meta">
|
||||
<span>${time}</span>
|
||||
<span>${Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : '-'}</span>
|
||||
</div>
|
||||
<div class="repl-result-section">
|
||||
<div class="repl-result-label">Code</div>
|
||||
<div class="repl-result-content">${escapeHtml(result.code || '')}</div>
|
||||
</div>
|
||||
<div class="repl-result-section">
|
||||
<div class="repl-result-label">Return</div>
|
||||
<div class="repl-result-content">${escapeHtml(returnValue)}</div>
|
||||
</div>
|
||||
<div class="repl-result-section">
|
||||
<div class="repl-result-label">Actions</div>
|
||||
<div class="repl-result-content">${escapeHtml(actionSummary)}</div>
|
||||
</div>
|
||||
<div class="repl-result-section">
|
||||
<div class="repl-result-label">Logs</div>
|
||||
<div class="repl-result-content">${escapeHtml(logsSummary)}</div>
|
||||
</div>
|
||||
${isError
|
||||
? `
|
||||
<div class="repl-result-section">
|
||||
<div class="repl-result-label">Error</div>
|
||||
<div class="repl-result-content">${escapeHtml(result.error)}</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
}
|
||||
}
|
||||
|
||||
class TimelinePanel {
|
||||
constructor(client) {
|
||||
this.client = client
|
||||
@@ -1320,6 +1509,7 @@ class DebugApp {
|
||||
this.saliencyPanel = new SaliencyPanel(this.client)
|
||||
this.timelinePanel = new TimelinePanel(this.client)
|
||||
this.toolsPanel = new ToolsPanel(this.client)
|
||||
this.replPanel = new ReplPanel(this.client)
|
||||
|
||||
this.panels = {
|
||||
queue: this.queuePanel,
|
||||
@@ -1330,6 +1520,7 @@ class DebugApp {
|
||||
saliency: this.saliencyPanel,
|
||||
timeline: this.timelinePanel,
|
||||
tools: this.toolsPanel,
|
||||
repl: this.replPanel,
|
||||
}
|
||||
this.paused = false
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<div class="tab-bar">
|
||||
<button class="tab-button active" data-target="main-view">Dashboard</button>
|
||||
<button class="tab-button" data-target="tools-view">Tools</button>
|
||||
<button class="tab-button" data-target="repl-view">REPL</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Layout -->
|
||||
@@ -246,7 +247,38 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- REPL View -->
|
||||
<section id="repl-view" class="view">
|
||||
<div class="repl-view-shell">
|
||||
<section class="panel repl-vars-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Registered Variables</h2>
|
||||
<div class="panel-controls">
|
||||
<input type="search" id="repl-vars-search" placeholder="Filter variables..." />
|
||||
<button id="repl-refresh-state" class="icon-btn" title="Refresh variable list">⟳</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div id="repl-vars-list" class="repl-vars-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel repl-console-panel">
|
||||
<div class="panel-header">
|
||||
<h2>REPL Console</h2>
|
||||
<div class="panel-controls">
|
||||
<button id="repl-run-btn">Run (Ctrl/Cmd+Enter)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content repl-console-content">
|
||||
<textarea id="repl-code-input" class="repl-code-input" spellcheck="false"
|
||||
placeholder="Example: await chat("hello from repl")"></textarea>
|
||||
<div id="repl-result-list" class="repl-result-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -225,6 +225,117 @@ button:hover,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.repl-view-shell {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 36%) 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.repl-vars-list {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#repl-vars-search {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.repl-var-row {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.repl-var-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.repl-var-name {
|
||||
color: var(--accent-info);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.repl-var-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.repl-var-preview {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 3px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.repl-console-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.repl-code-input {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.repl-code-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-info);
|
||||
}
|
||||
|
||||
.repl-result-list {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.repl-result-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
padding: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.repl-result-card.error {
|
||||
border-color: var(--accent-error);
|
||||
}
|
||||
|
||||
.repl-result-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.repl-result-section {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.repl-result-label {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.repl-result-content {
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.panel.full-height {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -238,6 +349,13 @@ button:hover,
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.repl-view-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(220px, 35%) 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
#main-grid {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -910,4 +1028,4 @@ button:hover,
|
||||
word-wrap: break-word;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user