diff --git a/src/components/editors/add-extension-dialog.tsx b/src/components/editors/add-extension-dialog.tsx index ca9c388d..14d41ea2 100644 --- a/src/components/editors/add-extension-dialog.tsx +++ b/src/components/editors/add-extension-dialog.tsx @@ -37,6 +37,7 @@ function buildEddiUri(rt: ResourceTypeConfig, id: string, version: number): stri output: "ai.labs.output", propertysetter: "ai.labs.property", mcpcalls: "ai.labs.mcpcalls", + rag: "ai.labs.rag", }; const host = slugToHost[rt.slug] ?? `ai.labs.${rt.slug}`; return `eddi://${host}/${rt.store}/${rt.plural}/${id}?version=${version}`; diff --git a/src/components/editors/ingestion-source-editor.tsx b/src/components/editors/ingestion-source-editor.tsx new file mode 100644 index 00000000..7dce6136 --- /dev/null +++ b/src/components/editors/ingestion-source-editor.tsx @@ -0,0 +1,846 @@ +import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { getErrorMessage } from "@/lib/api-client"; +import { + Globe, + GlobeLock, + Settings, + Clock, + Scissors, + Plus, + X, + Play, + Loader2, + ChevronDown, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + parseUriId, + type RagIngestionSource, + type WebSourceConfig, + type Scope, + type CrawlSettings, + type IngestionSettings, + type Schedule, +} from "@/lib/api/ingestion-sources"; +import { + useIngestionSources, + useCreateIngestionSource, + useUpdateIngestionSource, + useDeleteIngestionSource, + useTriggerIngestion, +} from "@/hooks/use-ingestion-sources"; + +const DEFAULT_SCOPE: Scope = { + sameDomainOnly: true, + pathPrefix: "/", + maxDepth: 3, + maxPages: 200, +}; + +const DEFAULT_CRAWL_SETTINGS: CrawlSettings = { + requestDelayMs: 100, + timeoutSeconds: 15, + userAgent: "EDDI-Crawler/1.0", +}; + +const DEFAULT_INGESTION_SETTINGS: IngestionSettings = { + chunkStrategy: "recursive", + chunkSize: 512, + chunkOverlap: 64, + contentHashDedup: true, + maxContentLength: 100000, +}; + +const DEFAULT_SCHEDULE: Schedule = { + enabled: true, + cronExpression: "0 2 * * *", +}; + +function mergeDefaults(source: RagIngestionSource): RagIngestionSource { + const base: RagIngestionSource = { + ...source, + ingestionSettings: { ...DEFAULT_INGESTION_SETTINGS, ...source.ingestionSettings }, + schedule: { ...DEFAULT_SCHEDULE, ...source.schedule }, + }; + + if (source.type === "web") { + return { + ...base, + sourceConfig: { + ...source.sourceConfig, + scope: { ...DEFAULT_SCOPE, ...(source.sourceConfig as WebSourceConfig).scope }, + crawlSettings: { ...DEFAULT_CRAWL_SETTINGS, ...(source.sourceConfig as WebSourceConfig).crawlSettings }, + } as WebSourceConfig, + }; + } + + return base; +} + +const EMPTY_SOURCE: RagIngestionSource = { + name: "", + type: "web", + sourceConfig: { startUrl: "", scope: DEFAULT_SCOPE, crawlSettings: DEFAULT_CRAWL_SETTINGS }, + ragConfigUri: "", + ingestionSettings: DEFAULT_INGESTION_SETTINGS, + schedule: DEFAULT_SCHEDULE, +}; + +const SOURCE_TYPES = [ + { value: "web", label: "Web Crawler", hint: "Crawl websites with TOC extraction", disabled: false }, + { value: "file", label: "File System", hint: "Coming soon", disabled: true }, + { value: "git", label: "Git Repository", hint: "Coming soon", disabled: true }, + { value: "api", label: "API Endpoint", hint: "Coming soon", disabled: true }, +] as const; + +const CHUNK_STRATEGIES = [ + { value: "recursive", label: "Recursive (recommended)" }, + { value: "paragraph", label: "Paragraph" }, + { value: "sentence", label: "Sentence" }, +] as const; + +const CRON_PRESETS = [ + { label: "Hourly", expr: "0 * * * *" }, + { label: "Daily", expr: "0 2 * * *" }, + { label: "Weekly", expr: "0 2 * * 0" }, + { label: "Monthly", expr: "0 2 1 * *" }, +] as const; + +function Section({ + label, + icon: Icon, + accent, + defaultOpen = true, + testId, + children, +}: { + label: string; + icon: React.ElementType; + accent: string; + defaultOpen?: boolean; + testId?: string; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open &&
{children}
} +
+ ); +} + +export interface IngestionSourceEditorProps { + initial?: RagIngestionSource; + ragConfigUri: string; + onSave: (source: RagIngestionSource) => void; + onCancel: () => void; + readOnly?: boolean; + isSaving?: boolean; +} + +export function IngestionSourceEditor({ + initial, + ragConfigUri, + onSave, + onCancel, + readOnly = false, + isSaving = false, +}: IngestionSourceEditorProps) { + const [source, setSource] = useState( + initial ? mergeDefaults(initial) : { ...EMPTY_SOURCE, ragConfigUri }, + ); + + const update = useCallback( + (patch: Partial) => setSource((prev) => ({ ...prev, ...patch })), + [], + ); + + const isWeb = source.type === "web"; + const webConfig = source.sourceConfig as WebSourceConfig | undefined; + const scope = webConfig?.scope; + const crawlSettings = webConfig?.crawlSettings; + const ingestSettings = source.ingestionSettings; + const schedule = source.schedule; + + return ( +
+
+
+
+ + update({ name: e.target.value })} + readOnly={readOnly} + placeholder="e.g. Product Documentation" + className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="ingestion-source-name" + /> +
+
+ + update({ description: e.target.value || undefined })} + readOnly={readOnly} + placeholder="Optional description" + className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="ingestion-source-description" + /> +
+
+ +
+ {SOURCE_TYPES.map((st) => ( + + ))} +
+
+
+
+ + {isWeb && ( +
+
+
+ + + update({ sourceConfig: { ...webConfig, startUrl: e.target.value } as WebSourceConfig }) + } + readOnly={readOnly} + placeholder="https://docs.example.com" + className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="source-start-url" + /> +
+ + +
+
+ + + Scope Constraints + +
+ +
+
+ + + update({ + sourceConfig: { + ...webConfig, + scope: { ...scope, pathPrefix: e.target.value || "/" } as Scope, + } as WebSourceConfig, + }) + } + readOnly={readOnly} + className="h-7 w-full rounded border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="source-path-prefix" + /> +
+
+ + + update({ + sourceConfig: { + ...webConfig, + scope: { ...scope, maxDepth: parseInt(e.target.value, 10) || 0 } as Scope, + } as WebSourceConfig, + }) + } + readOnly={readOnly} + className="h-7 w-full rounded border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="source-max-depth" + /> +
+
+ + + update({ + sourceConfig: { + ...webConfig, + scope: { ...scope, maxPages: parseInt(e.target.value, 10) || 1 } as Scope, + } as WebSourceConfig, + }) + } + readOnly={readOnly} + className="h-7 w-full rounded border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="source-max-pages" + /> +
+
+
+ + + update({ + sourceConfig: { + ...webConfig, + scope: { ...scope, excludePatterns: patterns.length > 0 ? patterns : undefined } as Scope, + } as WebSourceConfig, + }) + } + readOnly={readOnly} + /> +
+
+
+
+ +
+
+ + + Crawl Settings + +
+
+
+ + + update({ + sourceConfig: { + ...webConfig, + crawlSettings: { ...crawlSettings, requestDelayMs: parseInt(e.target.value, 10) || 0 } as CrawlSettings, + } as WebSourceConfig, + }) + } + readOnly={readOnly} + className="h-7 w-full rounded border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="source-request-delay" + /> +
+
+ + + update({ + sourceConfig: { + ...webConfig, + crawlSettings: { ...crawlSettings, timeoutSeconds: parseInt(e.target.value, 10) || 15 } as CrawlSettings, + } as WebSourceConfig, + }) + } + readOnly={readOnly} + className="h-7 w-full rounded border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="source-timeout" + /> +
+
+
+ + + update({ + sourceConfig: { + ...webConfig, + crawlSettings: { ...crawlSettings, userAgent: e.target.value || undefined } as CrawlSettings, + } as WebSourceConfig, + }) + } + readOnly={readOnly} + className="h-7 w-full rounded border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="source-user-agent" + /> +
+
+
+
+
+
+ )} + +
+
+
+ + +
+
+
+
+ + {ingestSettings?.chunkSize ?? 512} chars +
+ update({ ingestionSettings: { ...ingestSettings, chunkSize: parseInt(e.target.value, 10) } as IngestionSettings })} + disabled={readOnly} + className="w-full accent-primary" + data-testid="source-chunk-size" + /> +
+
+
+ + {ingestSettings?.chunkOverlap ?? 64} chars +
+ update({ ingestionSettings: { ...ingestSettings, chunkOverlap: parseInt(e.target.value, 10) } as IngestionSettings })} + disabled={readOnly} + className="w-full accent-primary" + data-testid="source-chunk-overlap" + /> +
+
+
+ +
+ + update({ ingestionSettings: { ...ingestSettings, maxContentLength: parseInt(e.target.value, 10) || 100000 } as IngestionSettings })} + readOnly={readOnly} + className="h-7 w-32 rounded border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="source-max-content" + /> +
+
+
+
+ +
+
+ +
+ +
+ update({ schedule: { ...schedule, cronExpression: e.target.value || "0 2 * * *" } as Schedule })} + readOnly={readOnly} + placeholder="0 2 * * *" + className="h-8 flex-1 rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="source-cron" + /> +
+

