diff --git a/admin/app/controllers/rag_controller.ts b/admin/app/controllers/rag_controller.ts index ad915b6..22106cf 100644 --- a/admin/app/controllers/rag_controller.ts +++ b/admin/app/controllers/rag_controller.ts @@ -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.' }) diff --git a/admin/constants/kb_collections.ts b/admin/constants/kb_collections.ts index ce636f8..a0b3a1e 100644 --- a/admin/constants/kb_collections.ts +++ b/admin/constants/kb_collections.ts @@ -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) +} diff --git a/admin/inertia/components/chat/CollectionCombobox.tsx b/admin/inertia/components/chat/CollectionCombobox.tsx new file mode 100644 index 0000000..a1b0c48 --- /dev/null +++ b/admin/inertia/components/chat/CollectionCombobox.tsx @@ -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(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 ( +
+ { + 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 && ( +
+ {allowUncategorized && ( + + )} + {filtered.map((opt) => ( + + ))} + {showCreateOption && ( + + )} + {filtered.length === 0 && !showCreateOption && ( +
No matches
+ )} +
+ )} +
+ ) +} diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index 727f11c..77ac969 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -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
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" - > - - {knownCollections.map((c) => ( - - ))} - - + className="w-40" + /> ) }, },