Search hits and entity-knowledge hits never selected an id column, so
every KnowledgeHit.Id defaulted to the zero UUID. The frontend's keyed
{#each results as hit (hit.id)} then had all-duplicate keys, which
silently broke Svelte 5's if-block branch swap for the results panel —
search would set searched=true (Clear button appeared) but the view
never switched away from "Recently learned". Select e.id in both
queries and key the each block on hit.slug (guaranteed unique) instead.
187 lines
7.8 KiB
Svelte
187 lines
7.8 KiB
Svelte
<script lang="ts">
|
||
import DOMPurify from 'dompurify'
|
||
import { searchKnowledge, fetchRecentKnowledge, type KnowledgeHit, type RecentKnowledge, type KnowledgeItem } from '$lib/api'
|
||
import * as Card from '$lib/components/ui/card'
|
||
import { Badge } from '$lib/components/ui/badge'
|
||
import { Input } from '$lib/components/ui/input'
|
||
import { Button } from '$lib/components/ui/button'
|
||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||
import SearchIcon from '@lucide/svelte/icons/search'
|
||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||
import BotIcon from '@lucide/svelte/icons/bot'
|
||
|
||
let query = $state('')
|
||
let results = $state<KnowledgeHit[]>([])
|
||
let loading = $state(false)
|
||
let searched = $state(false)
|
||
|
||
let recent = $state<RecentKnowledge>({ stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] })
|
||
let agentOnly = $state(false)
|
||
let loadingRecent = $state(true)
|
||
|
||
async function loadRecent() {
|
||
loadingRecent = true
|
||
recent = await fetchRecentKnowledge(agentOnly ? 'nomos-agent' : undefined)
|
||
loadingRecent = false
|
||
}
|
||
loadRecent()
|
||
|
||
function toggleAgentOnly() {
|
||
agentOnly = !agentOnly
|
||
loadRecent()
|
||
}
|
||
|
||
async function search() {
|
||
if (!query.trim()) { searched = false; return }
|
||
loading = true
|
||
results = await searchKnowledge(query)
|
||
loading = false
|
||
searched = true
|
||
}
|
||
|
||
function typeVariant(type: string): 'default' | 'secondary' | 'outline' {
|
||
if (type === 'runbook') return 'secondary'
|
||
if (type === 'investigation') return 'default'
|
||
return 'outline'
|
||
}
|
||
|
||
function relTime(iso: string): string {
|
||
const d = new Date(iso).getTime()
|
||
if (!d) return ''
|
||
const s = Math.round((Date.now() - d) / 1000)
|
||
if (s < 60) return 'just now'
|
||
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
|
||
return `${Math.floor(s / 86400)}d ago`
|
||
}
|
||
|
||
let sheetOpen = $state(false)
|
||
let selectedSlug = $state<string | null>(null)
|
||
|
||
function openEntity(slug: string) {
|
||
selectedSlug = slug
|
||
sheetOpen = true
|
||
}
|
||
</script>
|
||
|
||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||
<div class="flex items-center justify-between">
|
||
<h1 class="text-lg font-semibold">Knowledge</h1>
|
||
</div>
|
||
|
||
<!-- Learning stats: the system getting smarter, made visible -->
|
||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<Card.Root>
|
||
<Card.Header class="p-3">
|
||
<Card.Description class="text-xs">Total notes</Card.Description>
|
||
<Card.Title class="text-2xl">{recent.stats.total}</Card.Title>
|
||
</Card.Header>
|
||
</Card.Root>
|
||
<Card.Root class="border-primary/30 bg-primary/5">
|
||
<Card.Header class="p-3">
|
||
<Card.Description class="flex items-center gap-1 text-xs"><BotIcon class="size-3" /> Written by Nomos</Card.Description>
|
||
<Card.Title class="text-2xl text-primary">{recent.stats.agent_authored}</Card.Title>
|
||
</Card.Header>
|
||
</Card.Root>
|
||
<Card.Root class="border-success/30 bg-success/5">
|
||
<Card.Header class="p-3">
|
||
<Card.Description class="flex items-center gap-1 text-xs"><SparklesIcon class="size-3" /> Learned this week</Card.Description>
|
||
<Card.Title class="text-2xl text-success">{recent.stats.last_7d}</Card.Title>
|
||
</Card.Header>
|
||
</Card.Root>
|
||
<Card.Root>
|
||
<Card.Header class="p-3">
|
||
<Card.Description class="text-xs">Runbooks / investigations</Card.Description>
|
||
<Card.Title class="text-2xl">{(recent.stats.by_kind.runbook ?? 0)} / {(recent.stats.by_kind.investigation ?? 0)}</Card.Title>
|
||
</Card.Header>
|
||
</Card.Root>
|
||
</div>
|
||
|
||
<!-- Search -->
|
||
<form onsubmit={(e) => { e.preventDefault(); search() }} class="flex gap-2">
|
||
<div class="relative flex-1 max-w-lg">
|
||
<SearchIcon class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input placeholder="Search documents, runbooks, investigations…" bind:value={query} class="pl-8" />
|
||
</div>
|
||
<Button type="submit" disabled={loading || !query.trim()}>{loading ? 'Searching…' : 'Search'}</Button>
|
||
{#if searched}
|
||
<Button type="button" variant="ghost" onclick={() => { query = ''; searched = false }}>Clear</Button>
|
||
{/if}
|
||
</form>
|
||
|
||
{#if searched}
|
||
<!-- Search results mode -->
|
||
<p class="text-sm text-muted-foreground">{results.length} result{results.length === 1 ? '' : 's'} for "{query}"</p>
|
||
<ScrollArea class="flex-1">
|
||
<div class="flex flex-col gap-3 pr-4">
|
||
{#each results as hit (hit.slug)}
|
||
<Card.Root class="transition-colors hover:bg-muted/50">
|
||
<Card.Header>
|
||
<div class="flex items-center gap-2">
|
||
<Card.Title class="text-sm">{hit.title}</Card.Title>
|
||
<Badge variant={typeVariant(hit.type)}>{hit.type}</Badge>
|
||
</div>
|
||
{#if hit.snippet}
|
||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized below, ts_headline only ever emits <b> -->
|
||
<Card.Description class="text-xs"
|
||
>{@html DOMPurify.sanitize(hit.snippet, { ALLOWED_TAGS: ['b'], ALLOWED_ATTR: [] })}</Card.Description
|
||
>
|
||
{/if}
|
||
{#if hit.linked_entities?.length}
|
||
<div class="mt-1 flex flex-wrap gap-1">
|
||
{#each hit.linked_entities as slug}
|
||
<button type="button" class="font-mono text-xs text-muted-foreground underline" onclick={() => openEntity(slug)}>{slug}</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</Card.Header>
|
||
</Card.Root>
|
||
{:else}
|
||
{#if !loading}<p class="py-12 text-center text-muted-foreground">No results found.</p>{/if}
|
||
{/each}
|
||
</div>
|
||
</ScrollArea>
|
||
{:else}
|
||
<!-- Recently learned mode (default) -->
|
||
<div class="flex items-center justify-between">
|
||
<h2 class="text-sm font-medium text-muted-foreground">Recently learned</h2>
|
||
<Button size="sm" variant={agentOnly ? 'default' : 'outline'} class="h-7 gap-1 text-xs" onclick={toggleAgentOnly}>
|
||
<BotIcon class="size-3" /> {agentOnly ? 'Nomos only' : 'All sources'}
|
||
</Button>
|
||
</div>
|
||
<ScrollArea class="flex-1">
|
||
<div class="flex flex-col gap-2 pr-4">
|
||
{#each recent.items as it (it.slug)}
|
||
<div class="flex items-start gap-3 rounded-lg border px-3 py-2 transition-colors hover:bg-muted/40 {it.agent_authored ? 'border-primary/30 bg-primary/[0.03]' : ''}">
|
||
<div class="mt-0.5">
|
||
{#if it.agent_authored}<BotIcon class="size-4 text-primary" />{:else}<SearchIcon class="size-4 text-muted-foreground" />{/if}
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span class="text-sm font-medium">{it.title}</span>
|
||
<Badge variant={typeVariant(it.kind)} class="text-[10px]">{it.kind}</Badge>
|
||
{#if it.agent_authored}<Badge variant="outline" class="border-primary/40 text-[10px] text-primary">learned by Nomos</Badge>{/if}
|
||
</div>
|
||
{#if it.tags.length}
|
||
<div class="mt-1 flex flex-wrap gap-1">
|
||
{#each it.tags as t}<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{t}</span>{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<span class="shrink-0 text-xs text-muted-foreground">{relTime(it.updated_at)}</span>
|
||
</div>
|
||
{:else}
|
||
{#if !loadingRecent}
|
||
<p class="py-12 text-center text-sm text-muted-foreground">
|
||
{agentOnly ? 'Nomos hasn’t recorded any learnings yet — it will write them here as it solves problems.' : 'No knowledge yet.'}
|
||
</p>
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
</ScrollArea>
|
||
{/if}
|
||
</div>
|
||
|
||
<EntitySheet slug={selectedSlug} bind:open={sheetOpen} />
|