turnstone/web/src/components/ChatDiagnose.vue
pyr0ball f3d807d991 feat(diagnose): conversational chat mode + NL source discovery
- New ChatDiagnose.vue: multi-turn chat UI in the Diagnose tab
  - Textarea input (auto-grows) for long free-form problem descriptions
  - Source suggestion pre-flight: debounced POST /api/sources/suggest
    identifies relevant log sources from the query text and shows them
    as interactive chips (deselect to exclude before searching)
  - Conversation history preserved across turns with LLM reasoning,
    collapsible log entries, and "Save as incident" per turn
  - Reuses existing /api/diagnose/stream — no new pipeline
- DiagnoseView.vue: Chat is now default tab; viewport-height layout
- POST /api/sources/suggest: token-overlap source ranking, no LLM
- Fix: add missing 'import re' causing 500 on suggest route
2026-06-11 22:04:53 -07:00

370 lines
13 KiB
Vue

<template>
<div class="flex flex-col h-full min-h-0">
<!-- Conversation history -->
<div
ref="scrollEl"
class="flex-1 overflow-y-auto space-y-6 pb-4 pr-1"
aria-live="polite"
aria-label="Conversation history"
>
<!-- Empty state -->
<div v-if="!turns.length" class="flex flex-col items-center justify-center py-16 text-center px-4">
<div class="text-4xl mb-3" aria-hidden="true">🪵</div>
<p class="text-text-primary text-base font-medium mb-1">Describe your issue</p>
<p class="text-text-dim text-sm max-w-md">
Write what you're seeing — however you'd say it. Turnstone will search
your logs and explain what it finds. Mention a service name to focus
the search ("meshtasticd keeps disconnecting after 4.10 update").
</p>
</div>
<!-- Turn history -->
<template v-for="(turn, idx) in turns" :key="idx">
<!-- User bubble -->
<div class="flex justify-end">
<div class="max-w-[80%] rounded-2xl rounded-tr-sm bg-accent text-white px-4 py-2.5 text-sm whitespace-pre-wrap leading-relaxed">
{{ turn.query }}
</div>
</div>
<!-- Assistant response -->
<div class="flex flex-col gap-3">
<!-- Source chips -->
<div v-if="turn.sources?.length" class="flex flex-wrap gap-1.5 items-center">
<span class="text-xs text-text-dim">Searched:</span>
<span
v-for="s in turn.sources"
:key="s"
class="font-mono text-xs bg-surface-raised border border-surface-border rounded px-2 py-0.5 text-text-muted"
>{{ s }}</span>
</div>
<!-- Loading spinner -->
<div v-if="turn.loading" class="flex items-center gap-2 text-xs text-text-dim py-2">
<span class="inline-block w-3 h-3 rounded-full border-2 border-accent border-t-transparent animate-spin motion-reduce:animate-none" aria-hidden="true" />
<span>{{ turn.status ?? 'Searching logs…' }}</span>
</div>
<!-- LLM reasoning -->
<div
v-if="turn.reasoning"
class="rounded-r border-l-4 border-accent bg-surface-raised px-4 py-3"
>
<div class="flex items-center gap-2 mb-2 text-xs text-accent font-semibold uppercase tracking-wide">
<span aria-hidden="true"></span>
<span>Diagnosis</span>
</div>
<p class="text-sm text-text-primary leading-relaxed whitespace-pre-wrap">{{ turn.reasoning }}</p>
</div>
<!-- Summary bar -->
<div v-if="turn.summary" class="flex flex-wrap gap-x-5 gap-y-1 text-xs text-text-dim px-1">
<span class="font-medium text-text-muted">{{ turn.summary.total }} entr{{ turn.summary.total !== 1 ? 'ies' : 'y' }}</span>
<span v-if="turn.summary.window_start">
{{ fmtTs(turn.summary.window_start) }} {{ fmtTs(turn.summary.window_end) }}
</span>
<button
v-if="turn.entries?.length && !turn.showEntries"
@click="turn.showEntries = true"
class="text-accent hover:underline"
>show {{ turn.entries.length }} log lines</button>
<button
v-if="turn.showEntries"
@click="turn.showEntries = false"
class="text-text-dim hover:text-text-primary"
>hide entries</button>
</div>
<!-- Log entries (collapsible) -->
<div
v-if="turn.showEntries && turn.entries?.length"
class="rounded border border-surface-border overflow-hidden"
>
<LogEntryRow
v-for="entry in turn.entries"
:key="entry.entry_id"
:entry="entry"
/>
</div>
<!-- No results -->
<p
v-if="!turn.loading && turn.summary?.total === 0"
class="text-sm text-text-dim px-1"
>
No log evidence found for that query. Check Sources to confirm data is
gleaned, or try different wording.
</p>
<!-- Save as incident -->
<div v-if="!turn.loading && (turn.entries?.length ?? 0) > 0 && !turn.saved" class="flex gap-3 mt-1">
<button
@click="saveIncident(turn)"
:disabled="turn.saving"
class="px-3 py-1.5 bg-surface-raised border border-surface-border rounded text-xs text-text-muted hover:text-text-primary hover:border-accent transition-colors disabled:opacity-40"
>
{{ turn.saving ? 'Saving…' : 'Save as incident' }}
</button>
</div>
<p v-if="turn.saved" class="text-xs text-green-400 px-1">
Saved
<RouterLink to="/incidents" class="underline underline-offset-2 hover:text-green-300">view in Incidents</RouterLink>
</p>
</div>
</template>
</div>
<!-- Source suggestion pre-flight -->
<div
v-if="suggestedSources.length && !activeTurn"
class="mb-3 p-3 rounded border border-surface-border bg-surface-raised"
>
<p class="text-xs text-text-dim mb-2">Detected sources deselect to exclude:</p>
<div class="flex flex-wrap gap-2">
<button
v-for="s in suggestedSources"
:key="s.source_id"
@click="toggleSource(s.source_id)"
:aria-pressed="!excludedSources.has(s.source_id)"
:class="[
'font-mono text-xs rounded px-2 py-1 border transition-colors',
excludedSources.has(s.source_id)
? 'bg-surface border-surface-border text-text-dim line-through'
: 'bg-accent/10 border-accent/40 text-accent'
]"
>{{ s.source_id }}</button>
</div>
</div>
<!-- Input row -->
<div class="border-t border-surface-border pt-3">
<div class="flex gap-2 items-end">
<div class="flex-1">
<label :for="inputId" class="sr-only">Describe your issue</label>
<textarea
:id="inputId"
ref="textareaEl"
v-model="draft"
:disabled="!!activeTurn"
:placeholder="turns.length
? 'Follow up, or ask about something else…'
: 'Paste or type your issue — as much detail as you want…'"
rows="3"
class="w-full bg-surface-raised border border-surface-border rounded-xl px-4 py-2.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent transition-colors resize-none leading-relaxed disabled:opacity-50"
@input="onInput"
@keydown.enter.exact.prevent="submit"
@keydown.enter.shift.exact.stop
/>
<p class="text-right text-xs text-text-dim mt-1">Enter to search · Shift+Enter for new line</p>
</div>
<button
:disabled="!draft.trim() || !!activeTurn"
@click="submit"
class="shrink-0 px-4 py-2.5 rounded-xl bg-accent text-white text-sm font-semibold hover:bg-blue-400 transition-colors disabled:opacity-40 self-end mb-6"
aria-label="Search logs"
>
<span v-if="activeTurn"></span>
<span v-else>Search</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import LogEntryRow from '@/components/LogEntryRow.vue'
import type { LogEntry } from '@/stores/search'
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
const inputId = `chat-input-${Math.random().toString(36).slice(2, 7)}`
interface Summary {
total: number
window_start: string | null
window_end: string | null
time_detected: boolean
by_severity: Record<string, number>
by_source: Record<string, number>
}
interface SuggestedSource {
source_id: string
score: number
matched_tokens: string[]
}
interface Turn {
query: string
loading: boolean
status: string | null
reasoning: string | null
summary: Summary | null
entries: LogEntry[]
sources: string[]
showEntries: boolean
saved: boolean
saving: boolean
since: string | null
until: string | null
}
const turns = ref<Turn[]>([])
const draft = ref('')
const suggestedSources = ref<SuggestedSource[]>([])
const excludedSources = ref(new Set<string>())
const activeTurn = ref<Turn | null>(null)
const scrollEl = ref<HTMLElement | null>(null)
const textareaEl = ref<HTMLTextAreaElement | null>(null)
let suggestTimer: ReturnType<typeof setTimeout> | null = null
onMounted(() => textareaEl.value?.focus())
function onInput() {
// Auto-grow textarea
const el = textareaEl.value
if (el) {
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
}
// Debounce source suggestion
if (suggestTimer) clearTimeout(suggestTimer)
if (draft.value.trim().length > 8) {
suggestTimer = setTimeout(fetchSuggestions, 400)
} else {
suggestedSources.value = []
}
}
async function fetchSuggestions() {
try {
const res = await fetch(`${BASE}/api/sources/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: draft.value }),
})
if (!res.ok) return
const data = await res.json()
suggestedSources.value = (data.suggested ?? []).slice(0, 6)
// Reset exclusions when suggestions change
excludedSources.value = new Set()
} catch { /* non-critical */ }
}
function toggleSource(id: string) {
const next = new Set(excludedSources.value)
if (next.has(id)) next.delete(id)
else next.add(id)
excludedSources.value = next
}
async function submit() {
const text = draft.value.trim()
if (!text || activeTurn.value) return
draft.value = ''
suggestedSources.value = []
if (textareaEl.value) textareaEl.value.style.height = 'auto'
// Determine source scope from non-excluded suggestions
const sources = suggestedSources.value
.filter(s => !excludedSources.value.has(s.source_id))
.map(s => s.source_id)
excludedSources.value = new Set()
const turn: Turn = {
query: text,
loading: true,
status: 'Searching…',
reasoning: null,
summary: null,
entries: [],
sources,
showEntries: false,
saved: false,
saving: false,
since: null,
until: null,
}
turns.value.push(turn)
activeTurn.value = turn
await nextTick()
scrollEl.value?.scrollTo({ top: scrollEl.value.scrollHeight, behavior: 'smooth' })
try {
const res = await fetch(`${BASE}/api/diagnose/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: text,
source: sources.length === 1 ? sources[0] : null,
}),
})
if (!res.ok || !res.body) throw new Error(`API ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop() ?? ''
for (const part of parts) {
const line = part.trim()
if (!line.startsWith('data: ')) continue
const evt = JSON.parse(line.slice(6))
if (evt.type === 'status') { turn.status = evt.message }
else if (evt.type === 'summary') { turn.summary = evt.data; turn.since = evt.data.window_start; turn.until = evt.data.window_end }
else if (evt.type === 'entries') { turn.entries = evt.data; turn.showEntries = evt.data.length > 0 && evt.data.length <= 10 }
else if (evt.type === 'reasoning') { turn.reasoning = evt.text; await nextTick(); scrollEl.value?.scrollTo({ top: scrollEl.value.scrollHeight, behavior: 'smooth' }) }
else if (evt.type === 'done') { turn.status = null }
}
}
} catch (e) {
turn.reasoning = `Error: ${e instanceof Error ? e.message : String(e)}`
} finally {
turn.loading = false
turn.status = null
activeTurn.value = null
await nextTick()
scrollEl.value?.scrollTo({ top: scrollEl.value.scrollHeight, behavior: 'smooth' })
textareaEl.value?.focus()
}
}
async function saveIncident(turn: Turn) {
turn.saving = true
try {
const res = await fetch(`${BASE}/api/incidents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: turn.query.slice(0, 120),
started_at: turn.since,
ended_at: turn.until,
severity: 'medium',
notes: turn.reasoning ?? '',
}),
})
if (!res.ok) throw new Error(await res.text())
turn.saved = true
} catch { /* surface silently — not worth crashing the chat */ }
finally { turn.saving = false }
}
function fmtTs(iso: string | null): string {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})
} catch { return iso }
}
</script>