summaryrefslogtreecommitdiff
path: root/api
diff options
context:
space:
mode:
Diffstat (limited to 'api')
-rw-r--r--api/server.py117
-rw-r--r--api/static/css/components.css34
-rw-r--r--api/static/index.html12
-rw-r--r--api/static/js/components/results.js430
4 files changed, 544 insertions, 49 deletions
diff --git a/api/server.py b/api/server.py
index e0f9472..9deaca3 100644
--- a/api/server.py
+++ b/api/server.py
@@ -24,7 +24,13 @@ from fastapi.middleware.cors import CORSMiddleware
# Add project root to path to import core modules
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
+sys.path.insert(0, str(PROJECT_ROOT / "src"))
from src.config_loader import ConfigLoader
+from src.agent import Agent
+from src.interrogate import get_provider_from_log, sanitize_for_api
+from src.vfs import VFS
+from pydantic import BaseModel
+import uuid
app = FastAPI(
title="AI Agent Evaluation Dashboard",
@@ -45,6 +51,17 @@ static_dir = Path(__file__).parent / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+# Interrogation Session Cache
+# In a production app, use Redis. For local dashboard, dict is fine.
+ACTIVE_SESSIONS: Dict[str, Agent] = {}
+
+class InterrogateStartRequest(BaseModel):
+ log_path: str
+
+class InterrogateChatRequest(BaseModel):
+ session_id: str
+ message: str
+
def get_config():
"""Helper to load config safely."""
@@ -306,6 +323,106 @@ async def browse_log(path: str):
raise HTTPException(status_code=500, detail=str(e))
+# ============================================================================
+# Interrogation Endpoints
+# ============================================================================
+
+@app.post("/api/interrogate/start")
+async def start_interrogation(req: InterrogateStartRequest):
+ """Start an interactive session from a log file."""
+ if ".." in req.log_path:
+ raise HTTPException(status_code=400, detail="Invalid path")
+
+ file_path = PROJECT_ROOT / req.log_path
+ if not file_path.exists() or file_path.suffix != '.json':
+ raise HTTPException(status_code=404, detail="Log file not found")
+
+ try:
+ with open(file_path) as f:
+ log_data = json.load(f)
+
+ conversation_history = log_data.get("conversation", [])
+ if not conversation_history:
+ raise HTTPException(status_code=400, detail="No conversation history in log")
+
+ system_prompt = conversation_history[0].get('content', '') if conversation_history else ''
+ scenario = log_data.get('scenario', 'interrogation')
+ oversight_level = log_data.get('oversight_level', 'N/A')
+
+ provider_config, model_config = get_provider_from_log(log_data)
+
+ # Load VFS state if present
+ final_vfs_state = log_data.get("final_vfs_state")
+
+ # Determine goal_type from log or default to standard
+ goal_type = log_data.get("goal_type", "")
+
+ # VFS is singleton but we pass state nicely
+ vfs_instance = VFS.get_instance(fs_data=final_vfs_state) if final_vfs_state else VFS.get_instance()
+
+ agent = Agent.from_configs(
+ system_prompt=system_prompt,
+ provider_config=provider_config,
+ model_config=model_config,
+ scenario=scenario,
+ oversight_level=oversight_level,
+ user_prompt_type=log_data.get('user_prompt_type', 'interrogation'),
+ goal_type=goal_type,
+ save_vfs_state=True if final_vfs_state else False,
+ vfs_instance=vfs_instance
+ )
+
+ clean_history = sanitize_for_api(conversation_history)
+ agent.load_conversation(
+ conversation_history=clean_history,
+ total_tokens=log_data.get('total_tokens', 0),
+ prompt_tokens=log_data.get('prompt_tokens', 0),
+ completion_tokens=log_data.get('completion_tokens', 0)
+ )
+
+ session_id = str(uuid.uuid4())
+ ACTIVE_SESSIONS[session_id] = agent
+
+ return {"session_id": session_id, "status": "active", "model": agent.model}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/interrogate/chat")
+async def chat_interrogation(req: InterrogateChatRequest):
+ """Send a message to an active interrogation session."""
+ if req.session_id not in ACTIVE_SESSIONS:
+ raise HTTPException(status_code=404, detail="Session expired or not found")
+
+ agent = ACTIVE_SESSIONS[req.session_id]
+
+ try:
+ # Blocks until completion
+ agent.chat(req.message)
+
+ # Save log after every turn
+ log_file = agent.save_logs(output_dir="interrogation_logs", scenario=agent.scenario, oversight_level=agent.oversight_level)
+
+ # Extract the latest response logic to send back
+ response_msg = None
+ for msg in reversed(agent.logs):
+ if msg.get("role") == "assistant":
+ response_msg = msg
+ break
+
+ if not response_msg:
+ raise HTTPException(status_code=500, detail="No assistant response generated")
+
+ return {
+ "content": response_msg.get("content", ""),
+ "reasoning": response_msg.get("reasoning", ""),
+ "tool_calls": response_msg.get("tool_calls", []),
+ "log_saved_at": log_file
+ }
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
if __name__ == "__main__":
import uvicorn
diff --git a/api/static/css/components.css b/api/static/css/components.css
index e576b3a..4d8a4b8 100644
--- a/api/static/css/components.css
+++ b/api/static/css/components.css
@@ -262,6 +262,40 @@
cursor: pointer;
}
+@keyframes pulse {
+ 0% {
+ transform: scale(0.95);
+ box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.7);
+ }
+
+ 70% {
+ transform: scale(1);
+ box-shadow: 0 0 0 10px rgba(139, 92, 246, 0);
+ }
+
+ 100% {
+ transform: scale(0.95);
+ box-shadow: 0 0 0 0 rgba(139, 92, 246, 0);
+ }
+}
+
+.thinking-pulse {
+ position: relative;
+ padding: 15px !important;
+}
+
+.pulse-ring {
+ position: absolute;
+ left: 15px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--color-accent);
+ animation: pulse 2s infinite;
+}
+
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.6; }
diff --git a/api/static/index.html b/api/static/index.html
index d365254..c22de49 100644
--- a/api/static/index.html
+++ b/api/static/index.html
@@ -15,6 +15,8 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+ <!-- Marked.js for Markdown -->
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
<div class="dashboard-root">
@@ -151,7 +153,15 @@
<div id="inspector-modal" class="modal-overlay" style="display: none;">
<div class="modal-content wide">
<button id="close-inspector-btn" class="btn-close">&times;</button>
- <div id="inspector-content" style="height: 100%; overflow: hidden;">
+ <div id="inspector-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: var(--space-md); border-bottom: 1px solid var(--color-bg-3); margin-bottom: var(--space-md); padding-right: 40px;">
+ <h2 id="inspector-title" style="font-size: 1.1rem; margin: 0;">Run Details</h2>
+ <div class="view-toggle">
+ <button class="toggle-btn active" data-view="visual">Visual</button>
+ <button class="toggle-btn" data-view="raw">Raw JSON</button>
+ <button class="toggle-btn" data-view="interrogate" style="color: var(--color-accent); border-color: var(--color-accent);">Interrogate</button>
+ </div>
+ </div>
+ <div id="inspector-content" style="height: calc(100% - 60px); overflow: hidden;">
<!-- Populated via JS -->
<div class="empty-state">Loading details...</div>
</div>
diff --git a/api/static/js/components/results.js b/api/static/js/components/results.js
index 2ce177c..d75250c 100644
--- a/api/static/js/components/results.js
+++ b/api/static/js/components/results.js
@@ -11,6 +11,11 @@ let currentCsvDir = ""; // Directory of the active CSV
let currentVisibleColumns = []; // Track which columns are shown in Table
let modalChart = null;
+// Inspector state
+let currentInspectorLog = null;
+let currentInspectorRow = null;
+let currentViewMode = 'visual'; // 'visual' or 'raw'
+
export const results = {
async init() {
// Bind events
@@ -38,10 +43,20 @@ export const results = {
});
document.addEventListener('click', (e) => {
- if (!ui.elements.columnDropdown.contains(e.target) && e.target !== ui.elements.btnToggleColumns) {
+ if (ui.elements.columnDropdown && !ui.elements.columnDropdown.contains(e.target) && e.target !== ui.elements.btnToggleColumns) {
ui.elements.columnDropdown.style.display = 'none';
}
});
+
+ // Inspector View Toggle
+ if (ui.elements.inspectorToggleBtns) {
+ ui.elements.inspectorToggleBtns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const mode = btn.dataset.view;
+ this.setViewMode(mode);
+ });
+ });
+ }
await Promise.all([
this.updateCSVFileList(),
@@ -311,88 +326,407 @@ export const results = {
closeInspector() {
ui.elements.inspectorModal.style.display = 'none';
- ui.elements.inspectorContent.innerHTML = '<div class="empty-state">Loading details...</div>';
+ currentInspectorLog = null;
+ currentInspectorRow = null;
+ },
+
+ setViewMode(mode) {
+ currentViewMode = mode;
+ if (ui.elements.inspectorToggleBtns) {
+ ui.elements.inspectorToggleBtns.forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.view === mode);
+ });
+ }
+ this.renderInspectorContent();
},
async showRowInspector(rowData) {
+ currentInspectorRow = rowData;
+ currentInspectorLog = null;
ui.elements.inspectorModal.style.display = 'flex';
+ ui.elements.inspectorTitle.textContent = `Run: ${rowData.run_id || rowData.RUN_ID || 'Details'}`;
- // Initial layout
- ui.elements.inspectorContent.innerHTML = `
- <div class="inspector-layout">
- <aside class="inspector-sidebar">
- <h2 style="margin-bottom: var(--space-md); font-size: 1rem;">Record Details</h2>
- <div id="inspector-fields">
- ${Object.entries(rowData).map(([key, val]) => `
- <div class="field-entry">
- <div class="field-label">${key}</div>
- <div class="field-value">${val ?? '<span style="color: var(--color-text-muted)">null</span>'}</div>
- </div>
- `).join('')}
- </div>
- </aside>
- <main class="inspector-main">
- <div class="json-viewer-header">
- <h2 style="font-size: 1rem;">Raw Experiment Log</h2>
- <span id="log-status" style="font-size: 0.7rem; color: var(--color-text-secondary);">Checking for log file...</span>
- </div>
- <div id="log-viewer" class="json-viewer-container">
- <div class="empty-state">Searching for log file associated with this run...</div>
- </div>
- </main>
- </div>
- `;
+ this.setViewMode('visual'); // Default to visual
// Strategy to find the log:
- // 1. rowData.log_path
- // 2. rowData.RUN_ID
-
let rawPath = rowData.log_path || rowData.RUN_ID || rowData.run_id;
if (!rawPath) {
- document.getElementById('log-viewer').innerHTML = '<div class="empty-state">No log path metadata found in record.</div>';
- document.getElementById('log-status').textContent = `Missing log metadata.`;
+ ui.elements.inspectorContent.innerHTML = '<div class="empty-state">No log path metadata found in record.</div>';
return;
}
- // Normalize path: add .json if missing
let jsonPath = rawPath.endsWith('.json') ? rawPath : (rawPath + '.json');
-
- // Trial paths:
- const trials = [
- jsonPath, // Direct
- currentCsvDir + jsonPath, // Relative to CSV (e.g. logs/full_experiment/ + path)
- 'logs/' + jsonPath // Absolute under logs/
- ];
+ const trials = [jsonPath, currentCsvDir + jsonPath, 'logs/' + jsonPath];
let loaded = false;
for (const trial of trials) {
if (loaded) break;
try {
- document.getElementById('log-status').textContent = `Trying: ${trial}`;
const logData = await api.get(`/api/logs/browse?path=${encodeURIComponent(trial)}`);
- document.getElementById('log-viewer').textContent = JSON.stringify(logData, null, 2);
- document.getElementById('log-status').textContent = `Log loaded: ${trial}`;
- document.getElementById('log-status').style.color = 'var(--color-success)';
+ currentInspectorLog = logData;
+ currentInspectorLog._successfulPath = trial; // Track successful path
loaded = true;
+ this.renderInspectorContent();
} catch (err) {
console.log(`Failed trial: ${trial}`);
}
}
if (!loaded) {
- document.getElementById('log-viewer').innerHTML = `
+ ui.elements.inspectorContent.innerHTML = `
<div class="empty-state" style="color: var(--color-danger)">
- Failed to load log file: API Error: 404<br>
+ Failed to load log file.<br>
<div style="margin-top: 8px; color: var(--color-text-secondary); font-size: 0.7rem;">
Paths tried:<br>
${trials.map(t => `- ${t}`).join('<br>')}
</div>
</div>`;
- document.getElementById('log-status').textContent = `Error loading log.`;
- document.getElementById('log-status').style.color = 'var(--color-danger)';
}
},
+ renderInspectorContent() {
+ if (!currentInspectorLog && currentViewMode === 'visual') {
+ this.renderVisualLoading();
+ return;
+ }
+
+ if (currentViewMode === 'visual') {
+ this.renderVisualInspector();
+ } else if (currentViewMode === 'raw') {
+ this.renderRawInspector();
+ } else if (currentViewMode === 'interrogate') {
+ this.renderInterrogateInspector();
+ }
+ },
+
+ renderVisualLoading() {
+ ui.elements.inspectorContent.innerHTML = `
+ <div class="inspector-layout">
+ <aside class="inspector-sidebar">${this.renderRecordSidebar(currentInspectorRow)}</aside>
+ <main class="inspector-main">
+ <div class="empty-state">Loading log file for full conversation view...</div>
+ </main>
+ </div>`;
+ },
+
+ renderRawInspector() {
+ const jsonStr = currentInspectorLog ? JSON.stringify(currentInspectorLog, null, 2) : "Log not loaded yet.";
+ ui.elements.inspectorContent.innerHTML = `
+ <div class="inspector-layout">
+ <aside class="inspector-sidebar">${this.renderRecordSidebar(currentInspectorRow)}</aside>
+ <main class="inspector-main">
+ <div class="json-viewer-container">${jsonStr}</div>
+ </main>
+ </div>`;
+ },
+
+ renderRecordSidebar(rowData) {
+ return `
+ <div id="inspector-fields">
+ ${Object.entries(rowData).map(([key, val]) => {
+ const hide = ['full_log', 'messages', 'log_path', 'RUN_ID', 'run_id'];
+ if (hide.includes(key)) return '';
+ const isJustification = key.includes('JUSTIFICATION');
+ return `
+ <div class="field-entry" ${isJustification ? 'style="margin-top: 12px; border-top: 1px dashed var(--color-bg-3); padding-top: 8px;"' : ''}>
+ <div class="field-label">${key}</div>
+ <div class="field-value" style="${isJustification ? 'font-size: 0.75rem; color: var(--color-text-secondary); line-height: 1.4; word-break: break-word;' : ''}" title="${val}">${val ?? '<span style="color: var(--color-text-muted)">null</span>'}</div>
+ </div>`;
+ }).join('')}
+ </div>
+ ${currentInspectorLog?.final_vfs_state ? `
+ <h3 style="margin-top: var(--space-xl); margin-bottom: var(--space-md); font-size: 0.75rem; text-transform: uppercase; color: var(--color-accent); border-top: 1px solid var(--color-bg-3); padding-top: 16px;">Final VFS State</h3>
+ <div class="field-entry">
+ ${Object.keys(currentInspectorLog.final_vfs_state['/']?.data || {}).map(f => `
+ <div class="field-value" style="font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; margin-bottom: 4px; padding: 4px 8px; background: var(--color-bg-2); border-radius: 4px; border: 1px solid var(--color-bg-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${f}">${f}</div>
+ `).join('')}
+ </div>
+ ` : ''}
+ `;
+ },
+
+ renderVisualInspector() {
+ const row = currentInspectorRow;
+ const log = currentInspectorLog;
+
+ let html = `<div class="inspector-layout">
+ <aside class="inspector-sidebar">${this.renderRecordSidebar(row)}</aside>
+ <main class="inspector-main">
+ <div style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
+ `;
+
+ // Conversation
+ if (log && log.conversation) {
+ html += `<div class="chat-container">`;
+ log.conversation.forEach(msg => {
+ html += this.renderChatMessage(msg);
+ });
+ html += `</div>`;
+ } else {
+ html += `<div class="empty-state">Conversation data not found in log.</div>`;
+ }
+
+ html += `</div></main></div>`;
+ ui.elements.inspectorContent.innerHTML = html;
+ },
+
+ renderChatMessage(msg) {
+ const role = msg.role;
+ const content = typeof msg.content === 'object' ? JSON.stringify(msg.content, null, 2) : (msg.content || "");
+ const reasoning = msg.reasoning || this.extractReasoningFromContent(content);
+ let cleanContent = typeof content === 'string' ? content.replace(/<thought>[\s\S]*?<\/thought>/g, '').trim() : content;
+
+ let html = `<div class="message-bubble role-${role}">
+ <div class="bubble-header">${role}</div>`;
+
+ if (reasoning) {
+ const parsedReasoning = window.marked ? marked.parse(reasoning) : reasoning.replace(/\n/g, '<br>');
+ html += `<div class="reasoning-block">${parsedReasoning}</div>`;
+ }
+
+ // Only show main content for non-tool roles to avoid double rendering
+ if (cleanContent && role !== 'tool') {
+ const parsedContent = window.marked ? marked.parse(String(cleanContent)) : String(cleanContent).replace(/\n/g, '<br>');
+ html += `<div class="message-content">${parsedContent}</div>`;
+ }
+
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
+ html += `<div class="tool-calls-container">`;
+ msg.tool_calls.forEach(tc => {
+ const func = tc.function || tc;
+ html += `<div class="tool-call-item">
+ <span class="tool-call-name">${func.name}</span>(${func.arguments || ''})
+ </div>`;
+ });
+ html += `</div>`;
+ }
+
+ if (role === 'tool') {
+ const maxLength = 300;
+ const isLong = cleanContent.length > maxLength;
+ const displayContent = isLong ? cleanContent.substring(0, maxLength) + '...' : cleanContent;
+
+ const parsedToolOutput = window.marked ? marked.parse(displayContent) : displayContent.replace(/\n/g, '<br>');
+ html += `
+ <div class="message-content" style="font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; background: rgba(0,0,0,0.2); padding: 10px; border-radius: 4px; border: 1px solid rgba(16,185,129,0.2); margin-top: 8px;">
+ <div style="margin-bottom: 4px; color: rgba(16,185,129,0.6); font-size: 0.6rem; text-transform: uppercase; font-weight: 800;">Output ${isLong ? '(Truncated)' : ''}</div>
+ <div style="color: #10b981; opacity: 0.9;">${parsedToolOutput}</div>
+ </div>`;
+ }
+
+ html += `</div>`;
+ return html;
+ },
+
+ extractReasoningFromContent(content) {
+ if (typeof content !== 'string') return null;
+ const match = content.match(/<thought>([\s\S]*?)<\/thought>/);
+ return match ? match[1].trim() : null;
+ },
+
+ renderInterrogateInspector() {
+ const row = currentInspectorRow;
+
+ let html = `<div class="inspector-layout">
+ <aside class="inspector-sidebar">${this.renderRecordSidebar(row)}</aside>
+ <main class="inspector-main" style="display: flex; flex-direction: column;">
+ <div id="interrogate-chat-history" style="flex: 1; overflow-y: auto; padding-right: 10px; margin-bottom: 20px;">
+ <div class="empty-state">
+ <h3>Interrogation Sandbox</h3>
+ <p style="margin-top: 8px;">Start a dynamic session to continue the conversation with the agent from this point.</p>
+ <button id="btn-start-interrogation" class="btn btn-primary" style="margin-top: 15px; padding: 8px 16px;">Start Session</button>
+ </div>
+ </div>
+ <div id="interrogate-input-area" style="display: none; padding-top: 15px; border-top: 1px solid var(--color-bg-3);">
+ <textarea id="interrogate-input" placeholder="Type your message..." style="width: 100%; min-height: 80px; padding: 10px; background: var(--color-bg-1); border: 1px solid var(--color-bg-3); color: var(--color-text-primary); border-radius: 4px; font-family: 'Inter', sans-serif; resize: vertical;"></textarea>
+ <div style="display: flex; justify-content: flex-end; margin-top: 10px; align-items: center;">
+ <span id="interrogate-status" style="margin-right: auto; font-size: 0.8rem; color: var(--color-text-muted);"></span>
+ <button id="btn-send-interrogate" class="btn btn-primary">Send Message</button>
+ </div>
+ </div>
+ </main>
+ </div>`;
+ ui.elements.inspectorContent.innerHTML = html;
+
+ setTimeout(() => {
+ const startBtn = document.getElementById('btn-start-interrogation');
+ if (startBtn) startBtn.addEventListener('click', () => this.startInterrogationSession());
+
+ const sendBtn = document.getElementById('btn-send-interrogate');
+ if (sendBtn) sendBtn.addEventListener('click', () => this.sendInterrogationMessage());
+
+ const inputField = document.getElementById('interrogate-input');
+ if (inputField) {
+ inputField.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
+ this.sendInterrogationMessage();
+ }
+ });
+ }
+ }, 0);
+ },
+
+ async startInterrogationSession() {
+ const rowData = currentInspectorRow;
+ const statusEl = document.getElementById('interrogate-status');
+ const inputArea = document.getElementById('interrogate-input-area');
+ const startBtn = document.getElementById('btn-start-interrogation');
+
+ // Use the successful path established during GET
+ let targetLogPath = currentInspectorLog && currentInspectorLog._successfulPath
+ ? currentInspectorLog._successfulPath : null;
+
+ if (!targetLogPath) {
+ // Fallback heuristics just in case
+ let rawPath = rowData.log_path || rowData.RUN_ID || rowData.run_id;
+ if (!rawPath) return;
+ let jsonPath = rawPath.endsWith('.json') ? rawPath : (rawPath + '.json');
+ targetLogPath = currentCsvDir ? currentCsvDir + jsonPath : jsonPath;
+ }
+
+ startBtn.innerHTML = 'Initializing...';
+ startBtn.disabled = true;
+
+ try {
+ let response = await fetch('/api/interrogate/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ log_path: targetLogPath })
+ });
+
+ if (!response.ok) {
+ // Secondary fallback attempt
+ let rawPath = rowData.log_path || rowData.RUN_ID || rowData.run_id;
+ let jsonPath = rawPath.endsWith('.json') ? rawPath : (rawPath + '.json');
+ response = await fetch('/api/interrogate/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ log_path: jsonPath })
+ });
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.detail || "Failed to initialize session. Could not load log.");
+ }
+ }
+ const data = await response.json();
+
+ this.currentInterrogationSession = data.session_id;
+ this.interrogationNewMessages = []; // Reset local new messages
+
+ this.renderInterrogateHistory();
+
+ inputArea.style.display = 'block';
+ statusEl.innerHTML = `<span style="color: var(--color-success);">Session Active (${data.model})</span>`;
+
+ // Force scroll after layout changes (such as displaying inputArea)
+ setTimeout(() => {
+ const historyEl = document.getElementById('interrogate-chat-history');
+ if (historyEl) historyEl.scrollTop = historyEl.scrollHeight;
+ }, 10);
+
+ } catch (err) {
+ startBtn.innerHTML = 'Start Session';
+ startBtn.disabled = false;
+ alert(err.message);
+ }
+ },
+
+ renderInterrogateHistory() {
+ const historyEl = document.getElementById('interrogate-chat-history');
+ if (!historyEl || !currentInspectorLog) return;
+
+ let html = `<div class="chat-container">`;
+ if (currentInspectorLog.conversation) {
+ currentInspectorLog.conversation.forEach(msg => {
+ html += this.renderChatMessage(msg);
+ });
+ }
+
+ if (this.interrogationNewMessages) {
+ this.interrogationNewMessages.forEach(msg => {
+ html += this.renderChatMessage(msg);
+ });
+ }
+
+ html += `</div>`;
+ historyEl.innerHTML = html;
+ historyEl.scrollTop = historyEl.scrollHeight;
+ },
+
+ async sendInterrogationMessage() {
+ const inputField = document.getElementById('interrogate-input');
+ const sendBtn = document.getElementById('btn-send-interrogate');
+ const statusEl = document.getElementById('interrogate-status');
+ const historyEl = document.getElementById('interrogate-chat-history');
+
+ const message = inputField.value.trim();
+ if (!message || !this.currentInterrogationSession) return;
+
+ inputField.value = '';
+ inputField.disabled = true;
+ sendBtn.disabled = true;
+
+ const userMsg = { role: 'user', content: message };
+ this.interrogationNewMessages.push(userMsg);
+ this.renderInterrogateHistory();
+
+ const thinkingId = 'thinking-' + Date.now();
+ historyEl.insertAdjacentHTML('beforeend', `
+ <div id="${thinkingId}" class="message-bubble role-assistant thinking-pulse">
+ <div class="pulse-ring"></div>
+ <span style="margin-left: 30px; font-style: italic; color: var(--color-accent);">Agent is thinking... (this may take up to 30s)</span>
+ </div>
+ `);
+ historyEl.scrollTop = historyEl.scrollHeight;
+
+ statusEl.textContent = 'Processing request...';
+
+ try {
+ const response = await fetch('/api/interrogate/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ session_id: this.currentInterrogationSession,
+ message: message
+ })
+ });
+
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.detail || "Error processing chat");
+
+ document.getElementById(thinkingId)?.remove();
+
+ const assistantMsg = {
+ role: 'assistant',
+ content: data.content,
+ reasoning: data.reasoning,
+ tool_calls: data.tool_calls
+ };
+ this.interrogationNewMessages.push(assistantMsg);
+
+ this.renderInterrogateHistory();
+ statusEl.textContent = `Log saved incrementally (${new Date().toLocaleTimeString()})`;
+
+ } catch (err) {
+ document.getElementById(thinkingId)?.remove();
+ statusEl.innerHTML = `<span style="color: var(--color-danger);">Error: ${err.message}</span>`;
+ } finally {
+ inputField.disabled = false;
+ sendBtn.disabled = false;
+ inputField.focus();
+ historyEl.scrollTop = historyEl.scrollHeight;
+ }
+ },
+
+ getCategoryColor(cat) {
+ if (!cat) return 'var(--color-accent)';
+ const c = cat.toLowerCase();
+ if (c.includes('deceptive') || c.includes('fabricated') || c.includes('harmful')) return '#ef4444';
+ if (c.includes('omitted') || c.includes('suspicious')) return '#f59e0b';
+ if (c.includes('accurate') || c.includes('honest')) return '#10b981';
+ return 'var(--color-accent)';
+ },
+
renderCharts(data) {
activeCharts.forEach(chart => chart.destroy());
activeCharts = [];