Files
nvi/ui/app/src/pages/Ask.vue

448 lines
18 KiB
Vue

<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { Panel, SplitPane } from 'soleprint-ui'
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)?',
'Compare loan default rates between 1995 and 1996.',
'Which dimensions best explain regional differences in loan default rates?',
"What's driving variation in average loan size across districts?",
]
const {
status, runId, rationale, graphNodes, log,
toolCalls, subEventsByAnalysis, answer, error, isRunning, ask,
} = useRunStream()
const wrapLog = ref(true)
const logEl = ref<HTMLElement | null>(null)
const expanded = ref<Set<string>>(new Set())
function toggleNode(id: string) {
const s = new Set(expanded.value)
if (s.has(id)) s.delete(id)
else s.add(id)
expanded.value = s
}
watch(() => log.value.length, async () => {
await nextTick()
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
})
async function submit() {
if (!question.value.trim() || isRunning.value) return
await ask(question.value)
}
function pick(q: string) {
question.value = q
}
function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
if (s === 'streaming' || s === 'connecting') return 'processing'
if (s === 'done') return 'live'
if (s === 'error') return 'error'
return 'idle'
}
// Soft-link: scroll an element into view and flash it. The DOM access lives in
// script (not the template) — Vue templates don't expose `document` as a global.
function flashInto(elementId: string) {
nextTick(() => {
const el = document.getElementById(elementId)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
el?.classList.add('flash')
setTimeout(() => el?.classList.remove('flash'), 1200)
})
}
function focusNode(id: string | null) {
if (!id) return
if (!expanded.value.has(id)) toggleNode(id)
flashInto(`node-${id}`)
}
function focusTool(index: number) {
if (index < 0) return
flashInto(`tc-${index}`)
}
function focusFirstTool(id: string) {
focusTool(toolCalls.value.findIndex(c => c.analysisId === id))
}
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 === 'compose_sql' && call.output?.sql) return call.output.sql
if (call.tool === 'execute_sql' && call.input?.sql) return call.input.sql
if (call.tool === 'pick_for_question' && call.output?.pick) {
return JSON.stringify(call.output.pick, null, 2)
}
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">
<!-- Main split: ask/answer (left) | live trace (right). Trace wider by default. -->
<SplitPane direction="horizontal" size-mode="ratio" :initial-size="1.4" anchor="second" :min="0.3" :max="3">
<!-- Left: ask + answer (the user's surface) -->
<template #first>
<SplitPane direction="vertical" size-mode="px" :initial-size="340" :min="120" :max="600">
<template #first>
<Panel title="Ask" :status="statusOf(status)">
<div class="pane-scroll">
<div class="ask-form">
<textarea v-model="question" rows="3" :disabled="isRunning"
placeholder="Ask a question about the financial warehouse…" />
<div class="ask-actions">
<span class="hint">{{ isRunning ? `running… ${runId}` : 'Examples:' }}</span>
<button class="run-btn" @click="submit" :disabled="isRunning || !question.trim()">
{{ isRunning ? 'Running' : 'Ask' }}
</button>
</div>
<div v-if="!isRunning" class="examples">
<button v-for="e in examples" :key="e" class="example" @click="pick(e)">
{{ e }}
</button>
</div>
</div>
</div>
</Panel>
</template>
<template #second>
<Panel title="Answer" :status="answer ? 'live' : (error ? 'error' : 'idle')">
<div class="pane-scroll">
<div v-if="error" class="error-block">{{ error }}</div>
<div v-else-if="answer" class="answer-block">{{ answer }}</div>
<div v-else-if="isRunning" class="muted">Composing…</div>
<div v-else class="muted">Ask a question to see the answer here.</div>
</div>
</Panel>
</template>
</SplitPane>
</template>
<!-- Right: live trace (the demo's surface) -->
<template #second>
<SplitPane direction="vertical" size-mode="ratio" :initial-size="0.8" :min="0.25" :max="3">
<template #first>
<Panel title="Plan" :status="statusOf(status)">
<div class="pane-scroll">
<div v-if="rationale" class="rationale">{{ rationale }}</div>
<div class="graph">
<div v-for="(n, idx) in graphNodes" :key="n.id" class="graph-row">
<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
: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 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="focusTool(toolIndex(c))">
<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>
</div>
</div>
</div>
</Panel>
</template>
<template #second>
<SplitPane direction="vertical" size-mode="ratio" :initial-size="1" :min="0.25" :max="3">
<template #first>
<Panel title="Tool calls" class="tools-panel">
<div class="pane-scroll">
<div v-if="toolCalls.length === 0" class="muted small">No tool calls yet.</div>
<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 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="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>
</div>
</Panel>
</template>
<template #second>
<Panel title="Event log" class="log-panel">
<template #actions>
<label class="wrap-toggle">
<input type="checkbox" v-model="wrapLog" />
wrap
</label>
</template>
<div ref="logEl" :class="['log', { wrap: wrapLog }]">
<div v-for="(e, i) in log" :key="i" :class="['log-row', e.level]">
<span class="log-ts">{{ e.ts }}</span>
<span class="log-level">{{ e.level }}</span>
<span class="log-stage">{{ e.stage }}</span>
<span class="log-msg">{{ e.msg }}</span>
</div>
<div v-if="log.length === 0" class="muted small">No events yet.</div>
</div>
</Panel>
</template>
</SplitPane>
</template>
</SplitPane>
</template>
</SplitPane>
</div>
</template>
<style scoped>
.ask-layout {
height: calc(100vh - 80px);
min-height: 0;
}
/* Each Panel sits in a fixed-height SplitPane pane whose body is overflow:hidden;
this wrapper gives the panel content its own scroll region. */
.pane-scroll { height: 100%; overflow-y: auto; overflow-x: hidden; }
/* ── Ask form ── */
.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;
}
.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;
}
.run-btn:hover { background: var(--accent-dim); }
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.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); }
/* ── 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;
}
.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); }
.node {
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;
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 .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--status-idle, #555); flex-shrink: 0;
}
.node.running .dot {
background: var(--status-processing, #ffc107);
box-shadow: 0 0 8px var(--status-processing, #ffc107);
animation: pulse 1s ease-in-out infinite;
}
.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;} }
.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;
}
.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;
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; align-items: center; gap: 6px;
padding: 6px 10px;
font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3);
}
.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;
}
.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; }
/* ── 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;
}
.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;
}
.log-row {
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-ts { color: var(--text-dim); }
.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); }
.log-row.error .log-msg { color: var(--status-error, #ef4444); }
.log-row.warn .log-level { color: var(--status-processing, #ffc107); }
.log-stage { color: #7ab0ff; }
.log-msg { color: var(--text-primary); }
</style>