feat: turn JSONL logging with live web viewer (v2) (#567)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -60,3 +60,4 @@ bun.lock
|
||||
|
||||
# Telegram MTProto session data (contains auth secrets)
|
||||
data/telegram-mtproto/
|
||||
logs/
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"setup": "tsx src/setup.ts",
|
||||
"dev": "tsx src/main.ts",
|
||||
"build": "tsc",
|
||||
"postbuild": "cp -r src/looms/*.txt dist/looms/ && cp src/api/portal.html dist/api/portal.html && node scripts/fix-bin-permissions.mjs",
|
||||
"postbuild": "cp -r src/looms/*.txt dist/looms/ && cp src/api/portal.html dist/api/portal.html && cp src/core/turn-viewer.html dist/core/ && node scripts/fix-bin-permissions.mjs",
|
||||
"prepare": "npx patch-package || true",
|
||||
"prepublishOnly": "npm run build && npm run test:run",
|
||||
"start": "node dist/main.js",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { validateApiKey } from './auth.js';
|
||||
import type { SendMessageResponse, ChatRequest, ChatResponse, AsyncChatResponse, PairingListResponse, PairingApproveRequest, PairingApproveResponse } from './types.js';
|
||||
import { listPairingRequests, approvePairingCode } from '../pairing/store.js';
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
buildErrorResponse, buildModelList, validateChatRequest,
|
||||
} from './openai-compat.js';
|
||||
import type { OpenAIChatRequest } from './openai-compat.js';
|
||||
import { getTurnViewerHtml } from '../core/turn-viewer.js';
|
||||
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
@@ -40,6 +43,7 @@ interface ServerOptions {
|
||||
apiKey: string;
|
||||
host?: string; // Bind address (default: 127.0.0.1 for security)
|
||||
corsOrigin?: string; // CORS origin (default: same-origin only)
|
||||
turnLogFiles?: Record<string, string>; // agentName -> filePath; enables GET /turns viewer
|
||||
stores?: Map<string, Store>; // Agent stores for management endpoints
|
||||
agentChannels?: Map<string, string[]>; // Channel IDs per agent name
|
||||
sessionInvalidators?: Map<string, (key?: string) => void>; // Invalidate live sessions after store writes
|
||||
@@ -49,6 +53,155 @@ interface ServerOptions {
|
||||
* Create and start the HTTP API server
|
||||
*/
|
||||
export function createApiServer(deliverer: AgentRouter, options: ServerOptions): http.Server {
|
||||
// ── Turn viewer SSE infrastructure ──────────────────────────────────────
|
||||
interface SSEClient {
|
||||
res: http.ServerResponse;
|
||||
sentCount: number;
|
||||
lastTurnId?: string;
|
||||
}
|
||||
const sseClientsByAgent = new Map<string, Set<SSEClient>>();
|
||||
const broadcastQueues = new Map<string, Promise<void>>();
|
||||
|
||||
function getTurnId(turn: unknown): string | undefined {
|
||||
if (!turn || typeof turn !== 'object') return undefined;
|
||||
const id = (turn as { turnId?: unknown }).turnId;
|
||||
return typeof id === 'string' && id.trim() ? id : undefined;
|
||||
}
|
||||
|
||||
async function readTurns(filePath: string): Promise<unknown[]> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
return content.split('\n').filter(l => l.trim()).map(l => {
|
||||
try { return JSON.parse(l); } catch { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function broadcastNewTurns(agentName: string, filePath: string): Promise<void> {
|
||||
const clients = sseClientsByAgent.get(agentName);
|
||||
if (!clients || clients.size === 0) return;
|
||||
const allTurns = await readTurns(filePath);
|
||||
const currentLastTurnId = getTurnId(allTurns[allTurns.length - 1]);
|
||||
|
||||
for (const client of clients) {
|
||||
// If the log was cleared, reset the client snapshot and UI.
|
||||
if (allTurns.length === 0) {
|
||||
const shouldReset = client.sentCount !== 0 || !!client.lastTurnId;
|
||||
client.sentCount = 0;
|
||||
client.lastTurnId = undefined;
|
||||
if (shouldReset) {
|
||||
const payload = `data: ${JSON.stringify({ type: 'init', turns: [] })}\n\n`;
|
||||
try { client.res.write(payload); } catch { clients.delete(client); }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (client.lastTurnId === currentLastTurnId && client.sentCount === allTurns.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let turnsToAppend: unknown[] | null = null;
|
||||
if (client.lastTurnId) {
|
||||
let previousIndex = -1;
|
||||
for (let i = allTurns.length - 1; i >= 0; i--) {
|
||||
if (getTurnId(allTurns[i]) === client.lastTurnId) {
|
||||
previousIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (previousIndex >= 0) {
|
||||
turnsToAppend = allTurns.slice(previousIndex + 1);
|
||||
}
|
||||
} else if (client.sentCount < allTurns.length) {
|
||||
// Fallback for older records without turnId.
|
||||
turnsToAppend = allTurns.slice(client.sentCount);
|
||||
}
|
||||
|
||||
const appendTurns = turnsToAppend ?? [];
|
||||
const shouldResync = turnsToAppend === null
|
||||
|| (appendTurns.length === 0 && (client.sentCount !== allTurns.length || client.lastTurnId !== currentLastTurnId));
|
||||
|
||||
const payload = shouldResync
|
||||
? `data: ${JSON.stringify({ type: 'init', turns: allTurns })}\n\n`
|
||||
: appendTurns.length > 0
|
||||
? `data: ${JSON.stringify({ type: 'append', turns: appendTurns })}\n\n`
|
||||
: null;
|
||||
|
||||
if (payload) {
|
||||
try {
|
||||
client.res.write(payload);
|
||||
} catch {
|
||||
clients.delete(client);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
client.sentCount = allTurns.length;
|
||||
client.lastTurnId = currentLastTurnId;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueBroadcast(agentName: string, filePath: string): void {
|
||||
const prev = broadcastQueues.get(filePath) ?? Promise.resolve();
|
||||
const next = prev.then(() => broadcastNewTurns(agentName, filePath)).catch(() => {});
|
||||
broadcastQueues.set(filePath, next);
|
||||
}
|
||||
|
||||
const watchers = new Map<string, fs.FSWatcher>();
|
||||
|
||||
function ensureWatching(agentName: string, filePath: string): void {
|
||||
if (watchers.has(filePath)) return;
|
||||
let watcher: fs.FSWatcher;
|
||||
try {
|
||||
watcher = fs.watch(filePath, { persistent: false }, (eventType) => {
|
||||
if (eventType === 'rename') {
|
||||
// Inode replaced (trim via atomic rename on Linux). Restart watcher.
|
||||
watcher.close();
|
||||
watchers.delete(filePath);
|
||||
setTimeout(() => {
|
||||
const clients = sseClientsByAgent.get(agentName);
|
||||
if (clients && clients.size > 0) {
|
||||
enqueueBroadcast(agentName, filePath);
|
||||
ensureWatching(agentName, filePath);
|
||||
}
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
enqueueBroadcast(agentName, filePath);
|
||||
});
|
||||
} catch {
|
||||
setTimeout(() => {
|
||||
const clients = sseClientsByAgent.get(agentName);
|
||||
if (clients && clients.size > 0) ensureWatching(agentName, filePath);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
watcher.on('error', () => {
|
||||
watcher.close();
|
||||
watchers.delete(filePath);
|
||||
// Auto-restart watcher after trim (inode replacement on Linux)
|
||||
setTimeout(() => {
|
||||
const clients = sseClientsByAgent.get(agentName);
|
||||
if (clients && clients.size > 0) ensureWatching(agentName, filePath);
|
||||
}, 500);
|
||||
});
|
||||
watchers.set(filePath, watcher);
|
||||
}
|
||||
|
||||
function maybeUnwatch(filePath: string, clients: Set<SSEClient>): void {
|
||||
if (clients.size === 0 && watchers.has(filePath)) {
|
||||
watchers.get(filePath)!.close();
|
||||
watchers.delete(filePath);
|
||||
broadcastQueues.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.turnLogFiles) {
|
||||
for (const agentName of Object.keys(options.turnLogFiles)) {
|
||||
sseClientsByAgent.set(agentName, new Set());
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// Set CORS headers (configurable origin, defaults to same-origin for security)
|
||||
const corsOrigin = options.corsOrigin || req.headers.origin || 'null';
|
||||
@@ -70,6 +223,63 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions):
|
||||
return;
|
||||
}
|
||||
|
||||
// Turn viewer routes
|
||||
if (options.turnLogFiles && req.method === 'GET') {
|
||||
const agentNames = Object.keys(options.turnLogFiles);
|
||||
const parsedUrl = new URL(req.url ?? '/', `http://localhost`);
|
||||
|
||||
const validateTurnAuth = (): boolean => {
|
||||
if (validateApiKey(req.headers, options.apiKey)) return true;
|
||||
const qKey = parsedUrl.searchParams.get('key') || '';
|
||||
if (!qKey) return false;
|
||||
const a = Buffer.from(qKey);
|
||||
const b = Buffer.from(options.apiKey);
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
};
|
||||
|
||||
if (parsedUrl.pathname === '/turns') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(getTurnViewerHtml(agentNames));
|
||||
return;
|
||||
}
|
||||
if (parsedUrl.pathname === '/turns/data') {
|
||||
if (!validateTurnAuth()) { sendError(res, 401, 'Unauthorized'); return; }
|
||||
const agentName = parsedUrl.searchParams.get('agent') || agentNames[0];
|
||||
const filePath = options.turnLogFiles[agentName];
|
||||
if (!filePath) { res.writeHead(404); res.end('Unknown agent'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(await readTurns(filePath)));
|
||||
return;
|
||||
}
|
||||
if (parsedUrl.pathname === '/turns/stream') {
|
||||
if (!validateTurnAuth()) { sendError(res, 401, 'Unauthorized'); return; }
|
||||
const agentName = parsedUrl.searchParams.get('agent') || agentNames[0];
|
||||
const filePath = options.turnLogFiles[agentName];
|
||||
if (!filePath) { res.writeHead(404); res.end('Unknown agent'); return; }
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
const allTurns = await readTurns(filePath);
|
||||
res.write(`data: ${JSON.stringify({ type: 'init', turns: allTurns })}\n\n`);
|
||||
const clients = sseClientsByAgent.get(agentName)!;
|
||||
const client: SSEClient = {
|
||||
res,
|
||||
sentCount: allTurns.length,
|
||||
lastTurnId: getTurnId(allTurns[allTurns.length - 1]),
|
||||
};
|
||||
clients.add(client);
|
||||
ensureWatching(agentName, filePath);
|
||||
req.on('close', () => {
|
||||
clients.delete(client);
|
||||
maybeUnwatch(filePath, clients);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Route: POST /api/v1/messages (unified: supports both text and files)
|
||||
if (req.url === '/api/v1/messages' && req.method === 'POST') {
|
||||
try {
|
||||
|
||||
@@ -102,6 +102,10 @@ export interface AgentConfig {
|
||||
display?: DisplayConfig;
|
||||
allowedTools?: string[]; // Per-agent tool whitelist (overrides global/env ALLOWED_TOOLS)
|
||||
disallowedTools?: string[]; // Per-agent tool blocklist (overrides global/env DISALLOWED_TOOLS)
|
||||
logging?: {
|
||||
turnLogFile?: string; // Path to JSONL file for turn logging (one record per agent turn)
|
||||
maxTurns?: number; // Max turns to retain in the log file (default: 1000, oldest trimmed)
|
||||
};
|
||||
};
|
||||
/** Security settings */
|
||||
security?: {
|
||||
@@ -195,6 +199,10 @@ export interface LettaBotConfig {
|
||||
display?: DisplayConfig; // Show tool calls / reasoning in channel output
|
||||
allowedTools?: string[]; // Global tool whitelist (overridden by per-agent, falls back to ALLOWED_TOOLS env)
|
||||
disallowedTools?: string[]; // Global tool blocklist (overridden by per-agent, falls back to DISALLOWED_TOOLS env)
|
||||
logging?: {
|
||||
turnLogFile?: string; // Global turn log file (overridden by per-agent)
|
||||
maxTurns?: number; // Global maxTurns default (overridden by per-agent)
|
||||
};
|
||||
};
|
||||
|
||||
// Polling - system-level background checks (Gmail, etc.)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { access, unlink, realpath, stat, constants } from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { extname, resolve, join } from 'node:path';
|
||||
import type { ChannelAdapter } from '../channels/types.js';
|
||||
import type { BotConfig, InboundMessage, TriggerContext, StreamMsg } from './types.js';
|
||||
import type { BotConfig, InboundMessage, TriggerContext, TriggerType, StreamMsg } from './types.js';
|
||||
import { formatApiErrorForUser } from './errors.js';
|
||||
import { formatToolCallDisplay, formatReasoningDisplay, formatQuestionsForChannel } from './display.js';
|
||||
import type { AgentSession } from './interfaces.js';
|
||||
@@ -24,6 +24,7 @@ import { parseDirectives, stripActionsBlock, type Directive } from './directives
|
||||
import { resolveEmoji } from './emoji.js';
|
||||
import { SessionManager } from './session-manager.js';
|
||||
import { createDisplayPipeline, type DisplayEvent, type CompleteEvent, type ErrorEvent } from './display-pipeline.js';
|
||||
import { TurnLogger, TurnAccumulator, generateTurnId, type TurnRecord } from './turn-logger.js';
|
||||
|
||||
|
||||
import { createLogger } from '../logger.js';
|
||||
@@ -233,11 +234,15 @@ export class LettaBot implements AgentSession {
|
||||
|
||||
private conversationOverrides: Set<string> = new Set();
|
||||
private readonly sessionManager: SessionManager;
|
||||
private readonly turnLogger: TurnLogger | null;
|
||||
|
||||
constructor(config: BotConfig) {
|
||||
this.config = config;
|
||||
mkdirSync(config.workingDir, { recursive: true });
|
||||
this.store = new Store('lettabot-agent.json', config.agentName);
|
||||
this.turnLogger = config.logging?.turnLogFile
|
||||
? new TurnLogger(config.logging.turnLogFile, config.logging.maxTurns)
|
||||
: null;
|
||||
if (config.reuseSession === false) {
|
||||
log.warn('Session reuse disabled (conversations.reuseSession=false): each foreground/background message uses a fresh SDK subprocess (~5s overhead per turn).');
|
||||
}
|
||||
@@ -1172,7 +1177,7 @@ export class LettaBot implements AgentSession {
|
||||
private async processMessage(msg: InboundMessage, adapter: ChannelAdapter, retried = false): Promise<void> {
|
||||
// Track timing and last target
|
||||
const debugTiming = !!process.env.LETTABOT_DEBUG_TIMING;
|
||||
const t0 = debugTiming ? performance.now() : 0;
|
||||
const t0 = performance.now();
|
||||
const lap = (label: string) => {
|
||||
log.debug(`${label}: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
};
|
||||
@@ -1278,6 +1283,10 @@ export class LettaBot implements AgentSession {
|
||||
adapter.sendTypingIndicator(msg.chatId).catch(() => {});
|
||||
}, 4000);
|
||||
|
||||
const turnId = this.turnLogger ? generateTurnId() : '';
|
||||
const turnAcc = this.turnLogger ? new TurnAccumulator() : null;
|
||||
let turnWritten = false;
|
||||
|
||||
try {
|
||||
let firstChunkLogged = false;
|
||||
const pipeline = createDisplayPipeline(run.stream(), {
|
||||
@@ -1286,6 +1295,7 @@ export class LettaBot implements AgentSession {
|
||||
});
|
||||
|
||||
for await (const event of pipeline) {
|
||||
turnAcc?.feed(event);
|
||||
// Check for /cancel before processing each event
|
||||
if (this.cancelledKeys.has(convKey)) {
|
||||
log.info(`Stream cancelled by /cancel (key=${convKey})`);
|
||||
@@ -1627,6 +1637,24 @@ export class LettaBot implements AgentSession {
|
||||
} finally {
|
||||
clearInterval(typingInterval);
|
||||
adapter.stopTypingIndicator?.(msg.chatId)?.catch(() => {});
|
||||
// Write turn record (even partial turns on cancel/error)
|
||||
if (this.turnLogger && turnAcc && !turnWritten) {
|
||||
turnWritten = true;
|
||||
const { events, output } = turnAcc.finalize();
|
||||
this.turnLogger.write({
|
||||
ts: new Date().toISOString(),
|
||||
turnId,
|
||||
trigger: 'user_message' as const,
|
||||
channel: msg.channel,
|
||||
chatId: msg.chatId,
|
||||
userId: msg.userId,
|
||||
input: typeof messageToSend === 'string' ? messageToSend : '[multimodal]',
|
||||
events,
|
||||
output: output || response,
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
error: lastErrorDetail?.message,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
lap('stream complete');
|
||||
|
||||
@@ -1784,8 +1812,14 @@ export class LettaBot implements AgentSession {
|
||||
const convKey = this.resolveHeartbeatConversationKey();
|
||||
const acquired = await this.acquireLock(convKey);
|
||||
|
||||
const sendT0 = performance.now();
|
||||
const sendTurnId = this.turnLogger ? generateTurnId() : '';
|
||||
const sendTurnAcc = this.turnLogger ? new TurnAccumulator() : null;
|
||||
let sendTurnWritten = false;
|
||||
|
||||
try {
|
||||
let retried = false;
|
||||
|
||||
while (true) {
|
||||
const { stream } = await this.sessionManager.runSession(text, { convKey, retried });
|
||||
|
||||
@@ -1796,6 +1830,7 @@ export class LettaBot implements AgentSession {
|
||||
let usedMessageCli = false;
|
||||
let lastErrorDetail: StreamErrorDetail | undefined;
|
||||
for await (const msg of stream()) {
|
||||
sendTurnAcc?.feedRaw(msg);
|
||||
if (msg.type === 'tool_call') {
|
||||
this.sessionManager.syncTodoToolCall(msg);
|
||||
if (isSilent && msg.toolName === 'Bash') {
|
||||
@@ -1930,6 +1965,21 @@ export class LettaBot implements AgentSession {
|
||||
if (this.config.reuseSession === false) {
|
||||
this.sessionManager.invalidateSession(convKey);
|
||||
}
|
||||
// Write turn record
|
||||
if (this.turnLogger && sendTurnAcc && !sendTurnWritten) {
|
||||
sendTurnWritten = true;
|
||||
const { events, output } = sendTurnAcc.finalize();
|
||||
this.turnLogger.write({
|
||||
ts: new Date().toISOString(),
|
||||
turnId: sendTurnId,
|
||||
trigger: context?.type ?? 'heartbeat',
|
||||
channel: context?.sourceChannel,
|
||||
input: text,
|
||||
events,
|
||||
output,
|
||||
durationMs: Math.round(performance.now() - sendT0),
|
||||
}).catch(() => {});
|
||||
}
|
||||
this.releaseLock(convKey, acquired);
|
||||
}
|
||||
}
|
||||
@@ -1944,20 +1994,42 @@ export class LettaBot implements AgentSession {
|
||||
): AsyncGenerator<StreamMsg> {
|
||||
const convKey = this.resolveHeartbeatConversationKey();
|
||||
const acquired = await this.acquireLock(convKey);
|
||||
const streamT0 = performance.now();
|
||||
const streamTurnId = this.turnLogger ? generateTurnId() : '';
|
||||
const streamTurnAcc = this.turnLogger ? new TurnAccumulator() : null;
|
||||
let streamTurnError: string | undefined;
|
||||
|
||||
try {
|
||||
const { stream } = await this.sessionManager.runSession(text, { convKey });
|
||||
|
||||
try {
|
||||
yield* stream();
|
||||
for await (const msg of stream()) {
|
||||
streamTurnAcc?.feedRaw(msg);
|
||||
yield msg;
|
||||
}
|
||||
} catch (error) {
|
||||
this.sessionManager.invalidateSession(convKey);
|
||||
streamTurnError = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (this.config.reuseSession === false) {
|
||||
this.sessionManager.invalidateSession(convKey);
|
||||
}
|
||||
if (this.turnLogger && streamTurnAcc) {
|
||||
const { events, output } = streamTurnAcc.finalize();
|
||||
this.turnLogger.write({
|
||||
ts: new Date().toISOString(),
|
||||
turnId: streamTurnId,
|
||||
trigger: context?.type ?? 'heartbeat',
|
||||
channel: context?.sourceChannel,
|
||||
input: text,
|
||||
events,
|
||||
output,
|
||||
durationMs: Math.round(performance.now() - streamT0),
|
||||
error: streamTurnError,
|
||||
}).catch(() => {});
|
||||
}
|
||||
this.releaseLock(convKey, acquired);
|
||||
}
|
||||
}
|
||||
|
||||
222
src/core/turn-logger.ts
Normal file
222
src/core/turn-logger.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Turn logger -- writes one JSONL record per agent turn.
|
||||
*
|
||||
* TurnLogger handles file I/O with write serialization and bounded retention.
|
||||
* TurnAccumulator collects DisplayEvents during a turn and produces a TurnRecord.
|
||||
*/
|
||||
|
||||
import { appendFile, readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createLogger } from '../logger.js';
|
||||
import type { DisplayEvent } from './display-pipeline.js';
|
||||
import type { TriggerType, StreamMsg } from './types.js';
|
||||
|
||||
const log = createLogger('TurnLogger');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TurnEvent =
|
||||
| { type: 'reasoning'; content: string }
|
||||
| { type: 'tool_call'; id: string; name: string; args: unknown }
|
||||
| { type: 'tool_result'; id: string; content: string; isError: boolean };
|
||||
|
||||
export interface TurnRecord {
|
||||
ts: string;
|
||||
turnId: string;
|
||||
trigger: TriggerType;
|
||||
channel?: string;
|
||||
chatId?: string;
|
||||
userId?: string;
|
||||
input: string;
|
||||
events: TurnEvent[];
|
||||
output: string;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_MAX_TURNS = 1000;
|
||||
const MAX_TOOL_RESULT_BYTES = 4 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TurnAccumulator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Collects DisplayEvents during a single agent turn and produces a TurnRecord.
|
||||
* Works with the DisplayPipeline's event types -- no raw StreamMsg handling.
|
||||
*/
|
||||
export class TurnAccumulator {
|
||||
private _events: TurnEvent[] = [];
|
||||
private _output = '';
|
||||
// For feedRaw() only (sendToAgent/streamToAgent paths)
|
||||
private _reasoningAcc = '';
|
||||
private _lastRawType: string | null = null;
|
||||
|
||||
/** Feed a DisplayEvent from the pipeline. */
|
||||
feed(event: DisplayEvent): void {
|
||||
switch (event.type) {
|
||||
case 'reasoning':
|
||||
// Pipeline already accumulates and flushes reasoning chunks,
|
||||
// so each reasoning event is a complete block.
|
||||
this._events.push({ type: 'reasoning', content: event.content });
|
||||
break;
|
||||
case 'tool_call':
|
||||
this._events.push({
|
||||
type: 'tool_call',
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
});
|
||||
break;
|
||||
case 'tool_result': {
|
||||
const content = event.content.length > MAX_TOOL_RESULT_BYTES
|
||||
? event.content.slice(0, MAX_TOOL_RESULT_BYTES) + '…[truncated]'
|
||||
: event.content;
|
||||
this._events.push({
|
||||
type: 'tool_result',
|
||||
id: event.toolCallId,
|
||||
content,
|
||||
isError: event.isError,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'text':
|
||||
this._output += event.delta;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed a raw StreamMsg (for sendToAgent/streamToAgent which don't use DisplayPipeline).
|
||||
* Handles reasoning accumulation inline since there's no pipeline to do it.
|
||||
*/
|
||||
feedRaw(msg: StreamMsg): void {
|
||||
// Flush reasoning on transition away from reasoning
|
||||
if (this._lastRawType === 'reasoning' && msg.type !== 'reasoning' && this._reasoningAcc.trim()) {
|
||||
this._events.push({ type: 'reasoning', content: this._reasoningAcc.trim() });
|
||||
this._reasoningAcc = '';
|
||||
}
|
||||
switch (msg.type) {
|
||||
case 'reasoning':
|
||||
this._reasoningAcc += msg.content || '';
|
||||
break;
|
||||
case 'tool_call':
|
||||
this._events.push({
|
||||
type: 'tool_call',
|
||||
id: msg.toolCallId || '',
|
||||
name: msg.toolName || 'unknown',
|
||||
args: (msg as any).toolInput ?? (msg as any).rawArguments ?? null,
|
||||
});
|
||||
break;
|
||||
case 'tool_result': {
|
||||
const raw = (msg as any).content ?? '';
|
||||
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||
this._events.push({
|
||||
type: 'tool_result',
|
||||
id: msg.toolCallId || '',
|
||||
content: str.length > MAX_TOOL_RESULT_BYTES
|
||||
? str.slice(0, MAX_TOOL_RESULT_BYTES) + '…[truncated]'
|
||||
: str,
|
||||
isError: !!msg.isError,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'assistant':
|
||||
this._output += msg.content || '';
|
||||
break;
|
||||
}
|
||||
if (msg.type !== 'stream_event') this._lastRawType = msg.type;
|
||||
}
|
||||
|
||||
/** Return the accumulated events and output text. */
|
||||
finalize(): { events: TurnEvent[]; output: string } {
|
||||
// Flush trailing reasoning
|
||||
if (this._reasoningAcc.trim()) {
|
||||
this._events.push({ type: 'reasoning', content: this._reasoningAcc.trim() });
|
||||
this._reasoningAcc = '';
|
||||
}
|
||||
return { events: this._events, output: this._output };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TurnLogger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class TurnLogger {
|
||||
private filePath: string;
|
||||
private maxTurns: number;
|
||||
private ready = false;
|
||||
private lineCount = 0;
|
||||
private writeQueue = Promise.resolve();
|
||||
|
||||
constructor(filePath: string, maxTurns = DEFAULT_MAX_TURNS) {
|
||||
this.filePath = filePath;
|
||||
if (!Number.isInteger(maxTurns) || maxTurns <= 0) {
|
||||
throw new Error(`TurnLogger: maxTurns must be a positive integer (got ${maxTurns})`);
|
||||
}
|
||||
this.maxTurns = maxTurns;
|
||||
try {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
this.ready = true;
|
||||
try {
|
||||
const existing = readFileSync(filePath, 'utf8');
|
||||
this.lineCount = existing.split('\n').filter(l => l.trim()).length;
|
||||
} catch {
|
||||
this.lineCount = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Failed to create log directory for ${filePath}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialized write -- prevents concurrent trim races. */
|
||||
async write(record: TurnRecord): Promise<void> {
|
||||
if (!this.ready) return;
|
||||
this.writeQueue = this.writeQueue.then(() => this._write(record)).catch(() => {});
|
||||
return this.writeQueue;
|
||||
}
|
||||
|
||||
private async _write(record: TurnRecord): Promise<void> {
|
||||
try {
|
||||
await appendFile(this.filePath, JSON.stringify(record) + '\n');
|
||||
this.lineCount++;
|
||||
if (this.lineCount > this.maxTurns) {
|
||||
await this.trim();
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Failed to write turn record:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
private async trim(): Promise<void> {
|
||||
try {
|
||||
const content = await readFile(this.filePath, 'utf8');
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
const trimmed = lines.slice(lines.length - this.maxTurns).join('\n') + '\n';
|
||||
const tmp = this.filePath + '.tmp';
|
||||
await writeFile(tmp, trimmed);
|
||||
await rename(tmp, this.filePath);
|
||||
this.lineCount = this.maxTurns;
|
||||
} catch (err) {
|
||||
log.warn(`Failed to trim turn log:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a unique turn ID. */
|
||||
export function generateTurnId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
510
src/core/turn-viewer.html
Normal file
510
src/core/turn-viewer.html
Normal file
@@ -0,0 +1,510 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Turn Viewer</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0f1117;--surface:#1a1d27;--surface2:#22263a;--border:#2e3348;
|
||||
--accent:#6c8ef7;--accent2:#a78bfa;
|
||||
--text:#e2e6f0;--text2:#8b92a8;--text3:#5a6080;
|
||||
--green:#4ade80;--red:#f87171;--yellow:#fbbf24;
|
||||
--rb:#1e1a2e;--rbd:#5b4daa;
|
||||
--tb:#172434;--tbd:#2563eb;
|
||||
--trb:#1a2d1a;--trbd:#16a34a;
|
||||
--treb:#2d1a1a;--trebd:#dc2626;
|
||||
}
|
||||
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
||||
.header{padding:12px 20px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0}
|
||||
.header h1{font-size:16px;font-weight:600}
|
||||
.file-path{font-size:11px;color:var(--text3);font-family:monospace}
|
||||
.turn-count{font-size:12px;color:var(--text2);margin-left:auto}
|
||||
.status-dot{width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0}
|
||||
.status-dot.stale{background:var(--text3)}
|
||||
.status-dot.live{animation:pulse 2s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
.filters{padding:10px 20px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-wrap:wrap;flex-shrink:0}
|
||||
.filter-label{font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:.05em}
|
||||
.filter-group{display:flex;gap:4px;align-items:center}
|
||||
.tab{padding:4px 12px;border-radius:20px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:13px;cursor:pointer;transition:all .15s}
|
||||
.tab:hover{border-color:var(--accent);color:var(--text)}
|
||||
.tab.active{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
|
||||
.search-input{padding:4px 12px;border-radius:20px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-size:13px;outline:none;width:180px}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
.search-input::placeholder{color:var(--text3)}
|
||||
.table-wrapper{flex:1;overflow-y:auto}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
thead{position:sticky;top:0;z-index:10;background:var(--surface2)}
|
||||
th{padding:9px 14px;text-align:left;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text3);border-bottom:1px solid var(--border);white-space:nowrap}
|
||||
td{padding:9px 14px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
tr:hover td{background:var(--surface2);cursor:pointer}
|
||||
.col-time{width:155px;color:var(--text2);font-size:12px;font-family:monospace;white-space:nowrap}
|
||||
.col-trigger{width:130px}
|
||||
.col-channel{width:100px}
|
||||
.col-events{width:65px;text-align:center;color:var(--text2);font-size:12px}
|
||||
.col-duration{width:80px;text-align:right;color:var(--text2);font-size:12px;font-family:monospace;white-space:nowrap}
|
||||
.truncated{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:260px;color:var(--text2);font-size:13px}
|
||||
.empty-state{padding:60px;text-align:center;color:var(--text3)}
|
||||
.empty-state p{margin-top:8px;font-size:13px}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;letter-spacing:.03em}
|
||||
.badge-user_message{background:#1e3a5f;color:#60a5fa}
|
||||
.badge-heartbeat{background:#3b2d14;color:#fbbf24}
|
||||
|
||||
.badge-cron{background:#2d1e3a;color:#c084fc}
|
||||
.badge-webhook{background:#2d1e1e;color:#f87171}
|
||||
.badge-feed{background:#1e2d3a;color:#38bdf8}
|
||||
.badge-channel{background:var(--surface2);color:var(--text2);border:1px solid var(--border)}
|
||||
.detail-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none}
|
||||
.detail-overlay.open{display:block}
|
||||
.detail-panel{position:fixed;top:0;right:0;bottom:0;width:660px;max-width:90vw;background:var(--surface);border-left:1px solid var(--border);overflow-y:auto;z-index:101;transform:translateX(100%);transition:transform .2s ease;display:flex;flex-direction:column}
|
||||
.detail-panel.open{transform:translateX(0)}
|
||||
.detail-header{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:flex-start;gap:10px;flex-shrink:0;background:var(--surface);position:sticky;top:0;z-index:5}
|
||||
.detail-meta{flex:1;min-width:0}
|
||||
.detail-ts{font-size:12px;color:var(--text3);font-family:monospace}
|
||||
.detail-trigger-row{display:flex;gap:8px;align-items:center;margin-top:4px;flex-wrap:wrap}
|
||||
.close-btn{background:none;border:none;color:var(--text3);font-size:20px;cursor:pointer;padding:0 4px;line-height:1;flex-shrink:0}
|
||||
.close-btn:hover{color:var(--text)}
|
||||
.detail-body{padding:18px;display:flex;flex-direction:column;gap:18px}
|
||||
.section{display:flex;flex-direction:column;gap:8px}
|
||||
.section-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--text3)}
|
||||
.msg-box{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;font-size:13px;color:var(--text);line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:280px;overflow-y:auto}
|
||||
.msg-box.output{border-color:var(--accent2);background:#1a1a2e}
|
||||
.events-list{display:flex;flex-direction:column;gap:6px}
|
||||
.event{border-radius:8px;overflow:hidden}
|
||||
.event-hdr{padding:7px 12px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;cursor:pointer;user-select:none}
|
||||
.event-hdr:hover{filter:brightness(1.1)}
|
||||
.event-toggle{font-size:10px;margin-left:auto;opacity:.7}
|
||||
.event-bdy{padding:10px 12px;font-size:12px;font-family:'SF Mono','Fira Code',monospace;white-space:pre-wrap;word-break:break-word;line-height:1.5;max-height:360px;overflow-y:auto}
|
||||
.ev-reasoning{background:var(--rb);border:1px solid var(--rbd)}
|
||||
.ev-reasoning .event-hdr{background:var(--rbd);color:#c4b5fd}
|
||||
.ev-reasoning .event-bdy{color:#ddd6fe;font-family:inherit;font-style:italic;font-size:13px}
|
||||
.ev-tool_call{background:var(--tb);border:1px solid var(--tbd)}
|
||||
.ev-tool_call .event-hdr{background:var(--tbd);color:#93c5fd}
|
||||
.ev-tool_call .event-bdy{color:#bfdbfe}
|
||||
.ev-tool_result{background:var(--trb);border:1px solid var(--trbd)}
|
||||
.ev-tool_result .event-hdr{background:var(--trbd);color:#bbf7d0}
|
||||
.ev-tool_result .event-bdy{color:#d1fae5}
|
||||
.ev-tool_result.is-error{background:var(--treb);border-color:var(--trebd)}
|
||||
.ev-tool_result.is-error .event-hdr{background:var(--trebd);color:#fecaca}
|
||||
.ev-tool_result.is-error .event-bdy{color:#fee2e2}
|
||||
.no-events{color:var(--text3);font-size:13px;font-style:italic}
|
||||
::-webkit-scrollbar{width:6px;height:6px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||
::-webkit-scrollbar-thumb:hover{background:var(--text3)}
|
||||
.auth-overlay{position:fixed;inset:0;background:var(--bg);z-index:200;display:flex;align-items:center;justify-content:center}
|
||||
.auth-box{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:28px 24px;width:340px}
|
||||
.auth-box h2{font-size:16px;font-weight:600;margin-bottom:18px;color:var(--text)}
|
||||
.auth-box label{display:block;font-size:12px;color:var(--text3);margin-bottom:6px}
|
||||
.auth-box input{width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;font-family:monospace;outline:none}
|
||||
.auth-box input:focus{border-color:var(--accent)}
|
||||
.auth-box button{margin-top:12px;width:100%;padding:9px;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer}
|
||||
.auth-box button:hover{opacity:.9}
|
||||
.auth-err{color:#f87171;font-size:12px;margin-top:8px;display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-box">
|
||||
<h2>Turn Viewer</h2>
|
||||
<label for="apiKeyInput">API Key</label>
|
||||
<input type="password" id="apiKeyInput" placeholder="Paste your API key" autocomplete="off"
|
||||
onkeydown="if(event.key==='Enter')document.getElementById('authSubmit').click()">
|
||||
<button id="authSubmit">Connect</button>
|
||||
<div class="auth-err" id="authErr">Invalid API key</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header">
|
||||
<h1>Turn Viewer</h1>
|
||||
<span class="file-path" id="agentLabel" style="display:__LABEL_DISPLAY__">__DEFAULT_AGENT__</span>
|
||||
<select id="agentSelect" style="display:__SELECT_DISPLAY__;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:3px 8px;font-size:13px;cursor:pointer;outline:none">
|
||||
__AGENT_OPTIONS__
|
||||
</select>
|
||||
<span class="turn-count" id="turnCount"></span>
|
||||
<div class="status-dot stale" id="statusDot" title="Connecting…"></div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<span class="filter-label">Trigger</span>
|
||||
<div class="filter-group" id="triggerTabs">
|
||||
<button class="tab active" data-trigger="all">All</button>
|
||||
<button class="tab" data-trigger="user_message">User Message</button>
|
||||
<button class="tab" data-trigger="heartbeat">Heartbeat</button>
|
||||
<button class="tab" data-trigger="cron">Cron</button>
|
||||
<button class="tab" data-trigger="webhook">Webhook</button>
|
||||
<button class="tab" data-trigger="feed">Feed</button>
|
||||
</div>
|
||||
<div class="filter-group" id="channelFilterGroup" style="display:none">
|
||||
<span class="filter-label">Channel</span>
|
||||
<div id="channelTabs"></div>
|
||||
</div>
|
||||
<div class="filter-group" style="margin-left:auto">
|
||||
<input class="search-input" id="searchInput" type="text" placeholder="Search input / output…">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper" id="tableWrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-time">Time</th>
|
||||
<th class="col-trigger">Trigger</th>
|
||||
<th class="col-channel">Channel</th>
|
||||
<th class="col-events">Events</th>
|
||||
<th class="col-duration">Duration</th>
|
||||
<th>Input</th>
|
||||
<th>Output</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody"></tbody>
|
||||
</table>
|
||||
<div class="empty-state" id="emptyState" style="display:none">
|
||||
<div style="font-size:32px">📋</div>
|
||||
<p id="emptyMsg">Waiting for turns…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-overlay" id="detailOverlay"></div>
|
||||
<div class="detail-panel" id="detailPanel">
|
||||
<div class="detail-header">
|
||||
<div class="detail-meta">
|
||||
<div class="detail-ts" id="detailTs"></div>
|
||||
<div class="detail-trigger-row">
|
||||
<span id="detailTrigger"></span>
|
||||
<span id="detailChannel"></span>
|
||||
<span style="font-size:11px;color:var(--text3)" id="detailUserId"></span>
|
||||
<span style="font-size:11px;color:var(--text3);margin-left:auto;font-family:monospace" id="detailDuration"></span>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text3);font-family:monospace;margin-top:2px" id="detailTurnId"></div>
|
||||
</div>
|
||||
<button class="close-btn" id="closeBtn">✕</button>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<div class="section">
|
||||
<div class="section-label">Input</div>
|
||||
<div class="msg-box" id="detailInput"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-label">Events (<span id="detailEventsCount">0</span>)</div>
|
||||
<div class="events-list" id="detailEvents"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-label">Output</div>
|
||||
<div class="msg-box output" id="detailOutput"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var AGENT_NAMES = __AGENT_NAMES_JSON__;
|
||||
var activeAgent = AGENT_NAMES[0] || '';
|
||||
var allTurns = [];
|
||||
var activeTrigger = 'all';
|
||||
var activeChannel = 'all';
|
||||
var searchQuery = '';
|
||||
var openTurnId = null;
|
||||
var apiKey = sessionStorage.getItem('tv_key') || '';
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
if (!ts) return '\u2014';
|
||||
var d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return ts;
|
||||
var p = function(n){return n<10?'0'+n:''+n;};
|
||||
var mo = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
return mo[d.getMonth()]+' '+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());
|
||||
}
|
||||
|
||||
function triggerBadge(t) {
|
||||
return '<span class="badge badge-'+esc(t||'unknown')+'">'+esc(t||'unknown')+'</span>';
|
||||
}
|
||||
function channelBadge(c) {
|
||||
return c ? '<span class="badge badge-channel">'+esc(c)+'</span>' : '';
|
||||
}
|
||||
function fmtDuration(ms) {
|
||||
if (ms == null) return '<span style="color:var(--text3)">\u2014</span>';
|
||||
if (ms < 1000) return ms + 'ms';
|
||||
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
||||
return Math.floor(ms / 60000) + 'm ' + ((ms % 60000) / 1000).toFixed(0) + 's';
|
||||
}
|
||||
|
||||
function trunc(str, n) {
|
||||
n = n||120;
|
||||
if (!str) return '<span style="color:var(--text3);font-style:italic">\u2014</span>';
|
||||
str = str.trim();
|
||||
return str.length > n ? esc(str.slice(0,n))+'<span style="color:var(--text3)">\u2026</span>' : esc(str);
|
||||
}
|
||||
|
||||
function getChannels() {
|
||||
var seen = {};
|
||||
allTurns.forEach(function(t){ if (t.trigger==='user_message'&&t.channel) seen[t.channel]=true; });
|
||||
return Object.keys(seen).sort();
|
||||
}
|
||||
|
||||
function filterTurns() {
|
||||
return allTurns.filter(function(t) {
|
||||
if (activeTrigger !== 'all' && t.trigger !== activeTrigger) return false;
|
||||
if (activeTrigger === 'user_message' && activeChannel !== 'all' && t.channel !== activeChannel) return false;
|
||||
if (searchQuery) {
|
||||
var q = searchQuery.toLowerCase();
|
||||
if (((t.input||'').toLowerCase().indexOf(q)===-1) && ((t.output||'').toLowerCase().indexOf(q)===-1)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function setTurns(turns) {
|
||||
allTurns = turns.slice().reverse(); // newest first
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderTriggerTabs();
|
||||
renderChannelTabs();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTriggerTabs() {
|
||||
document.querySelectorAll('#triggerTabs .tab').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-trigger') === activeTrigger);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChannelTabs() {
|
||||
var group = document.getElementById('channelFilterGroup');
|
||||
var container = document.getElementById('channelTabs');
|
||||
if (activeTrigger !== 'user_message') { group.style.display = 'none'; return; }
|
||||
var channels = getChannels();
|
||||
if (!channels.length) { group.style.display = 'none'; return; }
|
||||
group.style.display = 'flex';
|
||||
container.innerHTML = ['all'].concat(channels).map(function(ch) {
|
||||
return '<button class="tab'+(activeChannel===ch?' active':'')+'" data-channel="'+esc(ch)+'">'+(ch==='all'?'All':esc(ch))+'</button>';
|
||||
}).join('');
|
||||
container.querySelectorAll('[data-channel]').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
activeChannel = btn.getAttribute('data-channel');
|
||||
renderChannelTabs();
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
var turns = filterTurns();
|
||||
var tbody = document.getElementById('tableBody');
|
||||
var empty = document.getElementById('emptyState');
|
||||
document.getElementById('turnCount').textContent =
|
||||
allTurns.length === turns.length ? allTurns.length+' turns' : turns.length+' / '+allTurns.length+' turns';
|
||||
|
||||
if (!turns.length) {
|
||||
tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
document.getElementById('emptyMsg').textContent =
|
||||
allTurns.length === 0 ? 'Waiting for turns\u2026' : 'No turns match the current filter.';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
|
||||
tbody.innerHTML = turns.map(function(t, i) {
|
||||
return '<tr data-idx="'+i+'">'+
|
||||
'<td class="col-time">'+fmtTime(t.ts)+'</td>'+
|
||||
'<td class="col-trigger">'+triggerBadge(t.trigger)+'</td>'+
|
||||
'<td class="col-channel">'+channelBadge(t.channel)+'</td>'+
|
||||
'<td class="col-events">'+((t.events||[]).length)+'</td>'+
|
||||
'<td class="col-duration">'+fmtDuration(t.durationMs)+'</td>'+
|
||||
'<td><div class="truncated">'+trunc(t.input)+'</div></td>'+
|
||||
'<td><div class="truncated">'+trunc(t.output)+'</div></td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('tr').forEach(function(row) {
|
||||
row.addEventListener('click', function() {
|
||||
openDetail(turns[parseInt(row.getAttribute('data-idx'),10)]);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-open detail panel if it was open and the turn still exists
|
||||
if (openTurnId) {
|
||||
var match = turns.find(function(t){ return t.turnId === openTurnId; });
|
||||
if (match) openDetail(match);
|
||||
else openTurnId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEvent(e, i) {
|
||||
if (e.type === 'reasoning') {
|
||||
return '<div class="event ev-reasoning">'+
|
||||
'<div class="event-hdr"><span>💭 Reasoning #'+(i+1)+'</span><span class="event-toggle">▲</span></div>'+
|
||||
'<div class="event-bdy">'+esc(e.content||'')+'</div></div>';
|
||||
}
|
||||
if (e.type === 'tool_call') {
|
||||
var args=''; try{args=JSON.stringify(e.args,null,2);}catch(ex){args=String(e.args);}
|
||||
return '<div class="event ev-tool_call">'+
|
||||
'<div class="event-hdr"><span>⚙️ '+esc(e.name||'unknown')+'</span>'+
|
||||
'<span style="font-family:monospace;opacity:.5;font-size:11px">'+esc((e.id||'').slice(0,12))+'</span>'+
|
||||
'<span class="event-toggle">▲</span></div>'+
|
||||
'<div class="event-bdy">'+esc(args)+'</div></div>';
|
||||
}
|
||||
if (e.type === 'tool_result') {
|
||||
var errCls=e.isError?' is-error':'', icon=e.isError?'❌':'✅';
|
||||
return '<div class="event ev-tool_result'+errCls+'">'+
|
||||
'<div class="event-hdr"><span>'+icon+' Result'+(e.isError?' (error)':'')+'</span>'+
|
||||
'<span style="font-family:monospace;opacity:.5;font-size:11px">'+esc((e.id||'').slice(0,12))+'</span>'+
|
||||
'<span class="event-toggle">▲</span></div>'+
|
||||
'<div class="event-bdy">'+esc(e.content||'')+'</div></div>';
|
||||
}
|
||||
return '<div style="border:1px solid var(--border);border-radius:8px;padding:8px 12px;color:var(--text3);font-size:12px">Unknown event: '+esc(e.type)+'</div>';
|
||||
}
|
||||
|
||||
function openDetail(turn) {
|
||||
if (!turn) return;
|
||||
openTurnId = turn.turnId;
|
||||
document.getElementById('detailTs').textContent = fmtTime(turn.ts);
|
||||
document.getElementById('detailTrigger').innerHTML = triggerBadge(turn.trigger);
|
||||
document.getElementById('detailChannel').innerHTML = channelBadge(turn.channel);
|
||||
document.getElementById('detailUserId').textContent = turn.userId ? 'user: '+turn.userId : '';
|
||||
document.getElementById('detailDuration').innerHTML = turn.durationMs != null ? fmtDuration(turn.durationMs) : '';
|
||||
document.getElementById('detailTurnId').textContent = turn.turnId ? 'turn: '+turn.turnId : '';
|
||||
document.getElementById('detailInput').textContent = turn.input||'(empty)';
|
||||
document.getElementById('detailOutput').textContent = turn.output||'(empty)';
|
||||
var events = turn.events||[];
|
||||
document.getElementById('detailEventsCount').textContent = events.length;
|
||||
var evList = document.getElementById('detailEvents');
|
||||
evList.innerHTML = events.length===0
|
||||
? '<div class="no-events">No events recorded for this turn.</div>'
|
||||
: events.map(renderEvent).join('');
|
||||
evList.querySelectorAll('.event-hdr').forEach(function(h) {
|
||||
h.addEventListener('click', function() {
|
||||
var body=h.nextElementSibling, toggle=h.querySelector('.event-toggle');
|
||||
var hidden=body.style.display==='none';
|
||||
body.style.display=hidden?'':'none';
|
||||
toggle.innerHTML=hidden?'▲':'▼';
|
||||
});
|
||||
});
|
||||
document.getElementById('detailPanel').classList.add('open');
|
||||
document.getElementById('detailOverlay').classList.add('open');
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
openTurnId = null;
|
||||
document.getElementById('detailPanel').classList.remove('open');
|
||||
document.getElementById('detailOverlay').classList.remove('open');
|
||||
}
|
||||
|
||||
document.getElementById('closeBtn').addEventListener('click', closeDetail);
|
||||
document.getElementById('detailOverlay').addEventListener('click', closeDetail);
|
||||
document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeDetail(); });
|
||||
|
||||
document.querySelectorAll('#triggerTabs .tab').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
activeTrigger = btn.getAttribute('data-trigger');
|
||||
activeChannel = 'all';
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('searchInput').addEventListener('input', function(e) {
|
||||
searchQuery = e.target.value;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
// ── Agent selector ─────────────────────────────────────────────────────────
|
||||
var agentSelect = document.getElementById('agentSelect');
|
||||
if (agentSelect) {
|
||||
agentSelect.addEventListener('change', function() {
|
||||
activeAgent = agentSelect.value;
|
||||
allTurns = [];
|
||||
activeTrigger = 'all';
|
||||
activeChannel = 'all';
|
||||
openTurnId = null;
|
||||
render();
|
||||
switchAgent();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Live data via SSE ──────────────────────────────────────────────────────
|
||||
var dot = document.getElementById('statusDot');
|
||||
var currentEs = null;
|
||||
|
||||
function connect() {
|
||||
if (currentEs) { currentEs.close(); currentEs = null; }
|
||||
var params = new URLSearchParams();
|
||||
if (activeAgent) params.set('agent', activeAgent);
|
||||
params.set('key', apiKey);
|
||||
var url = '/turns/stream?' + params.toString();
|
||||
var es = new EventSource(url);
|
||||
currentEs = es;
|
||||
es.onopen = function() {
|
||||
dot.className = 'status-dot live';
|
||||
dot.title = 'Live \u2014 auto-updating';
|
||||
};
|
||||
es.onmessage = function(e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
if (msg.type === 'init') { setTurns(msg.turns); }
|
||||
else if (msg.type === 'append' && msg.turns && msg.turns.length > 0) {
|
||||
allTurns = msg.turns.concat(allTurns);
|
||||
render();
|
||||
}
|
||||
} catch(ex) {}
|
||||
};
|
||||
es.onerror = function() {
|
||||
if (es !== currentEs) return; // stale, ignore
|
||||
dot.className = 'status-dot stale';
|
||||
dot.title = 'Disconnected \u2014 reconnecting\u2026';
|
||||
es.close();
|
||||
currentEs = null;
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function switchAgent() {
|
||||
var params = new URLSearchParams();
|
||||
if (activeAgent) params.set('agent', activeAgent);
|
||||
var url = '/turns/data?' + params.toString();
|
||||
fetch(url, { headers: { 'X-Api-Key': apiKey } })
|
||||
.then(function(r) {
|
||||
if (r.status === 401) { showAuth(); throw new Error('Unauthorized'); }
|
||||
return r.json();
|
||||
})
|
||||
.then(setTurns)
|
||||
.catch(function(){});
|
||||
connect();
|
||||
}
|
||||
|
||||
function showAuth() {
|
||||
document.getElementById('authOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideAuth() {
|
||||
document.getElementById('authOverlay').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('authSubmit').addEventListener('click', function() {
|
||||
var k = document.getElementById('apiKeyInput').value.trim();
|
||||
if (!k) return;
|
||||
apiKey = k;
|
||||
sessionStorage.setItem('tv_key', k);
|
||||
document.getElementById('authErr').style.display = 'none';
|
||||
hideAuth();
|
||||
switchAgent();
|
||||
});
|
||||
|
||||
// Initial load — show login if no key stored, otherwise load directly
|
||||
if (apiKey) {
|
||||
hideAuth();
|
||||
switchAgent();
|
||||
} else {
|
||||
showAuth();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
38
src/core/turn-viewer.ts
Normal file
38
src/core/turn-viewer.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Turn viewer SPA — served by the API server at GET /turns.
|
||||
*
|
||||
* The HTML lives in turn-viewer.html (same directory).
|
||||
* getTurnViewerHtml() injects agent metadata via simple token substitution
|
||||
* so the template file can be edited and linted independently.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
let TEMPLATE: string;
|
||||
try {
|
||||
TEMPLATE = readFileSync(join(__dirname, 'turn-viewer.html'), 'utf8');
|
||||
} catch {
|
||||
throw new Error('turn-viewer.html not found -- did you run npm run build?');
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function getTurnViewerHtml(agentNames: string[]): string {
|
||||
const multiAgent = agentNames.length > 1;
|
||||
const defaultAgent = agentNames[0] ?? '';
|
||||
// Escape </script> to prevent XSS if an agent name contains it
|
||||
const agentNamesJson = JSON.stringify(agentNames).replace(/<\/script>/gi, '<\\/script>');
|
||||
const agentOptions = agentNames.map(n => `<option value="${escHtml(n)}">${escHtml(n)}</option>`).join('');
|
||||
|
||||
return TEMPLATE
|
||||
.replaceAll('__AGENT_NAMES_JSON__', agentNamesJson)
|
||||
.replaceAll('__DEFAULT_AGENT__', escHtml(defaultAgent))
|
||||
.replaceAll('__LABEL_DISPLAY__', multiAgent ? 'none' : 'inline')
|
||||
.replaceAll('__SELECT_DISPLAY__', multiAgent ? 'inline-block' : 'none')
|
||||
.replaceAll('__AGENT_OPTIONS__', agentOptions);
|
||||
}
|
||||
@@ -192,6 +192,12 @@ export interface BotConfig {
|
||||
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
|
||||
sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete files after send (default: false)
|
||||
|
||||
// Logging
|
||||
logging?: {
|
||||
turnLogFile?: string; // Path to JSONL file for turn logging (one record per agent turn)
|
||||
maxTurns?: number; // Max turns to retain in the log file (default: 1000, oldest trimmed)
|
||||
};
|
||||
|
||||
// Cron
|
||||
cronStorePath?: string; // Resolved cron store path (per-agent in multi-agent mode)
|
||||
|
||||
|
||||
26
src/main.ts
26
src/main.ts
@@ -259,6 +259,25 @@ async function main() {
|
||||
const isMultiAgent = agents.length > 1;
|
||||
log.info(`${agents.length} agent(s) configured: ${agents.map(a => a.name).join(', ')}`);
|
||||
|
||||
// Validate agent names are unique
|
||||
const agentNames = agents.map(a => a.name);
|
||||
const duplicateAgentName = agentNames.find((n, i) => agentNames.indexOf(n) !== i);
|
||||
if (duplicateAgentName) {
|
||||
log.error(`Multiple agents share the same name: "${duplicateAgentName}". Each agent must have a unique name.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate no two agents share the same turnLogFile
|
||||
const turnLogFilePaths = agents
|
||||
.map(a => (a.features?.logging ?? yamlConfig.features?.logging)?.turnLogFile)
|
||||
.filter((p): p is string => !!p)
|
||||
.map(p => resolve(p));
|
||||
const duplicateTurnLog = turnLogFilePaths.find((p, i) => turnLogFilePaths.indexOf(p) !== i);
|
||||
if (duplicateTurnLog) {
|
||||
log.error(`Multiple agents share the same turnLogFile: "${duplicateTurnLog}". Each agent must use a unique log file path.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate at least one agent has channels
|
||||
const totalChannels = agents.reduce((sum, a) => sum + Object.keys(a.channels).length, 0);
|
||||
if (totalChannels === 0) {
|
||||
@@ -351,6 +370,7 @@ async function main() {
|
||||
maxSessions: agentConfig.conversations?.maxSessions,
|
||||
reuseSession: agentConfig.conversations?.reuseSession,
|
||||
redaction: agentConfig.security?.redaction,
|
||||
logging: agentConfig.features?.logging ?? yamlConfig.features?.logging,
|
||||
cronStorePath,
|
||||
skills: {
|
||||
cronEnabled: agentConfig.features?.cron ?? globalConfig.cronEnabled,
|
||||
@@ -530,11 +550,17 @@ async function main() {
|
||||
const apiPort = parseInt(process.env.PORT || '8080', 10);
|
||||
const apiHost = process.env.API_HOST || (isContainerDeploy ? '0.0.0.0' : undefined); // Container platforms need 0.0.0.0 for health checks
|
||||
const apiCorsOrigin = process.env.API_CORS_ORIGIN; // undefined = same-origin only
|
||||
const turnLogFiles: Record<string, string> = {};
|
||||
for (const a of agents) {
|
||||
const logging = a.features?.logging ?? yamlConfig.features?.logging;
|
||||
if (logging?.turnLogFile) turnLogFiles[a.name] = logging.turnLogFile;
|
||||
}
|
||||
const apiServer = createApiServer(gateway, {
|
||||
port: apiPort,
|
||||
apiKey: apiKey,
|
||||
host: apiHost,
|
||||
corsOrigin: apiCorsOrigin,
|
||||
turnLogFiles: Object.keys(turnLogFiles).length > 0 ? turnLogFiles : undefined,
|
||||
stores: agentStores,
|
||||
agentChannels: agentChannelMap,
|
||||
sessionInvalidators,
|
||||
|
||||
Reference in New Issue
Block a user