feat(rag): broaden preset tags and add creatable collection combobox

Replaces the survival-specific preset list with general-purpose starter
tags (recipes, diy, health, technology, finance, travel, hobbies,
reference, survival, energy) so the Knowledge Base reads well for
home-lab/reference use, not just prepping.

Adds sanitizeCollectionName() (trim, lowercase, length cap) applied on
every write path server-side, and a dependency-free CollectionCombobox
component replacing the plain <select> + window.prompt pattern for
tagging — autocompletes against presets + tags already in use, with a
'+ Create' option for anything new.
This commit is contained in:
John Cortright 2026-07-13 18:18:10 -07:00
parent c10ceda43e
commit b93ec297e4
4 changed files with 189 additions and 53 deletions

View File

@ -9,6 +9,7 @@ import { sanitizeFilename } from '../utils/fs.js'
import { basename } from 'node:path'
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, fileSourceSchema, getJobStatusSchema } from '#validators/rag'
import logger from '@adonisjs/core/services/logger'
import { sanitizeCollectionName } from '../../constants/kb_collections.js'
@inject()
export default class RagController {
@ -20,7 +21,7 @@ export default class RagController {
return response.status(400).json({ error: 'No file uploaded' })
}
const collection: string | null = request.input('collection', null)
const collection = sanitizeCollectionName(request.input('collection', null))
const randomSuffix = randomBytes(6).toString('hex')
const sanitizedName = sanitizeFilename(uploadedFile.clientName)
@ -78,10 +79,9 @@ export default class RagController {
public async updateFileCollection({ request, response }: HttpContext) {
const source: string | null = request.input('source', null)
// Empty string means "clear back to Uncategorized" — coerce to null rather
// than rejecting, so the frontend can offer an Uncategorized option.
const rawCollection: string | null = request.input('collection', null)
const collection = rawCollection && rawCollection.trim() !== '' ? rawCollection : null
// sanitizeCollectionName trims/lowercases/caps length, and returns null
// for empty input — which doubles as "clear back to Uncategorized".
const collection = sanitizeCollectionName(request.input('collection', null))
if (!source) {
return response.status(400).json({ error: 'source is required.' })
@ -95,8 +95,8 @@ export default class RagController {
}
public async renameKnowledgeCollection({ request, response }: HttpContext) {
const oldName: string | null = request.input('oldName', null)
const newName: string | null = request.input('newName', null)
const oldName = sanitizeCollectionName(request.input('oldName', null))
const newName = sanitizeCollectionName(request.input('newName', null))
if (!oldName || !newName) {
return response.status(400).json({ error: 'oldName and newName are required.' })
@ -110,7 +110,7 @@ export default class RagController {
}
public async deleteKnowledgeCollection({ request, response }: HttpContext) {
const name: string | null = request.input('name', null)
const name = sanitizeCollectionName(request.input('name', null))
if (!name) {
return response.status(400).json({ error: 'name is required.' })

View File

@ -1,13 +1,40 @@
/**
* Curated starter tags shown in the collection picker. These are just
* suggested defaults the actual set of usable tags is open-ended, since
* `collection` is a free-form string and getKnowledgeCollections() returns
* whatever's actually in use (see RagService). Kept general-purpose rather
* than survival-specific so NOMAD's Knowledge Base reads well for home-lab,
* reference, and everyday use too.
*/
export const KB_COLLECTIONS = [
'food',
'water',
'shelter',
'electricity',
'recipes',
'diy',
'health',
'technology',
'finance',
'travel',
'hobbies',
'reference',
'survival',
'farming',
'computer',
'medical',
'boating',
'energy',
] as const
export type KbCollection = (typeof KB_COLLECTIONS)[number]
/** Hard cap on a user-created tag's length, enforced client- and server-side. */
export const KB_COLLECTION_NAME_MAX_LENGTH = 40
/**
* Normalize a user-entered collection name: trim whitespace, lowercase, cap
* length. Returns null for empty/whitespace-only input, meaning
* "uncategorized". Lowercasing is what makes de-dupe work "Medical" and
* "medical" normalize to the same tag rather than forking into two, so this
* must run on every write path (upload, reassignment, rename) both
* client-side for instant feedback and server-side as the actual guarantee.
*/
export function sanitizeCollectionName(raw: string | null | undefined): string | null {
if (!raw) return null
const trimmed = raw.trim().toLowerCase()
if (!trimmed) return null
return trimmed.slice(0, KB_COLLECTION_NAME_MAX_LENGTH)
}

View File

@ -0,0 +1,129 @@
import { useEffect, useRef, useState } from 'react'
interface CollectionComboboxProps {
/** Current value. Empty string means "Uncategorized". */
value: string
onChange: (value: string) => void
/** Presets + known-in-use tags, already merged and deduped by the caller. */
options: string[]
placeholder?: string
allowUncategorized?: boolean
className?: string
disabled?: boolean
}
/**
* Dependency-free creatable combobox for collection tags. Lets you pick an
* existing option (preset or already-in-use) or type a brand new one the
* "+ Create" row only appears when the (normalized) input doesn't already
* match something. Actual normalization (trim/lowercase/length cap) happens
* server-side via sanitizeCollectionName; this just lowercases for the
* match-check and display so duplicates-by-case don't look like real options.
*/
export default function CollectionCombobox({
value,
onChange,
options,
placeholder = 'Uncategorized',
allowUncategorized = true,
className = '',
disabled = false,
}: CollectionComboboxProps) {
const [query, setQuery] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setQuery(value)
}, [value])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
setQuery(value)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [value])
const normalizedQuery = query.trim().toLowerCase()
const filtered = normalizedQuery
? options.filter((o) => o.toLowerCase().includes(normalizedQuery))
: options
const exactMatch = options.find((o) => o.toLowerCase() === normalizedQuery)
const showCreateOption = normalizedQuery.length > 0 && !exactMatch
const commit = (val: string) => {
onChange(val)
setQuery(val)
setIsOpen(false)
}
return (
<div ref={containerRef} className={`relative ${className}`}>
<input
type="text"
value={query}
disabled={disabled}
onChange={(e) => {
setQuery(e.target.value)
setIsOpen(true)
}}
onFocus={() => setIsOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const trimmed = query.trim()
if (!trimmed) {
if (allowUncategorized) commit('')
return
}
commit(exactMatch ?? trimmed.toLowerCase())
} else if (e.key === 'Escape') {
setIsOpen(false)
setQuery(value)
}
}}
placeholder={placeholder}
className="w-full rounded border border-border-subtle bg-surface-primary px-2 py-1 text-sm text-text-primary disabled:opacity-50"
/>
{isOpen && !disabled && (
<div className="absolute z-20 mt-1 w-full max-h-56 overflow-auto rounded border border-border-subtle bg-surface-primary shadow-lg">
{allowUncategorized && (
<button
type="button"
className="block w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:bg-surface-secondary"
onClick={() => commit('')}
>
Uncategorized
</button>
)}
{filtered.map((opt) => (
<button
key={opt}
type="button"
className="block w-full text-left px-2 py-1.5 text-sm text-text-primary hover:bg-surface-secondary"
onClick={() => commit(opt)}
>
{opt}
</button>
))}
{showCreateOption && (
<button
type="button"
className="block w-full text-left px-2 py-1.5 text-sm text-desert-green font-medium hover:bg-surface-secondary border-t border-border-subtle"
onClick={() => commit(query.trim().toLowerCase())}
>
+ Create "{query.trim().toLowerCase()}"
</button>
)}
{filtered.length === 0 && !showCreateOption && (
<div className="px-2 py-1.5 text-sm text-text-muted">No matches</div>
)}
</div>
)}
</div>
)
}

View File

@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import FileUploader from '~/components/file-uploader'
import StyledButton from '~/components/StyledButton'
import type { DynamicIconName } from '~/lib/icons'
@ -28,6 +28,8 @@ import StyledModal from '../StyledModal'
import ActiveEmbedJobs from '~/components/ActiveEmbedJobs'
import { SERVICE_NAMES } from '../../../constants/service_names'
import CollectionsManager from './CollectionsManager'
import { KB_COLLECTIONS } from '../../../constants/kb_collections'
import CollectionCombobox from './CollectionCombobox'
interface KnowledgeBaseModalProps {
aiAssistantName?: string
@ -165,6 +167,10 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
select: (data) => data?.collections ?? [],
})
const comboboxOptions = useMemo(() => {
return Array.from(new Set([...KB_COLLECTIONS, ...knownCollections])).sort()
}, [knownCollections])
const { data: warningsResult } = useQuery({
queryKey: ['kbFileWarnings'],
queryFn: () => api.getKbFileWarnings(),
@ -463,24 +469,12 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
<div className="flex justify-center items-center gap-4 my-6">
<label className="flex items-center gap-2 text-sm text-text-secondary">
Collection:
<select
<CollectionCombobox
value={uploadCollection}
onChange={(e) => {
if (e.target.value === '__new__') {
const name = window.prompt('New collection name:')
if (name && name.trim()) setUploadCollection(name.trim())
return
}
setUploadCollection(e.target.value)
}}
className="rounded border border-border-subtle bg-surface-primary px-3 py-2 text-text-primary"
>
<option value="">Uncategorized</option>
{knownCollections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
<option value="__new__">+ New collection</option>
</select>
onChange={setUploadCollection}
options={comboboxOptions}
className="w-48"
/>
</label>
<StyledButton
variant="primary"
@ -774,27 +768,13 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
updateCollectionMutation.isPending &&
updateCollectionMutation.variables?.source === record.source
return (
<select
<CollectionCombobox
value={record.collection ?? ''}
onChange={(val) => updateCollectionMutation.mutate({ source: record.source, collection: val })}
options={comboboxOptions}
disabled={isSaving}
onChange={(e) => {
if (e.target.value === '__new__') {
const name = window.prompt('New collection name:')
if (name && name.trim()) {
updateCollectionMutation.mutate({ source: record.source, collection: name.trim() })
}
return
}
updateCollectionMutation.mutate({ source: record.source, collection: e.target.value })
}}
className="rounded border border-border-subtle bg-surface-primary px-2 py-1 text-xs text-text-primary"
>
<option value="">Uncategorized</option>
{knownCollections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
<option value="__new__">+ New collection</option>
</select>
className="w-40"
/>
)
},
},