Merge remote-tracking branch 'origin/main' into claude/coolify-oikos-comparison-d8c2d3
This commit is contained in:
@@ -333,3 +333,92 @@ a:hover {
|
||||
border-left: 1px solid var(--border);
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Base markdown rendering — used by every {@html marked.parse(...)} output
|
||||
(EntityDetailContent, the Knowledge wiki's WikiReader, and as the
|
||||
foundation ChatThread's fuller "Art Nouveau" chat styling builds on top
|
||||
of). Global rather than a per-component <style> block: Svelte scopes
|
||||
<style> to one component, so three separate copies of this same ~50-line
|
||||
ruleset had accumulated (EntityDetailContent's copy was already a
|
||||
documented "can't share, Svelte scopes styles" duplicate of ChatThread's,
|
||||
and WikiReader added a third when the Knowledge wiki was built). Anything
|
||||
that renders sanitized markdown into an .markdown-body container gets
|
||||
this for free; a component only needs its own <style> block for looks
|
||||
that genuinely diverge from this baseline (see ChatThread.svelte's
|
||||
trimmed-down block for the pattern: same class, only the deltas kept,
|
||||
using a two-class selector so its overrides win on specificity rather
|
||||
than depending on <style> injection order).
|
||||
Includes explicit list-style-type — Tailwind's preflight reset (@import
|
||||
'tailwindcss' above) strips it from every <ul>/<ol>, so without this,
|
||||
markdown bullet/numbered lists silently render with no markers. */
|
||||
.markdown-body p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.markdown-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.markdown-body ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.markdown-body ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.markdown-body li {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.markdown-body code {
|
||||
background: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.markdown-body pre {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.625rem 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.markdown-body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3 {
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0 0.375rem;
|
||||
font-size: 1em;
|
||||
}
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid var(--border);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.markdown-body a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -675,8 +675,10 @@ export interface KnowledgeContent {
|
||||
title: string
|
||||
content: string
|
||||
source: string
|
||||
edited_by: string
|
||||
tags: string[]
|
||||
updated_at: string
|
||||
revisions: number
|
||||
}
|
||||
|
||||
// Full markdown body for a document/investigation/runbook entity — distinct
|
||||
@@ -688,6 +690,259 @@ export async function fetchKnowledgeContent(id: string): Promise<KnowledgeConten
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ─── Knowledge wiki: write path + drift tooling ────────────────────────────
|
||||
//
|
||||
// Everything below this line talks to internal/httpapi/knowledge_write.go
|
||||
// and knowledge_drift.go — the operator-facing CRUD surface added alongside
|
||||
// the wiki redesign. Before this, the only writer was the MCP tool the agent
|
||||
// uses; the web UI could search and read but never create, correct, or
|
||||
// retire a note.
|
||||
//
|
||||
// Mutations throw KnowledgeApiError on failure instead of returning null —
|
||||
// unlike the read helpers above, a write failure usually has a specific,
|
||||
// user-facing reason (409 "a note with this title already exists", 400
|
||||
// "content cannot be empty") that the caller needs to display, not just a
|
||||
// generic "something went wrong."
|
||||
|
||||
// Mirrors the RFC7807 problem+json shape internal/httpapi/problem.go writes.
|
||||
export class KnowledgeApiError extends Error {
|
||||
status: number
|
||||
detail: string
|
||||
constructor(status: number, title: string, detail: string) {
|
||||
super(title)
|
||||
this.status = status
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
async function parseKnowledgeError(res: Response): Promise<never> {
|
||||
let title = `request failed (${res.status})`
|
||||
let detail = ''
|
||||
try {
|
||||
const body = await res.json()
|
||||
title = body.title ?? title
|
||||
detail = body.detail ?? ''
|
||||
} catch {
|
||||
// non-JSON error body — fall back to the generic title above
|
||||
}
|
||||
throw new KnowledgeApiError(res.status, title, detail)
|
||||
}
|
||||
|
||||
export interface KnowledgeListItem {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
kind: 'document' | 'runbook' | 'investigation'
|
||||
source: string
|
||||
edited_by: string
|
||||
tags: string[]
|
||||
about: string[]
|
||||
size: number
|
||||
updated_at: string
|
||||
created_at: string
|
||||
revisions: number
|
||||
}
|
||||
|
||||
// The full live set, body-free — backs the wiki navigator tree. Distinct
|
||||
// from fetchRecentKnowledge, which caps at 200 and drives the stats/recency
|
||||
// view; the tree needs every note plus linked-entity slugs for the
|
||||
// group-by-entity arrangement.
|
||||
// Throws KnowledgeApiError on failure rather than returning [] — an empty
|
||||
// list here must mean "the collection really is empty," never "the request
|
||||
// failed." Silently treating a 500/network error as [] previously left the
|
||||
// whole wiki reporting "0 notes" indistinguishable from an actual outage;
|
||||
// see Knowledge.svelte's loadItems for how the caller surfaces this.
|
||||
export async function listKnowledge(): Promise<KnowledgeListItem[]> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/list`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface KnowledgeWriteInput {
|
||||
title?: string
|
||||
content?: string
|
||||
kind?: 'document' | 'investigation' | 'runbook'
|
||||
tags?: string[]
|
||||
folder?: string
|
||||
about?: string[]
|
||||
}
|
||||
|
||||
export async function createKnowledge(
|
||||
input: KnowledgeWriteInput
|
||||
): Promise<{ slug: string; id: string; linked: string[] }> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// idOrSlug identifies the note; only the fields present in `input` are
|
||||
// changed (undefined = leave alone), matching the PUT handler's COALESCE
|
||||
// semantics — see knowledge_write.go's serveUpdateKnowledge.
|
||||
// `linked` echoes back which `about` slugs actually resolved (only present
|
||||
// when `input.about` was supplied) — a typo'd entity slug otherwise fails
|
||||
// server-side with nothing but a log line, so the caller can diff this
|
||||
// against what it sent and warn about anything that silently didn't take.
|
||||
export async function updateKnowledge(
|
||||
idOrSlug: string,
|
||||
input: KnowledgeWriteInput
|
||||
): Promise<{ linked?: string[] }> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input)
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Soft delete — the note moves to the trash (fetchKnowledgeTrash) and can be
|
||||
// brought back with restoreKnowledge. Never a hard, unrecoverable delete.
|
||||
export async function deleteKnowledge(idOrSlug: string): Promise<void> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
}
|
||||
|
||||
export async function restoreKnowledge(idOrSlug: string): Promise<void> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/restore/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
}
|
||||
|
||||
export interface KnowledgeTrashItem {
|
||||
slug: string
|
||||
title: string
|
||||
kind: string
|
||||
deleted_by: string
|
||||
deleted_at: string
|
||||
}
|
||||
|
||||
// Throws on failure — see listKnowledge's comment on why "empty" and
|
||||
// "failed" must not collapse into the same [].
|
||||
export async function fetchKnowledgeTrash(): Promise<KnowledgeTrashItem[]> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/trash`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface KnowledgeRevision {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
edited_by: string
|
||||
tags: string[]
|
||||
version_at: string
|
||||
revised_at: string
|
||||
}
|
||||
|
||||
// Newest first. Works even for a soft-deleted note — inspecting what was
|
||||
// lost is exactly when history matters most (see resolveKnowledgeEntityAny
|
||||
// in knowledge_write.go).
|
||||
export async function fetchKnowledgeRevisions(idOrSlug: string): Promise<KnowledgeRevision[]> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/revisions/${encodeURIComponent(idOrSlug)}`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface KnowledgeTag {
|
||||
tag: string
|
||||
uses: number
|
||||
variants: string[]
|
||||
// True when the same tag is stored under more than one casing (e.g.
|
||||
// "oom" / "OOM") — the tag manager badges these as needing a normalize.
|
||||
split: boolean
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeTags(): Promise<KnowledgeTag[]> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/tags`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
// Rewrites every `from` tag to `to` across all live notes. Pass several
|
||||
// `from` values to merge them into one; pass a tag's own case variants to
|
||||
// normalize casing.
|
||||
export async function renameKnowledgeTag(from: string[], to: string): Promise<number> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/tags/rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ from, to })
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.notes_updated ?? 0
|
||||
}
|
||||
|
||||
export interface KnowledgeDuplicateMember {
|
||||
slug: string
|
||||
title: string
|
||||
kind: string
|
||||
size: number
|
||||
updated_at: string
|
||||
edited_by: string
|
||||
}
|
||||
|
||||
export interface KnowledgeDuplicateCluster {
|
||||
members: KnowledgeDuplicateMember[]
|
||||
top_similarity: number
|
||||
total_size: number
|
||||
}
|
||||
|
||||
// Title-similarity clusters — candidates for review, never a verdict. See
|
||||
// the Go handler: notes that share a naming template (e.g. the five
|
||||
// "Lifecycle: <verb> a node" runbooks) can cluster here despite being
|
||||
// genuinely distinct documents, so the UI must let the operator inspect
|
||||
// each cluster rather than offering a blind "merge all."
|
||||
export async function fetchKnowledgeDuplicates(
|
||||
threshold?: number
|
||||
): Promise<KnowledgeDuplicateCluster[]> {
|
||||
const params = threshold ? `?threshold=${threshold}` : ''
|
||||
const res = await fetchWithAuth(`${API}/knowledge/duplicates${params}`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.clusters ?? []
|
||||
}
|
||||
|
||||
export interface KnowledgeOrphan {
|
||||
slug: string
|
||||
title: string
|
||||
kind: string
|
||||
edited_by: string
|
||||
updated_at: string
|
||||
reasons: ('untagged' | 'unlinked' | 'stale')[]
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeOrphans(
|
||||
staleDays?: number
|
||||
): Promise<{ items: KnowledgeOrphan[]; counts: Record<string, number> }> {
|
||||
const params = staleDays ? `?stale_days=${staleDays}` : ''
|
||||
const res = await fetchWithAuth(`${API}/knowledge/orphans${params}`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Folds `sources` into `target`: each source's body is appended under a
|
||||
// provenance heading, tags are unioned, and the sources are soft-deleted
|
||||
// (recoverable from trash, same as a plain delete).
|
||||
export async function mergeKnowledge(
|
||||
target: string,
|
||||
sources: string[]
|
||||
): Promise<{ merged: string[]; tags_added: string[] }> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/merge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target, sources })
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchEntityEvents(
|
||||
entityId: string
|
||||
): Promise<import('./stores/events').OikosEvent[]> {
|
||||
|
||||
@@ -274,7 +274,9 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if msg.text}
|
||||
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
|
||||
<div
|
||||
class="markdown-body prose-chat max-w-none text-sm leading-relaxed assistant-msg"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
{@html render(msg.text)}
|
||||
{#if isLast && streaming}
|
||||
@@ -416,50 +418,36 @@
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Prose overrides */
|
||||
.prose-chat :global(p) {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.prose-chat :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose-chat :global(ul),
|
||||
.prose-chat :global(ol) {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.prose-chat :global(ul) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.prose-chat :global(ol) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
/* Prose overrides — deltas on top of the shared .markdown-body base
|
||||
(app.css) only. The template applies both classes together
|
||||
(class="markdown-body prose-chat ..."); everything below either adds a
|
||||
look .markdown-body doesn't have (li::marker, the pre/blockquote
|
||||
::before ornaments, hr, strong, the table-wrapper, the code-copy
|
||||
button) or overrides a .markdown-body value that this "Art Nouveau"
|
||||
chat treatment wants different (code/pre padding, heading size, th/td
|
||||
padding, blockquote border color, link underline style). Anywhere a
|
||||
value is actually overridden, the selector is
|
||||
`.markdown-body.prose-chat` rather than `.prose-chat` alone —
|
||||
:global() selectors from two different <style> blocks land in the same
|
||||
stylesheet with no scoping to arbitrate between them, so equal
|
||||
specificity would leave the winner to injection order (unreliable
|
||||
across dev/build). The two-class selector's higher specificity wins
|
||||
deterministically regardless. */
|
||||
.prose-chat :global(li) {
|
||||
margin-bottom: 0.125rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
.prose-chat :global(li::marker) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.prose-chat :global(code) {
|
||||
background: var(--muted);
|
||||
.markdown-body.prose-chat :global(code) {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.prose-chat :global(pre) {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
.markdown-body.prose-chat :global(pre) {
|
||||
padding: 0.75rem 0.875rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
.prose-chat :global(pre)::before {
|
||||
@@ -473,34 +461,28 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
.prose-chat :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
||||
margin separates sections; the first heading in a message doesn't. */
|
||||
.prose-chat :global(h1) {
|
||||
.markdown-body.prose-chat :global(h1) {
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.prose-chat :global(h2) {
|
||||
.markdown-body.prose-chat :global(h2) {
|
||||
font-size: 1.08em;
|
||||
font-weight: 600;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.prose-chat :global(h3) {
|
||||
.markdown-body.prose-chat :global(h3) {
|
||||
font-size: 1.02em;
|
||||
font-weight: 600;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
@@ -524,11 +506,6 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.prose-chat :global(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(.table-wrapper) {
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
@@ -540,18 +517,13 @@
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose-chat :global(th),
|
||||
.prose-chat :global(td) {
|
||||
border: 1px solid var(--border);
|
||||
.markdown-body.prose-chat :global(th),
|
||||
.markdown-body.prose-chat :global(td) {
|
||||
padding: 0.3rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose-chat :global(blockquote) {
|
||||
.markdown-body.prose-chat :global(blockquote) {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 0.5rem;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
}
|
||||
@@ -587,8 +559,7 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-chat :global(a) {
|
||||
color: var(--primary);
|
||||
.markdown-body.prose-chat :global(a) {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
@@ -483,8 +483,10 @@
|
||||
|
||||
{#snippet contentContent()}
|
||||
{#if ownContent}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
<div class="prose-chat max-w-none text-xs">{@html renderMarkdown(ownContent.content)}</div>
|
||||
<div class="markdown-body max-w-none text-xs">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
{@html renderMarkdown(ownContent.content)}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No content.</p>
|
||||
{/if}
|
||||
@@ -890,67 +892,3 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Minimal markdown styling for document/investigation/runbook content —
|
||||
mirrors Chat.svelte's .prose-chat (Svelte scopes styles per-component,
|
||||
so it can't be shared directly). */
|
||||
.prose-chat :global(p) {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.prose-chat :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose-chat :global(ul),
|
||||
.prose-chat :global(ol) {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.prose-chat :global(li) {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.prose-chat :global(code) {
|
||||
background: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.prose-chat :global(pre) {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.625rem 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.prose-chat :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(h1),
|
||||
.prose-chat :global(h2),
|
||||
.prose-chat :global(h3) {
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0 0.375rem;
|
||||
font-size: 1em;
|
||||
}
|
||||
.prose-chat :global(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(th),
|
||||
.prose-chat :global(td) {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
.prose-chat :global(blockquote) {
|
||||
border-left: 3px solid var(--border);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
626
web/src/lib/components/knowledge/WikiCleanup.svelte
Normal file
626
web/src/lib/components/knowledge/WikiCleanup.svelte
Normal file
@@ -0,0 +1,626 @@
|
||||
<script lang="ts">
|
||||
// Maintenance view for the knowledge base's own drift — duplicates, tag
|
||||
// casing splits, orphaned notes, and the trash. Surfaced as its own mode
|
||||
// rather than folded into the main three-pane view because none of this
|
||||
// is "browse a note," it's "audit the collection," and mixing the two
|
||||
// would clutter the read/edit flow with tools most visits don't need.
|
||||
//
|
||||
// Every action here (merge, rename, restore) is deliberately one click
|
||||
// away from a review step, never automatic — see fetchKnowledgeDuplicates'
|
||||
// own doc comment on why title-similarity clustering can't be trusted as
|
||||
// a verdict (the five "Lifecycle: <verb> a node" runbooks cluster despite
|
||||
// being genuinely distinct documents).
|
||||
import {
|
||||
fetchKnowledgeDuplicates,
|
||||
fetchKnowledgeTags,
|
||||
fetchKnowledgeOrphans,
|
||||
fetchKnowledgeTrash,
|
||||
renameKnowledgeTag,
|
||||
mergeKnowledge,
|
||||
restoreKnowledge,
|
||||
KnowledgeApiError,
|
||||
type KnowledgeDuplicateCluster,
|
||||
type KnowledgeTag,
|
||||
type KnowledgeOrphan,
|
||||
type KnowledgeTrashItem
|
||||
} from '$lib/api'
|
||||
import * as Tabs from '$lib/components/ui/tabs'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { kindMeta } from './kinds'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import CopyIcon from '@lucide/svelte/icons/copy'
|
||||
import TagIcon from '@lucide/svelte/icons/tag'
|
||||
import GhostIcon from '@lucide/svelte/icons/ghost'
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2'
|
||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
|
||||
let {
|
||||
onSelect,
|
||||
onChanged
|
||||
}: {
|
||||
onSelect: (slug: string) => void
|
||||
// Fired after any mutation (merge, tag rename, restore) so the parent's
|
||||
// note list — which this view reads a filtered copy of, indirectly —
|
||||
// stays in sync.
|
||||
onChanged: () => void
|
||||
} = $props()
|
||||
|
||||
let tab = $state<'duplicates' | 'tags' | 'orphans' | 'trash'>('duplicates')
|
||||
|
||||
// Shared across every loader/action below: the read helpers in api.ts now
|
||||
// throw KnowledgeApiError on a failed request instead of quietly returning
|
||||
// an empty list, so a real outage can't be mistaken for "nothing to
|
||||
// clean up" — see api.ts's comment on listKnowledge for the same fix
|
||||
// applied to the main note list.
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof KnowledgeApiError ? e.message : 'Request failed.'
|
||||
}
|
||||
|
||||
// ─── Duplicates ───────────────────────────────────────────────────────
|
||||
let clusters = $state<KnowledgeDuplicateCluster[] | null>(null)
|
||||
let duplicatesError = $state('')
|
||||
// Per cluster (indexed by the cluster's first member slug — stable across
|
||||
// a reload since clusters are keyed by content, not array position):
|
||||
// which slug is the merge target and which sources are checked.
|
||||
let mergeTarget = $state<Record<string, string>>({})
|
||||
let mergeSources = $state<Record<string, Set<string>>>({})
|
||||
let merging = $state<string | null>(null)
|
||||
let mergeError = $state('')
|
||||
|
||||
async function loadDuplicates(): Promise<void> {
|
||||
clusters = null
|
||||
duplicatesError = ''
|
||||
try {
|
||||
const result = await fetchKnowledgeDuplicates()
|
||||
clusters = result
|
||||
const targets: Record<string, string> = {}
|
||||
const sources: Record<string, Set<string>> = {}
|
||||
for (const c of result) {
|
||||
const key = c.members[0].slug
|
||||
targets[key] = c.members[0].slug // newest first — see the Go handler's sort
|
||||
sources[key] = new Set(c.members.slice(1).map((m) => m.slug))
|
||||
}
|
||||
mergeTarget = targets
|
||||
mergeSources = sources
|
||||
} catch (e) {
|
||||
duplicatesError = errMsg(e)
|
||||
clusters = [] // clear the loading skeleton — the error message above explains the empty state
|
||||
}
|
||||
}
|
||||
|
||||
// A merge target switch leaves the PREVIOUS target unchecked (it's not in
|
||||
// `sources` since it used to be excluded as "the target"), so recompute
|
||||
// the whole source set relative to the new target rather than leaving it
|
||||
// stale — otherwise the old target silently drops out of the merge
|
||||
// instead of folding in like every other member.
|
||||
function setMergeTarget(clusterKey: string, newTarget: string, allSlugs: string[]): void {
|
||||
mergeTarget = { ...mergeTarget, [clusterKey]: newTarget }
|
||||
mergeSources = {
|
||||
...mergeSources,
|
||||
[clusterKey]: new Set(allSlugs.filter((s) => s !== newTarget))
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSource(clusterKey: string, slug: string): void {
|
||||
const set = new Set(mergeSources[clusterKey])
|
||||
if (set.has(slug)) set.delete(slug)
|
||||
else set.add(slug)
|
||||
mergeSources = { ...mergeSources, [clusterKey]: set }
|
||||
}
|
||||
|
||||
async function doMerge(clusterKey: string): Promise<void> {
|
||||
const target = mergeTarget[clusterKey]
|
||||
const sources = [...(mergeSources[clusterKey] ?? [])]
|
||||
if (!target || sources.length === 0) return
|
||||
merging = clusterKey
|
||||
mergeError = ''
|
||||
try {
|
||||
const result = await mergeKnowledge(target, sources)
|
||||
toast.success(`Merged ${result.merged.length} note${result.merged.length === 1 ? '' : 's'}`)
|
||||
onChanged()
|
||||
await loadDuplicates()
|
||||
} catch (e) {
|
||||
mergeError = errMsg(e)
|
||||
} finally {
|
||||
merging = null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tags ─────────────────────────────────────────────────────────────
|
||||
let tags = $state<KnowledgeTag[] | null>(null)
|
||||
let tagsError = $state('')
|
||||
let renaming = $state<string | null>(null)
|
||||
let renameDraft = $state('')
|
||||
let renameBusy = $state(false)
|
||||
|
||||
async function loadTags(): Promise<void> {
|
||||
tags = null
|
||||
tagsError = ''
|
||||
try {
|
||||
tags = await fetchKnowledgeTags()
|
||||
} catch (e) {
|
||||
tagsError = errMsg(e)
|
||||
tags = []
|
||||
}
|
||||
}
|
||||
|
||||
async function normalize(t: KnowledgeTag): Promise<void> {
|
||||
renameBusy = true
|
||||
try {
|
||||
const n = await renameKnowledgeTag(t.variants, t.tag)
|
||||
toast.success(`Normalized "${t.tag}" across ${n} note${n === 1 ? '' : 's'}`)
|
||||
onChanged()
|
||||
await loadTags()
|
||||
} catch (e) {
|
||||
toast.error(errMsg(e))
|
||||
} finally {
|
||||
renameBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
function startRename(t: KnowledgeTag): void {
|
||||
renaming = t.tag
|
||||
renameDraft = t.tag
|
||||
}
|
||||
|
||||
async function confirmRename(t: KnowledgeTag): Promise<void> {
|
||||
const to = renameDraft.trim().toLowerCase()
|
||||
if (!to || to === t.tag) {
|
||||
renaming = null
|
||||
return
|
||||
}
|
||||
renameBusy = true
|
||||
try {
|
||||
const n = await renameKnowledgeTag(t.variants, to)
|
||||
toast.success(`Renamed "${t.tag}" to "${to}" across ${n} note${n === 1 ? '' : 's'}`)
|
||||
onChanged()
|
||||
await loadTags()
|
||||
renaming = null
|
||||
} catch (e) {
|
||||
// Leave the rename input open on failure — the operator's typed value
|
||||
// (and their reason for changing it) shouldn't vanish along with the
|
||||
// error, forcing them to retype it to try again.
|
||||
toast.error(errMsg(e))
|
||||
} finally {
|
||||
renameBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Orphans ──────────────────────────────────────────────────────────
|
||||
let orphans = $state<KnowledgeOrphan[] | null>(null)
|
||||
let orphanCounts = $state<Record<string, number>>({})
|
||||
let orphansError = $state('')
|
||||
|
||||
async function loadOrphans(): Promise<void> {
|
||||
orphans = null
|
||||
orphansError = ''
|
||||
try {
|
||||
const result = await fetchKnowledgeOrphans()
|
||||
orphans = result.items
|
||||
orphanCounts = result.counts
|
||||
} catch (e) {
|
||||
orphansError = errMsg(e)
|
||||
orphans = []
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Trash ────────────────────────────────────────────────────────────
|
||||
let trash = $state<KnowledgeTrashItem[] | null>(null)
|
||||
let trashError = $state('')
|
||||
let restoring = $state<string | null>(null)
|
||||
|
||||
async function loadTrash(): Promise<void> {
|
||||
trash = null
|
||||
trashError = ''
|
||||
try {
|
||||
trash = await fetchKnowledgeTrash()
|
||||
} catch (e) {
|
||||
trashError = errMsg(e)
|
||||
trash = []
|
||||
}
|
||||
}
|
||||
|
||||
async function doRestore(slug: string): Promise<void> {
|
||||
restoring = slug
|
||||
try {
|
||||
await restoreKnowledge(slug)
|
||||
toast.success('Note restored')
|
||||
onChanged()
|
||||
await loadTrash()
|
||||
} catch (e) {
|
||||
toast.error(errMsg(e))
|
||||
} finally {
|
||||
restoring = null
|
||||
}
|
||||
}
|
||||
|
||||
function activate(t: typeof tab): void {
|
||||
tab = t
|
||||
if (t === 'duplicates' && clusters === null) loadDuplicates()
|
||||
else if (t === 'tags' && tags === null) loadTags()
|
||||
else if (t === 'orphans' && orphans === null) loadOrphans()
|
||||
else if (t === 'trash' && trash === null) loadTrash()
|
||||
}
|
||||
|
||||
// Initial tab's data.
|
||||
loadDuplicates()
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-2">
|
||||
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
|
||||
<Tabs.List class="h-8 w-fit">
|
||||
<Tabs.Trigger value="duplicates" class="gap-1 text-xs" onclick={() => activate('duplicates')}>
|
||||
<CopyIcon class="size-3.5" /> Duplicates
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="tags" class="gap-1 text-xs" onclick={() => activate('tags')}>
|
||||
<TagIcon class="size-3.5" /> Tags
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="orphans" class="gap-1 text-xs" onclick={() => activate('orphans')}>
|
||||
<GhostIcon class="size-3.5" /> Orphans
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="trash" class="gap-1 text-xs" onclick={() => activate('trash')}>
|
||||
<Trash2Icon class="size-3.5" /> Trash
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="duplicates" class="min-h-0 flex-1 overflow-y-auto pt-2">
|
||||
<p class="mb-2 text-xs text-muted-foreground">
|
||||
Notes with near-identical titles, grouped for review — not a verdict. Pick a target and the
|
||||
sources to fold into it; sources are soft-deleted afterward and stay recoverable from Trash.
|
||||
</p>
|
||||
{#if mergeError}
|
||||
<p
|
||||
class="mb-2 rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
|
||||
>
|
||||
{mergeError}
|
||||
</p>
|
||||
{/if}
|
||||
{#if duplicatesError}
|
||||
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
|
||||
{duplicatesError}
|
||||
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadDuplicates}
|
||||
>Retry</Button
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{#if clusters === null}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each Array(2) as _, ci (ci)}
|
||||
<div class="overflow-hidden rounded-lg border">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
|
||||
>
|
||||
<Skeleton class="h-3 w-28" />
|
||||
<Skeleton class="h-6 w-28" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-2.5">
|
||||
{#each Array(ci === 0 ? 3 : 2) as _, ri (ri)}
|
||||
<Skeleton class="h-3.5" style="width: {70 - ri * 10}%" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if clusters.length === 0}
|
||||
<p class="py-8 text-center text-xs text-muted-foreground">No likely duplicates found.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each clusters as c (c.members[0].slug)}
|
||||
{@const key = c.members[0].slug}
|
||||
{@const sourceCount = mergeSources[key]?.size ?? 0}
|
||||
<div class="overflow-hidden rounded-lg border">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2 text-xs">
|
||||
<span class="font-medium">{c.members.length} similar notes</span>
|
||||
<!-- Similarity as a meter rather than only a number: it's a
|
||||
ratio, and the bar makes a 93% pileup visibly different
|
||||
from a borderline 61% at a glance down a long list. -->
|
||||
<span
|
||||
class="hidden h-1 w-12 shrink-0 overflow-hidden rounded-full bg-primary/15 sm:block"
|
||||
title="{(c.top_similarity * 100).toFixed(0)}% title similarity"
|
||||
>
|
||||
<span
|
||||
class="block h-full rounded-full bg-primary"
|
||||
style="width: {c.top_similarity * 100}%"
|
||||
></span>
|
||||
</span>
|
||||
<span class="shrink-0 tabular-nums text-muted-foreground"
|
||||
>{(c.top_similarity * 100).toFixed(0)}%</span
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-6 shrink-0 gap-1 text-xs"
|
||||
disabled={merging === key || sourceCount === 0}
|
||||
onclick={() => doMerge(key)}
|
||||
>
|
||||
{merging === key ? 'Merging…' : `Merge ${sourceCount} into target`}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Two bare inputs per row read as "…what do these do?", so
|
||||
name them once per cluster. An inline legend rather than
|
||||
column headers: the controls are 14px wide and the words
|
||||
are not, so headers sized to the columns just collide. -->
|
||||
<p class="flex items-center gap-3 px-2.5 pt-2 text-[10px] text-muted-foreground">
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block size-2 rounded-full ring-1 ring-muted-foreground/60"
|
||||
aria-hidden="true"
|
||||
></span> keep as target
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block size-2 rounded-[2px] ring-1 ring-muted-foreground/60"
|
||||
aria-hidden="true"
|
||||
></span> fold into it
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex flex-col p-1">
|
||||
{#each c.members as m (m.slug)}
|
||||
{@const isTarget = mergeTarget[key] === m.slug}
|
||||
{@const Icon = kindMeta(m.kind).icon}
|
||||
<label
|
||||
class="flex items-center gap-2 rounded px-1.5 py-1 text-xs {isTarget
|
||||
? 'bg-primary/5'
|
||||
: 'hover:bg-muted/40'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="size-3.5 shrink-0 accent-[var(--primary)]"
|
||||
name="target-{key}"
|
||||
aria-label="Keep "{m.title}" as the merge target"
|
||||
checked={isTarget}
|
||||
onchange={() =>
|
||||
setMergeTarget(
|
||||
key,
|
||||
m.slug,
|
||||
c.members.map((mm) => mm.slug)
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-3.5 shrink-0 accent-[var(--primary)]"
|
||||
aria-label="Fold "{m.title}" into the target"
|
||||
disabled={isTarget}
|
||||
checked={!isTarget && (mergeSources[key]?.has(m.slug) ?? false)}
|
||||
onchange={() => toggleSource(key, m.slug)}
|
||||
/>
|
||||
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 truncate text-left hover:underline {isTarget
|
||||
? 'font-medium text-primary'
|
||||
: ''}"
|
||||
onclick={() => onSelect(m.slug)}
|
||||
>
|
||||
{m.title}
|
||||
</button>
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground"
|
||||
>{relativeTime(m.updated_at)}</span
|
||||
>
|
||||
{#if isTarget}
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="shrink-0 border-primary/40 text-[9px] text-primary">target</Badge
|
||||
>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="tags" class="min-h-0 flex-1 overflow-y-auto pt-2">
|
||||
{#if tagsError}
|
||||
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
|
||||
{tagsError}
|
||||
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTags}>Retry</Button>
|
||||
</p>
|
||||
{/if}
|
||||
{#if tags === null}
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-muted-foreground">
|
||||
<th class="py-1 font-normal">Tag</th>
|
||||
<th class="py-1 font-normal" colspan="2">Uses</th>
|
||||
<th class="py-1 font-normal">Variants</th>
|
||||
<th class="py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Array(10) as _, i (i)}
|
||||
<tr class="border-b border-border/50">
|
||||
<td class="w-32 py-1.5 pr-2"
|
||||
><Skeleton class="h-3" style="width: {60 - i * 3}%" /></td
|
||||
>
|
||||
<td class="w-8 py-1.5 pr-1"><Skeleton class="ml-auto h-3 w-4" /></td>
|
||||
<td class="w-24 py-1.5 pr-3">
|
||||
<Skeleton class="h-1 rounded-full" style="width: {90 - i * 8}%" />
|
||||
</td>
|
||||
<td class="py-1.5 pr-2"><Skeleton class="h-3 w-6" /></td>
|
||||
<td class="py-1.5"></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
{@const maxUses = Math.max(1, ...tags.map((t) => t.uses))}
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-muted-foreground">
|
||||
<th class="py-1 font-normal">Tag</th>
|
||||
<th class="py-1 font-normal" colspan="2">Uses</th>
|
||||
<th class="py-1 font-normal">Variants</th>
|
||||
<th class="py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tags as t (t.tag)}
|
||||
<tr class="border-b border-border/50">
|
||||
<td class="py-1 pr-2">
|
||||
{#if renaming === t.tag}
|
||||
<div class="flex items-center gap-1">
|
||||
<Input bind:value={renameDraft} class="h-6 w-32 text-xs" />
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-6 px-1.5"
|
||||
disabled={renameBusy}
|
||||
onclick={() => confirmRename(t)}
|
||||
>
|
||||
<CheckIcon class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="font-mono hover:underline"
|
||||
onclick={() => startRename(t)}
|
||||
>
|
||||
{t.tag}
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
<!-- tabular-nums here (unlike the overview's standalone
|
||||
figures): these are a column that has to line up. -->
|
||||
<td class="w-8 py-1 pr-1 text-right tabular-nums">{t.uses}</td>
|
||||
<td class="w-24 py-1 pr-3">
|
||||
<!-- Magnitude, so: one hue, length-encoded, scaled to the
|
||||
most-used tag. Recessive by design — it's a reading aid
|
||||
down the column, not the subject of the table. -->
|
||||
<span class="block h-1 overflow-hidden rounded-full bg-primary/10">
|
||||
<span
|
||||
class="block h-full rounded-full bg-primary/60"
|
||||
style="width: {(t.uses / maxUses) * 100}%"
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-1 pr-2">
|
||||
{#if t.split}
|
||||
<span class="text-destructive">{t.variants.join(', ')}</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right">
|
||||
{#if t.split}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-6 text-xs"
|
||||
disabled={renameBusy}
|
||||
onclick={() => normalize(t)}
|
||||
>
|
||||
Normalize
|
||||
</Button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="orphans" class="min-h-0 flex-1 overflow-y-auto pt-2">
|
||||
<p class="mb-2 text-xs text-muted-foreground">
|
||||
Notes untagged, unlinked to any entity, or untouched for 90+ days — invisible to most
|
||||
navigation paths and easy to lose track of.
|
||||
{#if orphanCounts.untagged || orphanCounts.unlinked || orphanCounts.stale}
|
||||
({orphanCounts.untagged ?? 0} untagged · {orphanCounts.unlinked ?? 0} unlinked · {orphanCounts.stale ??
|
||||
0} stale)
|
||||
{/if}
|
||||
</p>
|
||||
{#if orphansError}
|
||||
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
|
||||
{orphansError}
|
||||
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadOrphans}
|
||||
>Retry</Button
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{#if orphans === null}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each Array(7) as _, i (i)}
|
||||
<div class="flex items-center gap-2 px-1.5 py-1.5">
|
||||
<Skeleton class="h-3.5 flex-1" style="max-width: {60 - (i % 4) * 8}%" />
|
||||
<Skeleton class="h-4 w-14 shrink-0 rounded-full" />
|
||||
<Skeleton class="h-3 w-10 shrink-0" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if orphans.length === 0}
|
||||
<p class="py-8 text-center text-xs text-muted-foreground">Nothing orphaned.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each orphans as o (o.slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted/40"
|
||||
onclick={() => onSelect(o.slug)}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{o.title}</span>
|
||||
{#each o.reasons as r (r)}<Badge variant="outline" class="shrink-0 text-[9px]"
|
||||
>{r}</Badge
|
||||
>{/each}
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground"
|
||||
>{relativeTime(o.updated_at)}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="trash" class="min-h-0 flex-1 overflow-y-auto pt-2">
|
||||
{#if trashError}
|
||||
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
|
||||
{trashError}
|
||||
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTrash}>Retry</Button>
|
||||
</p>
|
||||
{/if}
|
||||
{#if trash === null}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each Array(4) as _, i (i)}
|
||||
<div class="flex items-center gap-2 px-1.5 py-1.5">
|
||||
<Skeleton class="h-3.5 flex-1" style="max-width: {55 - i * 6}%" />
|
||||
<Skeleton class="h-3 w-32 shrink-0" />
|
||||
<Skeleton class="h-6 w-16 shrink-0" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if trash.length === 0}
|
||||
<p class="py-8 text-center text-xs text-muted-foreground">Trash is empty.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each trash as t (t.slug)}
|
||||
<div class="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40">
|
||||
<span class="min-w-0 flex-1 truncate">{t.title}</span>
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground">
|
||||
deleted {relativeTime(t.deleted_at)} by {t.deleted_by || 'unknown'}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-6 shrink-0 gap-1 text-xs"
|
||||
disabled={restoring === t.slug}
|
||||
onclick={() => doRestore(t.slug)}
|
||||
>
|
||||
<RotateCcwIcon class="size-3" /> Restore
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
124
web/src/lib/components/knowledge/WikiContextRail.svelte
Normal file
124
web/src/lib/components/knowledge/WikiContextRail.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
// Right pane: the discovery half of the wiki. From any note you can walk
|
||||
// to the entity it's about, and from there sideways to every other note
|
||||
// that concerns the same entity or shares a tag — this is what makes the
|
||||
// knowledge base a graph to browse rather than a flat list to scroll.
|
||||
//
|
||||
// "Related" and "tag neighbours" are derived client-side from the list
|
||||
// already loaded by Knowledge.svelte (KnowledgeListItem carries `about`
|
||||
// and `tags`), not a separate endpoint — with ~100 notes total, filtering
|
||||
// an in-memory array is cheaper and simpler than a bespoke backlinks
|
||||
// query, and it's exactly the same data WikiTree's group-by-entity/tag
|
||||
// modes already use.
|
||||
import type { KnowledgeListItem } from '$lib/api'
|
||||
import { openEntityWindow } from '$lib/stores/windows'
|
||||
import DetailSection from '$lib/components/DetailSection.svelte'
|
||||
import { kindMeta } from './kinds'
|
||||
import LinkIcon from '@lucide/svelte/icons/link'
|
||||
|
||||
let {
|
||||
item,
|
||||
allItems,
|
||||
onSelect
|
||||
}: {
|
||||
item: KnowledgeListItem | null
|
||||
allItems: KnowledgeListItem[]
|
||||
onSelect: (slug: string) => void
|
||||
} = $props()
|
||||
|
||||
const related = $derived.by(() => {
|
||||
if (!item || item.about.length === 0) return []
|
||||
const aboutSet = new Set(item.about)
|
||||
return allItems
|
||||
.filter((it) => it.slug !== item.slug && it.about.some((s) => aboutSet.has(s)))
|
||||
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
||||
})
|
||||
|
||||
const tagNeighbours = $derived.by(() => {
|
||||
if (!item || item.tags.length === 0) return []
|
||||
const tagSet = new Set(item.tags)
|
||||
return allItems
|
||||
.filter((it) => it.slug !== item.slug && it.tags.some((t) => tagSet.has(t)))
|
||||
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
||||
.slice(0, 20) // common tags (e.g. "backup") can otherwise pull in most of the KB
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-2 overflow-y-auto pr-1">
|
||||
{#if !item}
|
||||
<p class="py-8 text-center text-xs text-muted-foreground">Nothing selected.</p>
|
||||
{:else}
|
||||
<DetailSection title="About" count={item.about.length} defaultOpen={true}>
|
||||
{#if item.about.length === 0}
|
||||
<p class="text-xs text-muted-foreground">Not linked to any entity.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each item.about as slug (slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded px-1 py-0.5 text-left font-mono text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
onclick={() => openEntityWindow(slug)}
|
||||
>
|
||||
<LinkIcon class="size-3 shrink-0" />
|
||||
<span class="truncate">{slug}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title="Also about these entities"
|
||||
count={related.length}
|
||||
defaultOpen={related.length > 0}
|
||||
>
|
||||
{#if related.length === 0}
|
||||
<p class="text-xs text-muted-foreground">No other notes share a linked entity.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each related as it (it.slug)}
|
||||
{@const Icon = kindMeta(it.kind).icon}
|
||||
<button
|
||||
type="button"
|
||||
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
|
||||
onclick={() => onSelect(it.slug)}
|
||||
title={it.title}
|
||||
>
|
||||
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
|
||||
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</DetailSection>
|
||||
|
||||
<!-- Only auto-open a *tight* neighbour set. A generic tag like "container"
|
||||
is on 20 notes, and expanding all of those by default buries the
|
||||
stronger entity-based links above it under a wall of weak matches;
|
||||
a handful of shared-tag notes is a real cluster worth surfacing. -->
|
||||
<DetailSection
|
||||
title="Tag neighbours"
|
||||
count={tagNeighbours.length}
|
||||
defaultOpen={related.length === 0 && tagNeighbours.length > 0 && tagNeighbours.length <= 6}
|
||||
>
|
||||
{#if tagNeighbours.length === 0}
|
||||
<p class="text-xs text-muted-foreground">No other notes share a tag.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each tagNeighbours as it (it.slug)}
|
||||
{@const Icon = kindMeta(it.kind).icon}
|
||||
<button
|
||||
type="button"
|
||||
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
|
||||
onclick={() => onSelect(it.slug)}
|
||||
title={it.title}
|
||||
>
|
||||
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
|
||||
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</DetailSection>
|
||||
{/if}
|
||||
</div>
|
||||
139
web/src/lib/components/knowledge/WikiNewDialog.svelte
Normal file
139
web/src/lib/components/knowledge/WikiNewDialog.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
// "New note" dialog — the create half of the wiki. A plain toggle group for
|
||||
// kind (document/investigation/runbook) rather than the Select primitive:
|
||||
// three fixed, always-visible options don't need a popover, and this
|
||||
// mirrors the same toggle-group pattern WikiTree already uses for its
|
||||
// group-by switch.
|
||||
import { createKnowledge, KnowledgeApiError } from '$lib/api'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onCreated
|
||||
}: {
|
||||
open: boolean
|
||||
onCreated: (slug: string) => void
|
||||
} = $props()
|
||||
|
||||
const KINDS = ['document', 'investigation', 'runbook'] as const
|
||||
type Kind = (typeof KINDS)[number]
|
||||
|
||||
let title = $state('')
|
||||
let kind = $state<Kind>('document')
|
||||
let folder = $state('')
|
||||
let tags = $state('')
|
||||
let content = $state('')
|
||||
let saving = $state(false)
|
||||
let error = $state('')
|
||||
|
||||
function reset(): void {
|
||||
title = ''
|
||||
kind = 'document'
|
||||
folder = ''
|
||||
tags = ''
|
||||
content = ''
|
||||
error = ''
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!title.trim() || !content.trim()) {
|
||||
error = 'Title and content are required.'
|
||||
return
|
||||
}
|
||||
saving = true
|
||||
error = ''
|
||||
try {
|
||||
const result = await createKnowledge({
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
kind,
|
||||
folder: folder.trim() || undefined,
|
||||
tags: tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
onCreated(result.slug)
|
||||
open = false
|
||||
reset()
|
||||
} catch (e) {
|
||||
error =
|
||||
e instanceof KnowledgeApiError
|
||||
? `${e.message}${e.detail ? ` — ${e.detail}` : ''}`
|
||||
: 'Create failed.'
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>New knowledge note</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if error}
|
||||
<p
|
||||
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-title">
|
||||
Title
|
||||
<Input id="new-note-title" bind:value={title} placeholder="Short, specific, searchable" />
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">Type</span>
|
||||
<div class="inline-flex overflow-hidden rounded-md border">
|
||||
{#each KINDS as k (k)}
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs {kind === k
|
||||
? 'bg-secondary text-secondary-foreground'
|
||||
: 'hover:bg-muted/50'}"
|
||||
onclick={() => (kind = k)}
|
||||
>
|
||||
{k}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-folder">
|
||||
Folder <span class="text-muted-foreground/70">(optional — defaults to "operator")</span>
|
||||
<Input
|
||||
id="new-note-folder"
|
||||
bind:value={folder}
|
||||
placeholder="e.g. containers, infrastructure"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-tags">
|
||||
Tags <span class="text-muted-foreground/70">(comma-separated, optional)</span>
|
||||
<Input id="new-note-tags" bind:value={tags} placeholder="oom, rclone, gotcha" />
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-content">
|
||||
Content (markdown)
|
||||
<Textarea
|
||||
id="new-note-content"
|
||||
bind:value={content}
|
||||
class="min-h-[160px] font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (open = false)} disabled={saving}>Cancel</Button>
|
||||
<Button onclick={submit} disabled={saving}>{saving ? 'Creating…' : 'Create'}</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
201
web/src/lib/components/knowledge/WikiOverview.svelte
Normal file
201
web/src/lib/components/knowledge/WikiOverview.svelte
Normal file
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
// The reader pane's resting state — what you see every time the app opens
|
||||
// and nothing is selected yet.
|
||||
//
|
||||
// This used to be the sentence "Select a note, or create a new one."
|
||||
// centred in an otherwise empty 56%-width pane: the single most-seen screen
|
||||
// in the app doing no work at all. It's now the landing view, and it also
|
||||
// restores the collection-level numbers the wiki redesign dropped (the old
|
||||
// stats-only Knowledge page led with them, and they were the one thing that
|
||||
// page did well — "the system is getting smarter" is only visible in
|
||||
// aggregate).
|
||||
//
|
||||
// Every figure is derived from the `items` array the parent already loaded
|
||||
// for the tree, so this panel costs no extra request.
|
||||
import type { KnowledgeListItem } from '$lib/api'
|
||||
import { kindMeta, isAgentAuthored } from './kinds'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import BotIcon from '@lucide/svelte/icons/bot'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import ClockIcon from '@lucide/svelte/icons/clock'
|
||||
import HashIcon from '@lucide/svelte/icons/hash'
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
|
||||
let {
|
||||
items,
|
||||
onSelect,
|
||||
onNew
|
||||
}: {
|
||||
items: KnowledgeListItem[]
|
||||
onSelect: (slug: string) => void
|
||||
onNew: () => void
|
||||
} = $props()
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// Postgres renders timestamptz as "2026-07-26 10:50:53.475644+00" — a space
|
||||
// instead of ISO-8601's 'T', and a bare two-digit offset. V8 happens to
|
||||
// accept that verbatim, but Safari's parser requires the 'T' AND an offset
|
||||
// of 'Z' or ±HH:MM, so both have to be normalised together: swapping only
|
||||
// the separator yields "…475644+00", which is invalid ISO and parses to NaN
|
||||
// *everywhere* — strictly worse than leaving the string alone.
|
||||
function parseTimestamp(raw: string): number {
|
||||
return Date.parse(raw.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00'))
|
||||
}
|
||||
|
||||
const stats = $derived.by(() => {
|
||||
const now = Date.now()
|
||||
let agent = 0
|
||||
let lastWeek = 0
|
||||
const byKind = new Map<string, number>()
|
||||
const tagCounts = new Map<string, number>()
|
||||
|
||||
for (const it of items) {
|
||||
if (isAgentAuthored(it.edited_by)) agent++
|
||||
const ts = parseTimestamp(it.updated_at)
|
||||
if (!Number.isNaN(ts) && now - ts < WEEK_MS) lastWeek++
|
||||
byKind.set(it.kind, (byKind.get(it.kind) ?? 0) + 1)
|
||||
for (const t of it.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return {
|
||||
total: items.length,
|
||||
agent,
|
||||
lastWeek,
|
||||
byKind,
|
||||
topTags: [...tagCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
|
||||
}
|
||||
})
|
||||
|
||||
// Share of the collection the agent wrote — the "is this thing actually
|
||||
// learning" number, and the only ratio here worth a meter rather than
|
||||
// another tile.
|
||||
const agentShare = $derived(stats.total === 0 ? 0 : Math.round((stats.agent / stats.total) * 100))
|
||||
|
||||
const recent = $derived(
|
||||
[...items].sort((a, b) => b.updated_at.localeCompare(a.updated_at)).slice(0, 6)
|
||||
)
|
||||
|
||||
// Kinds in a fixed order so the row doesn't reshuffle as counts change.
|
||||
const KIND_ORDER = ['runbook', 'investigation', 'document'] as const
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex h-full w-full max-w-2xl flex-col gap-7 overflow-y-auto px-1 py-6">
|
||||
{#if stats.total === 0}
|
||||
<!-- Genuinely empty collection (not a failed load — the parent handles
|
||||
that case before rendering this component). -->
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-3 text-center">
|
||||
<h2 class="text-lg font-semibold">Nothing here yet</h2>
|
||||
<p class="max-w-sm text-sm text-muted-foreground">
|
||||
The knowledge base is empty. Write the first note, or let Nomos record what it learns as it
|
||||
works.
|
||||
</p>
|
||||
<Button size="sm" class="gap-1.5" onclick={onNew}>
|
||||
<PlusIcon class="size-3.5" /> New note
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Hero: the one number the view leads with. Sans, not the Inknut
|
||||
heading face — a serif at display size reads as decoration rather
|
||||
than data. Proportional figures (no tabular-nums): this is a
|
||||
standalone value, not a column that has to align. -->
|
||||
<div>
|
||||
<h2 class="text-sm font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Knowledge base
|
||||
</h2>
|
||||
<div class="mt-1 flex items-baseline gap-2.5">
|
||||
<span class="font-sans text-5xl leading-none font-semibold">{stats.total}</span>
|
||||
<span class="text-sm text-muted-foreground">notes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI row. Hairline dividers rather than boxed cards: at four items the
|
||||
boxes were doing more visual work than the numbers inside them. -->
|
||||
<div class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-border/60 sm:grid-cols-4">
|
||||
{#each KIND_ORDER as k (k)}
|
||||
{@const meta = kindMeta(k)}
|
||||
{@const Icon = meta.icon}
|
||||
<div class="flex flex-col gap-1 bg-card px-3 py-2.5">
|
||||
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<Icon class="size-3.5" />
|
||||
{meta.plural}
|
||||
</span>
|
||||
<span class="text-xl font-semibold">{stats.byKind.get(k) ?? 0}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex flex-col gap-1 bg-card px-3 py-2.5">
|
||||
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<SparklesIcon class="size-3.5" /> this week
|
||||
</span>
|
||||
<span class="text-xl font-semibold">{stats.lastWeek}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meter: one ratio, one hue. Track is a lighter step of the fill's own
|
||||
ramp so the whole bar reads as a single scale. -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-baseline justify-between text-xs">
|
||||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<BotIcon class="size-3.5" /> Written by Nomos
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
<span class="font-semibold text-foreground">{stats.agent}</span> of {stats.total} · {agentShare}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="h-1.5 overflow-hidden rounded-full bg-primary/15"
|
||||
role="meter"
|
||||
aria-valuenow={agentShare}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Share of notes written by Nomos"
|
||||
>
|
||||
<div class="h-full rounded-full bg-primary" style="width: {agentShare}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<ClockIcon class="size-3.5" /> Recently updated
|
||||
</h3>
|
||||
<div class="flex flex-col">
|
||||
{#each recent as it (it.slug)}
|
||||
{@const Icon = kindMeta(it.kind).icon}
|
||||
<button
|
||||
type="button"
|
||||
class="group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
|
||||
onclick={() => onSelect(it.slug)}
|
||||
>
|
||||
<Icon class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm group-hover:text-primary">{it.title}</span>
|
||||
{#if isAgentAuthored(it.edited_by)}
|
||||
<BotIcon class="size-3 shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground"
|
||||
>{relativeTime(it.updated_at)}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stats.topTags.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<HashIcon class="size-3.5" /> Busiest tags
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each stats.topTags as [tag, count] (tag)}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
<span class="text-foreground/70 tabular-nums">{count}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
119
web/src/lib/components/knowledge/WikiQuickOpen.svelte
Normal file
119
web/src/lib/components/knowledge/WikiQuickOpen.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
// Cmd/Ctrl+K quick-open over every note title — the fast path once you
|
||||
// already know roughly what you're looking for, as opposed to WikiTree's
|
||||
// browse-by-group path for when you don't. Built on the Dialog primitive
|
||||
// + a plain filtered list rather than shadcn-svelte's `command` component:
|
||||
// that component's interactive CLI installer couldn't be driven
|
||||
// non-interactively in this environment (it prompts to resolve overlapping
|
||||
// dependency files), and re-deriving the same arrow-key/Enter list nav by
|
||||
// hand here is a small amount of code for something this self-contained.
|
||||
import type { KnowledgeListItem } from '$lib/api'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
items,
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean
|
||||
items: KnowledgeListItem[]
|
||||
onSelect: (slug: string) => void
|
||||
} = $props()
|
||||
|
||||
let query = $state('')
|
||||
let activeIndex = $state(0)
|
||||
let inputEl = $state<HTMLInputElement | null>(null)
|
||||
|
||||
const results = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const pool = q
|
||||
? items.filter(
|
||||
(it) =>
|
||||
it.title.toLowerCase().includes(q) ||
|
||||
it.slug.toLowerCase().includes(q) ||
|
||||
it.tags.some((t) => t.toLowerCase().includes(q))
|
||||
)
|
||||
: items
|
||||
return pool.slice(0, 30) // 102 notes total — cap the render, not the match
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
void results // dependency only — re-run when the result set changes
|
||||
activeIndex = 0
|
||||
})
|
||||
|
||||
// Reset on every open so quick-open never remembers the last search, and
|
||||
// focus the input once the dialog has actually mounted it.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
query = ''
|
||||
queueMicrotask(() => inputEl?.focus())
|
||||
}
|
||||
})
|
||||
|
||||
function choose(slug: string): void {
|
||||
onSelect(slug)
|
||||
open = false
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if (!open) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex = Math.min(activeIndex + 1, results.length - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex = Math.max(activeIndex - 1, 0)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const hit = results[activeIndex]
|
||||
if (hit) choose(hit.slug)
|
||||
}
|
||||
}
|
||||
|
||||
// A window-level listener rather than one on Dialog.Content: bits-ui's
|
||||
// Dialog renders its content through a portal with its own focus-trap
|
||||
// wiring, and an onkeydown prop passed straight through to Content did not
|
||||
// reliably receive ArrowDown/Enter in testing (focus landing inside the
|
||||
// trap didn't guarantee the event reached the element this component
|
||||
// attached the listener to). Capturing at the window and gating on `open`
|
||||
// sidesteps that entirely — Escape-to-close is still bits-ui's own
|
||||
// behavior, this only adds the list-navigation keys.
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
onDestroy(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content
|
||||
class="top-[20%] max-w-lg -translate-y-0 gap-0 p-0 sm:max-w-lg"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<Input
|
||||
bind:ref={inputEl}
|
||||
bind:value={query}
|
||||
placeholder="Jump to a note…"
|
||||
class="h-11 rounded-b-none border-0 border-b px-3 text-sm focus-visible:ring-0"
|
||||
/>
|
||||
<div class="max-h-80 overflow-y-auto p-1">
|
||||
{#each results as it, i (it.slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm {i ===
|
||||
activeIndex
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted/50'}"
|
||||
onclick={() => choose(it.slug)}
|
||||
onmouseenter={() => (activeIndex = i)}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{it.title}</span>
|
||||
<StatusBadge kind="type" value={it.kind} class="shrink-0 text-[9px]" />
|
||||
</button>
|
||||
{:else}
|
||||
<p class="py-6 text-center text-xs text-muted-foreground">No notes match "{query}".</p>
|
||||
{/each}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
485
web/src/lib/components/knowledge/WikiReader.svelte
Normal file
485
web/src/lib/components/knowledge/WikiReader.svelte
Normal file
@@ -0,0 +1,485 @@
|
||||
<script lang="ts">
|
||||
// Center pane: read a note, edit it in place, or browse its history.
|
||||
//
|
||||
// `item` carries the list-derived metadata (kind, tags, about, edited_by —
|
||||
// everything WikiTree already has); the full body is fetched here lazily
|
||||
// per selection, same split as the API (serveKnowledgeList never returns
|
||||
// content — see knowledge_write.go — so the tree stays cheap and only the
|
||||
// note actually being read pays for its body).
|
||||
import {
|
||||
fetchKnowledgeContent,
|
||||
fetchKnowledgeRevisions,
|
||||
updateKnowledge,
|
||||
deleteKnowledge,
|
||||
KnowledgeApiError,
|
||||
type KnowledgeListItem,
|
||||
type KnowledgeContent,
|
||||
type KnowledgeRevision
|
||||
} from '$lib/api'
|
||||
import { renderWikiMarkdown, slugFromKbHref, diffLines } from './wikiText'
|
||||
import { kindMeta, isAgentAuthored } from './kinds'
|
||||
import WikiOverview from './WikiOverview.svelte'
|
||||
import { openEntityWindow } from '$lib/stores/windows'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import * as Tabs from '$lib/components/ui/tabs'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil'
|
||||
import TrashIcon from '@lucide/svelte/icons/trash-2'
|
||||
import HistoryIcon from '@lucide/svelte/icons/history'
|
||||
import BotIcon from '@lucide/svelte/icons/bot'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import SaveIcon from '@lucide/svelte/icons/save'
|
||||
|
||||
let {
|
||||
item,
|
||||
allItems,
|
||||
knownSlugs,
|
||||
onNavigate,
|
||||
onNew,
|
||||
onChanged,
|
||||
dirty = $bindable(false)
|
||||
}: {
|
||||
item: KnowledgeListItem | null
|
||||
// The whole collection — only used for the resting-state overview shown
|
||||
// when nothing is selected (WikiOverview derives its figures from it).
|
||||
allItems: KnowledgeListItem[]
|
||||
knownSlugs: Set<string>
|
||||
onNavigate: (slug: string) => void
|
||||
onNew: () => void
|
||||
// Fired after a save or delete that the parent's cached list needs to
|
||||
// reflect (title/tags changed, or the note is gone). Parent decides
|
||||
// whether to refetch the whole list or patch locally.
|
||||
onChanged: () => void
|
||||
// True while there's an in-progress edit that would be silently
|
||||
// discarded if `item` changed out from under this component. Knowledge.svelte
|
||||
// reads this before switching the selection (tree click, quick-open,
|
||||
// etc.) so it can confirm with the operator first — see its
|
||||
// requestSelect. Deliberately "in edit mode" rather than a real dirty
|
||||
// diff against the loaded content: simpler, and erring toward "ask
|
||||
// even if nothing actually changed" is the safe direction for a
|
||||
// destructive-by-default operation.
|
||||
dirty?: boolean
|
||||
} = $props()
|
||||
|
||||
let content = $state<KnowledgeContent | null>(null)
|
||||
let loading = $state(false)
|
||||
let mode = $state<'read' | 'edit'>('read')
|
||||
let tab = $state<'note' | 'history'>('note')
|
||||
let saveError = $state('')
|
||||
let saving = $state(false)
|
||||
|
||||
let draftTitle = $state('')
|
||||
let draftContent = $state('')
|
||||
let draftTags = $state('')
|
||||
let draftAbout = $state('')
|
||||
|
||||
let revisions = $state<KnowledgeRevision[] | null>(null)
|
||||
let revisionsLoading = $state(false)
|
||||
let selectedRevisionId = $state<number | null>(null)
|
||||
|
||||
async function load(slug: string): Promise<void> {
|
||||
loading = true
|
||||
mode = 'read'
|
||||
dirty = false
|
||||
tab = 'note'
|
||||
revisions = null
|
||||
selectedRevisionId = null
|
||||
saveError = ''
|
||||
content = await fetchKnowledgeContent(slug)
|
||||
loading = false
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (item) load(item.slug)
|
||||
else content = null
|
||||
})
|
||||
|
||||
function startEdit(): void {
|
||||
if (!content || !item) return
|
||||
draftTitle = content.title
|
||||
draftContent = content.content
|
||||
draftTags = content.tags.join(', ')
|
||||
draftAbout = item.about.join(', ')
|
||||
saveError = ''
|
||||
mode = 'edit'
|
||||
dirty = true
|
||||
}
|
||||
|
||||
function cancelEdit(): void {
|
||||
mode = 'read'
|
||||
dirty = false
|
||||
saveError = ''
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!item) return
|
||||
const title = draftTitle.trim()
|
||||
const body = draftContent.trim()
|
||||
if (!title || !body) {
|
||||
saveError = 'Title and content cannot be empty.'
|
||||
return
|
||||
}
|
||||
saving = true
|
||||
saveError = ''
|
||||
const about = draftAbout
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
try {
|
||||
const result = await updateKnowledge(item.slug, {
|
||||
title,
|
||||
content: body,
|
||||
tags: draftTags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
about
|
||||
})
|
||||
// A typo'd entity slug in "About" fails to link server-side with only
|
||||
// a log line (see linkKnowledgeAbout) — diff what came back against
|
||||
// what was submitted so that doesn't happen silently.
|
||||
const unresolved = about.filter((s) => !result.linked?.includes(s))
|
||||
if (unresolved.length > 0) {
|
||||
toast.error(`Couldn't link to: ${unresolved.join(', ')} — check the slug is correct.`)
|
||||
}
|
||||
mode = 'read'
|
||||
dirty = false
|
||||
await load(item.slug)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
saveError =
|
||||
e instanceof KnowledgeApiError
|
||||
? `${e.message}${e.detail ? ` — ${e.detail}` : ''}`
|
||||
: 'Save failed.'
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
let confirmDeleteOpen = $state(false)
|
||||
let deleting = $state(false)
|
||||
|
||||
// Soft delete (see migrations/022_knowledge_revisions.up.sql) — the note
|
||||
// goes to trash and can be brought back, so this is a lightweight confirm
|
||||
// rather than anything heavier. It's an in-app Dialog rather than the
|
||||
// browser's native confirm(): this app runs inside a custom floating
|
||||
// window (its own desktop-shell chrome), and a native confirm() blocks
|
||||
// the entire page's JS event loop until dismissed — in testing that froze
|
||||
// the tab hard enough that automated clicks stopped registering
|
||||
// entirely. A real dialog stays inside Svelte's event handling and can't
|
||||
// wedge the app that way.
|
||||
async function confirmDelete(): Promise<void> {
|
||||
if (!item) return
|
||||
deleting = true
|
||||
try {
|
||||
await deleteKnowledge(item.slug)
|
||||
confirmDeleteOpen = false
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
saveError = e instanceof KnowledgeApiError ? e.message : 'Delete failed.'
|
||||
} finally {
|
||||
deleting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openHistory(): Promise<void> {
|
||||
tab = 'history'
|
||||
if (revisions !== null || !item) return
|
||||
revisionsLoading = true
|
||||
revisions = await fetchKnowledgeRevisions(item.slug)
|
||||
selectedRevisionId = revisions[0]?.id ?? null
|
||||
revisionsLoading = false
|
||||
}
|
||||
|
||||
// Bare slugs are auto-linked (see wikiText.ts) as `#kb:<slug>` anchors.
|
||||
// Intercepted here via event delegation on the rendered container — the
|
||||
// markdown body is injected with {@html}, so component-level click
|
||||
// bindings can't attach to individual links, but a plain bubbling
|
||||
// listener on the wrapper works the same as it would for real DOM.
|
||||
function handleContentClick(e: MouseEvent): void {
|
||||
const anchor = (e.target as HTMLElement).closest('a')
|
||||
if (!anchor) return
|
||||
const slug = slugFromKbHref(anchor.getAttribute('href'))
|
||||
if (!slug) return
|
||||
e.preventDefault()
|
||||
if (knownSlugs.has(slug)) onNavigate(slug)
|
||||
else openEntityWindow(slug)
|
||||
}
|
||||
|
||||
const selectedRevision = $derived(revisions?.find((r) => r.id === selectedRevisionId) ?? null)
|
||||
// Diff against the CURRENT live body, not the next revision — the History
|
||||
// tab answers "what did this look like before it became what it is now,"
|
||||
// not "what changed between two arbitrary edits."
|
||||
const diff = $derived(
|
||||
selectedRevision && content ? diffLines(selectedRevision.content, content.content) : null
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-w-0 flex-col gap-2">
|
||||
{#if !item}
|
||||
<WikiOverview items={allItems} onSelect={onNavigate} {onNew} />
|
||||
{:else if loading}
|
||||
<!-- Shaped like the loaded header/tags/body below rather than a
|
||||
centered spinner, so the switch from "loading" to "loaded" is a
|
||||
content swap, not a layout jump — the title, meta line, tag row,
|
||||
and first few lines of body all keep their real position. -->
|
||||
<div class="flex items-start justify-between gap-2 border-b pb-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="size-4 shrink-0 rounded" />
|
||||
<Skeleton class="h-5 w-56" />
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<Skeleton class="h-3 w-16" />
|
||||
<Skeleton class="h-3 w-20" />
|
||||
<Skeleton class="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton class="h-7 w-16 shrink-0" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 pt-3">
|
||||
<Skeleton class="h-5 w-14 rounded-full" />
|
||||
<Skeleton class="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5 pt-2">
|
||||
{#each Array(6) as _, i (i)}
|
||||
<Skeleton class="h-4" style="width: {i === 5 ? 45 : 96 - i * 4}%" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !content}
|
||||
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Couldn't load this note.
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Header: kind + title, then a single provenance line. Previously these
|
||||
were one wrapping row of badges and text fragments; splitting
|
||||
"what this is" from "where it came from" stops the title competing
|
||||
with its own metadata. -->
|
||||
<div class="flex items-start justify-between gap-2 border-b pb-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if mode === 'edit'}
|
||||
<Input bind:value={draftTitle} class="mb-1 h-8 font-medium" placeholder="Title" />
|
||||
{:else}
|
||||
{@const KindIcon = kindMeta(item.kind).icon}
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<KindIcon class="size-4 shrink-0 text-muted-foreground" />
|
||||
<h2 class="truncate text-base font-semibold">{content.title}</h2>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<span class="capitalize">{kindMeta(item.kind).label}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
{#if isAgentAuthored(content.edited_by)}
|
||||
<span class="flex items-center gap-1 text-primary">
|
||||
<BotIcon class="size-3" /> Nomos
|
||||
</span>
|
||||
{:else if content.edited_by}
|
||||
<span>{content.edited_by}</span>
|
||||
{:else}
|
||||
<span>unknown author</span>
|
||||
{/if}
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>updated {relativeTime(content.updated_at)}</span>
|
||||
{#if content.revisions > 0}
|
||||
<span aria-hidden="true">·</span>
|
||||
<button
|
||||
type="button"
|
||||
class="underline decoration-dotted underline-offset-2 hover:text-foreground"
|
||||
onclick={openHistory}
|
||||
>
|
||||
{content.revisions} revision{content.revisions === 1 ? '' : 's'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
{#if mode === 'read'}
|
||||
<Button size="sm" variant="outline" class="h-7 gap-1 text-xs" onclick={startEdit}>
|
||||
<PencilIcon class="size-3.5" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 gap-1 text-xs text-destructive"
|
||||
onclick={() => (confirmDeleteOpen = true)}
|
||||
>
|
||||
<TrashIcon class="size-3.5" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 gap-1 text-xs"
|
||||
onclick={cancelEdit}
|
||||
disabled={saving}
|
||||
>
|
||||
<XIcon class="size-3.5" /> Cancel
|
||||
</Button>
|
||||
<Button size="sm" class="h-7 gap-1 text-xs" onclick={save} disabled={saving}>
|
||||
<SaveIcon class="size-3.5" />
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if saveError}
|
||||
<p
|
||||
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
|
||||
>
|
||||
{saveError}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'edit'}
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
<Textarea
|
||||
bind:value={draftContent}
|
||||
class="min-h-[240px] flex-1 resize-none font-mono text-xs"
|
||||
placeholder="Markdown content…"
|
||||
/>
|
||||
<label class="text-xs text-muted-foreground" for="wiki-tags">
|
||||
Tags (comma-separated)
|
||||
<Input
|
||||
id="wiki-tags"
|
||||
bind:value={draftTags}
|
||||
class="mt-1 h-7 text-xs"
|
||||
placeholder="oom, rclone, gotcha"
|
||||
/>
|
||||
</label>
|
||||
<label class="text-xs text-muted-foreground" for="wiki-about">
|
||||
About (entity slugs, comma-separated)
|
||||
<Input
|
||||
id="wiki-about"
|
||||
bind:value={draftAbout}
|
||||
class="mt-1 h-7 text-xs"
|
||||
placeholder="host:strong, lxc:gitea"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{:else}
|
||||
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
|
||||
<Tabs.List class="h-7 w-fit">
|
||||
<Tabs.Trigger value="note" class="text-xs">Note</Tabs.Trigger>
|
||||
<Tabs.Trigger value="history" class="gap-1 text-xs" onclick={openHistory}>
|
||||
<HistoryIcon class="size-3" /> History
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="note" class="min-h-0 flex-1 overflow-y-auto pt-3">
|
||||
{#if item.tags.length}
|
||||
<div class="mb-3 flex max-w-[68ch] flex-wrap gap-1.5">
|
||||
{#each item.tags as t (t)}<span
|
||||
class="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>{t}</span
|
||||
>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- event delegation over rendered markdown: the interactive elements are the <a>
|
||||
tags inside, already keyboard-operable on their own. -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- max-w-[68ch]: without a measure the body ran the full width of a
|
||||
resizable pane, which at a wide split is well past the ~75ch
|
||||
where prose stops being comfortable to read. -->
|
||||
<div
|
||||
class="markdown-body max-w-[68ch] text-sm leading-relaxed"
|
||||
onclick={handleContentClick}
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify in renderWikiMarkdown -->
|
||||
{@html renderWikiMarkdown(content.content)}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="history" class="min-h-0 flex-1 overflow-y-auto pt-2">
|
||||
{#if revisionsLoading}
|
||||
<div class="flex gap-3">
|
||||
<div class="flex w-40 shrink-0 flex-col gap-2 px-2 py-1">
|
||||
{#each Array(4) as _, i (i)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<Skeleton class="h-3 w-16" />
|
||||
<Skeleton class="h-2.5 w-20" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5 rounded border p-2">
|
||||
{#each Array(8) as _, i (i)}
|
||||
<Skeleton class="h-3" style="width: {90 - (i % 4) * 15}%" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if !revisions || revisions.length === 0}
|
||||
<p class="py-8 text-center text-xs text-muted-foreground">
|
||||
No prior revisions — this is the first version.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex gap-3">
|
||||
<div class="flex w-40 shrink-0 flex-col gap-0.5">
|
||||
{#each revisions as rev (rev.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-1 text-left text-[11px] hover:bg-muted/50 {selectedRevisionId ===
|
||||
rev.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: ''}"
|
||||
onclick={() => (selectedRevisionId = rev.id)}
|
||||
>
|
||||
<div class="font-medium">{relativeTime(rev.version_at)}</div>
|
||||
<div class="text-muted-foreground">{rev.edited_by || 'unknown'}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 overflow-x-auto rounded border">
|
||||
{#if diff}
|
||||
<pre class="p-2 text-[11px] leading-relaxed">{#each diff as op, i (i)}<div
|
||||
class={op.type === 'add'
|
||||
? 'bg-success/10 text-success'
|
||||
: op.type === 'remove'
|
||||
? 'bg-destructive/10 text-destructive line-through'
|
||||
: ''}>{op.type === 'add'
|
||||
? '+ '
|
||||
: op.type === 'remove'
|
||||
? '- '
|
||||
: ' '}{op.line}</div>{/each}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={confirmDeleteOpen}>
|
||||
<Dialog.Content class="sm:max-w-sm">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete note?</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if item}"{item.title}" will move to Trash and can be restored from there.{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if saveError}
|
||||
<p
|
||||
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
|
||||
>
|
||||
{saveError}
|
||||
</p>
|
||||
{/if}
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (confirmDeleteOpen = false)} disabled={deleting}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button variant="destructive" onclick={confirmDelete} disabled={deleting}>
|
||||
{deleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
198
web/src/lib/components/knowledge/WikiTree.svelte
Normal file
198
web/src/lib/components/knowledge/WikiTree.svelte
Normal file
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
// Left pane of the Knowledge wiki: a tree over every live note, with a
|
||||
// grouping switch so the same 102 notes are reachable four different
|
||||
// ways — which one helps depends on what the operator already remembers
|
||||
// about the thing they're looking for (its topic, its type, a tag, or the
|
||||
// machine it concerns).
|
||||
import type { KnowledgeListItem } from '$lib/api'
|
||||
import { groupNotes, type GroupBy } from './wikiText'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import * as Select from '$lib/components/ui/select'
|
||||
import * as Collapsible from '$lib/components/ui/collapsible'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { kindMeta, isAgentAuthored } from './kinds'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
import BotIcon from '@lucide/svelte/icons/bot'
|
||||
import FolderTreeIcon from '@lucide/svelte/icons/folder-tree'
|
||||
|
||||
let {
|
||||
items,
|
||||
selectedSlug,
|
||||
onSelect,
|
||||
onNew
|
||||
}: {
|
||||
items: KnowledgeListItem[]
|
||||
selectedSlug: string | null
|
||||
onSelect: (slug: string) => void
|
||||
onNew: () => void
|
||||
} = $props()
|
||||
|
||||
const GROUP_LABELS: Record<GroupBy, string> = {
|
||||
folder: 'Folder',
|
||||
kind: 'Type',
|
||||
tag: 'Tag',
|
||||
entity: 'Entity'
|
||||
}
|
||||
|
||||
function loadGroupBy(): GroupBy {
|
||||
if (typeof localStorage === 'undefined') return 'folder'
|
||||
const v = localStorage.getItem('oikos-wiki-groupby')
|
||||
return v === 'kind' || v === 'tag' || v === 'entity' ? v : 'folder'
|
||||
}
|
||||
|
||||
let groupBy = $state<GroupBy>(loadGroupBy())
|
||||
let filter = $state('')
|
||||
|
||||
function setGroupBy(v: string): void {
|
||||
if (v !== 'folder' && v !== 'kind' && v !== 'tag' && v !== 'entity') return
|
||||
groupBy = v
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-wiki-groupby', v)
|
||||
}
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = filter.trim().toLowerCase()
|
||||
if (!q) return items
|
||||
return items.filter(
|
||||
(it) =>
|
||||
it.title.toLowerCase().includes(q) ||
|
||||
it.slug.toLowerCase().includes(q) ||
|
||||
it.tags.some((t) => t.toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
|
||||
const groups = $derived(groupNotes(filtered, groupBy))
|
||||
|
||||
// Every group starts open when the filter is active (so a match is never
|
||||
// hidden inside a collapsed group) and only the group containing the
|
||||
// current selection starts open otherwise — with 102 notes across ~15
|
||||
// folders, all-open-by-default would just be a long undifferentiated
|
||||
// scroll.
|
||||
let openGroups = $state<Set<string>>(new Set())
|
||||
$effect(() => {
|
||||
if (filter.trim()) {
|
||||
openGroups = new Set(groups.map((g) => g.key))
|
||||
return
|
||||
}
|
||||
const owning = groups.find((g) => g.items.some((it) => it.slug === selectedSlug))
|
||||
openGroups = new Set(owning ? [owning.key] : groups[0] ? [groups[0].key] : [])
|
||||
})
|
||||
|
||||
function toggleGroup(key: string): void {
|
||||
const next = new Set(openGroups)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
openGroups = next
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-1.5">
|
||||
<!-- Search and "new note" share a row: both act on the list as a whole,
|
||||
and pairing them lets the field take the remaining width instead of
|
||||
being squeezed by a fixed-width control beside it. -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="relative flex-1">
|
||||
<SearchIcon class="absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input placeholder="Filter notes…" bind:value={filter} class="h-7 pl-7 text-xs" />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="size-7 shrink-0 p-0"
|
||||
onclick={onNew}
|
||||
title="New note"
|
||||
aria-label="New note"
|
||||
>
|
||||
<PlusIcon class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Group-by reads as a caption for the tree rather than a third boxed
|
||||
input: it labels how the list below is arranged, so it's styled like
|
||||
the group headers it governs (same muted 11px) and only reveals itself
|
||||
as a control on hover. Its own row of chrome was competing with the
|
||||
search field for attention while doing far less work. -->
|
||||
<Select.Root type="single" value={groupBy} onValueChange={setGroupBy}>
|
||||
<Select.Trigger
|
||||
size="sm"
|
||||
class="h-auto w-fit gap-1 rounded border-0 bg-transparent px-1 py-0.5 text-[11px] font-normal tracking-wide text-muted-foreground uppercase shadow-none hover:bg-muted/40 hover:text-foreground focus-visible:ring-0 data-[size=sm]:h-auto dark:bg-transparent dark:hover:bg-muted/40"
|
||||
title="Change how notes are grouped"
|
||||
>
|
||||
<FolderTreeIcon class="size-3 opacity-70" />
|
||||
by {GROUP_LABELS[groupBy]}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.entries(GROUP_LABELS) as [key, label] (key)}
|
||||
<Select.Item value={key} {label}>{label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<div class="-mx-1 min-h-0 flex-1 overflow-y-auto px-1">
|
||||
{#each groups as group (group.key)}
|
||||
{@const isOpen = openGroups.has(group.key)}
|
||||
<Collapsible.Root open={isOpen} onOpenChange={() => toggleGroup(group.key)}>
|
||||
<Collapsible.Trigger
|
||||
class="group/grp flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left select-none hover:bg-muted/40"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
class="size-3 shrink-0 text-muted-foreground transition-transform duration-150 {isOpen
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-[11px] font-medium tracking-wide text-muted-foreground uppercase group-hover/grp:text-foreground"
|
||||
>{group.label}</span
|
||||
>
|
||||
<span class="shrink-0 text-[10px] tabular-nums text-muted-foreground/70"
|
||||
>{group.items.length}</span
|
||||
>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<!-- The guide rule sits inside the indent rather than on each row so
|
||||
it reads as one continuous line down the group. -->
|
||||
<div class="mb-1 ml-[13px] flex flex-col border-l border-border/60 pl-1.5">
|
||||
{#each group.items as it (it.slug + group.key)}
|
||||
{@const selected = selectedSlug === it.slug}
|
||||
{@const Icon = kindMeta(it.kind).icon}
|
||||
<button
|
||||
type="button"
|
||||
title={it.title}
|
||||
class="relative flex items-center gap-2 rounded-md py-1.5 pr-1.5 pl-2 text-left text-xs transition-colors {selected
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'hover:bg-muted/50'}"
|
||||
onclick={() => onSelect(it.slug)}
|
||||
>
|
||||
<!-- Selection also gets an accent bar on the guide rule: the
|
||||
background tint alone is easy to lose against the window's
|
||||
own surface at this size. -->
|
||||
{#if selected}
|
||||
<span
|
||||
class="absolute top-1 bottom-1 -left-[7px] w-[2px] rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
<Icon
|
||||
class="size-3.5 shrink-0 {selected ? 'text-primary' : 'text-muted-foreground/70'}"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{it.title}</span>
|
||||
{#if isAgentAuthored(it.edited_by)}
|
||||
<BotIcon
|
||||
class="size-3 shrink-0 {selected
|
||||
? 'text-primary/70'
|
||||
: 'text-muted-foreground/50'}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{:else}
|
||||
<p class="px-2 py-8 text-center text-xs text-muted-foreground">
|
||||
No notes match “{filter}”.
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
50
web/src/lib/components/knowledge/kinds.ts
Normal file
50
web/src/lib/components/knowledge/kinds.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Per-kind presentation, shared by the tree, the overview, and anywhere else
|
||||
// a note's kind needs to be shown at a glance.
|
||||
//
|
||||
// Kind is encoded by **icon shape**, not colour. Three categories would be a
|
||||
// categorical palette, and at the 12px mark size the tree uses, colour alone
|
||||
// is the least reliable channel there is — it fails for colour-vision
|
||||
// deficiency, and small low-chroma marks on a dark surface are hard for
|
||||
// anyone to tell apart. Distinct silhouettes are legible at any size, in any
|
||||
// theme, for every reader. Colour is left to carry state (selection, the
|
||||
// agent badge), where it isn't the only thing distinguishing two items.
|
||||
//
|
||||
// It also fixes a plain redundancy: the tree previously stamped a literal
|
||||
// "document" text badge on every row, which in a folder of 20 documents is
|
||||
// 20 repetitions of the same word and no information at all.
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text'
|
||||
import MicroscopeIcon from '@lucide/svelte/icons/microscope'
|
||||
import ListChecksIcon from '@lucide/svelte/icons/list-checks'
|
||||
import type { Component } from 'svelte'
|
||||
|
||||
export type NoteKind = 'document' | 'investigation' | 'runbook'
|
||||
|
||||
export interface KindMeta {
|
||||
icon: Component
|
||||
label: string
|
||||
/** Plural, for counts and section headings. */
|
||||
plural: string
|
||||
}
|
||||
|
||||
const FALLBACK: KindMeta = { icon: FileTextIcon, label: 'note', plural: 'notes' }
|
||||
|
||||
const KIND_META: Record<NoteKind, KindMeta> = {
|
||||
document: { icon: FileTextIcon, label: 'document', plural: 'documents' },
|
||||
investigation: { icon: MicroscopeIcon, label: 'investigation', plural: 'investigations' },
|
||||
runbook: { icon: ListChecksIcon, label: 'runbook', plural: 'runbooks' }
|
||||
}
|
||||
|
||||
// Tolerates an unknown kind rather than throwing — `kind` comes from the
|
||||
// entity's type column, which the ontology could grow a fourth value for
|
||||
// without this file knowing.
|
||||
export function kindMeta(kind: string): KindMeta {
|
||||
return KIND_META[kind as NoteKind] ?? FALLBACK
|
||||
}
|
||||
|
||||
// True for notes last written by the agent rather than a human. Two spellings
|
||||
// exist in the live data: 'nomos-agent' (written via the MCP upsert_knowledge
|
||||
// tool) and 'agent:mcp' (the actor label the HTTP API records when the same
|
||||
// agent calls in over REST with the MCP bearer token).
|
||||
export function isAgentAuthored(editedBy: string): boolean {
|
||||
return editedBy === 'nomos-agent' || editedBy === 'agent:mcp'
|
||||
}
|
||||
197
web/src/lib/components/knowledge/wikiText.ts
Normal file
197
web/src/lib/components/knowledge/wikiText.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// Shared text helpers for the knowledge wiki (Knowledge.svelte and its
|
||||
// components). Markdown rendering, slug auto-linking, folder/grouping
|
||||
// derivation, and a small line diff for the History view — split out from
|
||||
// any one component since WikiReader and WikiContextRail both need the
|
||||
// rendering/linking half, and WikiTree needs the grouping half.
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { KnowledgeListItem } from '$lib/api'
|
||||
|
||||
// Matches a bare entity/knowledge slug like `lxc:gitea` or
|
||||
// `document:containers/101-jellyfin` — real examples pulled straight from
|
||||
// the data (`grep`-confirmed: operators and Nomos both write bare slugs
|
||||
// throughout note bodies today). The `[[wiki-link]]` bracket syntax some
|
||||
// wikis use was considered and dropped: only one note in the live DB
|
||||
// contains "[[" at all, and it's an HTML comment, not a link — building
|
||||
// bracket-syntax parsing would add real complexity (nesting, alias syntax,
|
||||
// double-substitution risk with this very regex) for a feature nobody
|
||||
// writes.
|
||||
//
|
||||
// Anchored to start with a lowercase letter specifically to reject
|
||||
// clock-times like "10:08" or "20:40" that are common in this dataset's
|
||||
// investigation titles/bodies (digits don't match `[a-z]`) and to reject
|
||||
// "https://..." (the char after ':' there is '/', which fails the
|
||||
// alnum-first requirement on the right-hand side).
|
||||
const SLUG_PATTERN = `\\b([a-z][a-z0-9-]{1,30}:[a-zA-Z0-9][a-zA-Z0-9\\-/._]*)\\b`
|
||||
|
||||
// A fenced code block (```...```, across lines) or an inline code span
|
||||
// (`...`, single line) OR a bare slug — tried in that order at every
|
||||
// position. Fenced/inline code always wins the match when present, so a
|
||||
// slug-shaped token *inside* a code example (a runbook's shell snippet
|
||||
// referencing e.g. `host:strong/some-path`) is consumed whole as code and
|
||||
// never reaches the slug branch. Without this, linkifySlugs ran the slug
|
||||
// regex over raw markdown with no idea code existed, rewrote the slug
|
||||
// inside the span to `[slug](#kb:slug)`, and `marked` then rendered that
|
||||
// literal bracket/paren syntax as text inside the <code> tag instead of
|
||||
// treating it as code. Doesn't handle every markdown code-span edge case
|
||||
// (double-backtick escaping for spans containing a literal backtick, `~~~`
|
||||
// fences) — just the two forms actually used in this corpus.
|
||||
const TOKEN_PATTERN = new RegExp('(```[\\s\\S]*?```)|(`[^`\\n]+`)|(' + SLUG_PATTERN + ')', 'g')
|
||||
|
||||
// Wraps every bare slug in `text` with a placeholder markdown link
|
||||
// (`[slug](#kb:slug)`) before it reaches `marked`, so the renderer emits a
|
||||
// real `<a>` that the reader's click handler (see WikiReader.svelte) can
|
||||
// intercept. The `#kb:` prefix is never a real anchor on this page — it's
|
||||
// just a tag so the click handler can tell "one of ours" apart from a
|
||||
// legitimate external link without inspecting every href.
|
||||
//
|
||||
// Trailing punctuation immediately after a slug (a period ending a
|
||||
// sentence, a comma, a closing paren) is peeled off and left outside the
|
||||
// link — "see host:strong." must not swallow the sentence's full stop into
|
||||
// the link target.
|
||||
function linkifySlugs(text: string): string {
|
||||
return text.replace(TOKEN_PATTERN, (match, fence, inlineCode) => {
|
||||
if (fence || inlineCode) return match // code — leave untouched, see TOKEN_PATTERN's comment
|
||||
const trailing = match.match(/[.,;:)]+$/)?.[0] ?? ''
|
||||
const slug = trailing ? match.slice(0, -trailing.length) : match
|
||||
if (!slug.includes(':')) return match // shouldn't happen given the pattern, but stay safe
|
||||
return `[${slug}](#kb:${encodeURIComponent(slug)})${trailing}`
|
||||
})
|
||||
}
|
||||
|
||||
// Full markdown render for the reader pane: linkify first (plain text, so
|
||||
// the regex never sees HTML), then render, then sanitize. Mirrors
|
||||
// EntityDetailContent.svelte's renderMarkdown (marked + DOMPurify, no tag
|
||||
// restriction) rather than Knowledge.svelte's old snippet-only sanitize
|
||||
// (which allowlisted only `<b>` for ts_headline output) — this renders a
|
||||
// full note body, not a search snippet.
|
||||
export function renderWikiMarkdown(text: string): string {
|
||||
const linked = linkifySlugs(text)
|
||||
return DOMPurify.sanitize(marked.parse(linked, { async: false }) as string)
|
||||
}
|
||||
|
||||
// Parses a `#kb:<encoded-slug>` href back into the slug, or null if `href`
|
||||
// isn't one of ours (a real external/relative link the browser should
|
||||
// handle normally).
|
||||
export function slugFromKbHref(href: string | null): string | null {
|
||||
if (!href || !href.startsWith('#kb:')) return null
|
||||
try {
|
||||
return decodeURIComponent(href.slice('#kb:'.length))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Grouping (navigator tree) ─────────────────────────────────────────────
|
||||
|
||||
export type GroupBy = 'folder' | 'kind' | 'tag' | 'entity'
|
||||
|
||||
const UNGROUPED = '(ungrouped)'
|
||||
|
||||
// The slug format is `<kind>:<folder>/<name>` for namespaced notes (agent
|
||||
// and seeded content) or plain `<kind>:<name>` for the flat runbooks
|
||||
// (runbook:lifecycle-activate-node). The latter has no folder segment, so
|
||||
// it groups under UNGROUPED rather than being silently dropped.
|
||||
export function noteFolder(item: KnowledgeListItem): string {
|
||||
const afterColon = item.slug.slice(item.slug.indexOf(':') + 1)
|
||||
const idx = afterColon.lastIndexOf('/')
|
||||
return idx === -1 ? UNGROUPED : afterColon.slice(0, idx)
|
||||
}
|
||||
|
||||
export interface WikiGroup {
|
||||
key: string
|
||||
label: string
|
||||
items: KnowledgeListItem[]
|
||||
}
|
||||
|
||||
// Groups `items` by the chosen dimension. `tag` and `entity` are
|
||||
// many-to-many — a note with three tags appears in three groups — which is
|
||||
// deliberate: those two modes are for "show me everything touching X," not
|
||||
// a strict partition like folder/kind are.
|
||||
export function groupNotes(items: KnowledgeListItem[], by: GroupBy): WikiGroup[] {
|
||||
const groups = new Map<string, KnowledgeListItem[]>()
|
||||
const push = (key: string, item: KnowledgeListItem) => {
|
||||
const arr = groups.get(key)
|
||||
if (arr) arr.push(item)
|
||||
else groups.set(key, [item])
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
switch (by) {
|
||||
case 'folder':
|
||||
push(noteFolder(item), item)
|
||||
break
|
||||
case 'kind':
|
||||
push(item.kind, item)
|
||||
break
|
||||
case 'tag':
|
||||
if (item.tags.length === 0) push(UNGROUPED, item)
|
||||
else for (const t of item.tags) push(t, item)
|
||||
break
|
||||
case 'entity':
|
||||
if (item.about.length === 0) push(UNGROUPED, item)
|
||||
else for (const slug of item.about) push(slug, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const out: WikiGroup[] = [...groups.entries()].map(([key, groupItems]) => ({
|
||||
key,
|
||||
label: key,
|
||||
items: groupItems.sort((a, b) => a.title.localeCompare(b.title))
|
||||
}))
|
||||
|
||||
// Ungrouped/misc always last; otherwise alphabetical, largest-first ties
|
||||
// broken by label so the ordering is stable across reloads.
|
||||
out.sort((a, b) => {
|
||||
if (a.key === UNGROUPED) return 1
|
||||
if (b.key === UNGROUPED) return -1
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── Line diff (History tab) ───────────────────────────────────────────────
|
||||
|
||||
export type DiffOp = { type: 'equal' | 'add' | 'remove'; line: string }
|
||||
|
||||
// Textbook O(n*m) LCS-based line diff. Notes in this system are small
|
||||
// (the seed data averages ~1KB, agent-written investigations rarely exceed
|
||||
// 2KB, so a few dozen lines at most) — the quadratic cost is invisible at
|
||||
// this size and a full Myers-diff dependency would be a lot of code for a
|
||||
// feature that only needs to render a readable before/after in the History
|
||||
// tab, not power a merge tool.
|
||||
export function diffLines(oldText: string, newText: string): DiffOp[] {
|
||||
const a = oldText.split('\n')
|
||||
const b = newText.split('\n')
|
||||
const n = a.length
|
||||
const m = b.length
|
||||
|
||||
// lcs[i][j] = length of the LCS of a[i:] and b[j:]
|
||||
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const ops: DiffOp[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
ops.push({ type: 'equal', line: a[i] })
|
||||
i++
|
||||
j++
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
ops.push({ type: 'remove', line: a[i] })
|
||||
i++
|
||||
} else {
|
||||
ops.push({ type: 'add', line: b[j] })
|
||||
j++
|
||||
}
|
||||
}
|
||||
while (i < n) ops.push({ type: 'remove', line: a[i++] })
|
||||
while (j < m) ops.push({ type: 'add', line: b[j++] })
|
||||
return ops
|
||||
}
|
||||
@@ -1,162 +1,267 @@
|
||||
<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'
|
||||
// The Knowledge app, redesigned as a wiki: browse, read, and now actually
|
||||
// edit everything the operator and Nomos have recorded — 102 notes that
|
||||
// were previously stats-only (the old page could search and show a
|
||||
// recency feed, but nothing was clickable: see git history on this file).
|
||||
//
|
||||
// Three panes (WikiTree | WikiReader | WikiContextRail) for browsing and
|
||||
// editing day to day, plus a separate Cleanup mode (WikiCleanup) for the
|
||||
// maintenance work the collection actually needs — duplicate pileups,
|
||||
// tag-casing drift, orphaned notes, and the trash. Both modes share the
|
||||
// same `items` list, loaded once here.
|
||||
import { onMount } from 'svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { listKnowledge, KnowledgeApiError, type KnowledgeListItem } from '$lib/api'
|
||||
import WikiTree from '$lib/components/knowledge/WikiTree.svelte'
|
||||
import WikiReader from '$lib/components/knowledge/WikiReader.svelte'
|
||||
import WikiContextRail from '$lib/components/knowledge/WikiContextRail.svelte'
|
||||
import WikiCleanup from '$lib/components/knowledge/WikiCleanup.svelte'
|
||||
import WikiNewDialog from '$lib/components/knowledge/WikiNewDialog.svelte'
|
||||
import WikiQuickOpen from '$lib/components/knowledge/WikiQuickOpen.svelte'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
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'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open'
|
||||
|
||||
let query = $state('')
|
||||
let results = $state<KnowledgeHit[]>([])
|
||||
let loading = $state(false)
|
||||
let searched = $state(false)
|
||||
type Mode = 'wiki' | 'cleanup'
|
||||
|
||||
let recent = $state<RecentKnowledge>({ stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] })
|
||||
let agentOnly = $state(false)
|
||||
let loadingRecent = $state(true)
|
||||
let items = $state<KnowledgeListItem[]>([])
|
||||
let itemsLoading = $state(true)
|
||||
let selectedSlug = $state<string | null>(null)
|
||||
let mode = $state<Mode>('wiki')
|
||||
let newDialogOpen = $state(false)
|
||||
let quickOpenOpen = $state(false)
|
||||
// True while WikiReader has an in-progress, unsaved edit — set via
|
||||
// bind:dirty. requestSelect below is the single choke point every
|
||||
// selection path (tree, context rail, quick-open, a newly-created note)
|
||||
// goes through, so gating it here is enough to stop a stray click from
|
||||
// silently discarding an edit in progress (see WikiReader's own comment
|
||||
// on why this is "in edit mode" rather than a real dirty-diff).
|
||||
let readerDirty = $state(false)
|
||||
let pendingSlug = $state<string | null>(null)
|
||||
// Distinct from "0 notes": listKnowledge now throws on a failed request
|
||||
// rather than returning [] (a real outage was previously indistinguishable
|
||||
// from an empty collection — see api.ts's comment on the same helper).
|
||||
let loadError = $state('')
|
||||
|
||||
async function loadRecent() {
|
||||
loadingRecent = true
|
||||
recent = await fetchRecentKnowledge(agentOnly ? 'nomos-agent' : undefined)
|
||||
loadingRecent = false
|
||||
}
|
||||
loadRecent()
|
||||
|
||||
function toggleAgentOnly() {
|
||||
agentOnly = !agentOnly
|
||||
loadRecent()
|
||||
async function loadItems(): Promise<void> {
|
||||
itemsLoading = true
|
||||
loadError = ''
|
||||
try {
|
||||
items = await listKnowledge()
|
||||
} catch (e) {
|
||||
loadError = e instanceof KnowledgeApiError ? e.message : 'Failed to load notes.'
|
||||
} finally {
|
||||
itemsLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!query.trim()) { searched = false; return }
|
||||
loading = true
|
||||
results = await searchKnowledge(query)
|
||||
loading = false
|
||||
searched = true
|
||||
// Cmd/Ctrl+K quick-open, scoped to while this app's window is around —
|
||||
// Desktop.svelte doesn't have a global command-palette convention to hook
|
||||
// into, so this is a plain window listener added/removed with the
|
||||
// component's lifetime rather than a shell-level keybinding.
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
quickOpenOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadItems()
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
return () => window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// Every note's own slug, for WikiReader's click handler to tell a
|
||||
// wiki-internal link (navigate in place) from an entity link (open an
|
||||
// entity window) — see wikiText.ts's slugFromKbHref.
|
||||
const knownSlugs = $derived(new Set(items.map((i) => i.slug)))
|
||||
const selectedItem = $derived(items.find((i) => i.slug === selectedSlug) ?? null)
|
||||
|
||||
function selectNow(slug: string): void {
|
||||
readerDirty = false
|
||||
selectedSlug = slug
|
||||
mode = 'wiki'
|
||||
}
|
||||
|
||||
// Every selection path (tree click, context-rail click, quick-open,
|
||||
// opening a just-created note) routes through here rather than mutating
|
||||
// selectedSlug directly, so an in-progress edit can never be silently
|
||||
// discarded by a stray click elsewhere in the app.
|
||||
function requestSelect(slug: string): void {
|
||||
if (slug === selectedSlug) {
|
||||
mode = 'wiki' // already selected — e.g. Cleanup's "view" links back into Wiki mode
|
||||
return
|
||||
}
|
||||
if (readerDirty) {
|
||||
pendingSlug = slug
|
||||
return
|
||||
}
|
||||
selectNow(slug)
|
||||
}
|
||||
|
||||
function confirmDiscardAndSwitch(): void {
|
||||
if (pendingSlug) selectNow(pendingSlug)
|
||||
pendingSlug = null
|
||||
}
|
||||
|
||||
function handleCreated(slug: string): void {
|
||||
loadItems()
|
||||
requestSelect(slug)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-4 p-2">
|
||||
<div class="flex h-full flex-col gap-2 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 class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">{items.length} notes</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground hover:bg-muted/50"
|
||||
onclick={() => (quickOpenOpen = true)}
|
||||
>
|
||||
⌘K
|
||||
</button>
|
||||
<div class="inline-flex overflow-hidden rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs {mode === 'wiki'
|
||||
? 'bg-secondary text-secondary-foreground'
|
||||
: 'hover:bg-muted/50'}"
|
||||
onclick={() => (mode = 'wiki')}
|
||||
>
|
||||
<BookOpenIcon class="size-3.5" /> Wiki
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs {mode === 'cleanup'
|
||||
? 'bg-secondary text-secondary-foreground'
|
||||
: 'hover:bg-muted/50'}"
|
||||
onclick={() => (mode = 'cleanup')}
|
||||
>
|
||||
<WrenchIcon class="size-3.5" /> Cleanup
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
{#if itemsLoading}
|
||||
<!-- Shaped like the real three-pane layout below (same Splitpanes
|
||||
proportions) rather than a centered spinner: a spinner tells the
|
||||
operator nothing about what's about to render and costs a full
|
||||
reflow the instant real content lands. This costs none — the
|
||||
panes are already the right size. -->
|
||||
<Splitpanes theme="oikos-theme" dblClickSplitter={false} class="h-full">
|
||||
<Pane size={22} minSize={15} maxSize={40}>
|
||||
<div class="flex h-full flex-col gap-3 overflow-hidden p-1.5">
|
||||
<Skeleton class="h-7 w-full" />
|
||||
<Skeleton class="h-3 w-16" />
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each Array(4) as _, gi (gi)}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Skeleton class="h-3 w-20" />
|
||||
<div class="ml-3 flex flex-col gap-2 border-l pl-2">
|
||||
{#each Array(3) as _, ri (ri)}
|
||||
<Skeleton class="h-3.5" style="width: {75 - ri * 15}%" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</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}
|
||||
</Pane>
|
||||
<Pane size={56} minSize={30}>
|
||||
<div class="flex h-full flex-col gap-3 overflow-hidden p-2">
|
||||
<Skeleton class="h-4 w-32" />
|
||||
<Skeleton class="h-9 w-40" />
|
||||
<div class="mt-1 grid grid-cols-4 gap-px overflow-hidden rounded-lg bg-border/60">
|
||||
{#each Array(4) as _, i (i)}
|
||||
<div class="flex flex-col gap-1.5 bg-card px-3 py-2.5">
|
||||
<Skeleton class="h-3 w-14" />
|
||||
<Skeleton class="h-5 w-8" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Skeleton class="mt-2 h-3 w-full" />
|
||||
<div class="mt-2 flex flex-col gap-2.5">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-4" style="width: {85 - i * 8}%" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{:else if loadError}
|
||||
<div class="flex h-full flex-col items-center justify-center gap-2 text-sm">
|
||||
<p class="text-destructive">{loadError}</p>
|
||||
<Button size="sm" variant="outline" onclick={loadItems}>Retry</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
{:else if mode === 'wiki'}
|
||||
<Splitpanes theme="oikos-theme" dblClickSplitter={false} class="h-full">
|
||||
<Pane size={22} minSize={15} maxSize={40}>
|
||||
<div class="h-full overflow-hidden p-1.5">
|
||||
<WikiTree
|
||||
{items}
|
||||
{selectedSlug}
|
||||
onSelect={requestSelect}
|
||||
onNew={() => (newDialogOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane size={56} minSize={30}>
|
||||
<div class="h-full overflow-hidden p-2">
|
||||
<WikiReader
|
||||
item={selectedItem}
|
||||
allItems={items}
|
||||
{knownSlugs}
|
||||
onNavigate={requestSelect}
|
||||
onNew={() => (newDialogOpen = true)}
|
||||
onChanged={loadItems}
|
||||
bind:dirty={readerDirty}
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
<!-- The context rail is about the selected note, so it only exists
|
||||
when there is one. Left mounted it rendered a "Nothing selected."
|
||||
placeholder next to the overview — two competing empty states,
|
||||
and a fifth of the width spent saying nothing. -->
|
||||
{#if selectedItem}
|
||||
<Pane size={22} minSize={15} maxSize={40}>
|
||||
<div class="h-full overflow-hidden p-1.5">
|
||||
<WikiContextRail item={selectedItem} allItems={items} onSelect={requestSelect} />
|
||||
</div>
|
||||
</Pane>
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
{:else}
|
||||
<WikiCleanup onSelect={requestSelect} onChanged={loadItems} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WikiNewDialog bind:open={newDialogOpen} onCreated={handleCreated} />
|
||||
<WikiQuickOpen bind:open={quickOpenOpen} {items} onSelect={requestSelect} />
|
||||
|
||||
<Dialog.Root
|
||||
open={pendingSlug !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) pendingSlug = null
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="sm:max-w-sm">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Discard unsaved changes?</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
You're editing a note. Switching now will discard what you haven't saved.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (pendingSlug = null)}>Keep editing</Button>
|
||||
<Button variant="destructive" onclick={confirmDiscardAndSwitch}>Discard and switch</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
Reference in New Issue
Block a user