- {/* Model & Debug */}
-
-
-
Model
-
{config.model}
-
-
- Debug
-
-
-
+ {isLoading && (
+
+ Loading configuration...
+
+ )}
- {/* System Prompt */}
- {config.system_prompt && (
-
-
System Prompt
-
- {promptExpanded
- ? config.system_prompt
- : config.system_prompt.slice(0, 200)}
- {config.system_prompt.length > 200 && (
-
- )}
+ {config && (
+
+ {/* Model & Debug */}
+
+
+
Model
+
{config.model}
+
+
+ Debug
+
- )}
- {/* Tools */}
- {config.tools.length > 0 && (
-
-
Tools ({config.tools.length})
-
- {config.tools.map((tool) => (
-
- ))}
+ {/* System Prompt */}
+ {config.system_prompt && (
+
+
System Prompt
+
+ {promptExpanded
+ ? config.system_prompt
+ : config.system_prompt.slice(0, 200)}
+ {config.system_prompt.length > 200 && (
+
+ )}
+
-
- )}
+ )}
- {/* Middleware */}
- {config.middleware.length > 0 && (
-
-
- Middleware ({config.middleware.length})
-
-
- {config.middleware.map((mw) => (
-
- ))}
+ {/* Tools */}
+ {config.tools.length > 0 && (
+
+
Tools ({config.tools.length})
+
+ {config.tools.map((tool) => (
+
+ ))}
+
-
- )}
+ )}
- {/* Backend */}
-
-
Backend
-
- Type: {config.backend.type}
- {config.backend.root_dir && (
- <>
- {" "}
- · Root:{" "}
- {config.backend.root_dir}
- >
- )}
-
-
+ {/* Middleware */}
+ {config.middleware.length > 0 && (
+
+
+ Middleware ({config.middleware.length})
+
+
+ {config.middleware.map((mw) => (
+
+ ))}
+
+
+ )}
- {/* HITL Rules */}
- {Object.keys(config.hitl.rules).length > 0 && (
+ {/* Backend */}
-
HITL Rules
-
- {Object.entries(config.hitl.rules).map(([key, value]) => (
-
-
{key}
-
- {(() => {
- if (typeof value === "boolean") return value ? "Enabled" : "Disabled";
- return JSON.stringify(value);
- })()}
+ Backend
+
+ Type: {config.backend.type}
+ {config.backend.root_dir && (
+ <>
+ {" "}
+ · Root:{" "}
+
+ {config.backend.root_dir}
-
- ))}
-
+ >
+ )}
+
- )}
- {/* MCP Servers */}
- {config.mcp_servers.length > 0 && (
-
-
- MCP Servers ({config.mcp_servers.length})
-
-
- {config.mcp_servers.map((server) => (
-
-
- {server.name}
-
-
-
- ))}
+ {/* HITL Rules */}
+ {Object.keys(config.hitl.rules).length > 0 && (
+
+
HITL Rules
+
+ {Object.entries(config.hitl.rules).map(([key, value]) => (
+
+ {key}
+
+ {(() => {
+ if (typeof value === "boolean")
+ return value ? "Enabled" : "Disabled";
+ return JSON.stringify(value);
+ })()}
+
+
+ ))}
+
-
- )}
+ )}
- {/* Subagents */}
- {config.subagents.length > 0 && (
-
-
- Subagents ({config.subagents.length})
-
-
- {config.subagents.map((sub) => (
-
-
- {sub.name}
-
-
- {sub.description}
-
-
- ))}
+ {/* MCP Servers */}
+ {config.mcp_servers.length > 0 && (
+
+
+ MCP Servers ({config.mcp_servers.length})
+
+
+ {config.mcp_servers.map((server) => (
+
+
+ {server.name}
+
+
+
+ ))}
+
-
- )}
-
- )}
+ )}
- {/* Actions */}
-
-
-
+ {/* Subagents */}
+ {config.subagents.length > 0 && (
+
+
+ Subagents ({config.subagents.length})
+
+
+ {config.subagents.map((sub) => (
+
+
+ {sub.name}
+
+
+ {sub.description}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* Actions */}
+
+
+
+
-
+
);
}
diff --git a/src/application/components/agent/CreateAgentDialog.tsx b/src/application/components/agent/CreateAgentDialog.tsx
index b7510bd..ce3ccc7 100644
--- a/src/application/components/agent/CreateAgentDialog.tsx
+++ b/src/application/components/agent/CreateAgentDialog.tsx
@@ -53,82 +53,95 @@ export default function CreateAgentDialog({
}
return (
-
@@ -129,45 +131,55 @@ export default function ThreadSidebar({ activeThreadId }: Readonly
setShowAgentDialog(false)}
- onKeyDown={(e) => { if (e.key === "Escape") setShowAgentDialog(false); }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") setShowAgentDialog(false);
+ }}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="agent-dialog-title"
>
e.stopPropagation()}
>
-
- Choose an Agent
-
- {agentsLoading ? (
-
- Loading agents...
-
- ) : (
-
- {agents?.map((agent) => (
-
- ))}
-
- )}
+
+
+ Choose an Agent
+
+ {agentsLoading ? (
+
+ Loading agents...
+
+ ) : (
+
+ {agents?.map((agent) => (
+
+ ))}
+
+ )}
+
-
+
)}
);
diff --git a/src/application/components/layout/TopNav.tsx b/src/application/components/layout/TopNav.tsx
index de82508..41b0941 100644
--- a/src/application/components/layout/TopNav.tsx
+++ b/src/application/components/layout/TopNav.tsx
@@ -4,6 +4,7 @@ import { cn } from "@/application/lib/utils";
const NAV_LINKS = [
{ to: "/chat", label: "Orchestration" },
{ to: "/agents", label: "Agents" },
+ { to: "/rag", label: "RAG" },
] as const;
export default function TopNav() {
diff --git a/src/application/components/rag/BreadcrumbBar.tsx b/src/application/components/rag/BreadcrumbBar.tsx
new file mode 100644
index 0000000..e7accea
--- /dev/null
+++ b/src/application/components/rag/BreadcrumbBar.tsx
@@ -0,0 +1,53 @@
+interface BreadcrumbBarProps {
+ segments: string[];
+ onNavigate: (index: number) => void;
+}
+
+export default function BreadcrumbBar({
+ segments,
+ onNavigate,
+}: Readonly
) {
+ return (
+
+ );
+}
diff --git a/src/application/components/rag/FileList.tsx b/src/application/components/rag/FileList.tsx
new file mode 100644
index 0000000..81a7112
--- /dev/null
+++ b/src/application/components/rag/FileList.tsx
@@ -0,0 +1,97 @@
+import FolderRow from "@/application/components/rag/FolderRow";
+import FileRow from "@/application/components/rag/FileRow";
+import type { FolderEntry } from "@/domain/entities/rag/fileEntry";
+import type { FileEntry } from "@/domain/entities/rag/fileEntry";
+
+interface FileListProps {
+ folders: FolderEntry[];
+ files: FileEntry[];
+ isLoading: boolean;
+ error: Error | null;
+ onFolderClick: (prefix: string) => void;
+ onRetry: () => void;
+}
+
+export default function FileList({
+ folders,
+ files,
+ isLoading,
+ error,
+ onFolderClick,
+ onRetry,
+}: Readonly) {
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ cloud_off
+
+
+ Unable to load files. Please try again.
+
+
+
+ );
+ }
+
+ if (folders.length === 0 && files.length === 0) {
+ return (
+
+
+ folder_off
+
+
+ No files or folders found.
+
+
+ );
+ }
+
+ return (
+
+ {folders.map((folder) => (
+ onFolderClick(folder.prefix)}
+ />
+ ))}
+ {files.map((file) => (
+
+ ))}
+
+ );
+}
diff --git a/src/application/components/rag/FileRow.tsx b/src/application/components/rag/FileRow.tsx
new file mode 100644
index 0000000..53468fd
--- /dev/null
+++ b/src/application/components/rag/FileRow.tsx
@@ -0,0 +1,71 @@
+import { formatFileSize } from "@/application/lib/formatFileSize";
+
+interface FileRowProps {
+ filename: string;
+ size: number;
+ lastModified: string | null;
+}
+
+function formatDate(dateStr: string | null): string {
+ if (!dateStr) return "\u2014";
+ const date = new Date(dateStr);
+ if (isNaN(date.getTime())) return "\u2014";
+ return date.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+function getFileIcon(filename: string): string {
+ const ext = filename.split(".").pop()?.toLowerCase() || "";
+ const icons: Record = {
+ pdf: "picture_as_pdf",
+ doc: "description",
+ docx: "description",
+ xls: "table_chart",
+ xlsx: "table_chart",
+ png: "image",
+ jpg: "image",
+ jpeg: "image",
+ gif: "image",
+ svg: "image",
+ md: "article",
+ txt: "article",
+ csv: "table_chart",
+ json: "data_object",
+ yaml: "data_object",
+ yml: "data_object",
+ zip: "folder_zip",
+ };
+ return icons[ext] || "description";
+}
+
+export default function FileRow({
+ filename,
+ size,
+ lastModified,
+}: Readonly) {
+ return (
+
+
+ {getFileIcon(filename)}
+
+
+ {filename}
+
+
+ {formatFileSize(size)}
+
+
+ {formatDate(lastModified)}
+
+
+ );
+}
diff --git a/src/application/components/rag/FolderRow.tsx b/src/application/components/rag/FolderRow.tsx
new file mode 100644
index 0000000..3fad470
--- /dev/null
+++ b/src/application/components/rag/FolderRow.tsx
@@ -0,0 +1,32 @@
+interface FolderRowProps {
+ name: string;
+ onClick: () => void;
+}
+
+export default function FolderRow({ name, onClick }: Readonly) {
+ return (
+
+ );
+}
diff --git a/src/application/components/rag/UploadButton.tsx b/src/application/components/rag/UploadButton.tsx
new file mode 100644
index 0000000..11580df
--- /dev/null
+++ b/src/application/components/rag/UploadButton.tsx
@@ -0,0 +1,77 @@
+import { useRef } from "react";
+import { toast } from "sonner";
+import { useUploadFile } from "@/application/hooks/rag/useUploadFile";
+
+interface UploadButtonProps {
+ prefix: string;
+ onUploadComplete?: () => void;
+}
+
+export default function UploadButton({
+ prefix,
+ onUploadComplete,
+}: Readonly) {
+ const fileInputRef = useRef(null);
+ const { mutate, isPending } = useUploadFile();
+
+ function handleButtonClick() {
+ fileInputRef.current?.click();
+ }
+
+ function handleFileChange(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ mutate(
+ { prefix, file },
+ {
+ onSuccess: () => {
+ toast.success("File uploaded successfully");
+ onUploadComplete?.();
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ },
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/application/hooks/chat/useStreamChat.ts b/src/application/hooks/chat/useStreamChat.ts
index 0528cd5..b8ea279 100644
--- a/src/application/hooks/chat/useStreamChat.ts
+++ b/src/application/hooks/chat/useStreamChat.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
import { chatApi } from "@/infrastructure/api/chat/chatApi";
import { useChatStore } from "@/application/stores/useChatStore";
import type { ChatRequest } from "@/domain/entities/chat/chatRequest";
@@ -23,15 +24,23 @@ export function useStreamChat(threadId: string | null) {
(chunk) => {
appendStreamChunk(chunk);
},
- () => {
- useChatStore.getState().setPendingUserMessage(null);
- setStreaming(false);
- queryClient.invalidateQueries({ queryKey: ["messages", threadId] });
+ async () => {
+ try {
+ await queryClient.invalidateQueries({
+ queryKey: ["messages", threadId],
+ });
+ } finally {
+ useChatStore.getState().setPendingUserMessage(null);
+ setStreaming(false);
+ }
},
(error) => {
console.error("Stream error:", error);
useChatStore.getState().setPendingUserMessage(null);
setStreaming(false);
+ toast.error("Stream error", {
+ description: error.message || "An error occurred while streaming.",
+ });
},
);
},
diff --git a/src/application/hooks/config/index.ts b/src/application/hooks/config/index.ts
new file mode 100644
index 0000000..a43d5e4
--- /dev/null
+++ b/src/application/hooks/config/index.ts
@@ -0,0 +1 @@
+export { useConfig } from "./useConfig";
diff --git a/src/application/hooks/config/useConfig.ts b/src/application/hooks/config/useConfig.ts
new file mode 100644
index 0000000..3190b4d
--- /dev/null
+++ b/src/application/hooks/config/useConfig.ts
@@ -0,0 +1,10 @@
+import { useQuery } from "@tanstack/react-query";
+import { configRepository } from "@/infrastructure/config/configRepositoryInstance";
+
+export function useConfig() {
+ return useQuery({
+ queryKey: ["config"],
+ queryFn: () => configRepository.getConfig(),
+ staleTime: Infinity,
+ });
+}
diff --git a/src/application/hooks/rag/useFiles.ts b/src/application/hooks/rag/useFiles.ts
new file mode 100644
index 0000000..ad579a7
--- /dev/null
+++ b/src/application/hooks/rag/useFiles.ts
@@ -0,0 +1,11 @@
+import { useQuery } from "@tanstack/react-query";
+import { ragApi } from "@/infrastructure/api/rag/ragApi";
+
+export function useFiles(prefix?: string, recursive = false) {
+ return useQuery({
+ queryKey: ["rag", "files", prefix, recursive],
+ queryFn: () => ragApi.listFiles(prefix, recursive),
+ enabled: prefix !== undefined,
+ staleTime: 30_000,
+ });
+}
diff --git a/src/application/hooks/rag/useFolders.ts b/src/application/hooks/rag/useFolders.ts
new file mode 100644
index 0000000..54fe604
--- /dev/null
+++ b/src/application/hooks/rag/useFolders.ts
@@ -0,0 +1,11 @@
+import { useQuery } from "@tanstack/react-query";
+import { ragApi } from "@/infrastructure/api/rag/ragApi";
+
+export function useFolders(prefix?: string) {
+ return useQuery({
+ queryKey: ["rag", "folders", prefix],
+ queryFn: () => ragApi.listFolders(prefix),
+ enabled: prefix !== undefined,
+ staleTime: 30_000,
+ });
+}
diff --git a/src/application/hooks/rag/useUploadFile.ts b/src/application/hooks/rag/useUploadFile.ts
new file mode 100644
index 0000000..f6ee198
--- /dev/null
+++ b/src/application/hooks/rag/useUploadFile.ts
@@ -0,0 +1,15 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { ragApi } from "@/infrastructure/api/rag/ragApi";
+
+export function useUploadFile() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ prefix, file }: { prefix: string; file: File }) =>
+ ragApi.uploadFile(prefix, file),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["rag", "files"] });
+ queryClient.invalidateQueries({ queryKey: ["rag", "folders"] });
+ },
+ });
+}
diff --git a/src/application/lib/formatFileSize.ts b/src/application/lib/formatFileSize.ts
new file mode 100644
index 0000000..ec1d4e8
--- /dev/null
+++ b/src/application/lib/formatFileSize.ts
@@ -0,0 +1,9 @@
+export function formatFileSize(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes < 0) return "\u2014";
+ if (bytes === 0) return "0 B";
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024)
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
+}
diff --git a/src/application/pages/AgentsPage.tsx b/src/application/pages/AgentsPage.tsx
index 8e3aba1..793a02f 100644
--- a/src/application/pages/AgentsPage.tsx
+++ b/src/application/pages/AgentsPage.tsx
@@ -30,8 +30,8 @@ export default function AgentsPage() {
>
add_circle
-
- {" "}Create Agent (YAML)
+ {" "}
+ Create Agent (YAML)
diff --git a/src/application/pages/RagPage.tsx b/src/application/pages/RagPage.tsx
new file mode 100644
index 0000000..fdb366c
--- /dev/null
+++ b/src/application/pages/RagPage.tsx
@@ -0,0 +1,87 @@
+import { useState, useMemo } from "react";
+import MainLayout from "@/application/components/layout/MainLayout";
+import BreadcrumbBar from "@/application/components/rag/BreadcrumbBar";
+import FileList from "@/application/components/rag/FileList";
+import UploadButton from "@/application/components/rag/UploadButton";
+import { useFolders } from "@/application/hooks/rag/useFolders";
+import { useFiles } from "@/application/hooks/rag/useFiles";
+
+function prefixToSegments(prefix: string): string[] {
+ const trimmed = prefix.replace(/\/+$/, "");
+ if (!trimmed) return [];
+ return trimmed.split("/");
+}
+
+function segmentsToPrefix(segments: string[], upToIndex: number): string {
+ if (upToIndex < 0) return "";
+ return segments.slice(0, upToIndex + 1).join("/") + "/";
+}
+
+export default function RagPage() {
+ const [currentPrefix, setCurrentPrefix] = useState("");
+
+ const segments = useMemo(
+ () => prefixToSegments(currentPrefix),
+ [currentPrefix],
+ );
+
+ const foldersQuery = useFolders(currentPrefix);
+ const filesQuery = useFiles(currentPrefix, false);
+
+ const handleNavigate = (index: number) => {
+ if (index < 0) {
+ setCurrentPrefix("");
+ } else {
+ setCurrentPrefix(segmentsToPrefix(segments, index));
+ }
+ };
+
+ const handleFolderClick = (prefix: string) => {
+ setCurrentPrefix(prefix);
+ };
+
+ const handleUploadComplete = () => {
+ foldersQuery.refetch();
+ filesQuery.refetch();
+ };
+
+ const isLoading = foldersQuery.isLoading || filesQuery.isLoading;
+ const error = foldersQuery.error || filesQuery.error;
+
+ return (
+
+
+
+
+
+
+ RAG Storage
+
+
+ Browse and upload documents in MinIO object storage.
+
+
+
+
+
+
+
+
{
+ foldersQuery.refetch();
+ filesQuery.refetch();
+ }}
+ />
+
+
+
+ );
+}
diff --git a/src/domain/entities/config/appConfig.ts b/src/domain/entities/config/appConfig.ts
new file mode 100644
index 0000000..4f131fa
--- /dev/null
+++ b/src/domain/entities/config/appConfig.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+
+export const AppConfigSchema = z.object({
+ apiBaseUrl: z.string().url(),
+ wsBaseUrl: z.string().url(),
+ ragApiBaseUrl: z.string().url().or(z.literal("")).optional().default(""),
+});
+
+export type AppConfig = z.infer
;
diff --git a/src/domain/entities/rag/fileEntry.ts b/src/domain/entities/rag/fileEntry.ts
new file mode 100644
index 0000000..4f24a73
--- /dev/null
+++ b/src/domain/entities/rag/fileEntry.ts
@@ -0,0 +1,38 @@
+export class FileEntry {
+ readonly objectName: string;
+ readonly size: number;
+ readonly lastModified: string | null;
+
+ constructor({
+ objectName,
+ size,
+ lastModified,
+ }: {
+ objectName: string;
+ size: number;
+ lastModified: string | null;
+ }) {
+ this.objectName = objectName;
+ this.size = size;
+ this.lastModified = lastModified;
+ }
+
+ get filename(): string {
+ const parts = this.objectName.split("/");
+ return parts[parts.length - 1] || this.objectName;
+ }
+}
+
+export class FolderEntry {
+ readonly prefix: string;
+
+ constructor({ prefix }: { prefix: string }) {
+ this.prefix = prefix;
+ }
+
+ get name(): string {
+ const trimmed = this.prefix.replace(/\/+$/, "");
+ const parts = trimmed.split("/");
+ return parts[parts.length - 1] || trimmed;
+ }
+}
diff --git a/src/domain/ports/config/configRepository.ts b/src/domain/ports/config/configRepository.ts
new file mode 100644
index 0000000..0f13ef0
--- /dev/null
+++ b/src/domain/ports/config/configRepository.ts
@@ -0,0 +1,6 @@
+import { AppConfig } from "@/domain/entities/config/appConfig";
+
+export interface IConfigRepository {
+ getConfig(): Promise;
+ isLoaded(): boolean;
+}
diff --git a/src/domain/ports/rag/ragFilePort.ts b/src/domain/ports/rag/ragFilePort.ts
new file mode 100644
index 0000000..eb68d05
--- /dev/null
+++ b/src/domain/ports/rag/ragFilePort.ts
@@ -0,0 +1,10 @@
+import type { FileEntry, FolderEntry } from "@/domain/entities/rag/fileEntry";
+
+export interface IRagFilePort {
+ listFolders(prefix?: string): Promise;
+ listFiles(prefix?: string, recursive?: boolean): Promise;
+ uploadFile(
+ prefix: string,
+ file: File,
+ ): Promise<{ object_name: string; size: number; message: string }>;
+}
diff --git a/src/infrastructure/api/axiosInstance.ts b/src/infrastructure/api/axiosInstance.ts
index 37e18cd..fb122c7 100644
--- a/src/infrastructure/api/axiosInstance.ts
+++ b/src/infrastructure/api/axiosInstance.ts
@@ -1,14 +1,24 @@
import axios from "axios";
-import { envConfig } from "@/infrastructure/config/envConfig";
+import { configRepository } from "@/infrastructure/config/configRepositoryInstance";
+
+let cachedBaseURL: string | null = null;
export const apiClient = axios.create({
- baseURL: envConfig.apiBaseUrl,
timeout: 30000,
headers: {
"Content-Type": "application/json",
},
});
+apiClient.interceptors.request.use(async (config) => {
+ if (!cachedBaseURL) {
+ const appConfig = await configRepository.getConfig();
+ cachedBaseURL = appConfig.apiBaseUrl;
+ }
+ config.baseURL = cachedBaseURL;
+ return config;
+});
+
apiClient.interceptors.response.use(
(response) => response,
(error) => {
diff --git a/src/infrastructure/api/chat/chatApi.ts b/src/infrastructure/api/chat/chatApi.ts
index c87350c..872d067 100644
--- a/src/infrastructure/api/chat/chatApi.ts
+++ b/src/infrastructure/api/chat/chatApi.ts
@@ -3,8 +3,8 @@ import type { Message } from "@/domain/entities/chat/message";
import type { Thread } from "@/domain/entities/chat/thread";
import type { IChatPort } from "@/domain/ports/chat/chatPort";
import { apiClient } from "@/infrastructure/api/axiosInstance";
+import { configRepository } from "@/infrastructure/config/configRepositoryInstance";
import { fetchEventSource } from "@microsoft/fetch-event-source";
-import { envConfig } from "@/infrastructure/config/envConfig";
export const chatApi: IChatPort = {
async createThread(agentName: string): Promise {
@@ -39,6 +39,7 @@ export const chatApi: IChatPort = {
const response = await apiClient.post(
`/api/v1/chat/${threadId}`,
request,
+ { timeout: 300000 },
);
return response.data;
},
@@ -52,23 +53,30 @@ export const chatApi: IChatPort = {
): AbortController {
const ctrl = new AbortController();
- fetchEventSource(`${envConfig.apiBaseUrl}/api/v1/chat/${threadId}/stream`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(request),
- signal: ctrl.signal,
- onmessage(ev) {
- if (ev.data) {
- onChunk(ev.data);
- }
- },
- onclose() {
- onComplete();
- },
- onerror(err) {
- onError(err instanceof Error ? err : new Error(String(err)));
- throw err;
- },
+ const streamUrl = async () => {
+ const config = await configRepository.getConfig();
+ return `${config.apiBaseUrl}/api/v1/chat/${threadId}/stream`;
+ };
+
+ streamUrl().then((url) => {
+ fetchEventSource(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
+ signal: ctrl.signal,
+ onmessage(ev) {
+ if (ev.data) {
+ onChunk(ev.data);
+ }
+ },
+ onclose() {
+ onComplete();
+ },
+ onerror(err) {
+ onError(err instanceof Error ? err : new Error(String(err)));
+ throw err;
+ },
+ });
});
return ctrl;
diff --git a/src/infrastructure/api/rag/ragApi.ts b/src/infrastructure/api/rag/ragApi.ts
new file mode 100644
index 0000000..3d5a152
--- /dev/null
+++ b/src/infrastructure/api/rag/ragApi.ts
@@ -0,0 +1,42 @@
+import { ragApiClient } from "@/infrastructure/api/ragAxiosInstance";
+import type { IRagFilePort } from "@/domain/ports/rag/ragFilePort";
+import { FileEntry, FolderEntry } from "@/domain/entities/rag/fileEntry";
+
+export const ragApi: IRagFilePort = {
+ async listFolders(prefix?: string) {
+ const params = new URLSearchParams();
+ if (prefix !== undefined) params.set("prefix", prefix);
+ const qs = params.toString();
+ const response = await ragApiClient.get(
+ `/api/v1/files/folders${qs ? `?${qs}` : ""}`,
+ );
+ return response.data.map((p) => new FolderEntry({ prefix: p }));
+ },
+
+ async listFiles(prefix?: string, recursive = false) {
+ const params = new URLSearchParams({
+ prefix: prefix ?? "",
+ recursive: String(recursive),
+ });
+ const response = await ragApiClient.get<
+ { object_name: string; size: number; last_modified: string | null }[]
+ >(`/api/v1/files/list?${params}`);
+
+ return response.data.map(
+ (f) =>
+ new FileEntry({
+ objectName: f.object_name,
+ size: f.size,
+ lastModified: f.last_modified,
+ }),
+ );
+ },
+
+ async uploadFile(prefix: string, file: File) {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("prefix", prefix);
+ const response = await ragApiClient.post("/api/v1/files/upload", formData);
+ return response.data;
+ },
+};
diff --git a/src/infrastructure/api/ragAxiosInstance.ts b/src/infrastructure/api/ragAxiosInstance.ts
new file mode 100644
index 0000000..be105bb
--- /dev/null
+++ b/src/infrastructure/api/ragAxiosInstance.ts
@@ -0,0 +1,31 @@
+import axios from "axios";
+import { configRepository } from "@/infrastructure/config/configRepositoryInstance";
+
+let cachedRagBaseURL: string | null = null;
+
+export const ragApiClient = axios.create({
+ timeout: 30000,
+ headers: {
+ "Content-Type": "application/json",
+ },
+});
+
+ragApiClient.interceptors.request.use(async (config) => {
+ if (!cachedRagBaseURL) {
+ const appConfig = await configRepository.getConfig();
+ cachedRagBaseURL = appConfig.ragApiBaseUrl || appConfig.apiBaseUrl;
+ }
+ config.baseURL = cachedRagBaseURL;
+ return config;
+});
+
+ragApiClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response) {
+ const detail = error.response.data?.detail || error.message;
+ return Promise.reject(new Error(detail));
+ }
+ return Promise.reject(error);
+ },
+);
diff --git a/src/infrastructure/config/configRepositoryInstance.ts b/src/infrastructure/config/configRepositoryInstance.ts
new file mode 100644
index 0000000..0152a68
--- /dev/null
+++ b/src/infrastructure/config/configRepositoryInstance.ts
@@ -0,0 +1,3 @@
+import { FileConfigRepository } from "./fileConfigRepository";
+
+export const configRepository = new FileConfigRepository();
diff --git a/src/infrastructure/config/envConfig.ts b/src/infrastructure/config/envConfig.ts
deleted file mode 100644
index d338056..0000000
--- a/src/infrastructure/config/envConfig.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const envConfig = {
- apiBaseUrl: import.meta.env.VITE_API_BASE_URL || "http://localhost:8010",
- wsBaseUrl: import.meta.env.VITE_WS_BASE_URL || "ws://localhost:8010",
-};
diff --git a/src/infrastructure/config/fileConfigRepository.ts b/src/infrastructure/config/fileConfigRepository.ts
new file mode 100644
index 0000000..af827a4
--- /dev/null
+++ b/src/infrastructure/config/fileConfigRepository.ts
@@ -0,0 +1,46 @@
+import { toast } from "sonner";
+import { AppConfig, AppConfigSchema } from "@/domain/entities/config/appConfig";
+import { IConfigRepository } from "@/domain/ports/config/configRepository";
+
+export class FileConfigRepository implements IConfigRepository {
+ private config: AppConfig | null = null;
+ private configPromise: Promise | null = null;
+
+ async getConfig(): Promise {
+ if (this.config) {
+ return this.config;
+ }
+
+ if (this.configPromise !== null) {
+ return this.configPromise;
+ }
+
+ this.configPromise = this.fetchConfig();
+ return this.configPromise;
+ }
+
+ private async fetchConfig(): Promise {
+ try {
+ const response = await fetch("/config.json");
+ if (!response.ok) {
+ throw new Error(`Failed to load config: ${response.status}`);
+ }
+
+ const rawConfig = await response.json();
+ const config = AppConfigSchema.parse(rawConfig);
+ this.config = config;
+ return config;
+ } catch (error) {
+ this.configPromise = null;
+ console.error("Config loading failed:", error);
+ toast.error("Configuration Error", {
+ description: "App is not configured.",
+ });
+ throw error;
+ }
+ }
+
+ isLoaded(): boolean {
+ return this.config !== null;
+ }
+}
diff --git a/tests/fixtures/rag.ts b/tests/fixtures/rag.ts
new file mode 100644
index 0000000..d025c35
--- /dev/null
+++ b/tests/fixtures/rag.ts
@@ -0,0 +1,29 @@
+export interface FileEntryFixture {
+ objectName: string;
+ size: number;
+ lastModified: string | null;
+}
+
+export interface FolderEntryFixture {
+ prefix: string;
+}
+
+export function createFileEntry(
+ overrides: Partial = {},
+): FileEntryFixture {
+ return {
+ objectName: "docs/readme.md",
+ size: 1024,
+ lastModified: "2026-04-06T10:00:00Z",
+ ...overrides,
+ };
+}
+
+export function createFolderEntry(
+ overrides: Partial = {},
+): FolderEntryFixture {
+ return {
+ prefix: "docs/",
+ ...overrides,
+ };
+}
diff --git a/tests/unit/application/lib/formatFileSize.test.ts b/tests/unit/application/lib/formatFileSize.test.ts
new file mode 100644
index 0000000..186c282
--- /dev/null
+++ b/tests/unit/application/lib/formatFileSize.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from "vitest";
+import { formatFileSize } from "@/application/lib/formatFileSize";
+
+describe("formatFileSize", () => {
+ it("returns 0 B for 0", () => {
+ expect(formatFileSize(0)).toBe("0 B");
+ });
+
+ it("returns bytes for values under 1 KB", () => {
+ expect(formatFileSize(500)).toBe("500 B");
+ });
+
+ it("returns 1.0 KB for 1024", () => {
+ expect(formatFileSize(1024)).toBe("1.0 KB");
+ });
+
+ it("returns 1.5 MB for 1536000", () => {
+ expect(formatFileSize(1536000)).toBe("1.5 MB");
+ });
+
+ it("returns 1.0 GB for 1073741824", () => {
+ expect(formatFileSize(1073741824)).toBe("1.0 GB");
+ });
+
+ it("returns 5.0 GB for 5368709120", () => {
+ expect(formatFileSize(5368709120)).toBe("5.0 GB");
+ });
+});
diff --git a/tests/unit/components/agent/AgentConfigViewer.test.tsx b/tests/unit/components/agent/AgentConfigViewer.test.tsx
index d4c1130..b5e659a 100644
--- a/tests/unit/components/agent/AgentConfigViewer.test.tsx
+++ b/tests/unit/components/agent/AgentConfigViewer.test.tsx
@@ -4,7 +4,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders } from "../../../utils/render";
import AgentConfigViewer from "@/application/components/agent/AgentConfigViewer";
import type { AgentConfig } from "@/domain/entities/agent/agentConfig";
-import { BackendType, MiddlewareType } from "@/domain/entities/agent/agentConfig";
+import {
+ BackendType,
+ MiddlewareType,
+} from "@/domain/entities/agent/agentConfig";
const { mockAgentConfigData, mockDeleteMutate } = vi.hoisted(() => {
return {
@@ -37,7 +40,8 @@ vi.mock("sonner", () => ({
const fullConfig: AgentConfig = {
name: "test-agent",
model: "openai:gpt-4o",
- system_prompt: "You are a helpful assistant that provides accurate information.",
+ system_prompt:
+ "You are a helpful assistant that provides accurate information.",
tools: ["search", "calculator"],
middleware: [],
backend: { type: "state" as BackendType },
@@ -246,7 +250,13 @@ describe("AgentConfigViewer", () => {
mockAgentConfigData.data = {
...fullConfig,
mcp_servers: [
- { name: "filesystem-server", transport: "stdio" as any, args: [], headers: {}, env: {} },
+ {
+ name: "filesystem-server",
+ transport: "stdio" as any,
+ args: [],
+ headers: {},
+ env: {},
+ },
],
};
@@ -351,7 +361,10 @@ describe("AgentConfigViewer", () => {
expect(mockDeleteMutate).toHaveBeenCalledWith(
"test-agent",
- expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ expect.objectContaining({
+ onSuccess: expect.any(Function),
+ onError: expect.any(Function),
+ }),
);
});
@@ -394,9 +407,7 @@ describe("AgentConfigViewer", () => {
// The footer "Close" button
const closeButtons = screen.getAllByRole("button");
- const footerClose = closeButtons.find(
- (btn) => btn.textContent === "Close",
- );
+ const footerClose = closeButtons.find((btn) => btn.textContent === "Close");
expect(footerClose).toBeDefined();
await user.click(footerClose!);
diff --git a/tests/unit/components/agent/CreateAgentDialog.test.tsx b/tests/unit/components/agent/CreateAgentDialog.test.tsx
index 11dae3c..ea1ae39 100644
--- a/tests/unit/components/agent/CreateAgentDialog.test.tsx
+++ b/tests/unit/components/agent/CreateAgentDialog.test.tsx
@@ -118,7 +118,10 @@ describe("CreateAgentDialog", () => {
expect(mockCreateAgentMutate).toHaveBeenCalledWith(
{ name: "my-agent", yamlFile: file },
- expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ expect.objectContaining({
+ onSuccess: expect.any(Function),
+ onError: expect.any(Function),
+ }),
);
});
diff --git a/tests/unit/components/chat/ChatInput.test.tsx b/tests/unit/components/chat/ChatInput.test.tsx
index 3dbdb53..70f8672 100644
--- a/tests/unit/components/chat/ChatInput.test.tsx
+++ b/tests/unit/components/chat/ChatInput.test.tsx
@@ -132,7 +132,10 @@ describe("ChatInput", () => {
});
expect(mockSendMessageMutate).toHaveBeenCalledWith(
{ message: "Standard message" },
- expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ expect.objectContaining({
+ onSuccess: expect.any(Function),
+ onError: expect.any(Function),
+ }),
);
// Restore
diff --git a/tests/unit/components/chat/HITLReviewPanel.test.tsx b/tests/unit/components/chat/HITLReviewPanel.test.tsx
index 4803364..0a607f0 100644
--- a/tests/unit/components/chat/HITLReviewPanel.test.tsx
+++ b/tests/unit/components/chat/HITLReviewPanel.test.tsx
@@ -67,7 +67,9 @@ describe("HITLReviewPanel", () => {
await user.click(screen.getByRole("button", { name: /review data/i }));
// In reviewing state, Approve and Reject buttons should appear
- expect(screen.getByRole("button", { name: /approve/i })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /approve/i }),
+ ).toBeInTheDocument();
expect(screen.getByRole("button", { name: /reject/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
});
diff --git a/tests/unit/components/layout/MainLayout.test.tsx b/tests/unit/components/layout/MainLayout.test.tsx
index b9af002..ba7e5f7 100644
--- a/tests/unit/components/layout/MainLayout.test.tsx
+++ b/tests/unit/components/layout/MainLayout.test.tsx
@@ -2,23 +2,32 @@ import { screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders } from "../../../utils/render";
import MainLayout from "@/application/components/layout/MainLayout";
-import { createThread, createAgentConfigMetadata } from "../../../fixtures/external";
+import {
+ createThread,
+ createAgentConfigMetadata,
+} from "../../../fixtures/external";
-const { mockThreadsData, mockAgentsData, mockCreateThreadMutate, mockSetActiveThread } =
- vi.hoisted(() => {
- return {
- mockThreadsData: {
- data: undefined as ReturnType[] | undefined,
- isLoading: false,
- },
- mockAgentsData: {
- data: undefined as ReturnType[] | undefined,
- isLoading: false,
- },
- mockCreateThreadMutate: vi.fn(),
- mockSetActiveThread: vi.fn(),
- };
- });
+const {
+ mockThreadsData,
+ mockAgentsData,
+ mockCreateThreadMutate,
+ mockSetActiveThread,
+} = vi.hoisted(() => {
+ return {
+ mockThreadsData: {
+ data: undefined as ReturnType[] | undefined,
+ isLoading: false,
+ },
+ mockAgentsData: {
+ data: undefined as
+ | ReturnType[]
+ | undefined,
+ isLoading: false,
+ },
+ mockCreateThreadMutate: vi.fn(),
+ mockSetActiveThread: vi.fn(),
+ };
+});
// Mock the hooks used by ThreadSidebar (child component)
vi.mock("@/application/hooks/chat/useThreads", () => ({
@@ -37,7 +46,11 @@ vi.mock("@/application/hooks/chat/useCreateThread", () => ({
}));
vi.mock("@/application/stores/useChatStore", () => {
- const fn = (selector: (state: { setActiveThread: typeof mockSetActiveThread }) => unknown) => {
+ const fn = (
+ selector: (state: {
+ setActiveThread: typeof mockSetActiveThread;
+ }) => unknown,
+ ) => {
return selector({ setActiveThread: mockSetActiveThread });
};
fn.getState = () => ({ setActiveThread: mockSetActiveThread });
diff --git a/tests/unit/components/layout/ThreadSidebar.test.tsx b/tests/unit/components/layout/ThreadSidebar.test.tsx
index 7f1151b..e33c59d 100644
--- a/tests/unit/components/layout/ThreadSidebar.test.tsx
+++ b/tests/unit/components/layout/ThreadSidebar.test.tsx
@@ -8,21 +8,27 @@ import {
createAgentConfigMetadata,
} from "../../../fixtures/external";
-const { mockThreadsData, mockAgentsData, mockCreateThreadMutate, mockSetActiveThread } =
- vi.hoisted(() => {
- return {
- mockThreadsData: {
- data: undefined as ReturnType[] | undefined,
- isLoading: false,
- },
- mockAgentsData: {
- data: undefined as ReturnType[] | undefined,
- isLoading: false,
- },
- mockCreateThreadMutate: vi.fn(),
- mockSetActiveThread: vi.fn(),
- };
- });
+const {
+ mockThreadsData,
+ mockAgentsData,
+ mockCreateThreadMutate,
+ mockSetActiveThread,
+} = vi.hoisted(() => {
+ return {
+ mockThreadsData: {
+ data: undefined as ReturnType[] | undefined,
+ isLoading: false,
+ },
+ mockAgentsData: {
+ data: undefined as
+ | ReturnType[]
+ | undefined,
+ isLoading: false,
+ },
+ mockCreateThreadMutate: vi.fn(),
+ mockSetActiveThread: vi.fn(),
+ };
+});
vi.mock("@/application/hooks/chat/useThreads", () => ({
useThreads: () => mockThreadsData,
@@ -40,7 +46,11 @@ vi.mock("@/application/hooks/chat/useCreateThread", () => ({
}));
vi.mock("@/application/stores/useChatStore", () => {
- const fn = (selector: (state: { setActiveThread: typeof mockSetActiveThread }) => unknown) => {
+ const fn = (
+ selector: (state: {
+ setActiveThread: typeof mockSetActiveThread;
+ }) => unknown,
+ ) => {
return selector({ setActiveThread: mockSetActiveThread });
};
fn.getState = () => ({ setActiveThread: mockSetActiveThread });
diff --git a/tests/unit/components/rag/BreadcrumbBar.test.tsx b/tests/unit/components/rag/BreadcrumbBar.test.tsx
new file mode 100644
index 0000000..d74790f
--- /dev/null
+++ b/tests/unit/components/rag/BreadcrumbBar.test.tsx
@@ -0,0 +1,87 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+import BreadcrumbBar from "@/application/components/rag/BreadcrumbBar";
+
+describe("BreadcrumbBar", () => {
+ it("renders path segments as breadcrumbs", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("docs")).toBeInTheDocument();
+ expect(screen.getByText("reports")).toBeInTheDocument();
+ expect(screen.getByText("2026")).toBeInTheDocument();
+ });
+
+ it("shows Home icon for root", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByLabelText("Home")).toBeInTheDocument();
+ });
+
+ it("current (last) segment is not clickable — plain text, not a button", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const lastSegment = screen.getByText("reports");
+ expect(lastSegment.tagName).not.toBe("BUTTON");
+ });
+
+ it("other segments are clickable buttons", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const docsSegment = screen.getByRole("button", { name: "docs" });
+ const reportsSegment = screen.getByRole("button", { name: "reports" });
+ expect(docsSegment).toBeInTheDocument();
+ expect(reportsSegment).toBeInTheDocument();
+ });
+
+ it("calls onNavigate with correct index when clicking a segment", async () => {
+ const user = userEvent.setup();
+ const onNavigate = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "docs" }));
+ expect(onNavigate).toHaveBeenCalledWith(0);
+
+ await user.click(screen.getByRole("button", { name: "reports" }));
+ expect(onNavigate).toHaveBeenCalledWith(1);
+ });
+
+ it("calls onNavigate(-1) when Home is clicked", async () => {
+ const user = userEvent.setup();
+ const onNavigate = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByLabelText("Home"));
+ expect(onNavigate).toHaveBeenCalledWith(-1);
+ });
+});
diff --git a/tests/unit/components/rag/FileList.test.tsx b/tests/unit/components/rag/FileList.test.tsx
new file mode 100644
index 0000000..95494da
--- /dev/null
+++ b/tests/unit/components/rag/FileList.test.tsx
@@ -0,0 +1,124 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+import FileList from "@/application/components/rag/FileList";
+
+describe("FileList", () => {
+ it("shows loading state", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText(/loading/i)).toBeInTheDocument();
+ });
+
+ it("shows error state with retry button", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText(/unable to load/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /retry/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows empty state when no files or folders", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText(/no files or folders/i)).toBeInTheDocument();
+ });
+
+ it("renders folders before files", () => {
+ const folders = [
+ { prefix: "docs/", name: "docs" },
+ { prefix: "images/", name: "images" },
+ ];
+ const files = [
+ { objectName: "readme.md", filename: "readme.md", size: 500, lastModified: "2026-04-06T10:00:00Z" },
+ ];
+
+ renderWithProviders(
+ ,
+ );
+
+ const allItems = screen.getAllByRole("row");
+ const docsIndex = allItems.findIndex((el) => el.textContent?.includes("docs"));
+ const imagesIndex = allItems.findIndex((el) => el.textContent?.includes("images"));
+ const readmeIndex = allItems.findIndex((el) => el.textContent?.includes("readme.md"));
+
+ expect(docsIndex).toBeLessThan(readmeIndex);
+ expect(imagesIndex).toBeLessThan(readmeIndex);
+ });
+
+ it("calls onFolderClick when a folder is clicked", async () => {
+ const user = userEvent.setup();
+ const onFolderClick = vi.fn();
+
+ const folders = [{ prefix: "docs/", name: "docs" }];
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /docs/i }));
+ expect(onFolderClick).toHaveBeenCalledWith("docs/");
+ });
+
+ it("calls onRetry when retry button is clicked", async () => {
+ const user = userEvent.setup();
+ const onRetry = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /retry/i }));
+ expect(onRetry).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tests/unit/components/rag/FileRow.test.tsx b/tests/unit/components/rag/FileRow.test.tsx
new file mode 100644
index 0000000..363ec47
--- /dev/null
+++ b/tests/unit/components/rag/FileRow.test.tsx
@@ -0,0 +1,86 @@
+import { screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+import FileRow from "@/application/components/rag/FileRow";
+
+describe("FileRow", () => {
+ it("renders filename as the last path segment", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("readme.md")).toBeInTheDocument();
+ });
+
+ it("renders human-readable file size", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("1.5 MB")).toBeInTheDocument();
+ });
+
+ it("renders formatted date", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText(/2026/)).toBeInTheDocument();
+ });
+
+ it("shows — for null lastModified", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("—")).toBeInTheDocument();
+ });
+
+ it("shows 0 B for zero size", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("0 B")).toBeInTheDocument();
+ });
+
+ it("shows 1.2 MB for large files", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("1.2 MB")).toBeInTheDocument();
+ });
+
+ it("does not have a clickable role — it is a row, not a button", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/components/rag/FolderRow.test.tsx b/tests/unit/components/rag/FolderRow.test.tsx
new file mode 100644
index 0000000..ee8014b
--- /dev/null
+++ b/tests/unit/components/rag/FolderRow.test.tsx
@@ -0,0 +1,42 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+import FolderRow from "@/application/components/rag/FolderRow";
+
+describe("FolderRow", () => {
+ it("renders folder name without trailing slash", () => {
+ renderWithProviders();
+
+ expect(screen.getByText("docs")).toBeInTheDocument();
+ });
+
+ it("shows folder icon", () => {
+ renderWithProviders();
+
+ expect(screen.getByLabelText("folder icon")).toBeInTheDocument();
+ });
+
+ it("has role button for accessibility", () => {
+ renderWithProviders();
+
+ expect(screen.getByRole("button", { name: /reports/i })).toBeInTheDocument();
+ });
+
+ it("has tabIndex for keyboard accessibility", () => {
+ renderWithProviders();
+
+ const button = screen.getByRole("button", { name: /reports/i });
+ expect(button).toHaveAttribute("tabindex", "0");
+ });
+
+ it("calls onClick when clicked", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+
+ renderWithProviders();
+
+ await user.click(screen.getByRole("button", { name: /docs/i }));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tests/unit/components/rag/UploadButton.test.tsx b/tests/unit/components/rag/UploadButton.test.tsx
new file mode 100644
index 0000000..10c5c28
--- /dev/null
+++ b/tests/unit/components/rag/UploadButton.test.tsx
@@ -0,0 +1,124 @@
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+import UploadButton from "@/application/components/rag/UploadButton";
+
+const { mockMutate, mockIsPending } = vi.hoisted(() => ({
+ mockMutate: vi.fn(),
+ mockIsPending: vi.fn().mockReturnValue(false),
+}));
+
+vi.mock("@/application/hooks/rag/useUploadFile", () => ({
+ useUploadFile: () => ({
+ mutate: mockMutate,
+ isPending: mockIsPending(),
+ }),
+}));
+
+describe("UploadButton", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockIsPending.mockReturnValue(false);
+ });
+
+ it("renders upload button with correct text", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", { name: /upload/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("has a hidden file input", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const fileInput = screen.getByLabelText(/choose file/i);
+ expect(fileInput).toBeInTheDocument();
+ expect(fileInput).toHaveAttribute("type", "file");
+ });
+
+ it("clicking button triggers file input click", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ const fileInput = screen.getByLabelText(/choose file/i);
+ const clickSpy = vi.spyOn(fileInput, "click");
+
+ await user.click(screen.getByRole("button", { name: /upload/i }));
+
+ expect(clickSpy).toHaveBeenCalledOnce();
+ });
+
+ it("selecting a file triggers upload with correct prefix", async () => {
+ const user = userEvent.setup();
+
+ // Set up the mock mutate to call onSuccess callback
+ mockMutate.mockImplementation((_args, options) => {
+ options?.onSuccess?.();
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ const file = new File(["dummy content"], "report.pdf", {
+ type: "application/pdf",
+ });
+ const fileInput = screen.getByLabelText(/choose file/i);
+ await user.upload(fileInput, file);
+
+ expect(mockMutate).toHaveBeenCalledWith(
+ { prefix: "documents/reports/", file },
+ expect.objectContaining({
+ onSuccess: expect.any(Function),
+ }),
+ );
+ });
+
+ it("shows loading indicator during upload", () => {
+ mockIsPending.mockReturnValue(true);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("upload-spinner")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /uploading/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onUploadComplete on success", async () => {
+ const user = userEvent.setup();
+ const onUploadComplete = vi.fn();
+
+ mockMutate.mockImplementation((_args, options) => {
+ options?.onSuccess?.();
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ const file = new File(["dummy content"], "report.pdf", {
+ type: "application/pdf",
+ });
+ const fileInput = screen.getByLabelText(/choose file/i);
+ await user.upload(fileInput, file);
+
+ await waitFor(() => {
+ expect(onUploadComplete).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/tests/unit/domain/entities/fileEntry.test.ts b/tests/unit/domain/entities/fileEntry.test.ts
new file mode 100644
index 0000000..d2eda64
--- /dev/null
+++ b/tests/unit/domain/entities/fileEntry.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from "vitest";
+
+describe("FileEntry", () => {
+ it("has objectName, size, and lastModified fields", async () => {
+ const { FileEntry } = await import(
+ "@/domain/entities/rag/fileEntry"
+ );
+
+ const entry = new FileEntry({
+ objectName: "docs/readme.md",
+ size: 1024,
+ lastModified: "2026-04-06T10:00:00Z",
+ });
+
+ expect(entry.objectName).toBe("docs/readme.md");
+ expect(entry.size).toBe(1024);
+ expect(entry.lastModified).toBe("2026-04-06T10:00:00Z");
+ });
+
+ it("allows null lastModified", async () => {
+ const { FileEntry } = await import(
+ "@/domain/entities/rag/fileEntry"
+ );
+
+ const entry = new FileEntry({
+ objectName: "docs/draft.txt",
+ size: 500,
+ lastModified: null,
+ });
+
+ expect(entry.lastModified).toBeNull();
+ });
+
+ it("extracts filename as the last path segment", async () => {
+ const { FileEntry } = await import(
+ "@/domain/entities/rag/fileEntry"
+ );
+
+ const entry = new FileEntry({
+ objectName: "docs/reports/q1-summary.pdf",
+ size: 2048,
+ lastModified: "2026-04-06T10:00:00Z",
+ });
+
+ expect(entry.filename).toBe("q1-summary.pdf");
+ });
+
+ it("returns the full objectName as filename when no slashes", async () => {
+ const { FileEntry } = await import(
+ "@/domain/entities/rag/fileEntry"
+ );
+
+ const entry = new FileEntry({
+ objectName: "readme.md",
+ size: 100,
+ lastModified: "2026-04-06T10:00:00Z",
+ });
+
+ expect(entry.filename).toBe("readme.md");
+ });
+});
+
+describe("FolderEntry", () => {
+ it("has a prefix field ending with slash", async () => {
+ const { FolderEntry } = await import(
+ "@/domain/entities/rag/fileEntry"
+ );
+
+ const entry = new FolderEntry({ prefix: "docs/" });
+
+ expect(entry.prefix).toBe("docs/");
+ });
+
+ it("extracts folder name without trailing slash", async () => {
+ const { FolderEntry } = await import(
+ "@/domain/entities/rag/fileEntry"
+ );
+
+ const entry = new FolderEntry({ prefix: "reports/2026/" });
+
+ expect(entry.name).toBe("2026");
+ });
+
+ it("returns prefix without slash as name for single-level folder", async () => {
+ const { FolderEntry } = await import(
+ "@/domain/entities/rag/fileEntry"
+ );
+
+ const entry = new FolderEntry({ prefix: "docs/" });
+
+ expect(entry.name).toBe("docs");
+ });
+});
diff --git a/tests/unit/hooks/agent/useAgentConfig.test.tsx b/tests/unit/hooks/agent/useAgentConfig.test.tsx
index 24fc0b1..03f5409 100644
--- a/tests/unit/hooks/agent/useAgentConfig.test.tsx
+++ b/tests/unit/hooks/agent/useAgentConfig.test.tsx
@@ -3,7 +3,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { useAgentConfig } from "@/application/hooks/agent/useAgentConfig";
import { agentApi } from "@/infrastructure/api/agent/agentApi";
-import type { AgentConfig, BackendType } from "@/domain/entities/agent/agentConfig";
+import type {
+ AgentConfig,
+ BackendType,
+} from "@/domain/entities/agent/agentConfig";
import type { ReactNode } from "react";
vi.mock("@/infrastructure/api/agent/agentApi", () => ({
diff --git a/tests/unit/hooks/agent/useCreateAgent.test.tsx b/tests/unit/hooks/agent/useCreateAgent.test.tsx
index c5663bd..65eeb0f 100644
--- a/tests/unit/hooks/agent/useCreateAgent.test.tsx
+++ b/tests/unit/hooks/agent/useCreateAgent.test.tsx
@@ -3,7 +3,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { useCreateAgent } from "@/application/hooks/agent/useCreateAgent";
import { agentApi } from "@/infrastructure/api/agent/agentApi";
-import type { AgentConfig, BackendType } from "@/domain/entities/agent/agentConfig";
+import type {
+ AgentConfig,
+ BackendType,
+} from "@/domain/entities/agent/agentConfig";
import type { ReactNode } from "react";
vi.mock("@/infrastructure/api/agent/agentApi", () => ({
diff --git a/tests/unit/hooks/agent/useDeleteAgent.test.tsx b/tests/unit/hooks/agent/useDeleteAgent.test.tsx
index dff5547..9e57636 100644
--- a/tests/unit/hooks/agent/useDeleteAgent.test.tsx
+++ b/tests/unit/hooks/agent/useDeleteAgent.test.tsx
@@ -65,9 +65,7 @@ describe("useDeleteAgent", () => {
});
it("returns error state when deletion fails", async () => {
- vi.mocked(agentApi.deleteAgent).mockRejectedValue(
- new Error("Forbidden"),
- );
+ vi.mocked(agentApi.deleteAgent).mockRejectedValue(new Error("Forbidden"));
const { wrapper } = createWrapper();
const { result } = renderHook(() => useDeleteAgent(), { wrapper });
diff --git a/tests/unit/hooks/agent/useUpdateAgent.test.tsx b/tests/unit/hooks/agent/useUpdateAgent.test.tsx
index a6a31bb..b614384 100644
--- a/tests/unit/hooks/agent/useUpdateAgent.test.tsx
+++ b/tests/unit/hooks/agent/useUpdateAgent.test.tsx
@@ -3,7 +3,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { useUpdateAgent } from "@/application/hooks/agent/useUpdateAgent";
import { agentApi } from "@/infrastructure/api/agent/agentApi";
-import type { AgentConfig, BackendType } from "@/domain/entities/agent/agentConfig";
+import type {
+ AgentConfig,
+ BackendType,
+} from "@/domain/entities/agent/agentConfig";
import type { ReactNode } from "react";
vi.mock("@/infrastructure/api/agent/agentApi", () => ({
diff --git a/tests/unit/hooks/chat/useCreateThread.test.tsx b/tests/unit/hooks/chat/useCreateThread.test.tsx
index 4c40580..852a517 100644
--- a/tests/unit/hooks/chat/useCreateThread.test.tsx
+++ b/tests/unit/hooks/chat/useCreateThread.test.tsx
@@ -39,7 +39,10 @@ describe("useCreateThread", () => {
});
it("calls chatApi.createThread on mutate", async () => {
- const mockThread = createThread({ id: "new-thread", agent_name: "my-agent" });
+ const mockThread = createThread({
+ id: "new-thread",
+ agent_name: "my-agent",
+ });
vi.mocked(chatApi.createThread).mockResolvedValue(mockThread);
const { wrapper } = createWrapper();
@@ -55,7 +58,10 @@ describe("useCreateThread", () => {
});
it("invalidates threads query on success", async () => {
- const mockThread = createThread({ id: "new-thread", agent_name: "my-agent" });
+ const mockThread = createThread({
+ id: "new-thread",
+ agent_name: "my-agent",
+ });
vi.mocked(chatApi.createThread).mockResolvedValue(mockThread);
const { wrapper, queryClient } = createWrapper();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
diff --git a/tests/unit/hooks/config/useConfig.test.tsx b/tests/unit/hooks/config/useConfig.test.tsx
new file mode 100644
index 0000000..b9b6169
--- /dev/null
+++ b/tests/unit/hooks/config/useConfig.test.tsx
@@ -0,0 +1,64 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { useConfig } from "@/application/hooks/config/useConfig";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactNode } from "react";
+
+vi.mock("@/infrastructure/config/configRepositoryInstance", () => ({
+ configRepository: {
+ getConfig: vi.fn().mockResolvedValue({
+ apiBaseUrl: "http://api.test.com",
+ wsBaseUrl: "ws://api.test.com",
+ }),
+ isLoaded: vi.fn().mockReturnValue(true),
+ },
+}));
+
+describe("useConfig", () => {
+ let queryClient: QueryClient;
+
+ const mockConfig = {
+ apiBaseUrl: "http://api.test.com",
+ wsBaseUrl: "ws://api.test.com",
+ };
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+ vi.clearAllMocks();
+ });
+
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ it("should fetch and return config", async () => {
+ const { result } = renderHook(() => useConfig(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data).toEqual(mockConfig);
+ });
+
+ it("should cache config with infinite stale time", async () => {
+ const { result } = renderHook(() => useConfig(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const fetchCount = queryClient.getQueryData(["config"]);
+ expect(fetchCount).toEqual(mockConfig);
+ });
+
+ it("should have correct query key", async () => {
+ const { result } = renderHook(() => useConfig(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data).toBeDefined();
+ });
+});
diff --git a/tests/unit/hooks/rag/useFiles.test.tsx b/tests/unit/hooks/rag/useFiles.test.tsx
new file mode 100644
index 0000000..56f2673
--- /dev/null
+++ b/tests/unit/hooks/rag/useFiles.test.tsx
@@ -0,0 +1,80 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { useFiles } from "@/application/hooks/rag/useFiles";
+import { ragApi } from "@/infrastructure/api/rag/ragApi";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/rag/ragApi", () => ({
+ ragApi: {
+ listFolders: vi.fn(),
+ listFiles: vi.fn(),
+ },
+}));
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+ return ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+}
+
+describe("useFiles", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns file list on success", async () => {
+ const files = [
+ {
+ objectName: "readme.md",
+ size: 500,
+ lastModified: "2026-04-06T10:00:00Z",
+ },
+ ];
+ vi.mocked(ragApi.listFiles).mockResolvedValue(files as any);
+
+ const { result } = renderHook(() => useFiles("docs/"), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toEqual(files);
+ expect(ragApi.listFiles).toHaveBeenCalledWith("docs/", false);
+ });
+
+ it("passes recursive param correctly", async () => {
+ vi.mocked(ragApi.listFiles).mockResolvedValue([]);
+
+ const { result } = renderHook(() => useFiles("docs/", true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(ragApi.listFiles).toHaveBeenCalledWith("docs/", true);
+ });
+
+ it("handles API errors", async () => {
+ vi.mocked(ragApi.listFiles).mockRejectedValue(
+ new Error("Server error"),
+ );
+
+ const { result } = renderHook(() => useFiles(""), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(result.current.error).toBeInstanceOf(Error);
+ });
+
+ it("does not fetch when prefix is undefined", () => {
+ const { result } = renderHook(() => useFiles(undefined), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.isFetching).toBe(false);
+ expect(ragApi.listFiles).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/hooks/rag/useFolders.test.tsx b/tests/unit/hooks/rag/useFolders.test.tsx
new file mode 100644
index 0000000..f906908
--- /dev/null
+++ b/tests/unit/hooks/rag/useFolders.test.tsx
@@ -0,0 +1,74 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { useFolders } from "@/application/hooks/rag/useFolders";
+import { ragApi } from "@/infrastructure/api/rag/ragApi";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/rag/ragApi", () => ({
+ ragApi: {
+ listFolders: vi.fn(),
+ listFiles: vi.fn(),
+ },
+}));
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+ return ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+}
+
+describe("useFolders", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns folder list on success", async () => {
+ const folders = [{ prefix: "docs/" }, { prefix: "images/" }];
+ vi.mocked(ragApi.listFolders).mockResolvedValue(folders as any);
+
+ const { result } = renderHook(() => useFolders(""), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toEqual(folders);
+ expect(ragApi.listFolders).toHaveBeenCalledWith("");
+ });
+
+ it("passes prefix to API call", async () => {
+ vi.mocked(ragApi.listFolders).mockResolvedValue([]);
+
+ const { result } = renderHook(() => useFolders("docs/reports/"), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(ragApi.listFolders).toHaveBeenCalledWith("docs/reports/");
+ });
+
+ it("handles API errors", async () => {
+ vi.mocked(ragApi.listFolders).mockRejectedValue(
+ new Error("Network error"),
+ );
+
+ const { result } = renderHook(() => useFolders(""), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(result.current.error).toBeInstanceOf(Error);
+ });
+
+ it("does not fetch when prefix is undefined", () => {
+ const { result } = renderHook(() => useFolders(undefined), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.isFetching).toBe(false);
+ expect(ragApi.listFolders).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/hooks/rag/useUploadFile.test.tsx b/tests/unit/hooks/rag/useUploadFile.test.tsx
new file mode 100644
index 0000000..22339e2
--- /dev/null
+++ b/tests/unit/hooks/rag/useUploadFile.test.tsx
@@ -0,0 +1,119 @@
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { useUploadFile } from "@/application/hooks/rag/useUploadFile";
+import { ragApi } from "@/infrastructure/api/rag/ragApi";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/rag/ragApi", () => ({
+ ragApi: {
+ listFiles: vi.fn(),
+ listFolders: vi.fn(),
+ uploadFile: vi.fn(),
+ },
+}));
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+ return {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ queryClient,
+ };
+}
+
+describe("useUploadFile", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("calls ragApi.uploadFile with correct prefix and file", async () => {
+ vi.mocked(ragApi.uploadFile).mockResolvedValue({ name: "report.pdf", prefix: "documents/" });
+ const { wrapper } = createWrapper();
+ const file = new File(["dummy content"], "report.pdf", {
+ type: "application/pdf",
+ });
+
+ const { result } = renderHook(() => useUploadFile(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ prefix: "documents/", file });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(ragApi.uploadFile).toHaveBeenCalledWith("documents/", file);
+ });
+
+ it("invalidates rag files and folders queries on success", async () => {
+ vi.mocked(ragApi.uploadFile).mockResolvedValue({ name: "report.pdf", prefix: "documents/" });
+ const { wrapper, queryClient } = createWrapper();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+ const file = new File(["dummy content"], "report.pdf", {
+ type: "application/pdf",
+ });
+
+ const { result } = renderHook(() => useUploadFile(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ prefix: "documents/", file });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["rag", "files"] });
+ expect(invalidateSpy).toHaveBeenCalledWith({
+ queryKey: ["rag", "folders"],
+ });
+ });
+
+ it("does not invalidate queries on failure", async () => {
+ vi.mocked(ragApi.uploadFile).mockRejectedValue(new Error("Upload failed"));
+ const { wrapper, queryClient } = createWrapper();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+ const file = new File(["dummy content"], "report.pdf", {
+ type: "application/pdf",
+ });
+
+ const { result } = renderHook(() => useUploadFile(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ prefix: "documents/", file });
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(invalidateSpy).not.toHaveBeenCalled();
+ });
+
+ it("returns correct loading state during upload", async () => {
+ let resolveUpload!: (value: unknown) => void;
+ vi.mocked(ragApi.uploadFile).mockImplementation(
+ () => new Promise((resolve) => (resolveUpload = resolve)),
+ );
+ const { wrapper } = createWrapper();
+ const file = new File(["dummy content"], "report.pdf", {
+ type: "application/pdf",
+ });
+
+ const { result } = renderHook(() => useUploadFile(), { wrapper });
+
+ expect(result.current.isPending).toBe(false);
+
+ act(() => {
+ result.current.mutate({ prefix: "documents/", file });
+ });
+
+ await waitFor(() => expect(result.current.isPending).toBe(true));
+
+ act(() => {
+ resolveUpload({ name: "report.pdf", prefix: "documents/" });
+ });
+
+ await waitFor(() => expect(result.current.isPending).toBe(false));
+ expect(result.current.isSuccess).toBe(true);
+ });
+});
diff --git a/tests/unit/infrastructure/api/rag/ragApi.test.ts b/tests/unit/infrastructure/api/rag/ragApi.test.ts
new file mode 100644
index 0000000..2177da5
--- /dev/null
+++ b/tests/unit/infrastructure/api/rag/ragApi.test.ts
@@ -0,0 +1,137 @@
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { ragApi } from "@/infrastructure/api/rag/ragApi";
+import { ragApiClient } from "@/infrastructure/api/ragAxiosInstance";
+
+vi.mock("@/infrastructure/api/ragAxiosInstance", () => ({
+ ragApiClient: {
+ get: vi.fn(),
+ post: vi.fn(),
+ put: vi.fn(),
+ delete: vi.fn(),
+ },
+}));
+
+describe("ragApi", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("listFolders", () => {
+ it("fetches folders from GET /api/v1/files/folders", async () => {
+ vi.mocked(ragApiClient.get).mockResolvedValue({
+ data: ["docs/", "images/"],
+ });
+
+ const result = await ragApi.listFolders();
+
+ expect(ragApiClient.get).toHaveBeenCalledWith("/api/v1/files/folders");
+ expect(result).toHaveLength(2);
+ expect(result[0].prefix).toBe("docs/");
+ expect(result[0].name).toBe("docs");
+ expect(result[1].prefix).toBe("images/");
+ expect(result[1].name).toBe("images");
+ });
+
+ it("fetches folders with prefix query param", async () => {
+ vi.mocked(ragApiClient.get).mockResolvedValue({
+ data: ["docs/reports/"],
+ });
+
+ const result = await ragApi.listFolders("docs/");
+
+ expect(ragApiClient.get).toHaveBeenCalledWith(
+ "/api/v1/files/folders?prefix=docs%2F",
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0].prefix).toBe("docs/reports/");
+ expect(result[0].name).toBe("reports");
+ });
+ });
+
+ describe("listFiles", () => {
+ it("fetches files from GET /api/v1/files/list with root prefix", async () => {
+ const rawFiles = [
+ {
+ object_name: "readme.md",
+ size: 500,
+ last_modified: "2026-04-06T10:00:00Z",
+ },
+ ];
+ vi.mocked(ragApiClient.get).mockResolvedValue({ data: rawFiles });
+
+ const result = await ragApi.listFiles("");
+
+ expect(ragApiClient.get).toHaveBeenCalledWith(
+ "/api/v1/files/list?prefix=&recursive=false",
+ );
+ expect(result).toEqual([
+ {
+ objectName: "readme.md",
+ size: 500,
+ lastModified: "2026-04-06T10:00:00Z",
+ },
+ ]);
+ });
+
+ it("fetches files with prefix and recursive=false by default", async () => {
+ const rawFiles = [
+ {
+ object_name: "docs/guide.md",
+ size: 2048,
+ last_modified: "2026-04-06T10:00:00Z",
+ },
+ ];
+ vi.mocked(ragApiClient.get).mockResolvedValue({ data: rawFiles });
+
+ const result = await ragApi.listFiles("docs/");
+
+ expect(ragApiClient.get).toHaveBeenCalledWith(
+ "/api/v1/files/list?prefix=docs%2F&recursive=false",
+ );
+ expect(result).toEqual([
+ {
+ objectName: "docs/guide.md",
+ size: 2048,
+ lastModified: "2026-04-06T10:00:00Z",
+ },
+ ]);
+ });
+
+ it("fetches files with recursive=true", async () => {
+ vi.mocked(ragApiClient.get).mockResolvedValue({ data: [] });
+
+ await ragApi.listFiles("docs/", true);
+
+ expect(ragApiClient.get).toHaveBeenCalledWith(
+ "/api/v1/files/list?prefix=docs%2F&recursive=true",
+ );
+ });
+
+ it("maps snake_case fields to camelCase", async () => {
+ const rawFiles = [
+ {
+ object_name: "report.pdf",
+ size: 1536000,
+ last_modified: null,
+ },
+ ];
+ vi.mocked(ragApiClient.get).mockResolvedValue({ data: rawFiles });
+
+ const result = await ragApi.listFiles("");
+
+ expect(result[0]).toEqual({
+ objectName: "report.pdf",
+ size: 1536000,
+ lastModified: null,
+ });
+ });
+
+ it("handles empty file list", async () => {
+ vi.mocked(ragApiClient.get).mockResolvedValue({ data: [] });
+
+ const result = await ragApi.listFiles("empty/");
+
+ expect(result).toEqual([]);
+ });
+ });
+});
diff --git a/tests/unit/infrastructure/axiosInstance.test.ts b/tests/unit/infrastructure/axiosInstance.test.ts
index ff825b9..6de73f7 100644
--- a/tests/unit/infrastructure/axiosInstance.test.ts
+++ b/tests/unit/infrastructure/axiosInstance.test.ts
@@ -1,17 +1,36 @@
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect, vi, beforeEach } from "vitest";
-vi.mock("@/infrastructure/config/envConfig", () => ({
- envConfig: {
+const { MockFileConfigRepository } = vi.hoisted(() => {
+ const mockConfig = {
apiBaseUrl: "http://test-api:8010",
wsBaseUrl: "ws://test-api:8010",
- },
+ };
+ const mockGetConfig = vi.fn().mockResolvedValue(mockConfig);
+ class MockFileConfigRepository {
+ getConfig = mockGetConfig;
+ isLoaded = vi.fn().mockReturnValue(false);
+ }
+ return { MockFileConfigRepository };
+});
+
+vi.mock("@/infrastructure/config/configRepositoryInstance", () => ({
+ configRepository: new MockFileConfigRepository(),
}));
describe("axiosInstance", () => {
- it("apiClient has correct baseURL from envConfig", async () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("apiClient fetches config and sets baseURL", async () => {
const { apiClient } = await import("@/infrastructure/api/axiosInstance");
- expect(apiClient.defaults.baseURL).toBe("http://test-api:8010");
+ const requestConfig =
+ await apiClient.interceptors.request.handlers[0].fulfilled({
+ headers: {},
+ });
+
+ expect(requestConfig.baseURL).toBe("http://test-api:8010");
});
it("apiClient has 30 second timeout", async () => {
@@ -29,7 +48,6 @@ describe("axiosInstance", () => {
it("error interceptor extracts detail from response", async () => {
const { apiClient } = await import("@/infrastructure/api/axiosInstance");
- // Simulate an axios error with response.data.detail
const axiosError = {
response: {
status: 400,
@@ -38,7 +56,6 @@ describe("axiosInstance", () => {
message: "Request failed with status code 400",
};
- // Get the error interceptor (second argument of the response interceptor)
const interceptors = (apiClient.interceptors.response as any).handlers;
const errorHandler = interceptors[0]?.rejected;
diff --git a/tests/unit/infrastructure/config/fileConfigRepository.test.ts b/tests/unit/infrastructure/config/fileConfigRepository.test.ts
new file mode 100644
index 0000000..e0777ce
--- /dev/null
+++ b/tests/unit/infrastructure/config/fileConfigRepository.test.ts
@@ -0,0 +1,148 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { FileConfigRepository } from "@/infrastructure/config/fileConfigRepository";
+
+const { mockToast } = vi.hoisted(() => ({
+ mockToast: { error: vi.fn(), success: vi.fn() },
+}));
+
+vi.mock("sonner", () => ({
+ toast: mockToast,
+}));
+
+describe("FileConfigRepository", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn());
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("getConfig", () => {
+ it("should fetch config from /config.json", async () => {
+ const mockConfig = {
+ apiBaseUrl: "http://api.test.com",
+ wsBaseUrl: "ws://api.test.com",
+ ragApiBaseUrl: "",
+ };
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve(mockConfig),
+ } as Response);
+
+ const repo = new FileConfigRepository();
+ const config = await repo.getConfig();
+
+ expect(fetch).toHaveBeenCalledWith("/config.json");
+ expect(config).toEqual(mockConfig);
+ });
+
+ it("should cache config after first load", async () => {
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ apiBaseUrl: "http://api.test.com",
+ wsBaseUrl: "ws://api.test.com",
+ }),
+ } as Response);
+
+ const repo = new FileConfigRepository();
+ await repo.getConfig();
+ await repo.getConfig();
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("should report isLoaded correctly", async () => {
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ apiBaseUrl: "http://api.test.com",
+ wsBaseUrl: "ws://api.test.com",
+ }),
+ } as Response);
+
+ const repo = new FileConfigRepository();
+ expect(repo.isLoaded()).toBe(false);
+
+ await repo.getConfig();
+ expect(repo.isLoaded()).toBe(true);
+ });
+
+ it("should show toast on fetch failure", async () => {
+ vi.mocked(fetch).mockResolvedValue({
+ ok: false,
+ status: 500,
+ } as Response);
+
+ const repo = new FileConfigRepository();
+ await expect(repo.getConfig()).rejects.toThrow(
+ "Failed to load config: 500",
+ );
+
+ expect(mockToast.error).toHaveBeenCalledWith("Configuration Error", {
+ description: "App is not configured.",
+ });
+ });
+
+ it("should show toast on network error", async () => {
+ vi.mocked(fetch).mockRejectedValue(new Error("Network error"));
+
+ const repo = new FileConfigRepository();
+ await expect(repo.getConfig()).rejects.toThrow("Network error");
+
+ expect(mockToast.error).toHaveBeenCalledWith("Configuration Error", {
+ description: "App is not configured.",
+ });
+ });
+
+ it("should allow retry after fetch failure", async () => {
+ vi.mocked(fetch).mockRejectedValueOnce(new Error("Network error"));
+
+ const repo = new FileConfigRepository();
+ await expect(repo.getConfig()).rejects.toThrow("Network error");
+
+ const mockConfig = {
+ apiBaseUrl: "http://api.test.com",
+ wsBaseUrl: "ws://api.test.com",
+ ragApiBaseUrl: "",
+ };
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve(mockConfig),
+ } as Response);
+
+ const config = await repo.getConfig();
+ expect(config).toEqual(mockConfig);
+ });
+
+ it("should handle concurrent getConfig calls", async () => {
+ let resolvePromise: (value: Response) => void;
+ vi.mocked(fetch).mockReturnValue(
+ new Promise((resolve) => {
+ resolvePromise = resolve;
+ }),
+ );
+
+ const repo = new FileConfigRepository();
+ const p1 = repo.getConfig();
+ const p2 = repo.getConfig();
+
+ resolvePromise!({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ apiBaseUrl: "http://api.test.com",
+ wsBaseUrl: "ws://api.test.com",
+ }),
+ } as Response);
+
+ const [config1, config2] = await Promise.all([p1, p2]);
+ expect(config1).toEqual(config2);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/tests/unit/pages/AgentsPage.test.tsx b/tests/unit/pages/AgentsPage.test.tsx
index 396191b..a2c55c1 100644
--- a/tests/unit/pages/AgentsPage.test.tsx
+++ b/tests/unit/pages/AgentsPage.test.tsx
@@ -69,9 +69,7 @@ describe("AgentsPage", () => {
renderWithProviders(, { initialEntries: ["/agents"] });
- await user.click(
- screen.getByRole("button", { name: /create agent/i }),
- );
+ await user.click(screen.getByRole("button", { name: /create agent/i }));
expect(screen.getByText("Create Agent")).toBeInTheDocument();
expect(screen.getByLabelText(/agent name/i)).toBeInTheDocument();
@@ -82,12 +80,12 @@ describe("AgentsPage", () => {
renderWithProviders(, { initialEntries: ["/agents"] });
- await user.click(
- screen.getByRole("button", { name: /configure/i }),
- );
+ await user.click(screen.getByRole("button", { name: /configure/i }));
// The AgentConfigViewer should be rendered with Delete button (unique to viewer)
- expect(screen.getByRole("button", { name: /^delete$/i })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /^delete$/i }),
+ ).toBeInTheDocument();
// The dialog should be present
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
diff --git a/tests/unit/pages/RagPage.test.tsx b/tests/unit/pages/RagPage.test.tsx
new file mode 100644
index 0000000..1df7349
--- /dev/null
+++ b/tests/unit/pages/RagPage.test.tsx
@@ -0,0 +1,50 @@
+import { screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../utils/render";
+import RagPage from "@/application/pages/RagPage";
+
+vi.mock("@/application/hooks/rag/useFolders", () => ({
+ useFolders: () => ({
+ data: [{ prefix: "docs/", name: "docs" }],
+ isLoading: false,
+ error: null,
+ }),
+}));
+
+vi.mock("@/application/hooks/rag/useFiles", () => ({
+ useFiles: () => ({
+ data: [
+ {
+ objectName: "readme.md",
+ filename: "readme.md",
+ size: 500,
+ lastModified: "2026-04-06T10:00:00Z",
+ },
+ ],
+ isLoading: false,
+ error: null,
+ }),
+}));
+
+describe("RagPage", () => {
+ it("renders page title RAG Storage", () => {
+ renderWithProviders(, { initialEntries: ["/rag"] });
+
+ expect(
+ screen.getByRole("heading", { name: /rag storage/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows breadcrumb navigation", () => {
+ renderWithProviders(, { initialEntries: ["/rag"] });
+
+ expect(screen.getByLabelText("Home")).toBeInTheDocument();
+ });
+
+ it("shows file list with folders and files", () => {
+ renderWithProviders(, { initialEntries: ["/rag"] });
+
+ expect(screen.getByText("docs")).toBeInTheDocument();
+ expect(screen.getByText("readme.md")).toBeInTheDocument();
+ });
+});
diff --git a/vite.config.ts b/vite.config.ts
index 9246bfb..7e91aca 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -7,6 +7,10 @@ export default defineConfig(() => ({
host: "localhost",
port: 8030,
proxy: {
+ "/api/v1/files": {
+ target: "http://localhost:8020",
+ changeOrigin: true,
+ },
"/api": {
target: "http://localhost:8010",
changeOrigin: true,