Format: minute hour day month weekday

+
+ {CRON_PRESETS.map((preset) => ( + + ))} +
+
+
+
+ + {!readOnly && ( +
+ + +
+ )} +
+ ); +} + +function ExcludePatternEditor({ + patterns, + onChange, + readOnly, +}: { + patterns: string[]; + onChange: (patterns: string[]) => void; + readOnly?: boolean; +}) { + const [input, setInput] = useState(""); + + const add = () => { + const trimmed = input.trim(); + if (!trimmed) return; + onChange([...patterns, trimmed]); + setInput(""); + }; + + return ( +
+ {patterns.length > 0 && ( +
+ {patterns.map((p, i) => ( + + {p} + {!readOnly && ( + + )} + + ))} +
+ )} + {!readOnly && ( +
+ setInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} + placeholder="**/legacy/**" + className="h-7 flex-1 rounded border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + +
+ )} +
+ ); +} + +export function IngestionSourcesPanel({ + resourceId, + version, + readOnly, +}: { + resourceId: string; + version: number; + readOnly?: boolean; +}) { + const ragConfigUri = `eddi://ai.labs.rag/ragstore/rags/${resourceId}?version=${version}`; + const { data: sources, isLoading } = useIngestionSources(ragConfigUri); + const createMutation = useCreateIngestionSource(); + const updateMutation = useUpdateIngestionSource(); + const deleteMutation = useDeleteIngestionSource(); + const triggerMutation = useTriggerIngestion(); + + const { t } = useTranslation(); + const [editing, setEditing] = useState<{ source: RagIngestionSource; id?: string; version?: number } | null>(null); + const [triggering, setTriggering] = useState(null); + const [isAdding, setIsAdding] = useState(false); + + const handleSave = (source: RagIngestionSource) => { + if (editing?.id && editing?.version !== undefined) { + updateMutation.mutate( + { id: editing.id, version: editing.version, source }, + { + onSuccess: () => { + toast.success(t("ragEditor.sourceUpdated", "Ingestion source updated")); + setEditing(null); + setIsAdding(false); + }, + onError: (err) => { + toast.error(t("ragEditor.sourceUpdateError", "Failed to update ingestion source"), { description: getErrorMessage(err) }); + }, + }, + ); + } else { + createMutation.mutate(source, { + onSuccess: () => { + toast.success(t("ragEditor.sourceCreated", "Ingestion source created")); + setIsAdding(false); + setEditing(null); + }, + onError: (err) => { + toast.error(t("ragEditor.sourceCreateError", "Failed to create ingestion source"), { description: getErrorMessage(err) }); + }, + }); + } + }; + + const handleDelete = (srcId: string, srcVersion: number) => { + deleteMutation.mutate( + { id: srcId, version: srcVersion, ragConfigUri }, + { + onSuccess: () => { + toast.success(t("ragEditor.sourceDeleted", "Ingestion source deleted")); + }, + onError: (err) => { + toast.error(t("ragEditor.sourceDeleteError", "Failed to delete ingestion source"), { description: getErrorMessage(err) }); + }, + }, + ); + }; + + const handleTrigger = (id: string, version: number) => { + setTriggering(id); + triggerMutation.mutate({ id, version }, { + onSuccess: () => toast.success(t("ragEditor.triggerSuccess", "Ingestion triggered")), + onError: (err) => toast.error(t("ragEditor.triggerError", "Failed to trigger ingestion"), { description: getErrorMessage(err) }), + onSettled: () => setTriggering(null), + }); + }; + + return ( +
+ {isLoading && ( +
+ + Loading ingestion sources... +
+ )} + + {!isLoading && sources && sources.length === 0 && !isAdding && ( +

+ No ingestion sources configured. Add one to automatically crawl content into this knowledge base. +

+ )} + + {sources && sources.length > 0 && ( +
+ {sources.map((src, idx) => { + const parsed = src.resource ? parseUriId(src.resource) : null; + const srcId = parsed?.id ?? `src-${idx}`; + const srcVersion = parsed?.version ?? 1; + const isWeb = src.type === "web"; + const wc = src.sourceConfig as WebSourceConfig; + return ( +
+
+
+
+ {src.name} + {src.type} +
+ {src.description && ( +

{src.description}

+ )} + {isWeb && wc?.startUrl && ( +

{wc.startUrl}

+ )} +
+
+ {!readOnly && ( + <> + + + + + )} +
+
+
+ ); + })} +
+ )} + + {!readOnly && !isAdding && ( + + )} + + {(isAdding || editing) && ( + { setEditing(null); setIsAdding(false); }} + readOnly={false} + isSaving={createMutation.isPending || updateMutation.isPending} + /> + )} +
+ ); +} diff --git a/src/components/editors/rag-editor.tsx b/src/components/editors/rag-editor.tsx index 3ee56e00..c89f078d 100644 --- a/src/components/editors/rag-editor.tsx +++ b/src/components/editors/rag-editor.tsx @@ -15,10 +15,13 @@ import { AlertCircle, Lock, ChevronDown, + Globe, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { api } from "@/lib/api-client"; +import { api, getErrorMessage } from "@/lib/api-client"; +import { toast } from "sonner"; import { SecretKeyPicker } from "@/components/shared/secret-key-picker"; +import { IngestionSourcesPanel } from "@/components/editors/ingestion-source-editor"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -154,6 +157,7 @@ function Section({ accent, defaultOpen = true, badge, + testId, children, }: { label: string; @@ -161,6 +165,7 @@ function Section({ accent: string; defaultOpen?: boolean; badge?: string; + testId?: string; children: React.ReactNode; }) { const [open, setOpen] = useState(defaultOpen); @@ -170,6 +175,7 @@ function Section({ type="button" onClick={() => setOpen(!open)} aria-expanded={open} + data-testid={testId} className="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/30" > @@ -447,15 +453,16 @@ function IngestionPanel({ ); // Poll status pollStatus(id); - } catch { + } catch (err) { setIngestions((prev) => prev.map((ing) => ing.ingestionId === tempId ? { ...ing, status: "failed: upload error" } : ing, ), ); + toast.error(t("ragEditor.ingestUploadError", "Failed to upload document"), { description: getErrorMessage(err) }); } }, - [kbId, version, pollStatus], + [kbId, version, pollStatus, t], ); const handleFiles = useCallback( @@ -471,11 +478,12 @@ function IngestionPanel({ ...prev, { ingestionId: `err-${Date.now()}`, status: `failed: could not read ${file.name}`, documentName: file.name }, ]); + toast.error(t("ragEditor.ingestReadError", "Could not read file"), { description: file.name }); }; reader.readAsText(file); }); }, - [startIngestion], + [startIngestion, t], ); const handleTextIngest = useCallback(() => { @@ -800,6 +808,7 @@ export function RagEditor({ data, onChange, readOnly, resourceId, version = 1 }: icon={Scissors} accent="text-amber-500" defaultOpen={false} + testId="section-chunking" >
@@ -1006,6 +1015,27 @@ export function RagEditor({ data, onChange, readOnly, resourceId, version = 1 }:

)} + + {/* ══════ Ingestion Sources ══════ */} +
+ {resourceId ? ( + + ) : ( +

+ {t("ragEditor.saveFirstIngestion", "Save this knowledge base first to enable document ingestion.")} +

+ )} +
); } diff --git a/src/hooks/use-extensions-store.ts b/src/hooks/use-extensions-store.ts index a8228acb..b95d9d29 100644 --- a/src/hooks/use-extensions-store.ts +++ b/src/hooks/use-extensions-store.ts @@ -21,7 +21,7 @@ const WELL_KNOWN_TYPES: ExtensionDescriptor[] = [ }, }, extensions: {}, - }, + } ]; /** diff --git a/src/hooks/use-ingestion-sources.ts b/src/hooks/use-ingestion-sources.ts new file mode 100644 index 00000000..78346607 --- /dev/null +++ b/src/hooks/use-ingestion-sources.ts @@ -0,0 +1,60 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + listIngestionSources, + createIngestionSource, + updateIngestionSource, + deleteIngestionSource, + triggerIngestion, + type RagIngestionSource, +} from "@/lib/api/ingestion-sources"; + +const ingestionKeys = { + all: (ragConfigUri: string) => ["ingestion-sources", ragConfigUri] as const, + list: (ragConfigUri: string) => ["ingestion-sources", ragConfigUri, "list"] as const, +}; + +export function useIngestionSources(ragConfigUri?: string) { + return useQuery({ + queryKey: ingestionKeys.list(ragConfigUri ?? ""), + queryFn: () => listIngestionSources(ragConfigUri!), + enabled: !!ragConfigUri, + }); +} + +export function useCreateIngestionSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (source: RagIngestionSource) => createIngestionSource(source), + onSuccess: (_data, source) => { + qc.invalidateQueries({ queryKey: ingestionKeys.all(source.ragConfigUri) }); + }, + }); +} + +export function useUpdateIngestionSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { id: string; version: number; source: RagIngestionSource }) => + updateIngestionSource(args.id, args.version, args.source), + onSuccess: (_data, args) => { + qc.invalidateQueries({ queryKey: ingestionKeys.all(args.source.ragConfigUri) }); + }, + }); +} + +export function useDeleteIngestionSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { id: string; version: number; ragConfigUri: string }) => + deleteIngestionSource(args.id, args.version), + onSuccess: (_data, args) => { + qc.invalidateQueries({ queryKey: ingestionKeys.all(args.ragConfigUri) }); + }, + }); +} + +export function useTriggerIngestion() { + return useMutation({ + mutationFn: (args: { id: string; version: number }) => triggerIngestion(args.id, args.version), + }); +} diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 20b9fd4f..9c59e13c 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1127,7 +1127,18 @@ "ingestText": "استيعاب النص", "ingestionHistory": "حالة الاستيعاب", "saveFirstIngestion": "احفظ قاعدة المعرفة هذه أولاً لتمكين استيعاب المستندات.", - "addParam": "إضافة معلمة" + "addParam": "إضافة معلمة", + "ingestionSources": "مصادر الاستيعاب", + "triggerSuccess": "تم بدء الاستيعاب", + "triggerError": "فشل بدء الاستيعاب", + "sourceCreated": "تم إنشاء مصدر الاستيعاب", + "sourceUpdated": "تم تحديث مصدر الاستيعاب", + "sourceDeleted": "تم حذف مصدر الاستيعاب", + "sourceCreateError": "فشل إنشاء مصدر الاستيعاب", + "sourceUpdateError": "فشل تحديث مصدر الاستيعاب", + "sourceDeleteError": "فشل حذف مصدر الاستيعاب", + "ingestUploadError": "فشل رفع المستند", + "ingestReadError": "تعذر قراءة الملف" }, "onboarding": { "welcome": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 0eac1de0..8b83da45 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1127,7 +1127,18 @@ "ingestText": "Text aufnehmen", "ingestionHistory": "Aufnahme-Status", "saveFirstIngestion": "Speichern Sie diese Knowledge Base zuerst, um die Dokumenten-Aufnahme zu aktivieren.", - "addParam": "Parameter hinzufügen" + "addParam": "Parameter hinzufügen", + "ingestionSources": "Ingestionsquellen", + "triggerSuccess": "Ingestion ausgelöst", + "triggerError": "Fehler beim Auslösen der Ingestion", + "sourceCreated": "Ingestionsquelle erstellt", + "sourceUpdated": "Ingestionsquelle aktualisiert", + "sourceDeleted": "Ingestionsquelle gelöscht", + "sourceCreateError": "Fehler beim Erstellen der Ingestionsquelle", + "sourceUpdateError": "Fehler beim Aktualisieren der Ingestionsquelle", + "sourceDeleteError": "Fehler beim Löschen der Ingestionsquelle", + "ingestUploadError": "Fehler beim Hochladen des Dokuments", + "ingestReadError": "Datei konnte nicht gelesen werden" }, "onboarding": { "welcome": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f5a7af28..f0fd1b49 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1200,7 +1200,18 @@ "ingestText": "Ingest Text", "ingestionHistory": "Ingestion Status", "saveFirstIngestion": "Save this knowledge base first to enable document ingestion.", - "addParam": "Add Parameter" + "addParam": "Add Parameter", + "ingestionSources": "Ingestion Sources", + "triggerSuccess": "Ingestion triggered", + "triggerError": "Failed to trigger ingestion", + "sourceCreated": "Ingestion source created", + "sourceUpdated": "Ingestion source updated", + "sourceDeleted": "Ingestion source deleted", + "sourceCreateError": "Failed to create ingestion source", + "sourceUpdateError": "Failed to update ingestion source", + "sourceDeleteError": "Failed to delete ingestion source", + "ingestUploadError": "Failed to upload document", + "ingestReadError": "Could not read file" }, "onboarding": { "welcome": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index bf30a046..d10717af 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1127,7 +1127,18 @@ "ingestText": "Ingerir texto", "ingestionHistory": "Estado de ingesta", "saveFirstIngestion": "Guarde esta base de conocimiento primero para activar la ingesta de documentos.", - "addParam": "Añadir parámetro" + "addParam": "Añadir parámetro", + "ingestionSources": "Fuentes de ingesta", + "triggerSuccess": "Ingesta iniciada", + "triggerError": "No se pudo iniciar la ingesta", + "sourceCreated": "Fuente de ingesta creada", + "sourceUpdated": "Fuente de ingesta actualizada", + "sourceDeleted": "Fuente de ingesta eliminada", + "sourceCreateError": "No se pudo crear la fuente de ingesta", + "sourceUpdateError": "No se pudo actualizar la fuente de ingesta", + "sourceDeleteError": "No se pudo eliminar la fuente de ingesta", + "ingestUploadError": "No se pudo subir el documento", + "ingestReadError": "No se pudo leer el archivo" }, "onboarding": { "welcome": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 6103d2d9..b6e1f7d8 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1127,7 +1127,18 @@ "ingestText": "Ingérer le texte", "ingestionHistory": "Statut d'ingestion", "saveFirstIngestion": "Enregistrez d'abord cette base pour autoriser l'ingestion.", - "addParam": "Ajout paramètre" + "addParam": "Ajouter un paramètre", + "ingestionSources": "Sources d'ingestion", + "triggerSuccess": "Ingestion déclenchée", + "triggerError": "Échec du déclenchement de l'ingestion", + "sourceCreated": "Source d'ingestion créée", + "sourceUpdated": "Source d'ingestion mise à jour", + "sourceDeleted": "Source d'ingestion supprimée", + "sourceCreateError": "Échec de la création de la source d'ingestion", + "sourceUpdateError": "Échec de la mise à jour de la source d'ingestion", + "sourceDeleteError": "Échec de la suppression de la source d'ingestion", + "ingestUploadError": "Échec du téléchargement du document", + "ingestReadError": "Impossible de lire le fichier" }, "onboarding": { "welcome": { diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 9cf46c67..280bf066 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1127,7 +1127,18 @@ "ingestText": "टेक्स्ट सुरक्षित करें", "ingestionHistory": "अंतर्ग्रहण स्थिति", "saveFirstIngestion": "दस्तावेज़ को प्रोसेस करने से पहले इस नॉलेज बेस को सहेजें।", - "addParam": "पैरामीटर जोड़ें" + "addParam": "पैरामीटर जोड़ें", + "ingestionSources": "इंजेशन स्रोत", + "triggerSuccess": "इंजेशन शुरू किया गया", + "triggerError": "इंजेशन शुरू करने में विफल", + "sourceCreated": "इंजेशन स्रोत बनाया गया", + "sourceUpdated": "इंजेशन स्रोत अपडेट किया गया", + "sourceDeleted": "इंजेशन स्रोत हटाया गया", + "sourceCreateError": "इंजेशन स्रोत बनाने में विफल", + "sourceUpdateError": "इंजेशन स्रोत अपडेट करने में विफल", + "sourceDeleteError": "इंजेशन स्रोत हटाने में विफल", + "ingestUploadError": "दस्तावेज़ अपलोड करने में विफल", + "ingestReadError": "फ़ाइल पढ़ नहीं सका" }, "onboarding": { "welcome": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b03b52ca..330c4362 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1127,7 +1127,18 @@ "ingestText": "テキストを取り込む", "ingestionHistory": "取り込みステータス", "saveFirstIngestion": "ドキュメントの取り込みを有効にするには、先にこのナレッジベースを保存してください。", - "addParam": "パラメーターの追加" + "addParam": "パラメーターの追加", + "ingestionSources": "取り込みソース", + "triggerSuccess": "取り込みを開始しました", + "triggerError": "取り込みの開始に失敗しました", + "sourceCreated": "取り込みソースを作成しました", + "sourceUpdated": "取り込みソースを更新しました", + "sourceDeleted": "取り込みソースを削除しました", + "sourceCreateError": "取り込みソースの作成に失敗しました", + "sourceUpdateError": "取り込みソースの更新に失敗しました", + "sourceDeleteError": "取り込みソースの削除に失敗しました", + "ingestUploadError": "ドキュメントのアップロードに失敗しました", + "ingestReadError": "ファイルを読み込めませんでした" }, "onboarding": { "welcome": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index f91c0b8a..46715909 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1127,7 +1127,18 @@ "ingestText": "텍스트 수집", "ingestionHistory": "수집 상태", "saveFirstIngestion": "문서 수집을 활성화하려면 먼저 이 지식 베이스를 저장하세요.", - "addParam": "파라미터 추가" + "addParam": "파라미터 추가", + "ingestionSources": "데이터 수집 소스", + "triggerSuccess": "데이터 수집이 시작되었습니다", + "triggerError": "데이터 수집 시작에 실패했습니다", + "sourceCreated": "데이터 수집 소스가 생성되었습니다", + "sourceUpdated": "데이터 수집 소스가 업데이트되었습니다", + "sourceDeleted": "데이터 수집 소스가 삭제되었습니다", + "sourceCreateError": "데이터 수집 소스 생성에 실패했습니다", + "sourceUpdateError": "데이터 수집 소스 업데이트에 실패했습니다", + "sourceDeleteError": "데이터 수집 소스 삭제에 실패했습니다", + "ingestUploadError": "문서 업로드에 실패했습니다", + "ingestReadError": "파일을 읽을 수 없습니다" }, "onboarding": { "welcome": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7df90d6f..d8372f5e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1127,7 +1127,18 @@ "ingestText": "Ingerir Texto", "ingestionHistory": "Status de Ingestão", "saveFirstIngestion": "Salve a base de conhecimento primeiro para ativar a ingestão.", - "addParam": "Adicionar Parâmetro" + "addParam": "Adicionar Parâmetro", + "ingestionSources": "Fontes de ingesta", + "triggerSuccess": "Ingesta iniciada", + "triggerError": "Falha ao iniciar a ingesta", + "sourceCreated": "Fonte de ingesta criada", + "sourceUpdated": "Fonte de ingesta atualizada", + "sourceDeleted": "Fonte de ingesta removida", + "sourceCreateError": "Falha ao criar fonte de ingesta", + "sourceUpdateError": "Falha ao atualizar fonte de ingesta", + "sourceDeleteError": "Falha ao remover fonte de ingesta", + "ingestUploadError": "Falha ao enviar documento", + "ingestReadError": "Não foi possível ler o arquivo" }, "onboarding": { "welcome": { diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index 4fbd0d1d..ccef028f 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -1127,7 +1127,18 @@ "ingestText": "นำเข้าชนิดข้อความ", "ingestionHistory": "สถานะการประมวล", "saveFirstIngestion": "หลังจากนี้ ค่อยเพิ่มความสมบูรณ์ฐานความรู้ให้กับเอกสารต่อๆไป", - "addParam": "แอดพารามิเตอร์" + "addParam": "แอดพารามิเตอร์", + "ingestionSources": "แหล่งนำเข้าข้อมูล", + "triggerSuccess": "เริ่มนำเข้าข้อมูลแล้ว", + "triggerError": "เริ่มนำเข้าข้อมูลไม่สำเร็จ", + "sourceCreated": "สร้างแหล่งนำเข้าข้อมูลแล้ว", + "sourceUpdated": "อัปเดตแหล่งนำเข้าข้อมูลแล้ว", + "sourceDeleted": "ลบแหล่งนำเข้าข้อมูลแล้ว", + "sourceCreateError": "สร้างแหล่งนำเข้าข้อมูลไม่สำเร็จ", + "sourceUpdateError": "อัปเดตแหล่งนำเข้าข้อมูลไม่สำเร็จ", + "sourceDeleteError": "ลบแหล่งนำเข้าข้อมูลไม่สำเร็จ", + "ingestUploadError": "อัปโหลดเอกสารไม่สำเร็จ", + "ingestReadError": "อ่านไฟล์ไม่ได้" }, "onboarding": { "welcome": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 96202913..1f640e14 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1127,7 +1127,18 @@ "ingestText": "摄入文本", "ingestionHistory": "摄入状态", "saveFirstIngestion": "使用前请先保存此知识库。", - "addParam": "添加参数" + "addParam": "添加参数", + "ingestionSources": "摄取来源", + "triggerSuccess": "已触发摄取", + "triggerError": "触发摄取失败", + "sourceCreated": "摄取来源已创建", + "sourceUpdated": "摄取来源已更新", + "sourceDeleted": "摄取来源已删除", + "sourceCreateError": "创建摄取来源失败", + "sourceUpdateError": "更新摄取来源失败", + "sourceDeleteError": "删除摄取来源失败", + "ingestUploadError": "上传文档失败", + "ingestReadError": "无法读取文件" }, "onboarding": { "welcome": { diff --git a/src/lib/api/extensions.ts b/src/lib/api/extensions.ts index 223664ec..0927b01d 100644 --- a/src/lib/api/extensions.ts +++ b/src/lib/api/extensions.ts @@ -1,4 +1,4 @@ -import { Brain, FileCode, FileText, GitBranch, Globe, MessageSquareText, Plug, Puzzle, Settings } from "lucide-react"; +import { Brain, BookOpenCheck, FileCode, FileText, GitBranch, Globe, MessageSquareText, Plug, Puzzle, Settings } from "lucide-react"; import { api } from "../api-client"; /** Matches EDDI backend ExtensionDescriptor.ConfigValue */ @@ -49,6 +49,7 @@ const iconMap: Record> = { Settings, FileCode, Plug, + BookOpenCheck, }; /** Well-known extension type IDs and their display info */ @@ -69,7 +70,7 @@ export const EXTENSION_TYPE_INFO: Record< "eddi://ai.labs.mcpcalls": { label: "MCP Calls", icon: "Plug", order: 11, color: "text-rose-400" }, "eddi://ai.labs.workflow": { label: "Workflow", icon: "FileText", order: 12, color: "text-indigo-400" }, "eddi://ai.labs.dictionary": { label: "Dictionary", icon: "FileText", order: 13, color: "text-amber-400" }, - "eddi://ai.labs.rag": { label: "RAG", icon: "FileText", order: 14, color: "text-purple-400" }, + "eddi://ai.labs.rag": { label: "Knowledge Bases", icon: "BookOpenCheck", order: 14, color: "text-purple-400" }, }; /** Get a human-readable label for an extension type */ diff --git a/src/lib/api/ingestion-sources.ts b/src/lib/api/ingestion-sources.ts new file mode 100644 index 00000000..05bd8144 --- /dev/null +++ b/src/lib/api/ingestion-sources.ts @@ -0,0 +1,118 @@ +import { api } from "@/lib/api-client"; + +export interface RagIngestionSource { + resource?: string; + name: string; + description?: string; + type: "web" | "file" | "git" | "api"; + sourceConfig: WebSourceConfig | FileSourceConfig | GitSourceConfig | ApiSourceConfig; + ragConfigUri: string; + ingestionSettings?: IngestionSettings; + schedule?: Schedule; +} + +export interface WebSourceConfig { + startUrl: string; + scope?: Scope; + crawlSettings?: CrawlSettings; +} + +export interface Scope { + sameDomainOnly?: boolean; + pathPrefix?: string; + maxDepth?: number; + maxPages?: number; + excludePatterns?: string[]; +} + +export interface CrawlSettings { + requestDelayMs?: number; + timeoutSeconds?: number; + userAgent?: string; +} + +export interface FileSourceConfig { + basePath: string; + globPattern: string; + recursive: boolean; + encoding?: string; +} + +export interface GitSourceConfig { + repoUrl: string; + branch: string; + path: string; + accessTokenSecret?: string; + filePattern?: string; +} + +export interface ApiSourceConfig { + endpointUrl: string; + method: "GET" | "POST"; + headers?: Record; + pagination?: PaginationConfig; + dataPath?: string; +} + +export interface PaginationConfig { + type: "page" | "cursor" | "offset"; + pageParam?: string; + limitParam?: string; + limit?: number; +} + +export interface IngestionSettings { + chunkStrategy?: "recursive" | "paragraph" | "sentence"; + chunkSize?: number; + chunkOverlap?: number; + contentHashDedup?: boolean; + maxContentLength?: number; +} + +export interface Schedule { + cronExpression?: string; + enabled?: boolean; +} + +const STORE = "ragstore"; +const PLURAL = "ingestion-sources"; + +export function listIngestionSources(ragConfigUri: string): Promise { + const params = new URLSearchParams({ ragConfigUri }); + return api.get(`/${STORE}/${PLURAL}/byRagConfig?${params}`); +} + +export function getIngestionSource(id: string): Promise { + return api.get(`/${STORE}/${PLURAL}/${id}`); +} + +export function createIngestionSource(source: RagIngestionSource): Promise<{ location: string }> { + return api.post<{ location: string }>(`/${STORE}/${PLURAL}`, source); +} + +export function updateIngestionSource( + id: string, + version: number, + source: RagIngestionSource, +): Promise<{ location: string }> { + return api.put<{ location: string }>(`/${STORE}/${PLURAL}/${id}?version=${version}`, source); +} + +export function deleteIngestionSource(id: string, version: number): Promise { + return api.delete(`/${STORE}/${PLURAL}/${id}?version=${version}`); +} + +export function triggerIngestion(id: string, version: number): Promise<{ status: string }> { + return api.post<{ status: string }>(`/${STORE}/${PLURAL}/${id}/trigger?version=${version}`); +} + +export function parseUriId(uri: string): { id: string; version: number } | null { + const match = uri.match(/\/([^/]+)\?version=(\d+)$/); + if (!match) return null; + return { id: match[1]!, version: parseInt(match[2]!, 10) }; +} + +export function parseUriVersion(uri: string): number { + const match = uri.match(/version=(\d+)/); + return match ? parseInt(match[1]!, 10) : 1; +} diff --git a/src/pages/__tests__/resource-detail-rag.test.tsx b/src/pages/__tests__/resource-detail-rag.test.tsx index e898abcf..4d73211e 100644 --- a/src/pages/__tests__/resource-detail-rag.test.tsx +++ b/src/pages/__tests__/resource-detail-rag.test.tsx @@ -4,6 +4,9 @@ import { render } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { ThemeProvider } from "@/components/layout/theme-provider"; +import { Toaster } from "sonner"; +import { http, HttpResponse } from "msw"; +import { server } from "@/test/mocks/server"; import { ResourceDetailPage } from "@/pages/resource-detail"; function renderPage(type: string, id = "res1") { @@ -234,12 +237,7 @@ describe("RAG Knowledge Base Editor", () => { expect(screen.queryByTestId("chunk-size")).not.toBeInTheDocument(); // Find the chunking section header button and click it - const sectionButtons = screen.getAllByRole("button", { expanded: false }); - const chunkingBtn = sectionButtons.find((btn) => - btn.textContent?.toLowerCase().includes("chunking"), - ); - expect(chunkingBtn).toBeDefined(); - fireEvent.click(chunkingBtn!); + fireEvent.click(screen.getByTestId("section-chunking")); await waitFor(() => { const slider = screen.getByTestId("chunk-size") as HTMLInputElement; @@ -254,11 +252,7 @@ describe("RAG Knowledge Base Editor", () => { }); // Click to expand chunking - const sectionButtons = screen.getAllByRole("button", { expanded: false }); - const chunkingBtn = sectionButtons.find((btn) => - btn.textContent?.toLowerCase().includes("chunking"), - ); - fireEvent.click(chunkingBtn!); + fireEvent.click(screen.getByTestId("section-chunking")); await waitFor(() => { const slider = screen.getByTestId("chunk-overlap") as HTMLInputElement; @@ -273,11 +267,7 @@ describe("RAG Knowledge Base Editor", () => { }); // Expand chunking section - const sectionButtons = screen.getAllByRole("button", { expanded: false }); - const chunkingBtn = sectionButtons.find((btn) => - btn.textContent?.toLowerCase().includes("chunking"), - ); - fireEvent.click(chunkingBtn!); + fireEvent.click(screen.getByTestId("section-chunking")); await waitFor(() => { expect(screen.getByTestId("chunk-strategy")).toBeInTheDocument(); @@ -305,4 +295,178 @@ describe("RAG Knowledge Base Editor", () => { expect(screen.getByTestId("json-view")).toBeInTheDocument(); }); }); + + // ─── Ingestion Sources ───────────────────────────────── + + /** Helper: expand the "Ingestion Sources" section (collapsed by default) */ + async function expandIngestionSources() { + await waitFor(() => { + expect(screen.getByTestId("rag-editor")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("section-ingestion-sources")); + await waitFor(() => { + expect(screen.getByTestId("ingestion-sources-panel")).toBeInTheDocument(); + }); + } + + it("renders ingestion sources section when expanded", async () => { + renderPage("rag"); + await expandIngestionSources(); + }); + + it("shows existing ingestion source from mock data", async () => { + renderPage("rag"); + await expandIngestionSources(); + await waitFor(() => { + expect(screen.getByTestId("source-name-0")).toBeInTheDocument(); + }); + expect(screen.getByTestId("source-name-0")).toHaveTextContent("Product Documentation"); + }); + + it("shows add ingestion source button when not read-only", async () => { + renderPage("rag"); + await expandIngestionSources(); + await waitFor(() => { + expect(screen.getByTestId("add-ingestion-source-btn")).toBeInTheDocument(); + }); + }); + + it("opens ingestion source editor form on add click", async () => { + renderPage("rag"); + await expandIngestionSources(); + fireEvent.click(screen.getByTestId("add-ingestion-source-btn")); + await waitFor(() => { + expect(screen.getByTestId("ingestion-source-editor")).toBeInTheDocument(); + }); + }); + + it("renders source type selection buttons", async () => { + renderPage("rag"); + await expandIngestionSources(); + fireEvent.click(screen.getByTestId("add-ingestion-source-btn")); + await waitFor(() => { + expect(screen.getByTestId("source-type-web")).toBeInTheDocument(); + expect(screen.getByTestId("source-type-file")).toBeInTheDocument(); + expect(screen.getByTestId("source-type-git")).toBeInTheDocument(); + expect(screen.getByTestId("source-type-api")).toBeInTheDocument(); + }); + }); + + it("web source type is selected by default", async () => { + renderPage("rag"); + await expandIngestionSources(); + fireEvent.click(screen.getByTestId("add-ingestion-source-btn")); + await waitFor(() => { + const webBtn = screen.getByTestId("source-type-web"); + expect(webBtn).toHaveAttribute("aria-pressed", "true"); + }); + }); + + it("renders start url input when adding web source", async () => { + renderPage("rag"); + await expandIngestionSources(); + fireEvent.click(screen.getByTestId("add-ingestion-source-btn")); + await waitFor(() => { + expect(screen.getByTestId("source-start-url")).toBeInTheDocument(); + }); + }); + + it("renders cron preset buttons", async () => { + renderPage("rag"); + await expandIngestionSources(); + fireEvent.click(screen.getByTestId("add-ingestion-source-btn")); + await waitFor(() => { + expect(screen.getByTestId("cron-preset-hourly")).toBeInTheDocument(); + expect(screen.getByTestId("cron-preset-daily")).toBeInTheDocument(); + expect(screen.getByTestId("cron-preset-weekly")).toBeInTheDocument(); + expect(screen.getByTestId("cron-preset-monthly")).toBeInTheDocument(); + }); + }); + + it("shows edit and delete buttons on existing sources", async () => { + renderPage("rag"); + await expandIngestionSources(); + await waitFor(() => { + expect(screen.getByTestId("source-item-0")).toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.getByTestId("source-edit-0")).toBeInTheDocument(); + expect(screen.getByTestId("source-delete-0")).toBeInTheDocument(); + expect(screen.getByTestId("source-trigger-0")).toBeInTheDocument(); + }); + }); + + // ─── Error Handling ───────────────────────────────────── + + describe("error handling", () => { + function renderPageWithToaster(type: string, id = "res1") { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + + + + } + /> + + + + + ); + } + + it("shows error toast and keeps editor open when source creation fails", async () => { + server.use( + http.post("*/ragstore/ingestion-sources", () => { + return HttpResponse.json( + { message: "Internal Server Error" }, + { status: 500 }, + ); + }), + ); + + renderPageWithToaster("rag"); + + // Expand ingestion sources section + await waitFor(() => { + expect(screen.getByTestId("rag-editor")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("section-ingestion-sources")); + await waitFor(() => { + expect(screen.getByTestId("ingestion-sources-panel")).toBeInTheDocument(); + }); + + // Open add form + fireEvent.click(screen.getByTestId("add-ingestion-source-btn")); + await waitFor(() => { + expect(screen.getByTestId("ingestion-source-editor")).toBeInTheDocument(); + }); + + // Fill in the name so save button is enabled + const nameInput = screen.getByTestId("ingestion-source-name") as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: "Test Source" } }); + + // Click save + fireEvent.click(screen.getByTestId("source-save-btn")); + + // Assert error toast is shown + await waitFor(() => { + expect(screen.getByText(/Failed to create ingestion source/)).toBeInTheDocument(); + }); + + // The editor should remain open (not close on error) + await waitFor(() => { + expect(screen.getByTestId("ingestion-source-editor")).toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/pages/workflow-detail.tsx b/src/pages/workflow-detail.tsx index 63238b18..2bd58502 100644 --- a/src/pages/workflow-detail.tsx +++ b/src/pages/workflow-detail.tsx @@ -152,8 +152,12 @@ export function WorkflowDetailPage() { const handleAddExtension = useCallback( (result: AddExtensionResult) => { + // Ensure type has eddi:// prefix (e.g., "eddi://ai.labs.rag") + const type = result.descriptor.type.startsWith("eddi://") + ? result.descriptor.type + : `eddi://${result.descriptor.type}`; const newExt: WorkflowExtension = { - type: result.descriptor.type, + type, extensions: {}, config: result.configUri ? { uri: result.configUri } : {}, }; diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index bac9559b..9fa87f2f 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -1846,6 +1846,109 @@ export const handlers = [ }); }), + // Ingestion Source mock data (before group store) + http.get("*/ragstore/ingestion-sources/byRagConfig", ({ request }) => { + const url = new URL(request.url); + const ragConfigUri = url.searchParams.get("ragConfigUri") ?? "eddi://ai.labs.rag/ragstore/rags/rag1?version=1"; + return HttpResponse.json([ + { + resource: "eddi://ai.labs.rag/ragstore/ingestion-sources/src1?version=1", + name: "Product Documentation", + description: "Crawls product docs from sidebar", + type: "web", + sourceConfig: { + startUrl: "https://docs.example.com", + scope: { + sameDomainOnly: true, + pathPrefix: "/docs/", + maxDepth: 3, + maxPages: 500, + excludePatterns: ["**/legacy/**"], + }, + crawlSettings: { + requestDelayMs: 1000, + timeoutSeconds: 30, + userAgent: "EDDI-Crawler/1.0", + }, + }, + ragConfigUri: ragConfigUri, + ingestionSettings: { + chunkStrategy: "recursive", + chunkSize: 1024, + chunkOverlap: 128, + contentHashDedup: true, + maxContentLength: 50000, + }, + schedule: { + cronExpression: "0 2 * * 0", + enabled: true, + }, + }, + ]); + }), + + http.get("*/ragstore/ingestion-sources/:id", () => { + return HttpResponse.json({ + name: "Product Documentation", + description: "Crawls product docs from sidebar", + type: "web", + sourceConfig: { + startUrl: "https://docs.example.com", + scope: { + sameDomainOnly: true, + pathPrefix: "/docs/", + maxDepth: 3, + maxPages: 500, + excludePatterns: ["**/legacy/**"], + }, + crawlSettings: { + requestDelayMs: 1000, + timeoutSeconds: 30, + userAgent: "EDDI-Crawler/1.0", + }, + }, + ragConfigUri: "eddi://ai.labs.rag/ragstore/rags/rag1?version=1", + ingestionSettings: { + chunkStrategy: "recursive", + chunkSize: 1024, + chunkOverlap: 128, + contentHashDedup: true, + maxContentLength: 50000, + }, + schedule: { + cronExpression: "0 2 * * 0", + enabled: true, + }, + }); + }), + + http.post("*/ragstore/ingestion-sources", () => { + return new HttpResponse(null, { + status: 201, + headers: { Location: "/ragstore/ingestion-sources/new-source?version=1" }, + }); + }), + + http.put("*/ragstore/ingestion-sources/:id", () => { + return new HttpResponse(null, { + status: 200, + headers: { Location: "/ragstore/ingestion-sources/updated-source?version=2" }, + }); + }), + + http.delete("*/ragstore/ingestion-sources/:id", () => { + return new HttpResponse(null, { status: 204 }); + }), + + http.post("*/ragstore/ingestion-sources/:id/trigger", ({ request }) => { + const url = new URL(request.url); + const version = url.searchParams.get("version"); + if (!version) { + return HttpResponse.json({ error: "version query parameter is required" }, { status: 400 }); + } + return HttpResponse.json({ status: "accepted" }, { status: 202 }); + }), + // --- Group Store Mock Handlers --- http.get("*/groupstore/groups/descriptors", () => { return HttpResponse.json([ diff --git a/src/test/setup.ts b/src/test/setup.ts index 54076cdc..fcd64d78 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -32,6 +32,22 @@ vi.mock("keycloak-js", () => ({ }, })); +// Mock localStorage (jsdom may not always have it in isolated environments) +if (typeof localStorage === "undefined" || localStorage.getItem === undefined) { + const store = new Map(); + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + get length() { return store.size; }, + key: (i: number) => [...store.keys()][i] ?? null, + }, + configurable: true, + }); +} + // Mock window.matchMedia for theme-provider (JSDOM doesn't implement it) Object.defineProperty(window, "matchMedia", { writable: true,