- drop the unused KnowledgeItem type import
- the svelte/no-at-html-tags disable comment sat on the wrong line: the
multi-line Card.Description opening tag meant "next line" wasn't the
line with {@html}, so it never suppressed. Reformatted so the {@html}
is on its own line, directly after the disable comment. Sanitization
(DOMPurify with ALLOWED_TAGS: ['b']) is unchanged — this was a false
positive, verified live (search results still render <b> highlights,
no script/attribute injection).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
7.1 KiB
Svelte
163 lines
7.1 KiB
Svelte
<script lang="ts">
|
||
import DOMPurify from 'dompurify'
|
||
import { searchKnowledge, fetchRecentKnowledge, type KnowledgeHit, type RecentKnowledge } 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 { openEntityWindow } from '$lib/stores/windows'
|
||
import { relativeTime } from '$lib/utils'
|
||
import StatusBadge from '$lib/components/StatusBadge.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
|
||
}
|
||
</script>
|
||
|
||
<div class="flex h-full flex-col gap-4 p-2">
|
||
<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>
|
||
<StatusBadge kind="type" value={hit.type} />
|
||
</div>
|
||
{#if hit.snippet}
|
||
<Card.Description class="text-xs">
|
||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized inline; ts_headline only ever emits <b> -->
|
||
{@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={() => openEntityWindow(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>
|
||
<StatusBadge kind="type" value={it.kind} class="text-[10px]" />
|
||
{#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">{relativeTime(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>
|