recon + sqlglot validator + drill_down package; guard ReAct dimension picks against candidate list

This commit is contained in:
2026-06-03 07:15:02 -03:00
parent e124a8a7d9
commit 2dad62f7e7
38 changed files with 1954 additions and 596 deletions

View File

@@ -1,5 +1,4 @@
import { ref, onUnmounted, computed } from 'vue'
import type { LogEntry } from 'soleprint-ui'
import { computed, onUnmounted, ref } from 'vue'
import { apiUrl } from '../config'
/* Mirror of the backend factories in api/runtime/events.py */
@@ -9,26 +8,41 @@ export interface PlanStep {
analysis: string
args: Record<string, any>
why: string
fallback?: PlanStep | null
}
export type NodeKind = 'plan' | 'analysis' | 'fallback' | 'synthesize'
export interface GraphNode {
id: string // e.g. "plan", "direct_answer", "synthesize"
kind: 'plan' | 'analysis' | 'synthesize'
/** Stable id used for scroll-into-view + bidirectional links */
id: string
kind: NodeKind
label: string
status: 'pending' | 'running' | 'done' | 'error'
status: 'pending' | 'running' | 'done' | 'error' | 'skipped'
detail?: string
/** For fallback nodes: id of the primary they back. */
fallbackFor?: string
}
export interface ToolCall {
/** owning Analysis node id (e.g. "direct_answer#0"); null for run-level calls */
analysisId: string | null
tool: string
input?: any
output?: any
error?: string | null
/** ms since the run started */
t_started: number
t_ended?: number
}
export interface LogEntry {
level: string
stage: string
msg: string
ts: string
}
export function useRunStream() {
const status = ref<RunStatus>('idle')
const runId = ref<string | null>(null)
@@ -80,6 +94,19 @@ export function useRunStream() {
}
}
/** Sub-events (tool calls + LLM calls) grouped by their owning Analysis
* step_id. UI uses this to render the nested sub-graph in each Analysis
* expansion. */
const subEventsByAnalysis = computed(() => {
const out: Record<string, ToolCall[]> = {}
for (const c of toolCalls.value) {
const key = c.analysisId ?? '__root__'
if (!out[key]) out[key] = []
out[key]!.push(c)
}
return out
})
async function ask(q: string): Promise<string | null> {
reset()
status.value = 'connecting'
@@ -140,36 +167,61 @@ export function useRunStream() {
rationale.value = d.rationale
planSteps.value = d.steps
// Replace placeholder analysis nodes with real ones from the plan.
const planNode = nodeBy('plan')!
planNode.status = 'done'
planNode.detail = d.rationale
const synth = graphNodes.value.find(n => n.id === 'synthesize')
graphNodes.value = [
planNode,
...d.steps.map((s, i): GraphNode => ({
id: `${s.analysis}#${i}`,
// Build the chain: plan → step[0] (and its fallback) → step[1] → ... → synth.
const synth: GraphNode = nodeBy('synthesize')
|| { id: 'synthesize', kind: 'synthesize', label: 'Synthesize', status: 'pending' }
const nodes: GraphNode[] = [planNode]
d.steps.forEach((s, i) => {
const primaryId = `${s.analysis}#${i}`
nodes.push({
id: primaryId,
kind: 'analysis',
label: s.analysis,
status: 'pending',
detail: s.why,
})),
synth || { id: 'synthesize', kind: 'synthesize', label: 'Synthesize', status: 'pending' },
]
push('info', 'plan', `${d.steps.length} step(s): ${d.steps.map(s => s.analysis).join(' → ')}`)
})
if (s.fallback) {
nodes.push({
id: `${s.fallback.analysis}#${i}.fallback`,
kind: 'fallback',
label: s.fallback.analysis,
status: 'skipped', // dimmed until proven needed
detail: `fallback for ${s.analysis}: ${s.fallback.why}`,
fallbackFor: primaryId,
})
}
})
nodes.push(synth)
graphNodes.value = nodes
push('info', 'plan', `${d.steps.length} step(s): ${d.steps.map(s =>
s.fallback ? `${s.analysis} (fallback: ${s.fallback.analysis})` : s.analysis,
).join(' → ')}`)
})
es.addEventListener('analysis_start', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { analysis: string; args: any; why: string }
// Mark the first matching pending analysis node as running.
const n = graphNodes.value.find(g => g.kind === 'analysis' && g.label === d.analysis && g.status === 'pending')
if (n) n.status = 'running'
const d = JSON.parse(e.data) as { analysis: string; step_id: string; args: any; why: string }
// Prefer the step_id-based match (works for both primary and fallback).
const n = nodeBy(d.step_id) || graphNodes.value.find(
g => (g.kind === 'analysis' || g.kind === 'fallback') && g.label === d.analysis && g.status === 'pending'
)
if (n) {
n.id = d.step_id // ensure id matches step_id for bidirectional linking
n.status = 'running'
}
push('info', d.analysis, `start: ${d.why || JSON.stringify(d.args)}`)
})
es.addEventListener('analysis_end', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { analysis: string; summary: string; error: string | null; row_count: number }
const n = graphNodes.value.find(g => g.kind === 'analysis' && g.label === d.analysis && g.status === 'running')
const d = JSON.parse(e.data) as {
analysis: string; step_id: string; summary: string; error: string | null; row_count: number
}
const n = nodeBy(d.step_id)
if (n) {
n.status = d.error ? 'error' : 'done'
n.detail = d.error || d.summary
@@ -179,9 +231,26 @@ export function useRunStream() {
push(level, d.analysis, msg)
})
es.addEventListener('analysis_fallback', (e: MessageEvent) => {
const d = JSON.parse(e.data) as {
primary: string; fallback: string; reason: string; step_id: string
}
const fbNode = nodeBy(d.step_id)
if (fbNode) {
fbNode.status = 'running' // promoted from 'skipped'
fbNode.detail = `fallback fired — ${d.reason}`
}
push('warn', 'fallback', `${d.primary}${d.fallback}: ${d.reason}`)
})
es.addEventListener('tool_call_start', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { tool: string; input: any }
toolCalls.value.push({ tool: d.tool, input: d.input, t_started: elapsed() })
const d = JSON.parse(e.data) as { tool: string; analysis: string | null; input: any }
toolCalls.value.push({
tool: d.tool,
analysisId: d.analysis,
input: d.input,
t_started: elapsed(),
})
const detail = d.tool === 'execute_sql' ? (d.input?.sql || '') :
d.tool === 'text_to_sql' ? (d.input?.question || '') :
JSON.stringify(d.input)
@@ -189,8 +258,10 @@ export function useRunStream() {
})
es.addEventListener('tool_call_end', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { tool: string; output: any; error: string | null }
const call = [...toolCalls.value].reverse().find(c => c.tool === d.tool && c.t_ended === undefined)
const d = JSON.parse(e.data) as { tool: string; analysis: string | null; output: any; error: string | null }
const call = [...toolCalls.value].reverse().find(
c => c.tool === d.tool && c.analysisId === d.analysis && c.t_ended === undefined,
)
if (call) {
call.output = d.output
call.error = d.error
@@ -203,7 +274,8 @@ export function useRunStream() {
if (d.tool === 'text_to_sql' && d.output?.sql) {
push('info', d.tool, `${d.output.sql}`)
} else if (d.tool === 'execute_sql') {
const label = d.output?.label ? `${d.output.label}: ` : ''
const label = d.output?.dimension ? `${d.output.dimension}: ` :
d.output?.label ? `${d.output.label}: ` : ''
push('info', d.tool, `${label}${d.output?.row_count} rows`)
} else if (d.tool === 'generate_pair') {
push('info', d.tool, `✓ a=${d.output?.a?.label} · b=${d.output?.b?.label}`)
@@ -213,7 +285,7 @@ export function useRunStream() {
})
es.addEventListener('llm_call', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { label: string; system_chars: number; user_chars: number }
const d = JSON.parse(e.data) as { label: string; analysis: string | null; system_chars: number; user_chars: number }
push('debug', d.label, `llm ← sys:${d.system_chars} usr:${d.user_chars}`)
})
@@ -241,8 +313,6 @@ export function useRunStream() {
})
es.addEventListener('error', (e: MessageEvent) => {
// The browser also fires a generic `error` event on disconnect; only
// surface it when the run hasn't already ended.
if (status.value === 'streaming') {
status.value = 'error'
const msg = e?.data ? JSON.parse(e.data)?.message : 'stream error'
@@ -261,7 +331,7 @@ export function useRunStream() {
return {
status, runId, question, rationale, planSteps, graphNodes,
log, toolCalls, answer, error, isRunning,
log, toolCalls, subEventsByAnalysis, answer, error, isRunning,
ask, disconnect,
}
}

View File

@@ -1,19 +1,19 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { Panel } from 'soleprint-ui'
import { useRunStream } from '../composables/useRunStream'
import { useRunStream, type GraphNode, type ToolCall } from '../composables/useRunStream'
const question = ref('How many loans are currently in debt (status D)?')
const examples = [
'How many loans are currently in debt (status D)?',
'Which districts have the highest average loan amount?',
'Compare loan default rates between 1995 and 1996.',
'What share of accounts have at least one credit card?',
'Which dimensions best explain regional differences in loan default rates?',
"What's driving variation in average loan size across districts?",
]
const {
status, runId, rationale, planSteps, graphNodes, log,
toolCalls, answer, error, isRunning, ask,
status, runId, rationale, graphNodes, log,
toolCalls, subEventsByAnalysis, answer, error, isRunning, ask,
} = useRunStream()
const wrapLog = ref(true)
@@ -27,6 +27,11 @@ function toggleNode(id: string) {
expanded.value = s
}
// Trace pane grows to 2/3 when the run gets visually dense.
const traceDense = computed(() =>
graphNodes.value.length > 4 || toolCalls.value.length > 6,
)
watch(() => log.value.length, async () => {
await nextTick()
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
@@ -47,10 +52,63 @@ function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
if (s === 'error') return 'error'
return 'idle'
}
function focusNode(id: string | null) {
if (!id) return
if (!expanded.value.has(id)) toggleNode(id)
nextTick(() => {
const el = document.getElementById(`node-${id}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
el?.classList.add('flash')
setTimeout(() => el?.classList.remove('flash'), 1200)
})
}
function focusFirstTool(id: string) {
const first = toolCalls.value.findIndex(c => c.analysisId === id)
if (first < 0) return
nextTick(() => {
const el = document.getElementById(`tc-${first}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
el?.classList.add('flash')
setTimeout(() => el?.classList.remove('flash'), 1200)
})
}
function toolIndex(call: ToolCall): number {
return toolCalls.value.indexOf(call)
}
function subEvents(nodeId: string): ToolCall[] {
return subEventsByAnalysis.value[nodeId] || []
}
function shortPreview(call: ToolCall): string {
if (call.tool === 'text_to_sql' && call.output?.sql) return call.output.sql
if (call.tool === 'execute_sql' && call.input?.sql) return call.input.sql
if (call.tool === 'generate_pair' && call.output) {
return `A: ${call.output.a?.label}\n${call.output.a?.sql}\n\nB: ${call.output.b?.label}\n${call.output.b?.sql}`
}
return ''
}
function isExpandable(n: GraphNode): boolean {
return !!n.detail || subEvents(n.id).length > 0
}
function nodeClasses(n: GraphNode) {
return [
'node', n.kind, n.status,
{
expanded: expanded.value.has(n.id),
expandable: isExpandable(n),
},
]
}
</script>
<template>
<div class="ask-layout">
<div :class="['ask-layout', { dense: traceDense }]">
<!-- Left / top: ask + answer (the user's surface) -->
<div class="ask-pane">
<Panel title="Ask" :status="statusOf(status)">
@@ -87,25 +145,43 @@ function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
<div class="graph">
<div v-for="(n, idx) in graphNodes" :key="n.id" class="graph-row">
<span class="connector" v-if="idx > 0"></span>
<span v-if="idx > 0 && n.kind !== 'fallback'" class="connector"></span>
<span v-else-if="n.kind === 'fallback'" class="connector fallback-arrow"> fallback</span>
<div
:class="['node', n.kind, n.status, {
expanded: expanded.has(n.id),
expandable: !!n.detail,
}]"
@click="n.detail && toggleNode(n.id)"
:role="n.detail ? 'button' : undefined"
:tabindex="n.detail ? 0 : undefined"
@keydown.enter="n.detail && toggleNode(n.id)"
@keydown.space.prevent="n.detail && toggleNode(n.id)"
:id="`node-${n.id}`"
:class="nodeClasses(n)"
@click="isExpandable(n) && toggleNode(n.id)"
:role="isExpandable(n) ? 'button' : undefined"
:tabindex="isExpandable(n) ? 0 : undefined"
>
<div class="node-head">
<span class="chev" v-if="n.detail">{{ expanded.has(n.id) ? '' : '' }}</span>
<span v-if="isExpandable(n)" class="chev">
{{ expanded.has(n.id) ? '' : '' }}
</span>
<span class="dot" />
<span class="label">{{ n.label }}</span>
<span v-if="n.kind === 'analysis' && subEvents(n.id).length" class="step-count">
{{ subEvents(n.id).length }} call(s)
</span>
<span v-if="n.detail && !expanded.has(n.id)" class="detail-preview">{{ n.detail }}</span>
<button v-if="n.kind === 'analysis' && subEvents(n.id).length"
class="goto" @click.stop="focusFirstTool(n.id)"
title="Jump to tool call list"></button>
</div>
<div v-if="expanded.has(n.id)" class="node-body">
<div v-if="n.detail" class="detail-full">{{ n.detail }}</div>
<div v-if="subEvents(n.id).length" class="subgraph">
<div class="subgraph-label">tool calls</div>
<div v-for="c in subEvents(n.id)" :key="toolIndex(c)"
:class="['sub-node', c.error ? 'error' : (c.t_ended ? 'done' : 'running')]"
@click.stop="document.getElementById(`tc-${toolIndex(c)}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })">
<span class="sub-dot" />
<span class="sub-tool">{{ c.tool }}</span>
<span class="sub-time">{{ c.t_ended ? `${c.t_ended - c.t_started}ms` : '…' }}</span>
</div>
</div>
</div>
<div v-if="n.detail && expanded.has(n.id)" class="detail-full">{{ n.detail }}</div>
</div>
</div>
</div>
@@ -113,16 +189,19 @@ function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
<Panel title="Tool calls" class="tools-panel">
<div v-if="toolCalls.length === 0" class="muted small">No tool calls yet.</div>
<div v-for="(t, i) in toolCalls" :key="i" :class="['tool', t.error ? 'err' : (t.t_ended ? 'done' : 'pending')]">
<div v-for="(t, i) in toolCalls" :key="i"
:id="`tc-${i}`"
:class="['tool', t.error ? 'err' : (t.t_ended ? 'done' : 'pending')]">
<div class="tool-head">
<span class="tool-name">{{ t.tool }}</span>
<span v-if="t.analysisId" class="tool-chip"
@click="focusNode(t.analysisId)"
:title="`Jump to ${t.analysisId} node`">
{{ t.analysisId }}
</span>
<span class="tool-name">· {{ t.tool }}</span>
<span class="tool-time">{{ t.t_ended ? `${t.t_ended - t.t_started}ms` : '…' }}</span>
</div>
<pre v-if="t.tool === 'text_to_sql' && t.output?.sql" class="tool-body sql">{{ t.output.sql }}</pre>
<pre v-else-if="t.tool === 'execute_sql' && t.input?.sql" class="tool-body sql">{{ t.input.sql }}</pre>
<pre v-else-if="t.tool === 'generate_pair' && t.output" class="tool-body sql">A ({{ t.output.a?.label }}): {{ t.output.a?.sql }}
B ({{ t.output.b?.label }}): {{ t.output.b?.sql }}</pre>
<pre v-if="shortPreview(t)" class="tool-body sql">{{ shortPreview(t) }}</pre>
<pre v-if="t.output?.preview && t.output.preview.length" class="tool-body rows">{{ JSON.stringify(t.output.preview, null, 2) }}</pre>
<div v-if="t.error" class="tool-error">{{ t.error }}</div>
</div>
@@ -156,15 +235,13 @@ B ({{ t.output.b?.label }}): {{ t.output.b?.sql }}</pre>
gap: 16px;
height: calc(100vh - 80px);
min-height: 0;
transition: grid-template-columns 0.25s ease;
}
.ask-layout.dense { grid-template-columns: 1fr 2fr; }
.ask-pane,
.trace-pane {
display: flex;
flex-direction: column;
gap: 12px;
overflow: auto;
min-height: 0;
.ask-pane, .trace-pane {
display: flex; flex-direction: column; gap: 12px;
overflow: auto; min-height: 0;
}
.trace-pane {
@@ -172,161 +249,69 @@ B ({{ t.output.b?.label }}): {{ t.output.b?.sql }}</pre>
padding-left: 16px;
}
.trace-pane > .tools-panel {
flex: 1;
min-height: 0;
}
.trace-pane > .log-panel {
flex: 1;
min-height: 200px;
}
.trace-pane > .tools-panel { flex: 1; min-height: 0; }
.trace-pane > .log-panel { flex: 1; min-height: 200px; }
/* ── Ask form ── */
.ask-form {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ask-form { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
textarea {
width: 100%;
background: var(--surface-2);
color: var(--text-primary);
border: var(--panel-border);
padding: 10px;
font-family: var(--font-mono);
font-size: 13px;
resize: vertical;
width: 100%; background: var(--surface-2); color: var(--text-primary);
border: var(--panel-border); padding: 10px;
font-family: var(--font-mono); font-size: 13px; resize: vertical;
}
.ask-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
.ask-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.hint { font-family: var(--font-mono); font-size: 11px; color: var(--text-dim); }
.run-btn {
background: var(--accent);
color: white;
border: none;
padding: 6px 18px;
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
background: var(--accent); color: white; border: none;
padding: 6px 18px; font-family: var(--font-mono); font-size: 12px; cursor: pointer;
}
.run-btn:hover { background: var(--accent-dim); }
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.examples {
display: flex;
flex-direction: column;
gap: 4px;
}
.examples { display: flex; flex-direction: column; gap: 4px; }
.example {
text-align: left;
background: transparent;
border: none;
border-left: 2px solid var(--surface-3);
padding: 4px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
}
.example:hover {
color: var(--text-primary);
border-left-color: var(--accent);
text-align: left; background: transparent; border: none;
border-left: 2px solid var(--surface-3); padding: 4px 10px;
font-family: var(--font-mono); font-size: 11px;
color: var(--text-dim); cursor: pointer;
}
.example:hover { color: var(--text-primary); border-left-color: var(--accent); }
/* ── Answer block ── */
.answer-block {
padding: 16px;
font-size: 14px;
color: var(--text-primary);
line-height: 1.6;
}
.error-block {
padding: 16px;
color: var(--status-error, #f55);
font-family: var(--font-mono);
font-size: 12px;
}
.muted {
padding: 16px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
/* ── Answer ── */
.answer-block { padding: 16px; font-size: 14px; color: var(--text-primary); line-height: 1.6; }
.error-block { padding: 16px; color: var(--status-error, #f55); font-family: var(--font-mono); font-size: 12px; }
.muted { padding: 16px; color: var(--text-dim); font-family: var(--font-mono); font-size: 12px; }
.muted.small { padding: 8px 12px; font-size: 11px; }
/* ── Plan + graph ── */
.rationale {
padding: 10px 12px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3);
font-style: italic;
padding: 10px 12px; font-size: 12px; color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3); font-style: italic;
}
.graph { display: flex; flex-direction: column; padding: 10px 12px; gap: 2px; }
.graph-row { display: flex; flex-direction: column; align-items: flex-start; }
.connector { font-family: var(--font-mono); color: var(--text-dim); font-size: 10px; margin-left: 6px; }
.connector.fallback-arrow { color: var(--status-processing, #ffc107); }
.graph {
display: flex;
flex-direction: column;
padding: 10px 12px;
gap: 2px;
}
.graph-row {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.connector {
font-family: var(--font-mono);
color: var(--text-dim);
font-size: 10px;
margin-left: 6px;
}
.node {
display: flex;
flex-direction: column;
display: flex; flex-direction: column;
padding: 6px 10px;
background: var(--surface-2);
border: var(--panel-border);
font-family: var(--font-mono);
font-size: 12px;
width: 100%;
box-sizing: border-box;
background: var(--surface-2); border: var(--panel-border);
font-family: var(--font-mono); font-size: 12px;
width: 100%; box-sizing: border-box;
transition: background 0.2s ease, opacity 0.2s ease, box-shadow 0.4s ease;
}
.node.expandable { cursor: pointer; }
.node.expandable:hover { background: var(--surface-3, #1a2340); }
.node.expandable:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
.node.skipped { opacity: 0.4; }
.node.fallback { border-left: 2px solid var(--status-processing, #ffc107); }
.node.flash { box-shadow: 0 0 0 2px var(--accent); }
.node-head {
display: flex;
align-items: center;
gap: 10px;
}
.node .chev {
color: var(--text-dim);
font-size: 10px;
width: 10px;
flex-shrink: 0;
}
.node-head { display: flex; align-items: center; gap: 10px; }
.node .chev { color: var(--text-dim); font-size: 10px; width: 10px; flex-shrink: 0; }
.node .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-idle, #555);
flex-shrink: 0;
width: 8px; height: 8px; border-radius: 50%;
background: var(--status-idle, #555); flex-shrink: 0;
}
.node.running .dot {
background: var(--status-processing, #ffc107);
@@ -335,124 +320,111 @@ textarea {
}
.node.done .dot { background: var(--status-live, #00c853); }
.node.error .dot { background: var(--status-error, #ef4444); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.5;} }
.node .label { font-weight: 500; }
.node .step-count { font-size: 10px; color: var(--text-dim); margin-left: 4px; }
.node .detail-preview {
margin-left: auto;
color: var(--text-dim);
font-size: 11px;
text-align: right;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: auto; color: var(--text-dim);
font-size: 11px; text-align: right; max-width: 60%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.node .detail-full {
margin-top: 6px;
padding: 6px 0 2px 20px;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
.node .goto {
margin-left: auto;
background: transparent; border: 1px solid var(--surface-3); color: var(--text-dim);
width: 22px; height: 18px; font-size: 11px; cursor: pointer; padding: 0; line-height: 1;
}
.node .goto:hover { color: var(--accent); border-color: var(--accent); }
.node .detail-preview + .goto { margin-left: 8px; }
.node-body {
margin-top: 6px; padding-top: 6px;
border-top: 1px dashed var(--surface-3);
}
.detail-full {
padding: 4px 0 8px 20px;
color: var(--text-secondary); font-size: 11px; line-height: 1.5;
white-space: pre-wrap; word-break: break-word;
}
.subgraph { padding-left: 20px; }
.subgraph-label {
font-size: 10px; color: var(--text-dim); text-transform: uppercase;
letter-spacing: 0.5px; margin-bottom: 4px;
}
.sub-node {
display: flex; align-items: center; gap: 8px;
padding: 3px 8px; background: var(--surface-3, #1a2340);
border-left: 2px solid var(--surface-3); cursor: pointer;
font-size: 11px; margin-bottom: 2px;
}
.sub-node:hover { background: var(--surface-2); }
.sub-node.done { border-left-color: var(--status-live, #00c853); }
.sub-node.running { border-left-color: var(--status-processing, #ffc107); }
.sub-node.error { border-left-color: var(--status-error, #ef4444); }
.sub-node .sub-dot {
width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim);
}
.sub-node.done .sub-dot { background: var(--status-live, #00c853); }
.sub-node.running .sub-dot { background: var(--status-processing, #ffc107); }
.sub-node.error .sub-dot { background: var(--status-error, #ef4444); }
.sub-tool { color: var(--text-secondary); }
.sub-time { margin-left: auto; color: var(--text-dim); font-size: 10px; }
/* ── Tool call cards ── */
.tool {
margin: 8px;
border: var(--panel-border);
border-left-width: 3px;
margin: 8px; border: var(--panel-border); border-left-width: 3px;
background: var(--surface-2);
transition: box-shadow 0.4s ease;
}
.tool.flash { box-shadow: 0 0 0 2px var(--accent); }
.tool.done { border-left-color: var(--status-live, #00c853); }
.tool.pending { border-left-color: var(--status-processing, #ffc107); }
.tool.err { border-left-color: var(--status-error, #ef4444); }
.tool-head {
display: flex;
justify-content: space-between;
display: flex; align-items: center; gap: 6px;
padding: 6px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3);
}
.tool-name { font-weight: 600; }
.tool-time { color: var(--text-dim); }
.tool-chip {
background: var(--surface-3); color: var(--text-primary);
padding: 1px 6px; border-radius: 2px;
cursor: pointer; font-weight: 600;
}
.tool-chip:hover { background: var(--accent-dim); color: white; }
.tool-name { color: var(--text-dim); }
.tool-time { margin-left: auto; color: var(--text-dim); }
.tool-body {
margin: 0;
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow: auto;
margin: 0; padding: 8px 10px;
font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary);
white-space: pre-wrap; word-break: break-word;
max-height: 200px; overflow: auto;
}
.tool-body.sql { color: #7ab0ff; }
.tool-body.rows { color: var(--text-dim); }
.tool-error {
padding: 8px 10px;
color: var(--status-error, #f55);
font-family: var(--font-mono);
font-size: 11px;
}
.tool-error { padding: 8px 10px; color: var(--status-error, #f55); font-family: var(--font-mono); font-size: 11px; }
/* ── Event log ── */
.wrap-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
user-select: none;
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 11px; color: var(--text-dim);
cursor: pointer; user-select: none;
}
.wrap-toggle input { margin: 0; }
.log {
height: 100%;
overflow: auto;
padding: 4px 0;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
height: 100%; overflow: auto; padding: 4px 0;
font-family: var(--font-mono); font-size: 11px; line-height: 1.5;
}
.log-row {
display: grid;
grid-template-columns: 64px 52px 100px 1fr;
gap: 8px;
padding: 2px 10px;
align-items: baseline;
display: grid; grid-template-columns: 64px 52px 100px 1fr;
gap: 8px; padding: 2px 10px; align-items: baseline;
}
.log:not(.wrap) .log-msg {
white-space: pre;
overflow-x: auto;
}
.log.wrap .log-msg {
white-space: pre-wrap;
word-break: break-word;
}
.log:not(.wrap) .log-msg { white-space: pre; overflow-x: auto; }
.log.wrap .log-msg { white-space: pre-wrap; word-break: break-word; }
.log-ts { color: var(--text-dim); }
.log-level {
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.5px;
}
.log-level { text-transform: uppercase; font-size: 10px; letter-spacing: 0.5px; }
.log-row.info .log-level { color: var(--text-secondary); }
.log-row.debug .log-level { color: var(--text-dim); }
.log-row.error .log-level { color: var(--status-error, #ef4444); }
@@ -463,15 +435,13 @@ textarea {
/* ── Mobile: stack vertically ── */
@media (max-width: 900px) {
.ask-layout {
.ask-layout, .ask-layout.dense {
grid-template-columns: 1fr;
height: auto;
}
.trace-pane {
border-left: none;
border-top: var(--panel-border);
padding-left: 0;
padding-top: 16px;
border-left: none; border-top: var(--panel-border);
padding-left: 0; padding-top: 16px;
}
}
</style>