@@ -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,