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:
parent
c10ceda43e
commit
b93ec297e4
|
|
@ -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.' })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue