Merge 38b880b5f2 into 6a4f02dd46
This commit is contained in:
commit
eab14f594e
|
|
@ -77,10 +77,12 @@ export default class OllamaController {
|
|||
|
||||
logger.debug(`[OllamaController] Rewritten query for RAG: "${rewrittenQuery}"`)
|
||||
if (rewrittenQuery) {
|
||||
const collectionFilter: string | null = request.input('collection', null)
|
||||
const relevantDocs = await this.ragService.searchSimilarDocuments(
|
||||
rewrittenQuery,
|
||||
5, // Top 5 most relevant chunks
|
||||
0.3 // Minimum similarity score of 0.3
|
||||
0.3, // Minimum similarity score of 0.3
|
||||
collectionFilter ?? undefined
|
||||
)
|
||||
|
||||
logger.debug(`[RAG] Retrieved ${relevantDocs.length} relevant documents for query: "${rewrittenQuery}"`)
|
||||
|
|
@ -456,3 +458,4 @@ export default class OllamaController {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +21,8 @@ export default class RagController {
|
|||
return response.status(400).json({ error: 'No file uploaded' })
|
||||
}
|
||||
|
||||
const collection = sanitizeCollectionName(request.input('collection', null))
|
||||
|
||||
const randomSuffix = randomBytes(6).toString('hex')
|
||||
const sanitizedName = sanitizeFilename(uploadedFile.clientName)
|
||||
|
||||
|
|
@ -34,6 +37,7 @@ export default class RagController {
|
|||
const result = await EmbedFileJob.dispatch({
|
||||
filePath: fullPath,
|
||||
fileName,
|
||||
...(collection ? { collection } : {}),
|
||||
})
|
||||
|
||||
return response.status(202).json({
|
||||
|
|
@ -42,9 +46,9 @@ export default class RagController {
|
|||
fileName,
|
||||
filePath: `/${RagService.UPLOADS_STORAGE_PATH}/${fileName}`,
|
||||
alreadyProcessing: !result.created,
|
||||
...(collection ? { collection } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
public async getActiveJobs({ response }: HttpContext) {
|
||||
const jobs = await EmbedFileJob.listActiveJobs()
|
||||
return response.status(200).json(jobs)
|
||||
|
|
@ -68,6 +72,57 @@ export default class RagController {
|
|||
return response.status(200).json({ files })
|
||||
}
|
||||
|
||||
public async getKnowledgeCollections({ response }: HttpContext) {
|
||||
const collections = await this.ragService.getKnowledgeCollections()
|
||||
return response.status(200).json({ collections })
|
||||
}
|
||||
|
||||
public async updateFileCollection({ request, response }: HttpContext) {
|
||||
const source: string | null = request.input('source', 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.' })
|
||||
}
|
||||
|
||||
const result = await this.ragService.updateFileCollection(source, collection)
|
||||
if (!result.success) {
|
||||
return response.status(500).json({ error: result.message })
|
||||
}
|
||||
return response.status(200).json({ message: result.message })
|
||||
}
|
||||
|
||||
public async renameKnowledgeCollection({ request, response }: HttpContext) {
|
||||
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.' })
|
||||
}
|
||||
|
||||
const result = await this.ragService.renameKnowledgeCollection(oldName, newName)
|
||||
if (!result.success) {
|
||||
return response.status(500).json({ error: result.message })
|
||||
}
|
||||
return response.status(200).json({ message: result.message })
|
||||
}
|
||||
|
||||
public async deleteKnowledgeCollection({ request, response }: HttpContext) {
|
||||
const name = sanitizeCollectionName(request.input('name', null))
|
||||
|
||||
if (!name) {
|
||||
return response.status(400).json({ error: 'name is required.' })
|
||||
}
|
||||
|
||||
const result = await this.ragService.deleteKnowledgeCollection(name)
|
||||
if (!result.success) {
|
||||
return response.status(500).json({ error: result.message })
|
||||
}
|
||||
return response.status(200).json({ message: result.message })
|
||||
}
|
||||
|
||||
public async getFileWarnings({ response }: HttpContext) {
|
||||
const result = await this.ragService.computeFileWarnings()
|
||||
return response.status(200).json(result)
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ export interface EmbedFileJobParams {
|
|||
isFinalBatch?: boolean // Whether this is the last batch (prevents premature deletion)
|
||||
// Running total of chunks embedded across prior batches in this dispatch chain.
|
||||
// Carried forward so the final batch can persist an accurate `chunks_embedded`
|
||||
// count via KbIngestState.markIndexed (see #933 — without this, only the last
|
||||
// count via KbIngestState.markIndexed (see #933 -- without this, only the last
|
||||
// batch's chunk count was stored while Qdrant held the full set).
|
||||
chunksSoFar?: number
|
||||
collection?: string
|
||||
}
|
||||
|
||||
export class EmbedFileJob {
|
||||
|
|
@ -56,7 +57,7 @@ export class EmbedFileJob {
|
|||
}
|
||||
|
||||
async handle(job: Job) {
|
||||
const { filePath, fileName, batchOffset, totalArticles } = job.data as EmbedFileJobParams
|
||||
const { filePath, fileName, batchOffset, totalArticles, collection } = job.data as EmbedFileJobParams
|
||||
|
||||
const isZimBatch = batchOffset !== undefined
|
||||
const batchInfo = isZimBatch ? ` (batch offset: ${batchOffset})` : ''
|
||||
|
|
@ -136,7 +137,8 @@ export class EmbedFileJob {
|
|||
filePath,
|
||||
allowDeletion,
|
||||
batchOffset,
|
||||
onProgress
|
||||
onProgress,
|
||||
collection
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -242,7 +244,7 @@ export class EmbedFileJob {
|
|||
// BullMQ's :completed retention (50 jobs) ages out, so the state row is
|
||||
// the only durable record of "this file finished embedding".
|
||||
try {
|
||||
await KbIngestState.markIndexed(filePath, totalChunks)
|
||||
await KbIngestState.markIndexed(filePath, totalChunks, collection)
|
||||
} catch (stateErr) {
|
||||
logger.warn(
|
||||
`[EmbedFileJob] Failed to persist ingest state for ${fileName}: %s`,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ export default class KbIngestState extends BaseModel {
|
|||
@column()
|
||||
declare chunks_embedded: number
|
||||
|
||||
@column()
|
||||
declare collection: string | null
|
||||
|
||||
@column()
|
||||
declare last_error: string | null
|
||||
|
||||
|
|
@ -37,18 +40,19 @@ export default class KbIngestState extends BaseModel {
|
|||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updated_at: DateTime
|
||||
|
||||
static async getOrCreate(filePath: string): Promise<KbIngestState> {
|
||||
static async getOrCreate(filePath: string, collection?: string): Promise<KbIngestState> {
|
||||
return this.firstOrCreate(
|
||||
{ file_path: filePath },
|
||||
{ file_path: filePath, state: 'pending_decision', chunks_embedded: 0 }
|
||||
{ file_path: filePath, state: 'pending_decision', chunks_embedded: 0, collection: collection ?? null }
|
||||
)
|
||||
}
|
||||
|
||||
static async markIndexed(filePath: string, chunksEmbedded: number): Promise<void> {
|
||||
const row = await this.getOrCreate(filePath)
|
||||
static async markIndexed(filePath: string, chunksEmbedded: number, collection?: string): Promise<void> {
|
||||
const row = await this.getOrCreate(filePath, collection)
|
||||
row.state = 'indexed'
|
||||
row.chunks_embedded = chunksEmbedded
|
||||
row.last_error = null
|
||||
if (collection) row.collection = collection
|
||||
await row.save()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,10 @@ export class RagService {
|
|||
field_name: 'content_type',
|
||||
field_schema: 'keyword',
|
||||
})
|
||||
await this.qdrant!.createPayloadIndex(collectionName, {
|
||||
field_name: 'collection',
|
||||
field_schema: 'keyword',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error ensuring Qdrant collection:', error)
|
||||
throw error
|
||||
|
|
@ -695,14 +699,16 @@ export class RagService {
|
|||
extractedText: string,
|
||||
filepath: string,
|
||||
deleteAfterEmbedding: boolean = false,
|
||||
onProgress?: (percent: number) => Promise<void>
|
||||
onProgress?: (percent: number) => Promise<void>,
|
||||
collection?: string
|
||||
): Promise<{ success: boolean; message: string; chunks?: number }> {
|
||||
if (!extractedText || extractedText.trim().length === 0) {
|
||||
return { success: false, message: 'Process completed succesfully, but no text was found to embed.' }
|
||||
}
|
||||
|
||||
const embedResult = await this.embedAndStoreText(extractedText, {
|
||||
source: filepath
|
||||
source: filepath,
|
||||
...(collection ? { collection } : {})
|
||||
}, onProgress)
|
||||
|
||||
if (!embedResult) {
|
||||
|
|
@ -732,7 +738,8 @@ export class RagService {
|
|||
filepath: string,
|
||||
deleteAfterEmbedding: boolean = false,
|
||||
batchOffset?: number,
|
||||
onProgress?: (percent: number) => Promise<void>
|
||||
onProgress?: (percent: number) => Promise<void>,
|
||||
collection?: string
|
||||
): Promise<ProcessAndEmbedFileResponse> {
|
||||
try {
|
||||
const fileType = determineFileType(filepath)
|
||||
|
|
@ -781,7 +788,7 @@ export class RagService {
|
|||
: undefined
|
||||
|
||||
// Embed extracted text and cleanup
|
||||
return await this.embedTextAndCleanup(extractedText, filepath, deleteAfterEmbedding, scaledProgress)
|
||||
return await this.embedTextAndCleanup(extractedText, filepath, deleteAfterEmbedding, scaledProgress, collection)
|
||||
} catch (error) {
|
||||
logger.error('[RAG] Error processing and embedding file:', error)
|
||||
return { success: false, message: 'Error processing and embedding file.' }
|
||||
|
|
@ -800,7 +807,8 @@ export class RagService {
|
|||
public async searchSimilarDocuments(
|
||||
query: string,
|
||||
limit: number = 5,
|
||||
scoreThreshold: number = 0.3 // Lower default threshold - was 0.7, now 0.3
|
||||
scoreThreshold: number = 0.3, // Lower default threshold - was 0.7, now 0.3
|
||||
collection?: string
|
||||
): Promise<Array<{ text: string; score: number; metadata?: Record<string, any> }>> {
|
||||
try {
|
||||
logger.debug(`[RAG] Starting similarity search for query: "${query}"`)
|
||||
|
|
@ -873,6 +881,7 @@ export class RagService {
|
|||
limit: searchLimit,
|
||||
score_threshold: scoreThreshold,
|
||||
with_payload: true,
|
||||
...(collection ? { filter: { must: [{ key: 'collection', match: { value: collection } }] } } : {}),
|
||||
})
|
||||
|
||||
logger.debug(`[RAG] Found ${searchResults.length} results above threshold ${scoreThreshold}`)
|
||||
|
|
@ -1121,14 +1130,15 @@ export class RagService {
|
|||
// in particular) have no row to attach to. The state machine is the
|
||||
// authoritative "what's on disk?" view; Qdrant is "what made it into
|
||||
// the vector store?". Both are needed to render the KB UI honestly.
|
||||
const stateByPath = new Map<string, { state: KbIngestStateValue; chunks_embedded: number }>()
|
||||
const stateByPath = new Map<string, { state: KbIngestStateValue; chunks_embedded: number; collection: string | null }>()
|
||||
try {
|
||||
const stateRows = await KbIngestState.query().select('file_path', 'state', 'chunks_embedded')
|
||||
const stateRows = await KbIngestState.query().select('file_path', 'state', 'chunks_embedded', 'collection')
|
||||
for (const row of stateRows) {
|
||||
sources.add(row.file_path)
|
||||
stateByPath.set(row.file_path, {
|
||||
state: row.state,
|
||||
chunks_embedded: row.chunks_embedded,
|
||||
collection: row.collection,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1155,6 +1165,7 @@ export class RagService {
|
|||
size: stats?.size ?? null,
|
||||
uploadedAt: stats?.modifiedTime.toISOString() ?? null,
|
||||
isUserUpload,
|
||||
collection: row?.collection ?? null,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
@ -1164,6 +1175,114 @@ export class RagService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate distinct `collection` values currently in the knowledge base,
|
||||
* for populating a subject-picker in the upload/chat UI. Mirrors the
|
||||
* `source` facet pattern used elsewhere in this file (see getStoredFiles).
|
||||
*/
|
||||
public async getKnowledgeCollections(): Promise<string[]> {
|
||||
await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION)
|
||||
const facetResult = await this.qdrant!.facet(RagService.CONTENT_COLLECTION_NAME, {
|
||||
key: 'collection',
|
||||
limit: RagService.FACET_SOURCE_LIMIT,
|
||||
exact: true,
|
||||
})
|
||||
const collections = new Set<string>()
|
||||
for (const hit of facetResult.hits) {
|
||||
if (typeof hit.value === 'string') collections.add(hit.value)
|
||||
}
|
||||
return Array.from(collections).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign a stored file's collection after the fact. Updates the `collection`
|
||||
* payload field on every existing Qdrant point for this source in place (no
|
||||
* re-chunking or re-embedding needed), then mirrors the change onto the
|
||||
* KbIngestState row so getStoredFiles() reflects it immediately.
|
||||
*/
|
||||
public async updateFileCollection(
|
||||
source: string,
|
||||
collection: string | null
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION)
|
||||
|
||||
await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, {
|
||||
payload: { collection },
|
||||
filter: { must: [{ key: 'source', match: { value: source } }] },
|
||||
})
|
||||
|
||||
const row = await KbIngestState.query().where('file_path', source).first()
|
||||
if (row) {
|
||||
row.collection = collection
|
||||
await row.save()
|
||||
}
|
||||
|
||||
return { success: true, message: collection ? `Moved to "${collection}".` : 'Moved to Uncategorized.' }
|
||||
} catch (error) {
|
||||
logger.error('[RAG] Error updating file collection:', error)
|
||||
return { success: false, message: 'Error updating file collection.' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a knowledge-base collection everywhere it's referenced: updates every
|
||||
* Qdrant point tagged with the old name in place, and mirrors the change onto
|
||||
* any matching KbIngestState rows so getStoredFiles() reflects it immediately.
|
||||
*/
|
||||
public async renameKnowledgeCollection(
|
||||
oldName: string,
|
||||
newName: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (!oldName || !newName || oldName === newName) {
|
||||
return { success: false, message: 'Invalid collection names.' }
|
||||
}
|
||||
await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION)
|
||||
|
||||
await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, {
|
||||
payload: { collection: newName },
|
||||
filter: { must: [{ key: 'collection', match: { value: oldName } }] },
|
||||
})
|
||||
|
||||
await KbIngestState.query().where('collection', oldName).update({ collection: newName })
|
||||
|
||||
return { success: true, message: `Renamed "${oldName}" to "${newName}".` }
|
||||
} catch (error) {
|
||||
logger.error('[RAG] Error renaming knowledge collection:', error)
|
||||
return { success: false, message: 'Error renaming collection.' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a collection by reassigning every file tagged with it back to
|
||||
* Uncategorized (collection: null). Non-destructive — no files or embeddings
|
||||
* are deleted, only the grouping label is cleared so items can be
|
||||
* recategorized later.
|
||||
*/
|
||||
public async deleteKnowledgeCollection(
|
||||
name: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (!name) {
|
||||
return { success: false, message: 'Invalid collection name.' }
|
||||
}
|
||||
await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION)
|
||||
|
||||
await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, {
|
||||
payload: { collection: null },
|
||||
filter: { must: [{ key: 'collection', match: { value: name } }] },
|
||||
})
|
||||
|
||||
await KbIngestState.query().where('collection', name).update({ collection: null })
|
||||
|
||||
return { success: true, message: `"${name}" removed. Files moved to Uncategorized.` }
|
||||
} catch (error) {
|
||||
logger.error('[RAG] Error deleting knowledge collection:', error)
|
||||
return { success: false, message: 'Error deleting collection.' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stored-file `source` to an absolute disk path, but only if the
|
||||
* path lives under the uploads directory. Mirrors the docs_service traversal
|
||||
|
|
|
|||
|
|
@ -0,0 +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 = [
|
||||
'recipes',
|
||||
'diy',
|
||||
'health',
|
||||
'technology',
|
||||
'finance',
|
||||
'travel',
|
||||
'hobbies',
|
||||
'reference',
|
||||
'survival',
|
||||
'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,17 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'kb_ingest_state'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('collection').nullable().index()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('collection')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import StyledModal from '../StyledModal'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
import { useNotifications } from '~/context/NotificationContext'
|
||||
import api from '~/lib/api'
|
||||
|
||||
interface CollectionsManagerProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function CollectionsManager({ onClose }: CollectionsManagerProps) {
|
||||
const { addNotification } = useNotifications()
|
||||
const queryClient = useQueryClient()
|
||||
const [editingName, setEditingName] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
|
||||
const { data: collections = [], isLoading } = useQuery({
|
||||
queryKey: ['kbCollections'],
|
||||
queryFn: () => api.getKnowledgeCollections(),
|
||||
select: (data) => data?.collections ?? [],
|
||||
})
|
||||
|
||||
const invalidateAll = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['kbCollections'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
|
||||
}
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) =>
|
||||
api.renameCollection(oldName, newName),
|
||||
onSuccess: (data) => {
|
||||
addNotification({ type: 'success', message: data?.message || 'Collection renamed.' })
|
||||
setEditingName(null)
|
||||
invalidateAll()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
addNotification({ type: 'error', message: error?.message || 'Failed to rename collection.' })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (name: string) => api.deleteCollection(name),
|
||||
onSuccess: (data) => {
|
||||
addNotification({ type: 'success', message: data?.message || 'Collection removed.' })
|
||||
setConfirmDelete(null)
|
||||
invalidateAll()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
addNotification({ type: 'error', message: error?.message || 'Failed to remove collection.' })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
open={true}
|
||||
title="Manage Collections"
|
||||
onClose={onClose}
|
||||
cancelText="Close"
|
||||
onCancel={onClose}
|
||||
large
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Rename or remove collections. Removing a collection doesn't delete any files —
|
||||
they're simply moved back to Uncategorized so you can re-sort them.
|
||||
</p>
|
||||
|
||||
{isLoading && <p className="text-sm text-text-muted">Loading…</p>}
|
||||
{!isLoading && collections.length === 0 && (
|
||||
<p className="text-sm text-text-muted">
|
||||
No collections yet. Assign a file to a collection from the Knowledge Base table to create one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="divide-y divide-border-subtle">
|
||||
{collections.map((name) => (
|
||||
<li key={name} className="flex items-center justify-between gap-3 py-3">
|
||||
{editingName === name ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="flex-1 rounded border border-border-subtle bg-surface-primary px-2 py-1 text-sm text-text-primary"
|
||||
/>
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
icon="IconCheck"
|
||||
loading={renameMutation.isPending}
|
||||
disabled={!editValue.trim() || editValue.trim() === name}
|
||||
onClick={() =>
|
||||
renameMutation.mutate({ oldName: name, newName: editValue.trim() })
|
||||
}
|
||||
>
|
||||
Save
|
||||
</StyledButton>
|
||||
<StyledButton variant="outline" onClick={() => setEditingName(null)}>
|
||||
Cancel
|
||||
</StyledButton>
|
||||
</>
|
||||
) : confirmDelete === name ? (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-text-primary">
|
||||
Remove "{name}"? Files move to Uncategorized.
|
||||
</span>
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
icon="IconTrash"
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(name)}
|
||||
>
|
||||
Confirm
|
||||
</StyledButton>
|
||||
<StyledButton variant="outline" onClick={() => setConfirmDelete(null)}>
|
||||
Cancel
|
||||
</StyledButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-text-primary">{name}</span>
|
||||
<StyledButton
|
||||
variant="secondary"
|
||||
icon="IconPencil"
|
||||
onClick={() => {
|
||||
setEditingName(name)
|
||||
setEditValue(name)
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
icon="IconTrash"
|
||||
onClick={() => setConfirmDelete(name)}
|
||||
>
|
||||
Remove
|
||||
</StyledButton>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -27,6 +27,9 @@ import { useModals } from '~/context/ModalContext'
|
|||
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
|
||||
|
|
@ -34,7 +37,7 @@ interface KnowledgeBaseModalProps {
|
|||
}
|
||||
|
||||
// File extensions the in-browser viewer can render. Must stay in sync with
|
||||
// `RagService.VIEWABLE_TEXT_EXTENSIONS` — anything outside this set falls back
|
||||
// `RagService.VIEWABLE_TEXT_EXTENSIONS` -- anything outside this set falls back
|
||||
// to Download.
|
||||
const VIEWABLE_EXTENSIONS = new Set(['md', 'txt', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'html'])
|
||||
|
||||
|
|
@ -117,7 +120,7 @@ type RowAction =
|
|||
/**
|
||||
* Pick the single adaptive per-row action button. Returns null when no action
|
||||
* makes sense for the current state (e.g. healthy indexed file with no
|
||||
* warnings — bulk Re-embed All covers that case). `hasWarnings` lets us
|
||||
* warnings -- bulk Re-embed All covers that case). `hasWarnings` lets us
|
||||
* surface a Re-embed affordance specifically when a file *looks* indexed but
|
||||
* has zero chunks or a stalled-mid-ingestion warning attached.
|
||||
*/
|
||||
|
|
@ -143,6 +146,9 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
const { addNotification } = useNotifications()
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadCollection, setUploadCollection] = useState<string>('')
|
||||
const [collectionFilter, setCollectionFilter] = useState<string>('All')
|
||||
const [manageCollectionsOpen, setManageCollectionsOpen] = useState(false)
|
||||
const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)
|
||||
const [confirmReembed, setConfirmReembed] = useState<{ source: string; displayName: string } | null>(null)
|
||||
const [bulkMode, setBulkMode] = useState<null | 'reembed' | 'reset'>(null)
|
||||
|
|
@ -172,8 +178,18 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
select: (data) => data || [],
|
||||
})
|
||||
|
||||
// Per-file conditional warnings (RFC #883 §6). `ok: false` means the
|
||||
// computation itself failed (Qdrant/DB/FS) — distinct from `ok: true` with
|
||||
const { data: knownCollections = [] } = useQuery({
|
||||
queryKey: ['kbCollections'],
|
||||
queryFn: () => api.getKnowledgeCollections(),
|
||||
select: (data) => data?.collections ?? [],
|
||||
})
|
||||
|
||||
const comboboxOptions = useMemo(() => {
|
||||
return Array.from(new Set([...KB_COLLECTIONS, ...knownCollections])).sort()
|
||||
}, [knownCollections])
|
||||
|
||||
// Per-file conditional warnings (RFC #883 section 6). `ok: false` means the
|
||||
// computation itself failed (Qdrant/DB/FS) -- distinct from `ok: true` with
|
||||
// an empty map, which means everything is healthy. We surface the failure
|
||||
// explicitly so a silent backend failure doesn't masquerade as health.
|
||||
const { data: warningsResult } = useQuery({
|
||||
|
|
@ -216,7 +232,20 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
})
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => api.uploadDocument(file),
|
||||
mutationFn: (file: File) => api.uploadDocument(file, uploadCollection || undefined),
|
||||
})
|
||||
|
||||
const updateCollectionMutation = useMutation({
|
||||
mutationFn: ({ source, collection }: { source: string; collection: string }) =>
|
||||
api.updateFileCollection(source, collection || null),
|
||||
onSuccess: (data) => {
|
||||
addNotification({ type: 'success', message: data?.message || 'Collection updated.' })
|
||||
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['kbCollections'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
addNotification({ type: 'error', message: error?.message || 'Failed to update collection.' })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
|
|
@ -461,7 +490,16 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
setFiles(Array.from(uploadedFiles))
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-center gap-4 my-6">
|
||||
<div className="flex justify-center items-center gap-4 my-6">
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
Collection:
|
||||
<CollectionCombobox
|
||||
value={uploadCollection}
|
||||
onChange={setUploadCollection}
|
||||
options={comboboxOptions}
|
||||
className="w-48"
|
||||
/>
|
||||
</label>
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
size="lg"
|
||||
|
|
@ -585,8 +623,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
>
|
||||
Clean Up Failed
|
||||
</StyledButton>
|
||||
{/* Not gated on qdrantOffline: clearing stuck jobs must work during
|
||||
a Qdrant/Ollama outage, which is exactly when they wedge. */}
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
size="md"
|
||||
|
|
@ -607,6 +643,27 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
<div className='flex items-center justify-between mb-6 gap-2 flex-wrap'>
|
||||
<StyledSectionHeader title="Stored Knowledge Base Files" className='!mb-0' />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
Search in:
|
||||
<select
|
||||
value={collectionFilter}
|
||||
onChange={(e) => setCollectionFilter(e.target.value)}
|
||||
className="rounded border border-border-subtle bg-surface-primary px-3 py-2 text-text-primary"
|
||||
>
|
||||
<option value="All">All</option>
|
||||
{knownCollections.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<StyledButton
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon="IconSettings"
|
||||
onClick={() => setManageCollectionsOpen(true)}
|
||||
>
|
||||
Manage Collections
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
size="md"
|
||||
|
|
@ -701,8 +758,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
title: renderSortHeader('Size', 'size', sort, setSort),
|
||||
className: 'whitespace-nowrap',
|
||||
render(record) {
|
||||
// The collapsed admin_docs group has no single size — leave blank
|
||||
// rather than misleadingly summing across N files.
|
||||
if (record.bucket === 'admin_docs' || record.size === null) {
|
||||
return <span className="text-text-muted">—</span>
|
||||
}
|
||||
|
|
@ -725,13 +780,32 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: 'collection',
|
||||
title: 'Collection',
|
||||
className: 'whitespace-nowrap',
|
||||
render(record) {
|
||||
if (record.bucket === 'admin_docs') {
|
||||
return <span className="text-text-muted">—</span>
|
||||
}
|
||||
const isSaving =
|
||||
updateCollectionMutation.isPending &&
|
||||
updateCollectionMutation.variables?.source === record.source
|
||||
return (
|
||||
<CollectionCombobox
|
||||
value={record.collection ?? ''}
|
||||
onChange={(val) => updateCollectionMutation.mutate({ source: record.source, collection: val })}
|
||||
options={comboboxOptions}
|
||||
disabled={isSaving}
|
||||
className="w-40"
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: 'source',
|
||||
title: '',
|
||||
render(record) {
|
||||
// Admin docs are auto-discovered and managed by NOMAD itself —
|
||||
// deleting one would just be re-embedded on the next sync, so
|
||||
// we surface them as informational only and hide Delete.
|
||||
if (record.bucket === 'admin_docs') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
|
|
@ -827,7 +901,12 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
},
|
||||
},
|
||||
]}
|
||||
data={groupAndSortKbFiles(storedFiles, sort)}
|
||||
data={groupAndSortKbFiles(
|
||||
collectionFilter === 'All'
|
||||
? storedFiles
|
||||
: storedFiles.filter((f) => f.collection === collectionFilter),
|
||||
sort
|
||||
)}
|
||||
loading={isLoadingFiles}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -966,6 +1045,10 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
onClose={() => setViewerSource(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{manageCollectionsOpen && (
|
||||
<CollectionsManager onClose={() => setManageCollectionsOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -982,7 +1065,7 @@ function FileViewerModal({ source, onClose }: { source: string; onClose: () => v
|
|||
const fallbackName = source.split(/[/\\]/).at(-1) ?? source
|
||||
const title = data?.fileName ?? fallbackName
|
||||
// `catchInternal` swallows errors and resolves to undefined, surfacing a
|
||||
// toast — so the "couldn't load" branch is gated on a finished-but-empty
|
||||
// toast -- so the "couldn't load" branch is gated on a finished-but-empty
|
||||
// fetch rather than on react-query's `isError`.
|
||||
const showError = isFetched && !data
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export default function Chat({
|
|||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState<string>('')
|
||||
const [collectionFilter, setCollectionFilter] = useState<string>('')
|
||||
const [pendingModelSwitch, setPendingModelSwitch] = useState<string | null>(null)
|
||||
const pageLoadNormalizedRef = useRef(false)
|
||||
const [isStreamingResponse, setIsStreamingResponse] = useState(false)
|
||||
|
|
@ -72,6 +73,12 @@ export default function Chat({
|
|||
select: (data) => data || [],
|
||||
})
|
||||
|
||||
const { data: knownCollections = [] } = useQuery({
|
||||
queryKey: ['kbCollections'],
|
||||
queryFn: () => api.getKnowledgeCollections(),
|
||||
select: (data) => data?.collections ?? [],
|
||||
})
|
||||
|
||||
const { data: chatSuggestions, isLoading: chatSuggestionsLoading } = useQuery<string[]>({
|
||||
queryKey: ['chatSuggestions'],
|
||||
queryFn: async ({ signal }) => {
|
||||
|
|
@ -102,6 +109,7 @@ export default function Chat({
|
|||
model: string
|
||||
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
|
||||
sessionId?: number
|
||||
collection?: string
|
||||
}) => api.sendChatMessage({ ...request, stream: false }),
|
||||
onSuccess: async (data) => {
|
||||
if (!data || !activeSessionId) {
|
||||
|
|
@ -323,7 +331,13 @@ export default function Chat({
|
|||
|
||||
try {
|
||||
await api.streamChatMessage(
|
||||
{ model: selectedModel || 'llama3.2', messages: chatMessages, stream: true, sessionId: sessionId ? Number(sessionId) : undefined },
|
||||
{
|
||||
model: selectedModel || 'llama3.2',
|
||||
messages: chatMessages,
|
||||
stream: true,
|
||||
sessionId: sessionId ? Number(sessionId) : undefined,
|
||||
collection: collectionFilter || undefined,
|
||||
},
|
||||
(chunkContent, chunkThinking, done) => {
|
||||
if (chunkThinking.length > 0 && thinkingStartTime === null) {
|
||||
thinkingStartTime = Date.now()
|
||||
|
|
@ -414,10 +428,11 @@ export default function Chat({
|
|||
model: selectedModel || 'llama3.2',
|
||||
messages: chatMessages,
|
||||
sessionId: sessionId ? Number(sessionId) : undefined,
|
||||
collection: collectionFilter || undefined,
|
||||
})
|
||||
}
|
||||
},
|
||||
[activeSessionId, messages, selectedModel, chatMutation, queryClient, streamingEnabled]
|
||||
[activeSessionId, messages, selectedModel, collectionFilter, chatMutation, queryClient, streamingEnabled]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -471,6 +486,22 @@ export default function Chat({
|
|||
{remoteStatus?.connected === false ? 'Remote Disconnected' : 'Remote Connected'}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="collection-select" className="text-sm text-text-secondary">
|
||||
Search in:
|
||||
</label>
|
||||
<select
|
||||
id="collection-select"
|
||||
value={collectionFilter}
|
||||
onChange={(e) => setCollectionFilter(e.target.value)}
|
||||
className="px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{knownCollections.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="model-select" className="text-sm text-text-secondary">
|
||||
Model:
|
||||
|
|
|
|||
|
|
@ -124,9 +124,10 @@ class API {
|
|||
|
||||
async downloadRemoteMapRegionPreflight(url: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<
|
||||
{ filename: string; size: number } | { message: string }
|
||||
>('/maps/download-remote-preflight', { url })
|
||||
const response = await this.client.post<{ filename: string; size: number } | { message: string }>(
|
||||
'/maps/download-remote-preflight',
|
||||
{ url }
|
||||
)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
|
@ -275,7 +276,7 @@ class API {
|
|||
/**
|
||||
* Ask the backend to send Ollama `keep_alive: 0` to every currently-loaded
|
||||
* chat model except `targetModel` (and the embedding model, which is always
|
||||
* exempt server-side). Fire-and-forget — the chat UI doesn't await this
|
||||
* exempt server-side). Fire-and-forget -- the chat UI doesn't await this
|
||||
* before creating a new session, since unload is housekeeping.
|
||||
*
|
||||
* Pass `null` to unload every chat model.
|
||||
|
|
@ -376,15 +377,13 @@ class API {
|
|||
|
||||
async getChatSessions() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<
|
||||
Array<{
|
||||
id: string
|
||||
title: string
|
||||
model: string | null
|
||||
timestamp: string
|
||||
lastMessage: string | null
|
||||
}>
|
||||
>('/chat/sessions')
|
||||
const response = await this.client.get<Array<{
|
||||
id: string
|
||||
title: string
|
||||
model: string | null
|
||||
timestamp: string
|
||||
lastMessage: string | null
|
||||
}>>('/chat/sessions')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
|
@ -713,27 +712,21 @@ class API {
|
|||
|
||||
async listMapMarkers() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<
|
||||
Array<{ id: number; name: string; longitude: number; latitude: number; color: string; notes: string | null; created_at: string }>
|
||||
>('/maps/markers')
|
||||
const response = await this.client.get<Array<{ id: number; name: string; longitude: number; latitude: number; color: string; notes: string | null; created_at: string }>>('/maps/markers')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async createMapMarker(data: { name: string; longitude: number; latitude: number; color?: string }) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<
|
||||
{ id: number; name: string; longitude: number; latitude: number; color: string; notes: string | null; created_at: string }
|
||||
>('/maps/markers', data)
|
||||
const response = await this.client.post<{ id: number; name: string; longitude: number; latitude: number; color: string; notes: string | null; created_at: string }>('/maps/markers', data)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async updateMapMarker(id: number, data: { name?: string; color?: string }) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.patch<
|
||||
{ id: number; name: string; longitude: number; latitude: number; color: string }
|
||||
>(`/maps/markers/${id}`, data)
|
||||
const response = await this.client.patch<{ id: number; name: string; longitude: number; latitude: number; color: string }>(`/maps/markers/${id}`, data)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
|
@ -984,10 +977,11 @@ class API {
|
|||
})()
|
||||
}
|
||||
|
||||
async uploadDocument(file: File) {
|
||||
async uploadDocument(file: File, collection?: string) {
|
||||
return catchInternal(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (collection) formData.append('collection', collection)
|
||||
const response = await this.client.post<{ message: string; file_path: string }>(
|
||||
'/rag/upload',
|
||||
formData,
|
||||
|
|
@ -1001,6 +995,42 @@ class API {
|
|||
})()
|
||||
}
|
||||
|
||||
async getKnowledgeCollections() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{ collections: string[] }>('/rag/collections')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async updateFileCollection(source: string, collection: string | null) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{ message: string }>('/rag/update-collection', {
|
||||
source,
|
||||
collection,
|
||||
})
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async renameCollection(oldName: string, newName: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{ message: string }>('/rag/rename-collection', {
|
||||
oldName,
|
||||
newName,
|
||||
})
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async deleteCollection(name: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{ message: string }>('/rag/delete-collection', {
|
||||
name,
|
||||
})
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getSetting(key: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{ key: string; value: any }>(
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ export interface KbFileGroup {
|
|||
/** True when the row corresponds to a user upload — drives whether the
|
||||
* view/download buttons render. False for the collapsed admin_docs group. */
|
||||
isUserUpload: boolean
|
||||
/** Subject/category tag, or null if uncategorized. Always null for the
|
||||
* collapsed admin_docs group. */
|
||||
collection: string | null
|
||||
}
|
||||
|
||||
const BUCKET_SORT_ORDER: KbFileBucket[] = ['zim', 'upload', 'admin_docs', 'other']
|
||||
|
|
@ -136,6 +139,7 @@ export function groupAndSortKbFiles(
|
|||
size: null,
|
||||
uploadedAt: null,
|
||||
isUserUpload: false,
|
||||
collection: null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
|
@ -152,6 +156,7 @@ export function groupAndSortKbFiles(
|
|||
size: file.size,
|
||||
uploadedAt: file.uploadedAt,
|
||||
isUserUpload: file.isUserUpload,
|
||||
collection: file.collection,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,6 +161,10 @@ router
|
|||
router.post('/estimate-batch', [RagController, 'estimateBatch'])
|
||||
router.get('/policy-prompt-state', [RagController, 'policyPromptState'])
|
||||
router.get('/health', [RagController, 'health'])
|
||||
router.get('/collections', [RagController, 'getKnowledgeCollections'])
|
||||
router.post('/update-collection', [RagController, 'updateFileCollection'])
|
||||
router.post('/rename-collection', [RagController, 'renameKnowledgeCollection'])
|
||||
router.post('/delete-collection', [RagController, 'deleteKnowledgeCollection'])
|
||||
})
|
||||
.prefix('/api/rag')
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const asInfos = (sources: string[]): StoredFileInfo[] =>
|
|||
size: null,
|
||||
uploadedAt: null,
|
||||
isUserUpload: classifyKbFile(source) === 'upload',
|
||||
collection: null,
|
||||
}))
|
||||
|
||||
test('classifyKbFile distinguishes ZIM, upload, admin_docs, and other', () => {
|
||||
|
|
@ -121,9 +122,9 @@ test('groupAndSortKbFiles preserves a stable synthetic key for the admin docs gr
|
|||
* three sort keys (name, size, uploadedAt) produce visibly different orders —
|
||||
* if a test passed for name it had better fail for size. */
|
||||
const sized: StoredFileInfo[] = [
|
||||
{ source: '/app/storage/kb_uploads/charlie.txt', state: null, chunksEmbedded: 0, fileName: 'charlie.txt', size: 100, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: true },
|
||||
{ source: '/app/storage/kb_uploads/alpha.txt', state: null, chunksEmbedded: 0, fileName: 'alpha.txt', size: 300, uploadedAt: '2026-03-01T00:00:00Z', isUserUpload: true },
|
||||
{ source: '/app/storage/kb_uploads/bravo.txt', state: null, chunksEmbedded: 0, fileName: 'bravo.txt', size: 200, uploadedAt: '2026-02-01T00:00:00Z', isUserUpload: true },
|
||||
{ source: '/app/storage/kb_uploads/charlie.txt', state: null, chunksEmbedded: 0, fileName: 'charlie.txt', size: 100, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: true, collection: null },
|
||||
{ source: '/app/storage/kb_uploads/alpha.txt', state: null, chunksEmbedded: 0, fileName: 'alpha.txt', size: 300, uploadedAt: '2026-03-01T00:00:00Z', isUserUpload: true, collection: null },
|
||||
{ source: '/app/storage/kb_uploads/bravo.txt', state: null, chunksEmbedded: 0, fileName: 'bravo.txt', size: 200, uploadedAt: '2026-02-01T00:00:00Z', isUserUpload: true, collection: null },
|
||||
]
|
||||
|
||||
test('groupAndSortKbFiles sorts by size ascending', () => {
|
||||
|
|
@ -149,7 +150,7 @@ test('groupAndSortKbFiles sorts by uploadedAt descending', () => {
|
|||
test('groupAndSortKbFiles parks files with null size at the end of size sort', () => {
|
||||
const withMissing: StoredFileInfo[] = [
|
||||
...sized,
|
||||
{ source: '/app/storage/kb_uploads/zzz_missing.txt', state: null, chunksEmbedded: 0, fileName: 'zzz_missing.txt', size: null, uploadedAt: null, isUserUpload: true },
|
||||
{ source: '/app/storage/kb_uploads/zzz_missing.txt', state: null, chunksEmbedded: 0, fileName: 'zzz_missing.txt', size: null, uploadedAt: null, isUserUpload: true, collection: null },
|
||||
]
|
||||
// Missing-size files sort last regardless of direction so the "real" data
|
||||
// owns the top of the view either way.
|
||||
|
|
@ -161,9 +162,9 @@ test('groupAndSortKbFiles parks files with null size at the end of size sort', (
|
|||
|
||||
test('groupAndSortKbFiles preserves bucket order across all sort modes', () => {
|
||||
const mixed: StoredFileInfo[] = [
|
||||
{ source: '/app/storage/zim/big.zim', state: null, chunksEmbedded: 0, fileName: 'big.zim', size: 999, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: false },
|
||||
{ source: '/app/storage/kb_uploads/small.txt', state: null, chunksEmbedded: 0, fileName: 'small.txt', size: 1, uploadedAt: '2026-09-01T00:00:00Z', isUserUpload: true },
|
||||
{ source: '/app/docs/release-notes.md', state: null, chunksEmbedded: 0, fileName: 'release-notes.md', size: 50, uploadedAt: '2026-05-01T00:00:00Z', isUserUpload: false },
|
||||
{ source: '/app/storage/zim/big.zim', state: null, chunksEmbedded: 0, fileName: 'big.zim', size: 999, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: false, collection: null },
|
||||
{ source: '/app/storage/kb_uploads/small.txt', state: null, chunksEmbedded: 0, fileName: 'small.txt', size: 1, uploadedAt: '2026-09-01T00:00:00Z', isUserUpload: true, collection: null },
|
||||
{ source: '/app/docs/release-notes.md', state: null, chunksEmbedded: 0, fileName: 'release-notes.md', size: 50, uploadedAt: '2026-05-01T00:00:00Z', isUserUpload: false, collection: null },
|
||||
]
|
||||
// Even if size-desc would put zim first naturally, sort runs *within* a
|
||||
// bucket — buckets themselves stay in the canonical zim → upload → admin_docs
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export type OllamaChatRequest = {
|
|||
messages: OllamaChatMessage[]
|
||||
stream?: boolean
|
||||
sessionId?: number
|
||||
collection?: string
|
||||
}
|
||||
|
||||
export type OllamaChatResponse = {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ export type StoredFileInfo = {
|
|||
/** True when `source` lives under the user-uploads directory. Drives which
|
||||
* rows offer view/download in the UI. */
|
||||
isUserUpload: boolean
|
||||
/** Subject/category tag, or null if uncategorized. */
|
||||
collection: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue