verbose live UI + tool-level SSE events + Groq default + regression tests

This commit is contained in:
2026-06-03 05:04:29 -03:00
parent 131f4d9b86
commit e124a8a7d9
69 changed files with 3030 additions and 137 deletions

View File

@@ -1,80 +1,477 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { nextTick, ref, watch } from 'vue'
import { Panel } from 'soleprint-ui'
import { useRunStream } from '../composables/useRunStream'
const question = ref('Which districts had the sharpest rise in loan defaults?')
const submitting = ref(false)
const error = ref<string | null>(null)
const router = useRouter()
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?',
]
const {
status, runId, rationale, planSteps, graphNodes, log,
toolCalls, 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()) return
submitting.value = true
error.value = null
try {
const res = await fetch('/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: question.value }),
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const { run_id } = await res.json()
router.push(`/runs/${run_id}`)
} catch (e: any) {
error.value = e.message ?? String(e)
} finally {
submitting.value = false
}
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'
}
</script>
<template>
<div class="ask">
<h2>Ask a question</h2>
<textarea v-model="question" rows="3" :disabled="submitting" />
<div class="actions">
<button @click="submit" :disabled="submitting || !question.trim()">
{{ submitting ? 'Submitting…' : 'Ask' }}
</button>
<div class="ask-layout">
<!-- Left / top: ask + answer (the user's surface) -->
<div class="ask-pane">
<Panel title="Ask" :status="statusOf(status)">
<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>
</Panel>
<Panel title="Answer" :status="answer ? 'live' : (error ? 'error' : 'idle')">
<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>
</Panel>
</div>
<!-- Right / bottom: live trace (the demo's surface) -->
<div class="trace-pane">
<Panel title="Plan" :status="statusOf(status)">
<div v-if="rationale" class="rationale">{{ rationale }}</div>
<div v-else class="muted small">Waiting for the planner</div>
<div class="graph">
<div v-for="(n, idx) in graphNodes" :key="n.id" class="graph-row">
<span class="connector" v-if="idx > 0"></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)"
>
<div class="node-head">
<span class="chev" v-if="n.detail">{{ expanded.has(n.id) ? '' : '' }}</span>
<span class="dot" />
<span class="label">{{ n.label }}</span>
<span v-if="n.detail && !expanded.has(n.id)" class="detail-preview">{{ n.detail }}</span>
</div>
<div v-if="n.detail && expanded.has(n.id)" class="detail-full">{{ n.detail }}</div>
</div>
</div>
</div>
</Panel>
<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 class="tool-head">
<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="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>
</Panel>
<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>
</div>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<style scoped>
.ask {
max-width: 720px;
.ask-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: calc(100vh - 80px);
min-height: 0;
}
.ask-pane,
.trace-pane {
display: flex;
flex-direction: column;
gap: 12px;
overflow: auto;
min-height: 0;
}
.trace-pane {
border-left: var(--panel-border);
padding-left: 16px;
}
.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;
}
textarea {
width: 100%;
background: var(--surface-2);
color: var(--text-primary);
border: var(--panel-border);
padding: 12px;
padding: 10px;
font-family: var(--font-mono);
font-size: 14px;
font-size: 13px;
resize: vertical;
}
.actions {
.ask-actions {
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
gap: 12px;
}
button {
padding: 8px 20px;
.hint {
font-family: var(--font-mono);
background: var(--accent-dim);
color: var(--text-primary);
border: var(--panel-border);
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;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
.run-btn:hover { background: var(--accent-dim); }
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.examples {
display: flex;
flex-direction: column;
gap: 4px;
}
.error {
.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 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;
}
.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;
}
.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;
}
.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-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 .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 .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;
border-top: 1px dashed var(--surface-3);
}
/* ── Tool call cards ── */
.tool {
margin: 8px;
border: var(--panel-border);
border-left-width: 3px;
background: var(--surface-2);
}
.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;
padding: 6px 10px;
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-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); }
/* ── Mobile: stack vertically ── */
@media (max-width: 900px) {
.ask-layout {
grid-template-columns: 1fr;
height: auto;
}
.trace-pane {
border-left: none;
border-top: var(--panel-border);
padding-left: 0;
padding-top: 16px;
}
}
</style>